test(20-01): add failing tests for member-profile update + last-admin guard + isAdmin read
- Test A: PATCH displayName happy path → 200, GET reflects change
- Test B: PATCH isAdmin promote → 200, GET shows isAdmin true
- Test C: last-admin guard → 409 when only admin demotes self
- Test D: self-demotion → 200 when second admin exists
- Test E: non-admin PATCH → 403 (requireAdmin boundary)
- Test F: wrong-type body → 400 { error: "Invalid request" }; malformed :id → 400
- Test G: non-existent member id → 404
- Test H: GET /members includes boolean isAdmin per member
All 7 new tests fail RED for the right reasons (route 404 / isAdmin missing)
This commit is contained in:
@@ -1071,3 +1071,182 @@ describe('POST /api/admin/members', () => {
|
|||||||
expect(parsed.error).toBe('Invalid request');
|
expect(parsed.error).toBe('Invalid request');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// PATCH /api/admin/members/:id — member-profile update + last-admin guard (Plan 20-01)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('PATCH /api/admin/members/:id', () => {
|
||||||
|
// Test A: happy path — update displayName only
|
||||||
|
it('Test A (happy path displayName): PATCH with { displayName } as admin returns 200; GET reflects new name', async () => {
|
||||||
|
const adminId = await seedUser('admin-patch-name', true);
|
||||||
|
const memberId = await seedUser('member-patch-target', false);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', `/api/admin/members/${memberId}`, { displayName: 'New Name' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { ok: boolean };
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
|
||||||
|
// GET /members should reflect the updated displayName
|
||||||
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as { members: Array<{ id: number; displayName: string }> };
|
||||||
|
const updated = getBody.members.find((m) => m.id === memberId);
|
||||||
|
expect(updated).toBeDefined();
|
||||||
|
expect(updated!.displayName).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test B: happy path — promote non-admin to admin
|
||||||
|
it('Test B (happy path isAdmin promote): PATCH with { isAdmin: true } returns 200; GET shows isAdmin true', async () => {
|
||||||
|
const adminId = await seedUser('admin-patch-promote', true);
|
||||||
|
const memberId = await seedUser('member-patch-promote', false);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', `/api/admin/members/${memberId}`, { isAdmin: true }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as {
|
||||||
|
members: Array<{ id: number; isAdmin: boolean }>;
|
||||||
|
};
|
||||||
|
const promoted = getBody.members.find((m) => m.id === memberId);
|
||||||
|
expect(promoted).toBeDefined();
|
||||||
|
expect(promoted!.isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test C: last-admin guard — only admin cannot demote themselves
|
||||||
|
it('Test C (last-admin guard): with exactly one admin, PATCH { isAdmin: false } returns 409; member stays admin', async () => {
|
||||||
|
const adminId = await seedUser('admin-last-admin', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', `/api/admin/members/${adminId}`, { isAdmin: false }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
const body = (await res.json()) as { error: string };
|
||||||
|
expect(typeof body.error).toBe('string');
|
||||||
|
expect(body.error.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The admin flag must still be true after the rejected demotion
|
||||||
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as {
|
||||||
|
members: Array<{ id: number; isAdmin: boolean }>;
|
||||||
|
};
|
||||||
|
const adminRow = getBody.members.find((m) => m.id === adminId);
|
||||||
|
expect(adminRow).toBeDefined();
|
||||||
|
expect(adminRow!.isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test D: self-demotion allowed when another admin exists
|
||||||
|
it('Test D (self-demotion allowed): with two admins, PATCH { isAdmin: false } returns 200; one admin remains', async () => {
|
||||||
|
const adminId1 = await seedUser('admin-demote-1', true);
|
||||||
|
const adminId2 = await seedUser('admin-demote-2', true);
|
||||||
|
currentDevUserId = adminId1;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', `/api/admin/members/${adminId1}`, { isAdmin: false }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Only adminId2 should remain as admin
|
||||||
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
const getBody = (await getRes.json()) as {
|
||||||
|
members: Array<{ id: number; isAdmin: boolean }>;
|
||||||
|
};
|
||||||
|
const row1 = getBody.members.find((m) => m.id === adminId1);
|
||||||
|
const row2 = getBody.members.find((m) => m.id === adminId2);
|
||||||
|
expect(row1!.isAdmin).toBe(false);
|
||||||
|
expect(row2!.isAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test E: auth boundary — non-admin gets 403
|
||||||
|
it('Test E (auth boundary): non-admin PATCH returns 403', async () => {
|
||||||
|
const adminId = await seedUser('admin-patch-auth', true);
|
||||||
|
const nonAdminId = await seedUser('non-admin-patch', false);
|
||||||
|
currentDevUserId = nonAdminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', `/api/admin/members/${adminId}`, { displayName: 'Hacked' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test F: validation — wrong type and malformed id
|
||||||
|
it('Test F (validation): PATCH with { isAdmin: "yes" } returns 400 { error: "Invalid request" }', async () => {
|
||||||
|
const adminId = await seedUser('admin-patch-validation', true);
|
||||||
|
const memberId = await seedUser('member-patch-validation', false);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', `/api/admin/members/${memberId}`, { isAdmin: 'yes' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as { error: string };
|
||||||
|
expect(body.error).toBe('Invalid request');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test F (malformed id): PATCH with malformed :id (e.g. "1abc") returns 400', async () => {
|
||||||
|
const adminId = await seedUser('admin-patch-badid', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', '/api/admin/members/1abc', { displayName: 'Test' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test G: not found — non-existent member id
|
||||||
|
it('Test G (not found): PATCH non-existent member id returns 404', async () => {
|
||||||
|
const adminId = await seedUser('admin-patch-notfound', true);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(
|
||||||
|
jsonRequest('PATCH', '/api/admin/members/99999999', { displayName: 'Ghost' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// GET /api/admin/members — isAdmin field (Plan 20-01)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
describe('GET /api/admin/members — isAdmin field', () => {
|
||||||
|
it('Test H (GET isAdmin field): each member object includes a boolean isAdmin field', async () => {
|
||||||
|
const adminId = await seedUser('admin-isadmin-field', true);
|
||||||
|
const memberId = await seedUser('member-isadmin-field', false);
|
||||||
|
currentDevUserId = adminId;
|
||||||
|
const app = await getApp();
|
||||||
|
|
||||||
|
const res = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
members: Array<{ id: number; isAdmin: boolean }>;
|
||||||
|
};
|
||||||
|
// Both seeded users should have a boolean isAdmin field
|
||||||
|
const adminRow = body.members.find((m) => m.id === adminId);
|
||||||
|
const memberRow = body.members.find((m) => m.id === memberId);
|
||||||
|
expect(adminRow).toBeDefined();
|
||||||
|
expect(typeof adminRow!.isAdmin).toBe('boolean');
|
||||||
|
expect(adminRow!.isAdmin).toBe(true);
|
||||||
|
expect(memberRow).toBeDefined();
|
||||||
|
expect(typeof memberRow!.isAdmin).toBe('boolean');
|
||||||
|
expect(memberRow!.isAdmin).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user