0817520e49
Second PR of the portal-admin User-list chantier per ADR-0020 §"v1 scope — User list (read-only)". Ships the read side: paginated, filterable HTTP endpoint that queries the `public.users` directory populated at sign-in by PR #140. The SPA viewer screen lands in the final PR of the chantier. What lands - AdminUsersQueryDto (admin/users-query.dto.ts): mirrors AdminAuditQueryDto's posture — filters all optional, every unknown key rejected by `forbidNonWhitelisted`, limit capped at MAX_LIMIT (200) / default 50. Filters: username (exact prefix), displayName (case-insensitive contains), audience (workforce | customer enum), lastSeenAtFrom/To (ISO-8601). - AdminUsersReader (admin/admin-users-reader.service.ts): Prisma typed client against `public.users` — no `SET LOCAL ROLE` dance because `public.users` has no role-based privilege gate; the trust boundary is the controller's @RequireAdmin guard. Order: `last_seen_at DESC, oid ASC` (the second clause is a deterministic tie-breaker for pagination during sign-in bursts that share a timestamp). COUNT + SELECT run in a single Prisma transaction so the `total` reported to the SPA matches what's on the page even under a concurrent sign-in. - AdminUsersController (admin/admin-users.controller.ts): GET /api/admin/users, @RequireAdmin at the class level, forwards the validated DTO to AdminUsersReader, then emits admin.users.query with { filters, resultCount } as the fishing-expedition deterrent (mirror of admin.audit.query from PR #132). - AuditWriter.adminUsersQuery() typed method + AdminUsersQueryInput type. Same outcome=success / payload shape as adminAuditQuery — two distinct event types so a reviewer can pivot directly on eventType without parsing payload. Tests: +18 specs (DTO validation 9, reader 9 covering COUNT+SELECT ordering, filter forwarding, default/cap on limit, range filter composition; controller 6; audit typed method 2). All 391 BFF specs pass. Out of scope (next PR of the chantier): - portal-admin /users screen — the SPA viewer with filter form + table + pagination, mirroring the /audit page that the audit viewer PR shipped. - Sign-in counts joined from `audit.events` on `actor_id_hash` (computed via HashUserIdService on the fly). Deferred until the v1 list ships and the admin demand makes itself known.
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import { Test } from '@nestjs/testing';
|
|
import { PrismaService } from 'nestjs-prisma';
|
|
import { AdminUsersReader } from './admin-users-reader.service';
|
|
|
|
interface MockPrisma {
|
|
user: {
|
|
count: jest.Mock;
|
|
findMany: jest.Mock;
|
|
};
|
|
$transaction: jest.Mock;
|
|
}
|
|
|
|
function buildPrisma(opts?: { count?: number; items?: unknown[] }): MockPrisma {
|
|
const count = jest.fn().mockResolvedValue(opts?.count ?? 0);
|
|
const findMany = jest.fn().mockResolvedValue(opts?.items ?? []);
|
|
return {
|
|
user: { count, findMany },
|
|
// The reader calls $transaction with the promises returned by
|
|
// `count()` + `findMany()` — the operations have already fired
|
|
// by the time $transaction sees them. The mock just resolves
|
|
// them together so Prisma's tuple-return contract is honoured.
|
|
$transaction: jest
|
|
.fn()
|
|
.mockImplementation((promises: ReadonlyArray<Promise<unknown>>) => Promise.all(promises)),
|
|
};
|
|
}
|
|
|
|
async function createSubject(prisma: MockPrisma): Promise<AdminUsersReader> {
|
|
const moduleRef = await Test.createTestingModule({
|
|
providers: [AdminUsersReader, { provide: PrismaService, useValue: prisma }],
|
|
}).compile();
|
|
return moduleRef.get(AdminUsersReader);
|
|
}
|
|
|
|
const ROW = {
|
|
oid: 'user-oid',
|
|
tid: 'tenant-1',
|
|
audience: 'workforce',
|
|
username: 'jane.doe@apf.example',
|
|
displayName: 'Jane Doe',
|
|
firstSeenAt: new Date('2026-05-10T10:00:00.000Z'),
|
|
lastSeenAt: new Date('2026-05-14T08:30:00.000Z'),
|
|
};
|
|
|
|
describe('AdminUsersReader.findUsers', () => {
|
|
it('issues a COUNT + SELECT in a single Prisma transaction', async () => {
|
|
const prisma = buildPrisma({ count: 5, items: [ROW] });
|
|
const reader = await createSubject(prisma);
|
|
const page = await reader.findUsers({});
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
expect(prisma.user.count).toHaveBeenCalledTimes(1);
|
|
expect(prisma.user.findMany).toHaveBeenCalledTimes(1);
|
|
expect(page.total).toBe(5);
|
|
expect(page.items).toHaveLength(1);
|
|
});
|
|
|
|
it('projects rows into the SPA-facing shape (ISO timestamps)', async () => {
|
|
const prisma = buildPrisma({ items: [ROW] });
|
|
const reader = await createSubject(prisma);
|
|
const page = await reader.findUsers({});
|
|
expect(page.items[0]).toEqual({
|
|
oid: 'user-oid',
|
|
tid: 'tenant-1',
|
|
audience: 'workforce',
|
|
username: 'jane.doe@apf.example',
|
|
displayName: 'Jane Doe',
|
|
firstSeenAt: '2026-05-10T10:00:00.000Z',
|
|
lastSeenAt: '2026-05-14T08:30:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('orders by last_seen_at DESC, oid ASC for deterministic pagination', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({});
|
|
const findManyArgs = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
orderBy: ReadonlyArray<Record<string, string>>;
|
|
};
|
|
expect(findManyArgs.orderBy).toEqual([{ lastSeenAt: 'desc' }, { oid: 'asc' }]);
|
|
});
|
|
|
|
it('passes startsWith filter on username through to Prisma', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({ username: 'jane' });
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
where: { username?: { startsWith?: string } };
|
|
};
|
|
expect(args.where.username).toEqual({ startsWith: 'jane' });
|
|
});
|
|
|
|
it('passes case-insensitive contains filter on displayName through to Prisma', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({ displayName: 'doe' });
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
where: { displayName?: { contains?: string; mode?: string } };
|
|
};
|
|
expect(args.where.displayName).toEqual({ contains: 'doe', mode: 'insensitive' });
|
|
});
|
|
|
|
it('combines lastSeenAtFrom and lastSeenAtTo into a single gte/lt range filter', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({
|
|
lastSeenAtFrom: '2026-05-01T00:00:00.000Z',
|
|
lastSeenAtTo: '2026-05-14T23:59:59.999Z',
|
|
});
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
where: { lastSeenAt?: { gte?: Date; lt?: Date } };
|
|
};
|
|
expect(args.where.lastSeenAt?.gte).toBeInstanceOf(Date);
|
|
expect(args.where.lastSeenAt?.lt).toBeInstanceOf(Date);
|
|
expect(args.where.lastSeenAt?.gte?.toISOString()).toBe('2026-05-01T00:00:00.000Z');
|
|
expect(args.where.lastSeenAt?.lt?.toISOString()).toBe('2026-05-14T23:59:59.999Z');
|
|
});
|
|
|
|
it('applies default limit (50) and offset (0) when not provided', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
const page = await reader.findUsers({});
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as {
|
|
take: number;
|
|
skip: number;
|
|
};
|
|
expect(args.take).toBe(50);
|
|
expect(args.skip).toBe(0);
|
|
expect(page.limit).toBe(50);
|
|
expect(page.offset).toBe(0);
|
|
});
|
|
|
|
it('caps limit at MAX_LIMIT (200) even when a caller bypasses the DTO', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({ limit: 1000 });
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as { take: number };
|
|
expect(args.take).toBe(200);
|
|
});
|
|
|
|
it('emits no WHERE clause when no filter is provided', async () => {
|
|
const prisma = buildPrisma();
|
|
const reader = await createSubject(prisma);
|
|
await reader.findUsers({});
|
|
const args = prisma.user.findMany.mock.calls[0]?.[0] as { where: object };
|
|
expect(args.where).toEqual({});
|
|
});
|
|
});
|