diff --git a/apps/pwa/src/api/client.test.ts b/apps/pwa/src/api/client.test.ts index c4cae79..c370188 100644 --- a/apps/pwa/src/api/client.test.ts +++ b/apps/pwa/src/api/client.test.ts @@ -684,3 +684,142 @@ describe('fetchAdminResetPassword — URL contract (Phase 19, AUTH-LOCAL-08)', ( ); }); }); + +// ── Phase 20 (Plan 20-02): updateMemberProfile + AdminMember.isAdmin ───────── +// TDD RED: these tests MUST fail before the implementation is added to client.ts. + +describe('updateMemberProfile — URL + verb contract (Phase 20, Plan 20-02)', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('PATCHes /api/admin/members/:id (must match PATCH /members/:id in admin.ts)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await updateMemberProfile(7, { displayName: 'Alice' }); + + expect(fetch).toHaveBeenCalledWith( + '/api/admin/members/7', + expect.objectContaining({ method: 'PATCH' }), + ); + }); + + it('sends credentials:include and redirect:manual', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await updateMemberProfile(3, { isAdmin: true }); + + expect(fetch).toHaveBeenCalledWith( + '/api/admin/members/3', + expect.objectContaining({ + credentials: 'include', + redirect: 'manual', + }), + ); + }); + + it('resolves void on 200', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + type: 'basic', + status: 200, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + const result = await updateMemberProfile(7, { displayName: 'Bob' }); + expect(result).toBeUndefined(); + }); + + it('throws SessionExpiredError on opaqueredirect', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'opaqueredirect', + status: 0, + } as unknown as Response); + + const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); + await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf( + SessionExpiredError, + ); + }); + + it('throws SessionExpiredError on 401', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 401, + } as unknown as Response); + + const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); + await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toBeInstanceOf( + SessionExpiredError, + ); + }); + + it('throws Error("last-admin") on 409 (last-admin demotion sentinel)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 409, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin'); + }); + + it('throws Error("last-admin") on 422 as well (server may return either)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 422, + } as unknown as Response); + + const { updateMemberProfile } = await import('./client.js'); + await expect(updateMemberProfile(7, { isAdmin: false })).rejects.toThrow('last-admin'); + }); + + it('throws a generic error on any other non-ok status (not last-admin sentinel)', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + type: 'basic', + status: 500, + } as unknown as Response); + + const { updateMemberProfile, SessionExpiredError } = await import('./client.js'); + await expect(updateMemberProfile(7, { displayName: 'X' })).rejects.toSatisfy( + (e: unknown) => + e instanceof Error && + !(e instanceof SessionExpiredError) && + e.message !== 'last-admin', + ); + }); +}); + +describe('AdminMember.isAdmin field (Phase 20, Plan 20-02)', () => { + it('AdminMember interface has isAdmin: boolean (compile-time type check via runtime shape)', async () => { + // Construct a conforming object — TypeScript will error at compile time if + // isAdmin is missing from the AdminMember interface (caught by tsc --noEmit). + const member: import('./client.js').AdminMember = { + id: 1, + displayName: 'Test', + color: '#abc', + isAdmin: true, + hasCredential: false, + hasLocalCredential: false, + }; + expect(member.isAdmin).toBe(true); + }); +});