@studious-lms/server 1.8.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/middleware/security.d.ts.map +1 -1
  2. package/dist/middleware/security.js +3 -3
  3. package/dist/middleware/security.js.map +1 -1
  4. package/dist/models/agenda.d.ts +2 -2
  5. package/dist/models/announcement.d.ts +2 -2
  6. package/dist/models/assignment.d.ts +33 -33
  7. package/dist/models/class.d.ts +2 -2
  8. package/dist/models/cloudStorage.d.ts +14 -1
  9. package/dist/models/cloudStorage.d.ts.map +1 -1
  10. package/dist/models/cloudStorage.js +123 -3
  11. package/dist/models/cloudStorage.js.map +1 -1
  12. package/dist/models/event.d.ts +3 -3
  13. package/dist/models/file.d.ts +27 -26
  14. package/dist/models/file.d.ts.map +1 -1
  15. package/dist/models/file.js +4 -39
  16. package/dist/models/file.js.map +1 -1
  17. package/dist/models/labChat.d.ts +6 -6
  18. package/dist/models/section.d.ts +1 -1
  19. package/dist/routers/_app.d.ts +444 -100
  20. package/dist/routers/_app.d.ts.map +1 -1
  21. package/dist/routers/_app.js +4 -2
  22. package/dist/routers/_app.js.map +1 -1
  23. package/dist/routers/agenda.d.ts +2 -2
  24. package/dist/routers/assignment.d.ts +31 -31
  25. package/dist/routers/class.d.ts +5 -5
  26. package/dist/routers/event.d.ts +2 -2
  27. package/dist/routers/file.d.ts +2 -2
  28. package/dist/routers/folder.d.ts +24 -0
  29. package/dist/routers/folder.d.ts.map +1 -1
  30. package/dist/routers/folder.js +27 -5
  31. package/dist/routers/folder.js.map +1 -1
  32. package/dist/routers/labChat.d.ts +2 -2
  33. package/dist/routers/newtonChat.d.ts +1 -1
  34. package/dist/routers/permissions.d.ts +120 -0
  35. package/dist/routers/permissions.d.ts.map +1 -0
  36. package/dist/routers/permissions.js +50 -0
  37. package/dist/routers/permissions.js.map +1 -0
  38. package/dist/routers/studentProgress.d.ts +45 -0
  39. package/dist/routers/studentProgress.d.ts.map +1 -1
  40. package/dist/routers/studentProgress.js +12 -3
  41. package/dist/routers/studentProgress.js.map +1 -1
  42. package/dist/routers/tag.d.ts +5 -5
  43. package/dist/services/agenda.d.ts +2 -2
  44. package/dist/services/assignment.d.ts +31 -31
  45. package/dist/services/class.d.ts +5 -5
  46. package/dist/services/class.js +3 -3
  47. package/dist/services/class.js.map +1 -1
  48. package/dist/services/event.d.ts +2 -2
  49. package/dist/services/file.d.ts +22 -5
  50. package/dist/services/file.d.ts.map +1 -1
  51. package/dist/services/file.js +81 -45
  52. package/dist/services/file.js.map +1 -1
  53. package/dist/services/folder.d.ts +97 -2
  54. package/dist/services/folder.d.ts.map +1 -1
  55. package/dist/services/folder.js +158 -87
  56. package/dist/services/folder.js.map +1 -1
  57. package/dist/services/labChat.d.ts +2 -2
  58. package/dist/services/permissions.d.ts +188 -0
  59. package/dist/services/permissions.d.ts.map +1 -0
  60. package/dist/services/permissions.js +661 -0
  61. package/dist/services/permissions.js.map +1 -0
  62. package/dist/services/studentProgress.d.ts +94 -0
  63. package/dist/services/studentProgress.d.ts.map +1 -1
  64. package/dist/services/studentProgress.js +94 -3
  65. package/dist/services/studentProgress.js.map +1 -1
  66. package/package.json +1 -1
  67. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
  68. package/prisma/schema.prisma +74 -0
  69. package/src/middleware/security.ts +1 -1
  70. package/src/models/cloudStorage.ts +134 -1
  71. package/src/models/file.ts +2 -37
  72. package/src/routers/_app.ts +2 -0
  73. package/src/routers/folder.ts +32 -2
  74. package/src/routers/permissions.ts +71 -0
  75. package/src/routers/studentProgress.ts +21 -0
  76. package/src/services/class.ts +1 -1
  77. package/src/services/file.ts +76 -47
  78. package/src/services/folder.ts +180 -91
  79. package/src/services/permissions.ts +778 -0
  80. package/src/services/studentProgress.ts +197 -11
  81. package/tests/unit/services/permissions.test.ts +463 -0
  82. package/tests/unit/services/studentProgress.test.ts +168 -2
@@ -0,0 +1,463 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { TRPCError } from "@trpc/server";
3
+
4
+ const { prismaMocks, resolveFolderScope } = vi.hoisted(() => ({
5
+ prismaMocks: {
6
+ user: { findUnique: vi.fn() },
7
+ class: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() },
8
+ department: { findMany: vi.fn(), findUnique: vi.fn() },
9
+ schoolMembership: { findMany: vi.fn(), findFirst: vi.fn() },
10
+ folder: { findUnique: vi.fn() },
11
+ file: { findUnique: vi.fn() },
12
+ tag: { findUnique: vi.fn() },
13
+ permission: {
14
+ findFirst: vi.fn(),
15
+ findMany: vi.fn(),
16
+ findUnique: vi.fn(),
17
+ create: vi.fn(),
18
+ update: vi.fn(),
19
+ delete: vi.fn(),
20
+ },
21
+ },
22
+ resolveFolderScope: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("../../../src/lib/prisma.js", () => ({
26
+ prisma: prismaMocks,
27
+ }));
28
+
29
+ vi.mock("../../../src/models/cloudStorage.js", () => ({
30
+ resolveFolderScope,
31
+ }));
32
+
33
+ import {
34
+ expandUserPrincipals,
35
+ getScopeDefault,
36
+ grantPermission,
37
+ listSharedWithMe,
38
+ requireFileAccess,
39
+ requireFolderAccess,
40
+ } from "../../../src/services/permissions.js";
41
+
42
+ function setNoMemberships() {
43
+ prismaMocks.user.findUnique.mockResolvedValue({ tags: [] });
44
+ prismaMocks.class.findMany.mockResolvedValue([]);
45
+ prismaMocks.department.findMany.mockResolvedValue([]);
46
+ prismaMocks.schoolMembership.findMany.mockResolvedValue([]);
47
+ }
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ prismaMocks.permission.findFirst.mockResolvedValue(null);
52
+ prismaMocks.permission.findMany.mockResolvedValue([]);
53
+ });
54
+
55
+ describe("expandUserPrincipals", () => {
56
+ test("collects tags, classes (split by role), departments, and active school memberships", async () => {
57
+ prismaMocks.user.findUnique.mockResolvedValue({
58
+ tags: [{ id: "tag-a" }, { id: "tag-b" }],
59
+ });
60
+ prismaMocks.class.findMany
61
+ .mockResolvedValueOnce([{ id: "class-teach" }])
62
+ .mockResolvedValueOnce([
63
+ { id: "class-stud-1" },
64
+ { id: "class-stud-2" },
65
+ ]);
66
+ prismaMocks.department.findMany.mockResolvedValue([{ id: "dep-1" }]);
67
+ prismaMocks.schoolMembership.findMany.mockResolvedValue([
68
+ { schoolId: "school-1" },
69
+ ]);
70
+
71
+ const principals = await expandUserPrincipals("user-1");
72
+
73
+ expect(principals.tagIds).toEqual(["tag-a", "tag-b"]);
74
+ expect(principals.classTeacherIds).toEqual(["class-teach"]);
75
+ expect(principals.classStudentIds).toEqual(["class-stud-1", "class-stud-2"]);
76
+ expect(principals.classIds.sort()).toEqual(
77
+ ["class-teach", "class-stud-1", "class-stud-2"].sort()
78
+ );
79
+ expect(principals.departmentIds).toEqual(["dep-1"]);
80
+ expect(principals.schoolIds).toEqual(["school-1"]);
81
+ });
82
+
83
+ test("returns empty arrays when the user has no memberships", async () => {
84
+ setNoMemberships();
85
+ const principals = await expandUserPrincipals("user-1");
86
+ expect(principals.tagIds).toEqual([]);
87
+ expect(principals.classIds).toEqual([]);
88
+ expect(principals.departmentIds).toEqual([]);
89
+ expect(principals.schoolIds).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe("getScopeDefault", () => {
94
+ test("class teacher gets WRITE, student gets READ, outsider gets null", async () => {
95
+ prismaMocks.class.findFirst
96
+ .mockResolvedValueOnce({
97
+ id: "class-1",
98
+ schoolId: null,
99
+ teachers: [{ id: "user-1" }],
100
+ students: [],
101
+ })
102
+ .mockResolvedValueOnce({
103
+ id: "class-1",
104
+ schoolId: null,
105
+ teachers: [],
106
+ students: [{ id: "user-2" }],
107
+ })
108
+ .mockResolvedValueOnce({
109
+ id: "class-1",
110
+ schoolId: null,
111
+ teachers: [],
112
+ students: [],
113
+ });
114
+
115
+ expect(
116
+ await getScopeDefault("user-1", { kind: "CLASS", classId: "class-1" })
117
+ ).toBe("WRITE");
118
+ expect(
119
+ await getScopeDefault("user-2", { kind: "CLASS", classId: "class-1" })
120
+ ).toBe("READ");
121
+ expect(
122
+ await getScopeDefault("user-3", { kind: "CLASS", classId: "class-1" })
123
+ ).toBeNull();
124
+ });
125
+
126
+ test("school admin gets WRITE on class even without being teacher/student", async () => {
127
+ prismaMocks.class.findFirst.mockResolvedValueOnce({
128
+ id: "class-1",
129
+ schoolId: "school-1",
130
+ teachers: [],
131
+ students: [],
132
+ });
133
+ prismaMocks.schoolMembership.findFirst.mockResolvedValueOnce({
134
+ id: "membership-1",
135
+ });
136
+
137
+ expect(
138
+ await getScopeDefault("admin-1", { kind: "CLASS", classId: "class-1" })
139
+ ).toBe("WRITE");
140
+ });
141
+
142
+ test("school storage: ADMIN/TEACHER write, STUDENT reads, non-member denied", async () => {
143
+ prismaMocks.schoolMembership.findFirst
144
+ .mockResolvedValueOnce({ role: "TEACHER" })
145
+ .mockResolvedValueOnce({ role: "STUDENT" })
146
+ .mockResolvedValueOnce(null);
147
+
148
+ expect(
149
+ await getScopeDefault("u", {
150
+ kind: "SCHOOL_STORAGE",
151
+ schoolId: "school-1",
152
+ })
153
+ ).toBe("WRITE");
154
+ expect(
155
+ await getScopeDefault("u", {
156
+ kind: "SCHOOL_STORAGE",
157
+ schoolId: "school-1",
158
+ })
159
+ ).toBe("READ");
160
+ expect(
161
+ await getScopeDefault("u", {
162
+ kind: "SCHOOL_STORAGE",
163
+ schoolId: "school-1",
164
+ })
165
+ ).toBeNull();
166
+ });
167
+
168
+ test("personal scope only the owner has WRITE", async () => {
169
+ expect(
170
+ await getScopeDefault("user-1", { kind: "PERSONAL", userId: "user-1" })
171
+ ).toBe("WRITE");
172
+ expect(
173
+ await getScopeDefault("user-2", { kind: "PERSONAL", userId: "user-1" })
174
+ ).toBeNull();
175
+ });
176
+ });
177
+
178
+ describe("requireFolderAccess", () => {
179
+ beforeEach(() => {
180
+ setNoMemberships();
181
+ });
182
+
183
+ test("scope default grants access without consulting permissions", async () => {
184
+ resolveFolderScope.mockResolvedValue({
185
+ kind: "PERSONAL",
186
+ userId: "user-1",
187
+ });
188
+
189
+ const result = await requireFolderAccess("user-1", "folder-1", "READ");
190
+ expect(result.scopeDefault).toBe("WRITE");
191
+ expect(result.viaGrant).toBe(false);
192
+ expect(prismaMocks.permission.findFirst).not.toHaveBeenCalled();
193
+ });
194
+
195
+ test("ancestor inheritance: grant on parent folder lets a non-member access a child", async () => {
196
+ // user is in NO scope (PERSONAL of someone else => null default)
197
+ resolveFolderScope.mockResolvedValue({
198
+ kind: "PERSONAL",
199
+ userId: "owner-x",
200
+ });
201
+ // child folder has parent
202
+ prismaMocks.folder.findUnique
203
+ .mockResolvedValueOnce({ parentFolderId: "folder-parent" })
204
+ .mockResolvedValueOnce({ parentFolderId: null });
205
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({
206
+ folderId: "folder-parent",
207
+ });
208
+
209
+ const result = await requireFolderAccess("user-1", "folder-child", "READ");
210
+ expect(result.viaGrant).toBe(true);
211
+ expect(result.scopeDefault).toBeNull();
212
+ });
213
+
214
+ test("direct grant on the folder itself grants access", async () => {
215
+ resolveFolderScope.mockResolvedValue({
216
+ kind: "PERSONAL",
217
+ userId: "owner-x",
218
+ });
219
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
220
+ parentFolderId: null,
221
+ });
222
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({
223
+ folderId: "folder-1",
224
+ });
225
+
226
+ const result = await requireFolderAccess("user-1", "folder-1", "READ");
227
+ expect(result.viaGrant).toBe(true);
228
+ });
229
+
230
+ test("denies when neither scope default nor any ancestor grant matches", async () => {
231
+ resolveFolderScope.mockResolvedValue({
232
+ kind: "PERSONAL",
233
+ userId: "owner-x",
234
+ });
235
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
236
+ parentFolderId: null,
237
+ });
238
+ prismaMocks.permission.findFirst.mockResolvedValueOnce(null);
239
+
240
+ await expect(
241
+ requireFolderAccess("user-1", "folder-1", "READ")
242
+ ).rejects.toThrow(TRPCError);
243
+ });
244
+
245
+ test("WRITE denied when only a READ grant exists", async () => {
246
+ resolveFolderScope.mockResolvedValue({
247
+ kind: "PERSONAL",
248
+ userId: "owner-x",
249
+ });
250
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
251
+ parentFolderId: null,
252
+ });
253
+ // resolver query is filtered with `level: "WRITE"` so a READ grant
254
+ // simply doesn't match — returns null.
255
+ prismaMocks.permission.findFirst.mockResolvedValueOnce(null);
256
+
257
+ await expect(
258
+ requireFolderAccess("user-1", "folder-1", "WRITE")
259
+ ).rejects.toThrow(TRPCError);
260
+ });
261
+
262
+ test("Drive-style: mutatesSelf WRITE is denied via grant, allowed via scope default", async () => {
263
+ // Case A: grantee has WRITE via explicit grant, but the action targets
264
+ // the share root itself — must be rejected with the Drive-style message.
265
+ resolveFolderScope.mockResolvedValueOnce({
266
+ kind: "PERSONAL",
267
+ userId: "owner-x", // user-1 is NOT the owner -> no scope default
268
+ });
269
+
270
+ await expect(
271
+ requireFolderAccess("user-1", "folder-1", "WRITE", { mutatesSelf: true })
272
+ ).rejects.toThrow(/owner/i);
273
+ // We never even got to the grant lookup because mutatesSelf short-circuits.
274
+ expect(prismaMocks.permission.findFirst).not.toHaveBeenCalled();
275
+
276
+ // Case B: the actual scope owner can mutatesSelf without issue.
277
+ resolveFolderScope.mockResolvedValueOnce({
278
+ kind: "PERSONAL",
279
+ userId: "user-1",
280
+ });
281
+ const result = await requireFolderAccess(
282
+ "user-1",
283
+ "folder-1",
284
+ "WRITE",
285
+ { mutatesSelf: true }
286
+ );
287
+ expect(result.scopeDefault).toBe("WRITE");
288
+ expect(result.viaGrant).toBe(false);
289
+ });
290
+ });
291
+
292
+ describe("requireFileAccess", () => {
293
+ beforeEach(() => {
294
+ setNoMemberships();
295
+ });
296
+
297
+ test("delegates to folder access when the file lives in a folder", async () => {
298
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
299
+ id: "file-1",
300
+ userId: null,
301
+ folderId: "folder-x",
302
+ });
303
+ resolveFolderScope.mockResolvedValue({
304
+ kind: "PERSONAL",
305
+ userId: "user-1",
306
+ });
307
+
308
+ const result = await requireFileAccess("user-1", "file-1", "READ");
309
+ expect(result.scopeDefault).toBe("WRITE");
310
+ });
311
+
312
+ test("unfiled files fall back to owner-only access", async () => {
313
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
314
+ id: "file-1",
315
+ userId: "user-1",
316
+ folderId: null,
317
+ });
318
+
319
+ const result = await requireFileAccess("user-1", "file-1", "READ");
320
+ expect(result.scopeDefault).toBe("WRITE");
321
+ expect(result.viaGrant).toBe(false);
322
+ });
323
+
324
+ test("unfiled files with another owner deny access", async () => {
325
+ prismaMocks.file.findUnique.mockResolvedValueOnce({
326
+ id: "file-1",
327
+ userId: "owner-x",
328
+ folderId: null,
329
+ });
330
+
331
+ await expect(
332
+ requireFileAccess("user-1", "file-1", "READ")
333
+ ).rejects.toThrow(TRPCError);
334
+ });
335
+ });
336
+
337
+ describe("listSharedWithMe (topmost-grant computation)", () => {
338
+ beforeEach(() => {
339
+ setNoMemberships();
340
+ });
341
+
342
+ test("filters out grants whose ancestor is also granted (collapse to the topmost)", async () => {
343
+ prismaMocks.permission.findMany.mockResolvedValueOnce([
344
+ { id: "g-parent", folderId: "parent" },
345
+ { id: "g-child", folderId: "child" },
346
+ ]);
347
+
348
+ // Ancestor walks: child -> parent; parent -> root (no parent).
349
+ prismaMocks.folder.findUnique
350
+ // Walk for "parent" (ancestors)
351
+ .mockResolvedValueOnce({ parentFolderId: null })
352
+ // Folder details fetch for "parent"
353
+ .mockResolvedValueOnce({
354
+ id: "parent",
355
+ name: "Parent",
356
+ color: null,
357
+ })
358
+ // Walk for "child" (ancestors) — first hit child, then parent
359
+ .mockResolvedValueOnce({ parentFolderId: "parent" })
360
+ .mockResolvedValueOnce({ parentFolderId: null });
361
+
362
+ resolveFolderScope.mockResolvedValue(null);
363
+
364
+ const result = await listSharedWithMe("user-1");
365
+ expect(result.folders.map((f) => f.id)).toEqual(["parent"]);
366
+ expect(result.files).toEqual([]);
367
+ });
368
+
369
+ test("filters out grants already covered by the user's scope default", async () => {
370
+ prismaMocks.permission.findMany.mockResolvedValueOnce([
371
+ { id: "g-1", folderId: "folder-1" },
372
+ ]);
373
+ prismaMocks.folder.findUnique.mockResolvedValueOnce({
374
+ parentFolderId: null,
375
+ });
376
+ resolveFolderScope.mockResolvedValue({
377
+ kind: "PERSONAL",
378
+ userId: "user-1",
379
+ });
380
+ // user-1 IS the owner -> scope default WRITE -> filtered out
381
+
382
+ const result = await listSharedWithMe("user-1");
383
+ expect(result.folders).toEqual([]);
384
+ });
385
+ });
386
+
387
+ describe("grantPermission cross-school leak prevention", () => {
388
+ beforeEach(() => {
389
+ setNoMemberships();
390
+ });
391
+
392
+ test("rejects a TAG principal whose school differs from the target's scope school", async () => {
393
+ // Granter has WRITE via scope default (class teacher in school-A).
394
+ resolveFolderScope.mockResolvedValue({
395
+ kind: "CLASS",
396
+ classId: "class-A",
397
+ });
398
+ prismaMocks.class.findFirst.mockResolvedValueOnce({
399
+ id: "class-A",
400
+ schoolId: "school-A",
401
+ teachers: [{ id: "granter-1" }],
402
+ students: [],
403
+ });
404
+ // resolveTargetSchoolId(folder) -> walks scope -> school-A
405
+ prismaMocks.class.findUnique
406
+ .mockResolvedValueOnce({ schoolId: "school-A" })
407
+ // principalSchoolId(TAG: tag-X) -> school-B
408
+ .mockResolvedValue(null);
409
+ prismaMocks.tag.findUnique.mockResolvedValueOnce({ schoolId: "school-B" });
410
+
411
+ await expect(
412
+ grantPermission("granter-1", {
413
+ folderId: "folder-1",
414
+ principal: { kind: "TAG", tagId: "tag-X" },
415
+ level: "READ",
416
+ })
417
+ ).rejects.toThrow(/different school/);
418
+ });
419
+
420
+ test("rejects sharing a personal-scope folder with a non-USER principal", async () => {
421
+ resolveFolderScope.mockResolvedValue({
422
+ kind: "PERSONAL",
423
+ userId: "granter-1",
424
+ });
425
+ // resolveTargetSchoolId(folder) -> personal scope -> null
426
+ prismaMocks.tag.findUnique.mockResolvedValueOnce({ schoolId: "school-A" });
427
+
428
+ await expect(
429
+ grantPermission("granter-1", {
430
+ folderId: "folder-1",
431
+ principal: { kind: "TAG", tagId: "tag-X" },
432
+ level: "READ",
433
+ })
434
+ ).rejects.toThrow(/specific users/i);
435
+ });
436
+
437
+ test("re-granting the same (folder, principal) updates the existing row instead of erroring", async () => {
438
+ // Granter has WRITE via personal-scope ownership.
439
+ resolveFolderScope.mockResolvedValue({
440
+ kind: "PERSONAL",
441
+ userId: "granter-1",
442
+ });
443
+ // Existing READ grant for user-X on this folder.
444
+ prismaMocks.permission.findFirst.mockResolvedValueOnce({ id: "perm-1" });
445
+ prismaMocks.permission.update.mockResolvedValueOnce({
446
+ id: "perm-1",
447
+ level: "WRITE",
448
+ });
449
+
450
+ const result = await grantPermission("granter-1", {
451
+ folderId: "folder-1",
452
+ principal: { kind: "USER", userId: "user-X" },
453
+ level: "WRITE",
454
+ });
455
+
456
+ expect(prismaMocks.permission.update).toHaveBeenCalledWith({
457
+ where: { id: "perm-1" },
458
+ data: { level: "WRITE", grantedById: "granter-1" },
459
+ });
460
+ expect(prismaMocks.permission.create).not.toHaveBeenCalled();
461
+ expect(result.level).toBe("WRITE");
462
+ });
463
+ });
@@ -31,6 +31,9 @@ import {
31
31
  chatAboutStudentProgress,
32
32
  dismissStudentRecommendation,
33
33
  getClassProgressRecommendations,
34
+ getStudentProgressFocus,
35
+ getStudentProgressForecast,
36
+ getStudentProgressInsights,
34
37
  getStudentProgressRecommendations,
35
38
  } from "../../../src/services/studentProgress.js";
36
39
 
@@ -221,7 +224,10 @@ describe("studentProgress service", () => {
221
224
  ],
222
225
  });
223
226
 
224
- const result = await getClassProgressRecommendations("teacher-1", "class-1");
227
+ const result = await getClassProgressRecommendations(
228
+ "teacher-1",
229
+ "class-1",
230
+ );
225
231
 
226
232
  expect(result.class).toMatchObject({
227
233
  id: "class-1",
@@ -297,7 +303,9 @@ describe("studentProgress service", () => {
297
303
  });
298
304
 
299
305
  expect(result.isFallback).toBe(true);
300
- expect(result.message).toContain("Student One's current overall grade is 60%.");
306
+ expect(result.message).toContain(
307
+ "Student One's current overall grade is 60%.",
308
+ );
301
309
  expect(result.message).toContain("1 assignment appears overdue.");
302
310
  expect(result.message).toContain("1 graded assignment is below 70%.");
303
311
  expect(logger.error).toHaveBeenCalled();
@@ -340,6 +348,164 @@ describe("studentProgress service", () => {
340
348
  expect(result.recommendations).toHaveLength(0);
341
349
  });
342
350
 
351
+ test("generates structured student insights from progress context", async () => {
352
+ isTeacherInClass.mockResolvedValue(true);
353
+ inference.mockResolvedValue({
354
+ summary:
355
+ "Student One is at 72% with a steady trajectory. Fractions Test is a strength, while Missing Quiz needs attention.",
356
+ strengths: [
357
+ { label: "Fraction fluency", evidence: "Fractions Test scored 84%." },
358
+ { label: "Quiz recovery", evidence: "Retake Review improved to 78%." },
359
+ ],
360
+ growth: [
361
+ { label: "Missing work", evidence: "Missing Quiz is overdue." },
362
+ { label: "Test precision", evidence: "Unit Test scored 62%." },
363
+ ],
364
+ talkingPoints: [
365
+ "What helped you on Fractions Test?",
366
+ "What blocked Missing Quiz?",
367
+ "Which review format would help this week?",
368
+ ],
369
+ });
370
+ prismaMocks.class.findUnique.mockResolvedValue({
371
+ id: "class-1",
372
+ name: "Algebra",
373
+ subject: "Math",
374
+ });
375
+ prismaMocks.user.findFirst.mockResolvedValue({
376
+ id: "student-1",
377
+ username: "student1",
378
+ profile: { displayName: "Student One" },
379
+ });
380
+ prismaMocks.submission.findMany.mockResolvedValue([
381
+ createSubmission({
382
+ id: "graded-submission",
383
+ gradeReceived: 84,
384
+ submitted: true,
385
+ returned: true,
386
+ submittedAt: daysFromNow(-4),
387
+ assignment: {
388
+ id: "graded-assignment",
389
+ title: "Fractions Test",
390
+ dueDate: daysFromNow(-5),
391
+ type: "TEST",
392
+ },
393
+ }),
394
+ ]);
395
+
396
+ const result = await getStudentProgressInsights(
397
+ "teacher-1",
398
+ "class-1",
399
+ "student-1",
400
+ );
401
+
402
+ expect(result.strengths[0]?.label).toBe("Fraction fluency");
403
+ expect(inference).toHaveBeenCalledWith(
404
+ expect.arrayContaining([
405
+ expect.objectContaining({ role: "system" }),
406
+ expect.objectContaining({
407
+ role: "user",
408
+ content: expect.stringContaining("Fractions Test"),
409
+ }),
410
+ ]),
411
+ expect.any(Object),
412
+ expect.objectContaining({
413
+ reason: "student_progress_insights",
414
+ metadata: {
415
+ viewerId: "teacher-1",
416
+ classId: "class-1",
417
+ studentId: "student-1",
418
+ },
419
+ }),
420
+ );
421
+ });
422
+
423
+ test("generates a structured student forecast", async () => {
424
+ isTeacherInClass.mockResolvedValue(false);
425
+ inference.mockResolvedValue({
426
+ forecastLow: 62,
427
+ forecastHigh: 74,
428
+ risk: "watch",
429
+ reason:
430
+ "Recent low score and one missing assignment create moderate risk.",
431
+ });
432
+ prismaMocks.class.findUnique.mockResolvedValue({
433
+ id: "class-1",
434
+ name: "Algebra",
435
+ subject: "Math",
436
+ });
437
+ prismaMocks.user.findFirst.mockResolvedValue({
438
+ id: "student-1",
439
+ username: "student1",
440
+ profile: { displayName: "Student One" },
441
+ });
442
+ prismaMocks.submission.findMany.mockResolvedValue([
443
+ createSubmission({
444
+ gradeReceived: 62,
445
+ submitted: true,
446
+ returned: true,
447
+ submittedAt: daysFromNow(-2),
448
+ assignment: { title: "Unit Test", dueDate: daysFromNow(-3) },
449
+ }),
450
+ ]);
451
+
452
+ const result = await getStudentProgressForecast(
453
+ "student-1",
454
+ "class-1",
455
+ "student-1",
456
+ );
457
+
458
+ expect(result).toMatchObject({
459
+ forecastLow: 62,
460
+ forecastHigh: 74,
461
+ risk: "watch",
462
+ });
463
+ expect(inference).toHaveBeenCalledWith(
464
+ expect.any(Array),
465
+ expect.any(Object),
466
+ expect.objectContaining({ reason: "student_progress_forecast" }),
467
+ );
468
+ });
469
+
470
+ test("generates student-facing weekly focus items", async () => {
471
+ isTeacherInClass.mockResolvedValue(false);
472
+ inference.mockResolvedValue({
473
+ focusItems: [
474
+ "Finish Missing Quiz make-up.",
475
+ "Review Unit Test mistakes.",
476
+ "Ask for help on fractions.",
477
+ ],
478
+ });
479
+ prismaMocks.class.findUnique.mockResolvedValue({
480
+ id: "class-1",
481
+ name: "Algebra",
482
+ subject: "Math",
483
+ });
484
+ prismaMocks.user.findFirst.mockResolvedValue({
485
+ id: "student-1",
486
+ username: "student1",
487
+ profile: { displayName: "Student One" },
488
+ });
489
+ prismaMocks.submission.findMany.mockResolvedValue([
490
+ createSubmission({
491
+ assignment: { title: "Missing Quiz", dueDate: daysFromNow(-2) },
492
+ }),
493
+ ]);
494
+
495
+ const result = await getStudentProgressFocus(
496
+ "student-1",
497
+ "class-1",
498
+ "student-1",
499
+ );
500
+
501
+ expect(result.focusItems).toHaveLength(3);
502
+ expect(inference).toHaveBeenCalledWith(
503
+ expect.any(Array),
504
+ expect.any(Object),
505
+ expect.objectContaining({ reason: "student_progress_focus" }),
506
+ );
507
+ });
508
+
343
509
  test("allows teachers to dismiss a recommendation", async () => {
344
510
  isTeacherInClass.mockResolvedValue(true);
345
511
  prismaMocks.submission.findFirst.mockResolvedValue({ id: "submission-1" });