test(20-02): add failing tests for updateMemberProfile + AdminMember.isAdmin

- 8 RED tests covering: PATCH URL contract, credentials/redirect shape,
  void on 200, SessionExpiredError on 401/opaqueredirect, last-admin
  sentinel on 409 and 422, generic error on 500
- 1 compile-time shape test for AdminMember.isAdmin: boolean
- All new tests fail (updateMemberProfile is not a function); 42 existing pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-18 17:18:21 -04:00
co-authored by Claude Sonnet 4.6
parent 9b8b84edbe
commit 18da7e9476
+139
View File
@@ -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);
});
});