Skip to content

Commit 90d8bdb

Browse files
authored
Merge pull request #246 from RJK134/claude/phase-21a-teaching-assignment
2 parents 7fb2444 + 9176ea7 commit 90d8bdb

19 files changed

Lines changed: 837 additions & 11 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ SJMS 2.5 is a **unified enterprise student records system** for UK higher educat
4848

4949
## Target Metrics
5050

51-
196 Prisma models · 129 pages · 271 API endpoints (57 routers) · 36 roles · 15 n8n workflows
51+
196 Prisma models · 129 pages · 271 API endpoints (58 routers) · 36 roles · 15 n8n workflows
5252
Sub-2s page loads · Sub-500ms API (p95) · WCAG 2.1 AA · British English throughout
5353

5454
## Critical Rules for Claude

package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/check-docs-truth.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ if (modelCount !== expectedModels) {
6767
// router → 57. Update when *.router.ts files change.
6868

6969
const routerFiles = listFiles('server/src/api', (p) => p.endsWith('.router.ts'));
70-
const expectedRouters = 57;
70+
const expectedRouters = 58;
7171
if (routerFiles.length !== expectedRouters) {
7272
fail(
7373
'Flat router count',
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { ForbiddenError } from '../../utils/errors';
3+
4+
// Phase 21A — coverage for the `staffId` branch of `scopeToUser` and its
5+
// integration with `resolveIdentity`'s new `staffId` projection. The
6+
// existing prisma mock surface is reused but extended to allow the
7+
// Person → Staff include to return a value.
8+
9+
vi.mock('../../utils/prisma', () => ({
10+
default: {
11+
person: { findFirst: vi.fn() },
12+
},
13+
}));
14+
15+
import { scopeToUser } from '../../middleware/data-scope';
16+
import prisma from '../../utils/prisma';
17+
18+
const mockedPersonFind = vi.mocked((prisma as any).person.findFirst);
19+
20+
// Each test uses a unique sub because resolveIdentity caches by sub and
21+
// the cache is module-scoped — reusing a sub leaks the prior test's
22+
// resolved identity into the next test's lookup.
23+
let subCounter = 0;
24+
const baseUser = (overrides: any = {}) => ({
25+
sub: `kc-sub-${++subCounter}`,
26+
email: 'user@example.com',
27+
realm_access: { roles: [] },
28+
resource_access: {},
29+
...overrides,
30+
});
31+
32+
const makeReq = (user: any): any => ({
33+
user,
34+
query: {},
35+
});
36+
37+
const makeNext = () => vi.fn();
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
describe('scopeToUser("staffId")', () => {
44+
it('admin staff bypass — no scope applied', async () => {
45+
const req = makeReq(baseUser({ realm_access: { roles: ['system_admin'] } }));
46+
const next = makeNext();
47+
48+
await scopeToUser('staffId')(req, {} as any, next);
49+
50+
expect(next).toHaveBeenCalledWith();
51+
expect(req.query.staffId).toBeUndefined();
52+
expect(mockedPersonFind).not.toHaveBeenCalled();
53+
});
54+
55+
it('teaching staff: injects resolved staffId into the query', async () => {
56+
mockedPersonFind.mockResolvedValue({
57+
id: 'per-1',
58+
student: null,
59+
staff: { id: 'sta-7' },
60+
} as any);
61+
62+
const req = makeReq(baseUser({ realm_access: { roles: ['lecturer'] } }));
63+
const next = makeNext();
64+
65+
await scopeToUser('staffId')(req, {} as any, next);
66+
67+
expect(req.query.staffId).toBe('sta-7');
68+
expect(next).toHaveBeenCalledWith();
69+
});
70+
71+
it('teaching staff WITHOUT a linked Staff record: denied with ForbiddenError', async () => {
72+
mockedPersonFind.mockResolvedValue({
73+
id: 'per-1',
74+
student: null,
75+
staff: null,
76+
} as any);
77+
78+
const req = makeReq(baseUser({ realm_access: { roles: ['lecturer'] } }));
79+
const next = makeNext();
80+
81+
await scopeToUser('staffId')(req, {} as any, next);
82+
83+
expect(req.query.staffId).toBeUndefined();
84+
expect(next).toHaveBeenCalledWith(expect.any(ForbiddenError));
85+
});
86+
87+
it('teaching staff on a studentId-filter route: bypasses (no staff scope applied)', async () => {
88+
const req = makeReq(baseUser({ realm_access: { roles: ['lecturer'] } }));
89+
const next = makeNext();
90+
91+
await scopeToUser('studentId')(req, {} as any, next);
92+
93+
expect(req.query.studentId).toBeUndefined();
94+
expect(next).toHaveBeenCalledWith();
95+
expect(mockedPersonFind).not.toHaveBeenCalled();
96+
});
97+
98+
it('student hitting a staffId route: defensively denied with ForbiddenError', async () => {
99+
mockedPersonFind.mockResolvedValue({
100+
id: 'per-stu-1',
101+
student: { id: 'stu-1' },
102+
staff: null,
103+
} as any);
104+
105+
const req = makeReq(baseUser({ realm_access: { roles: ['student'] } }));
106+
const next = makeNext();
107+
108+
await scopeToUser('staffId')(req, {} as any, next);
109+
110+
expect(next).toHaveBeenCalledWith(expect.any(ForbiddenError));
111+
});
112+
});
113+
114+
describe('scopeToUser("studentId") — unchanged behaviour', () => {
115+
it('students still get studentId injected', async () => {
116+
mockedPersonFind.mockResolvedValue({
117+
id: 'per-stu-1',
118+
student: { id: 'stu-1' },
119+
staff: null,
120+
} as any);
121+
122+
const req = makeReq(baseUser({ realm_access: { roles: ['student'] } }));
123+
const next = makeNext();
124+
125+
await scopeToUser('studentId')(req, {} as any, next);
126+
127+
expect(req.query.studentId).toBe('stu-1');
128+
expect(next).toHaveBeenCalledWith();
129+
});
130+
131+
it('applicants still get personId injected on personId routes', async () => {
132+
mockedPersonFind.mockResolvedValue({
133+
id: 'per-app-1',
134+
student: null,
135+
staff: null,
136+
} as any);
137+
138+
const req = makeReq(baseUser({ realm_access: { roles: ['applicant'] } }));
139+
const next = makeNext();
140+
141+
await scopeToUser('personId')(req, {} as any, next);
142+
143+
expect(req.query.personId).toBe('per-app-1');
144+
expect(next).toHaveBeenCalledWith();
145+
});
146+
147+
it('unauthenticated requests are passed through to next', async () => {
148+
const req: any = { query: {} };
149+
const next = makeNext();
150+
151+
await scopeToUser('staffId')(req, {} as any, next);
152+
153+
expect(next).toHaveBeenCalledWith();
154+
expect(req.query.staffId).toBeUndefined();
155+
});
156+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { NotFoundError } from '../../utils/errors';
3+
4+
// ── Mock dependencies before importing the service under test ──────────────
5+
vi.mock('../../repositories/moduleDelivery.repository', () => ({
6+
list: vi.fn(),
7+
getById: vi.fn(),
8+
create: vi.fn(),
9+
update: vi.fn(),
10+
remove: vi.fn(),
11+
findByModuleStaffYear: vi.fn(),
12+
findModuleIdsByStaff: vi.fn(),
13+
}));
14+
vi.mock('../../utils/audit', () => ({ logAudit: vi.fn() }));
15+
vi.mock('../../utils/webhooks', () => ({ emitEvent: vi.fn() }));
16+
17+
import * as service from '../../api/module-deliveries/module-deliveries.service';
18+
import * as repo from '../../repositories/moduleDelivery.repository';
19+
import { logAudit } from '../../utils/audit';
20+
import { emitEvent } from '../../utils/webhooks';
21+
22+
const mockedRepo = vi.mocked(repo);
23+
const mockedLogAudit = vi.mocked(logAudit);
24+
const mockedEmitEvent = vi.mocked(emitEvent);
25+
26+
const fakeReq = { ip: '127.0.0.1', user: {}, get: vi.fn() } as any;
27+
28+
const fakeRow = {
29+
id: 'mdl-1',
30+
moduleId: 'mod-1',
31+
staffId: 'sta-1',
32+
academicYear: '2025/26',
33+
capacity: 30,
34+
notes: null,
35+
createdAt: new Date(),
36+
updatedAt: new Date(),
37+
};
38+
39+
const findEvent = (eventName: string) =>
40+
mockedEmitEvent.mock.calls
41+
.map((c) => (typeof c[0] === 'object' ? c[0] : null))
42+
.find((e) => e && (e as { event?: string }).event === eventName);
43+
44+
beforeEach(() => {
45+
vi.clearAllMocks();
46+
});
47+
48+
describe('module-deliveries.service.list', () => {
49+
it('forwards all filters to the repository unchanged', async () => {
50+
mockedRepo.list.mockResolvedValue({ data: [], total: 0, hasMore: false, nextCursor: null } as any);
51+
52+
await service.list({
53+
cursor: 'c1',
54+
limit: 50,
55+
sort: 'academicYear',
56+
order: 'asc',
57+
moduleId: 'mod-1',
58+
staffId: 'sta-1',
59+
academicYear: '2025/26',
60+
});
61+
62+
expect(mockedRepo.list).toHaveBeenCalledWith(
63+
{ moduleId: 'mod-1', staffId: 'sta-1', academicYear: '2025/26' },
64+
{ cursor: 'c1', limit: 50, sort: 'academicYear', order: 'asc' },
65+
);
66+
});
67+
});
68+
69+
describe('module-deliveries.service.getById', () => {
70+
it('returns the row when present', async () => {
71+
mockedRepo.getById.mockResolvedValue(fakeRow as any);
72+
const result = await service.getById('mdl-1');
73+
expect(result).toBe(fakeRow);
74+
});
75+
76+
it('throws NotFoundError when the row is absent', async () => {
77+
mockedRepo.getById.mockResolvedValue(null);
78+
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
79+
});
80+
});
81+
82+
describe('module-deliveries.service.create', () => {
83+
it('persists, audits CREATE, and emits module_delivery.created', async () => {
84+
mockedRepo.create.mockResolvedValue(fakeRow as any);
85+
86+
const result = await service.create(
87+
{ moduleId: 'mod-1', staffId: 'sta-1', academicYear: '2025/26', capacity: 30 } as any,
88+
'user-1',
89+
fakeReq,
90+
);
91+
92+
expect(result).toBe(fakeRow);
93+
expect(mockedLogAudit).toHaveBeenCalledWith(
94+
'ModuleDelivery',
95+
'mdl-1',
96+
'CREATE',
97+
'user-1',
98+
null,
99+
fakeRow,
100+
fakeReq,
101+
);
102+
const ev = findEvent('module_delivery.created') as any;
103+
expect(ev).toBeTruthy();
104+
expect(ev.entityType).toBe('ModuleDelivery');
105+
expect(ev.entityId).toBe('mdl-1');
106+
expect(ev.actorId).toBe('user-1');
107+
expect(ev.data).toEqual({ moduleId: 'mod-1', staffId: 'sta-1', academicYear: '2025/26' });
108+
});
109+
});
110+
111+
describe('module-deliveries.service.update', () => {
112+
it('reads previous, persists, audits UPDATE, and emits module_delivery.updated', async () => {
113+
mockedRepo.getById.mockResolvedValue(fakeRow as any);
114+
const next = { ...fakeRow, capacity: 40 };
115+
mockedRepo.update.mockResolvedValue(next as any);
116+
117+
const result = await service.update('mdl-1', { capacity: 40 } as any, 'user-1', fakeReq);
118+
119+
expect(result).toEqual(next);
120+
expect(mockedLogAudit).toHaveBeenCalledWith(
121+
'ModuleDelivery',
122+
'mdl-1',
123+
'UPDATE',
124+
'user-1',
125+
fakeRow,
126+
next,
127+
fakeReq,
128+
);
129+
expect(findEvent('module_delivery.updated')).toBeTruthy();
130+
});
131+
132+
it('throws NotFoundError when updating an unknown id', async () => {
133+
mockedRepo.getById.mockResolvedValue(null);
134+
await expect(
135+
service.update('missing', { capacity: 40 } as any, 'user-1', fakeReq),
136+
).rejects.toThrow(NotFoundError);
137+
expect(mockedRepo.update).not.toHaveBeenCalled();
138+
});
139+
});
140+
141+
describe('module-deliveries.service.remove', () => {
142+
it('reads previous, hard-deletes, audits DELETE, and emits module_delivery.deleted', async () => {
143+
mockedRepo.getById.mockResolvedValue(fakeRow as any);
144+
mockedRepo.remove.mockResolvedValue(fakeRow as any);
145+
146+
await service.remove('mdl-1', 'user-1', fakeReq);
147+
148+
expect(mockedRepo.remove).toHaveBeenCalledWith('mdl-1');
149+
expect(mockedLogAudit).toHaveBeenCalledWith(
150+
'ModuleDelivery',
151+
'mdl-1',
152+
'DELETE',
153+
'user-1',
154+
fakeRow,
155+
null,
156+
fakeReq,
157+
);
158+
const ev = findEvent('module_delivery.deleted') as any;
159+
expect(ev).toBeTruthy();
160+
expect(ev.data).toEqual({ moduleId: 'mod-1', staffId: 'sta-1', academicYear: '2025/26' });
161+
});
162+
163+
it('throws NotFoundError when removing an unknown id', async () => {
164+
mockedRepo.getById.mockResolvedValue(null);
165+
await expect(service.remove('missing', 'user-1', fakeReq)).rejects.toThrow(NotFoundError);
166+
expect(mockedRepo.remove).not.toHaveBeenCalled();
167+
});
168+
});

0 commit comments

Comments
 (0)