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:
co-authored by
Claude Sonnet 4.6
parent
9b8b84edbe
commit
18da7e9476
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user