Files
marketplaces/src/app/features/website/account/identities/account-identities.component.spec.ts
sdarbinyan 1c87a53f02 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

94 lines
2.9 KiB
TypeScript

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');
});
});