Files
marketplaces/src/app/features/website/account/identities/account-identities.component.spec.ts

94 lines
2.9 KiB
TypeScript
Raw Normal View History

feat(identity): account-linking UI + Telegram-as-identity surface (FH-4.7, FH-4.6, FH-4.8) FH-4.7 - AccountIdentitiesComponent under features/website/account/identities/. Lists linked identities from GET /me/identities, offers attach buttons only for OAuth providers not already linked (reusing SocialLoginButtonComponent), detaches through unlink(). Refuses to detach the last remaining identity - it is the only way back in - with the control disabled and an explanatory title, matching the backend's last-identity 409. Loading / error / ready states; a load failure surfaces an error rather than rendering an empty account, and a slot carries the identity-conflict message from PHASE-8 §2.3. 6 unit tests. Not wired into a route: the storefront has no customer account area yet and no live OAuth application to authorize against (FH-0.1). This is the surface both depend on, buildable and tested now. FH-4.6 (client + contract) - the gateway now separates the two provider sets. SocialProvider (vk | yandex) is what has an OAuth authorize redirect; ExternalIdentityProvider (adds telegram | max) is what can be listed and unlinked. unlink() widened to the latter so Telegram detaches through the same path as VK, with no second code path. The dev local gateway seeds a Telegram identity so the linking screen is exercisable before any real provider exists. PHASE-8 §2.6 specifies the backend migration: a Telegram login writes an ExternalIdentity row under the same uniqueness and identity-conflict rule as VK, appears in /me/identities, is removable subject to the last-identity 409, and keeps customer (marketplace_session) and admin (bo_session) sessions as distinct cookies - closing the shared customer/admin Telegram session the audit flagged. The identity row and the messaging BotConversationBinding stay separate records. FH-4.8 - PHASE-8 §3 now states email/phone OTP's position explicitly: recovery when a linked messenger is unreachable and an addable second factor, never the primary login, and one more identity on the same customer rather than a parallel account. 262 tests pass. Build green, boundaries and cycles green, bundle scan clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:36:36 +04:00
import { TestBed } from '@angular/core/testing';
import { Observable, of, throwError } from 'rxjs';
import { AccountIdentitiesComponent } from './account-identities.component';
import { SOCIAL_IDENTITY_GATEWAY } from '../../../../core/identity/services/social-identity-gateway.token';
import { SocialIdentityGateway } from '../../../../core/identity/services/social-identity-gateway.interface';
import {
ExternalIdentity,
ExternalIdentityProvider,
} from '../../../../core/identity/models/customer-identity.model';
function identity(provider: ExternalIdentityProvider): ExternalIdentity {
return {
customerId: 'c1',
provider,
providerUserId: 'u_' + provider,
verifiedAt: '2026-08-01T00:00:00.000Z',
lastUsedAt: '2026-08-21T00:00:00.000Z',
};
}
class FakeGateway implements SocialIdentityGateway {
linked: ExternalIdentity[] = [];
unlinkCalls: ExternalIdentityProvider[] = [];
failList = false;
getAuthorizeUrl(): Observable<string> {
return of('about:blank');
}
listIdentities(): Observable<ExternalIdentity[]> {
return this.failList ? throwError(() => new Error('boom')) : of(this.linked);
}
unlink(provider: ExternalIdentityProvider): Observable<void> {
this.unlinkCalls.push(provider);
return of(void 0);
}
}
function make(gateway: FakeGateway): AccountIdentitiesComponent {
TestBed.configureTestingModule({
providers: [{ provide: SOCIAL_IDENTITY_GATEWAY, useValue: gateway }],
});
return TestBed.createComponent(AccountIdentitiesComponent).componentInstance;
}
describe('AccountIdentitiesComponent', () => {
it('lists the linked identities and reports ready', () => {
const g = new FakeGateway();
g.linked = [identity('telegram'), identity('vk_id')];
const c = make(g);
expect(c.state()).toBe('ready');
expect(c.identities().map(i => i.provider)).toEqual(['telegram', 'vk_id']);
});
it('offers only the OAuth providers that are not already linked', () => {
const g = new FakeGateway();
g.linked = [identity('vk_id')]; // vk linked, yandex not
const c = make(g);
expect(c.linkable()).toEqual(['yandex']);
});
it('refuses to unlink the last remaining identity', () => {
const g = new FakeGateway();
g.linked = [identity('telegram')];
const c = make(g);
expect(c.isLastIdentity()).toBe(true);
c.unlink('telegram');
expect(g.unlinkCalls).toEqual([]);
expect(c.identities().length).toBe(1);
});
it('unlinks a provider when more than one is linked', () => {
const g = new FakeGateway();
g.linked = [identity('telegram'), identity('vk_id')];
const c = make(g);
c.unlink('vk_id');
expect(g.unlinkCalls).toEqual(['vk_id']);
expect(c.identities().map(i => i.provider)).toEqual(['telegram']);
});
it('surfaces a load failure instead of showing an empty account', () => {
const g = new FakeGateway();
g.failList = true;
const c = make(g);
expect(c.state()).toBe('error');
});
});