@studious-lms/server 1.4.1 → 1.4.2

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 (149) hide show
  1. package/.env.example +6 -0
  2. package/.env.test.example +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +36 -50
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/config/cors.d.ts +16 -0
  7. package/dist/lib/config/cors.d.ts.map +1 -0
  8. package/dist/lib/config/cors.js +75 -0
  9. package/dist/lib/config/cors.js.map +1 -0
  10. package/dist/lib/config/env.d.ts +14 -0
  11. package/dist/lib/config/env.d.ts.map +1 -1
  12. package/dist/lib/config/env.js +9 -2
  13. package/dist/lib/config/env.js.map +1 -1
  14. package/dist/lib/prisma.d.ts +14 -2
  15. package/dist/lib/prisma.d.ts.map +1 -1
  16. package/dist/lib/prisma.js +27 -8
  17. package/dist/lib/prisma.js.map +1 -1
  18. package/dist/middleware/security.d.ts.map +1 -1
  19. package/dist/middleware/security.js +3 -3
  20. package/dist/middleware/security.js.map +1 -1
  21. package/dist/models/agenda.d.ts +16 -16
  22. package/dist/models/announcement.d.ts +59 -23
  23. package/dist/models/announcement.d.ts.map +1 -1
  24. package/dist/models/assignment.d.ts +363 -276
  25. package/dist/models/assignment.d.ts.map +1 -1
  26. package/dist/models/attendance.d.ts +63 -21
  27. package/dist/models/attendance.d.ts.map +1 -1
  28. package/dist/models/auth.d.ts +102 -18
  29. package/dist/models/auth.d.ts.map +1 -1
  30. package/dist/models/class.d.ts +112 -64
  31. package/dist/models/class.d.ts.map +1 -1
  32. package/dist/models/comment.d.ts +52 -16
  33. package/dist/models/comment.d.ts.map +1 -1
  34. package/dist/models/conversation.d.ts +46 -16
  35. package/dist/models/conversation.d.ts.map +1 -1
  36. package/dist/models/event.d.ts +107 -53
  37. package/dist/models/event.d.ts.map +1 -1
  38. package/dist/models/file.d.ts +213 -165
  39. package/dist/models/file.d.ts.map +1 -1
  40. package/dist/models/folder.d.ts +161 -77
  41. package/dist/models/folder.d.ts.map +1 -1
  42. package/dist/models/labChat.d.ts +73 -31
  43. package/dist/models/labChat.d.ts.map +1 -1
  44. package/dist/models/marketing.d.ts +25 -7
  45. package/dist/models/marketing.d.ts.map +1 -1
  46. package/dist/models/message.d.ts +31 -13
  47. package/dist/models/message.d.ts.map +1 -1
  48. package/dist/models/newtonChat.d.ts +34 -10
  49. package/dist/models/newtonChat.d.ts.map +1 -1
  50. package/dist/models/notification.d.ts +25 -7
  51. package/dist/models/notification.d.ts.map +1 -1
  52. package/dist/models/section.d.ts +71 -23
  53. package/dist/models/section.d.ts.map +1 -1
  54. package/dist/models/user.d.ts +27 -9
  55. package/dist/models/user.d.ts.map +1 -1
  56. package/dist/models/worksheet.d.ts +237 -108
  57. package/dist/models/worksheet.d.ts.map +1 -1
  58. package/dist/pipelines/aiLabChat.d.ts +22 -2
  59. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  60. package/dist/pipelines/aiLabChat.js +125 -95
  61. package/dist/pipelines/aiLabChat.js.map +1 -1
  62. package/dist/pipelines/aiLabChatContract.d.ts +22 -22
  63. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  64. package/dist/pipelines/gradeWorksheet.js +4 -4
  65. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  66. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  67. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  68. package/dist/pipelines/labChatPrompt.js +143 -69
  69. package/dist/pipelines/labChatPrompt.js.map +1 -1
  70. package/dist/routers/_app.d.ts +1439 -1223
  71. package/dist/routers/_app.d.ts.map +1 -1
  72. package/dist/routers/agenda.d.ts +16 -16
  73. package/dist/routers/announcement.d.ts +19 -19
  74. package/dist/routers/assignment.d.ts +307 -291
  75. package/dist/routers/assignment.d.ts.map +1 -1
  76. package/dist/routers/assignment.js +3 -2
  77. package/dist/routers/assignment.js.map +1 -1
  78. package/dist/routers/attendance.d.ts +7 -7
  79. package/dist/routers/auth.d.ts +1 -1
  80. package/dist/routers/class.d.ts +77 -71
  81. package/dist/routers/class.d.ts.map +1 -1
  82. package/dist/routers/comment.d.ts +6 -6
  83. package/dist/routers/conversation.d.ts +11 -11
  84. package/dist/routers/event.d.ts +35 -35
  85. package/dist/routers/file.d.ts +12 -12
  86. package/dist/routers/folder.d.ts +54 -54
  87. package/dist/routers/labChat.d.ts +12 -12
  88. package/dist/routers/marketing.d.ts +2 -2
  89. package/dist/routers/message.d.ts +2 -2
  90. package/dist/routers/newtonChat.d.ts +1 -1
  91. package/dist/routers/notifications.d.ts +4 -4
  92. package/dist/routers/section.d.ts +7 -7
  93. package/dist/routers/studentProgress.d.ts +86 -0
  94. package/dist/routers/studentProgress.d.ts.map +1 -1
  95. package/dist/routers/studentProgress.js +14 -4
  96. package/dist/routers/studentProgress.js.map +1 -1
  97. package/dist/routers/user.d.ts +1 -1
  98. package/dist/routers/worksheet.d.ts +58 -58
  99. package/dist/seedDatabase.d.ts +1 -1
  100. package/dist/services/agenda.d.ts +16 -16
  101. package/dist/services/announcement.d.ts +8 -8
  102. package/dist/services/assignment.d.ts +299 -283
  103. package/dist/services/assignment.d.ts.map +1 -1
  104. package/dist/services/assignment.js +24 -5
  105. package/dist/services/assignment.js.map +1 -1
  106. package/dist/services/attendance.d.ts +7 -7
  107. package/dist/services/auth.d.ts +1 -1
  108. package/dist/services/class.d.ts +73 -67
  109. package/dist/services/class.d.ts.map +1 -1
  110. package/dist/services/comment.d.ts +6 -6
  111. package/dist/services/conversation.d.ts +11 -11
  112. package/dist/services/event.d.ts +31 -31
  113. package/dist/services/file.d.ts +12 -12
  114. package/dist/services/folder.d.ts +52 -52
  115. package/dist/services/labChat.d.ts +12 -12
  116. package/dist/services/marketing.d.ts +2 -2
  117. package/dist/services/notification.d.ts +4 -4
  118. package/dist/services/section.d.ts +6 -6
  119. package/dist/services/studentProgress.d.ts +75 -0
  120. package/dist/services/studentProgress.d.ts.map +1 -1
  121. package/dist/services/studentProgress.js +296 -106
  122. package/dist/services/studentProgress.js.map +1 -1
  123. package/dist/services/worksheet.d.ts +49 -49
  124. package/dist/utils/inference.d.ts +0 -11
  125. package/dist/utils/inference.d.ts.map +1 -1
  126. package/dist/utils/inference.js +2 -50
  127. package/dist/utils/inference.js.map +1 -1
  128. package/package.json +1 -1
  129. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  130. package/prisma/schema.prisma +14 -0
  131. package/src/index.ts +39 -51
  132. package/src/lib/config/cors.ts +96 -0
  133. package/src/lib/config/env.ts +12 -1
  134. package/src/lib/prisma.ts +25 -6
  135. package/src/middleware/security.ts +1 -1
  136. package/src/pipelines/aiLabChat.ts +175 -104
  137. package/src/pipelines/gradeWorksheet.ts +2 -2
  138. package/src/pipelines/labChatPrompt.ts +196 -68
  139. package/src/routers/assignment.ts +1 -0
  140. package/src/routers/studentProgress.ts +25 -1
  141. package/src/services/assignment.ts +30 -2
  142. package/src/services/studentProgress.ts +421 -120
  143. package/src/utils/inference.ts +0 -61
  144. package/tests/lib/cors.test.ts +103 -0
  145. package/tests/pipelines/aiLabChat.test.ts +64 -84
  146. package/tests/routers/studentProgress.test.ts +2 -31
  147. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  148. package/tests/utils/studentProgress.test.ts +361 -0
  149. package/vitest.unit.config.ts +1 -0
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ createCorsOriginMatcher,
4
+ isValidCorsOriginPatternList,
5
+ parseCorsOriginPatterns,
6
+ parseCorsOrigins,
7
+ } from '../../src/lib/config/cors.js';
8
+
9
+ describe('CORS configuration', () => {
10
+ test('parses comma-separated origins while trimming empty entries', () => {
11
+ expect(parseCorsOrigins(' https://one.example, ,https://two.example ')).toEqual([
12
+ 'https://one.example',
13
+ 'https://two.example',
14
+ ]);
15
+ });
16
+
17
+ test('normalizes configured origin URLs before matching', () => {
18
+ const { allowedOrigins, isAllowedOrigin } = createCorsOriginMatcher({
19
+ NODE_ENV: 'production',
20
+ NEXT_PUBLIC_APP_URL: 'https://app.example/dashboard/',
21
+ CORS_ALLOWED_ORIGINS: 'https://preview.example/some/path/, https://branch.example/',
22
+ });
23
+
24
+ expect(allowedOrigins).toContain('https://app.example');
25
+ expect(allowedOrigins).toContain('https://preview.example');
26
+ expect(allowedOrigins).toContain('https://branch.example');
27
+ expect(isAllowedOrigin('https://preview.example/')).toBe(true);
28
+ expect(isAllowedOrigin('https://branch.example/path')).toBe(true);
29
+ });
30
+
31
+ test('allows exact configured origins', () => {
32
+ const { isAllowedOrigin } = createCorsOriginMatcher({
33
+ NODE_ENV: 'production',
34
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
35
+ CORS_ALLOWED_ORIGINS: 'https://preview.example,https://branch.example',
36
+ });
37
+
38
+ expect(isAllowedOrigin('https://www.studious.sh')).toBe(true);
39
+ expect(isAllowedOrigin('https://app.example')).toBe(true);
40
+ expect(isAllowedOrigin('https://preview.example')).toBe(true);
41
+ expect(isAllowedOrigin('https://branch.example')).toBe(true);
42
+ });
43
+
44
+ test('does not allow dev subdomains in production unless configured', () => {
45
+ const defaultProductionMatcher = createCorsOriginMatcher({
46
+ NODE_ENV: 'production',
47
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
48
+ });
49
+ const configuredProductionMatcher = createCorsOriginMatcher({
50
+ NODE_ENV: 'production',
51
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
52
+ CORS_ALLOWED_ORIGINS: 'https://dev.studious.sh',
53
+ });
54
+
55
+ expect(defaultProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(false);
56
+ expect(defaultProductionMatcher.isAllowedOrigin('https://www.dev.studious.sh')).toBe(false);
57
+ expect(configuredProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(true);
58
+ });
59
+
60
+ test('allows narrow configured origin patterns', () => {
61
+ const { isAllowedOrigin } = createCorsOriginMatcher({
62
+ NODE_ENV: 'production',
63
+ CORS_ALLOWED_ORIGIN_PATTERNS: '^https://studious-git-[a-z0-9-]+-studious-lms\\.vercel\\.app$',
64
+ });
65
+
66
+ expect(isAllowedOrigin('https://studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(true);
67
+ expect(isAllowedOrigin('https://malicious-studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(false);
68
+ });
69
+
70
+ test('rejects missing and unconfigured origins instead of using a fallback', () => {
71
+ const { isAllowedOrigin } = createCorsOriginMatcher({
72
+ NODE_ENV: 'production',
73
+ NEXT_PUBLIC_APP_URL: 'https://app.example',
74
+ CORS_ALLOWED_ORIGINS: 'https://preview.example',
75
+ });
76
+
77
+ expect(isAllowedOrigin()).toBe(false);
78
+ expect(isAllowedOrigin('https://evil.example')).toBe(false);
79
+ expect(isAllowedOrigin('http://localhost:3000')).toBe(false);
80
+ });
81
+
82
+ test('compiles configured regex patterns', () => {
83
+ expect(parseCorsOriginPatterns('^https://one\\.example$, ^https://two\\.example$').map((pattern) => pattern.source)).toEqual([
84
+ '^https:\\/\\/one\\.example$',
85
+ '^https:\\/\\/two\\.example$',
86
+ ]);
87
+ });
88
+
89
+ test('validates origin pattern syntax for env parsing', () => {
90
+ expect(isValidCorsOriginPatternList()).toBe(true);
91
+ expect(isValidCorsOriginPatternList('')).toBe(true);
92
+ expect(isValidCorsOriginPatternList('^https://one\\.example$')).toBe(true);
93
+ expect(isValidCorsOriginPatternList('(')).toBe(false);
94
+ });
95
+
96
+ test('rejects overly long origin patterns', () => {
97
+ expect(isValidCorsOriginPatternList(`^https://${'a'.repeat(250)}\\.example$`)).toBe(false);
98
+ });
99
+
100
+ test('rejects origin patterns with nested quantifiers', () => {
101
+ expect(isValidCorsOriginPatternList('^(a+)+$')).toBe(false);
102
+ });
103
+ });
@@ -1,95 +1,75 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
1
+ import { describe, expect, test } from "vitest";
2
2
 
3
- const prismaMock = {
4
- labChat: {
5
- findUnique: vi.fn(),
6
- },
7
- message: {
8
- update: vi.fn(),
9
- },
10
- };
3
+ import { sliceMessagesThroughAnchor } from "../../src/pipelines/aiLabChat.js";
11
4
 
12
- const pusherTriggerMock = vi.fn();
13
- const teacherChannelMock = vi.fn((classId: string) => `teacher-${classId}`);
14
- const loggerErrorMock = vi.fn();
15
- const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
5
+ describe("sliceMessagesThroughAnchor", () => {
6
+ const messages = [
7
+ {
8
+ id: "latest-teacher",
9
+ content: "Actually make it harder",
10
+ senderId: "teacher-1",
11
+ createdAt: new Date("2026-04-08T12:04:00Z"),
12
+ sender: null,
13
+ },
14
+ {
15
+ id: "anchor-first",
16
+ content: "Create a quiz",
17
+ senderId: "teacher-1",
18
+ createdAt: new Date("2026-04-08T12:03:00Z"),
19
+ sender: null,
20
+ },
21
+ {
22
+ id: "middle-ai",
23
+ content: "What topic should I cover?",
24
+ senderId: "ai-user",
25
+ createdAt: new Date("2026-04-08T12:02:00Z"),
26
+ sender: null,
27
+ },
28
+ {
29
+ id: "anchor-last",
30
+ content: "Earlier context",
31
+ senderId: "teacher-1",
32
+ createdAt: new Date("2026-04-08T12:01:00Z"),
33
+ sender: null,
34
+ },
35
+ ];
16
36
 
17
- vi.mock('../../src/lib/prisma.js', () => ({
18
- prisma: prismaMock,
19
- }));
20
-
21
- vi.mock('../../src/lib/pusher.js', () => ({
22
- pusher: {
23
- trigger: pusherTriggerMock,
24
- },
25
- teacherChannel: teacherChannelMock,
26
- }));
27
-
28
- vi.mock('../../src/utils/logger.js', () => ({
29
- logger: {
30
- error: loggerErrorMock,
31
- info: vi.fn(),
32
- },
33
- }));
34
-
35
- vi.mock('../../src/utils/aiUser.js', () => ({
36
- getAIUserId: vi.fn(),
37
- isAIUser: vi.fn(() => false),
38
- }));
39
-
40
- vi.mock('../../src/utils/inference.js', () => ({
41
- inference: vi.fn(),
42
- inferenceClient: {},
43
- sendAIMessage: vi.fn(),
44
- }));
45
-
46
- vi.mock('../../src/lib/jsonConversion.js', () => ({
47
- createPdf: vi.fn(),
48
- }));
49
-
50
- vi.mock('../../src/lib/googleCloudStorage.js', () => ({
51
- bucket: {
52
- file: vi.fn(),
53
- },
54
- }));
55
-
56
- const { generateAndSendLabResponse } = await import('../../src/pipelines/aiLabChat.js');
37
+ test("drops messages newer than the anchor message", () => {
38
+ expect(sliceMessagesThroughAnchor(messages, "anchor-first")).toEqual([
39
+ messages[1],
40
+ messages[2],
41
+ messages[3],
42
+ ]);
43
+ });
57
44
 
58
- describe('generateAndSendLabResponse', () => {
59
- beforeEach(() => {
60
- vi.clearAllMocks();
61
- consoleErrorMock.mockClear();
45
+ test("returns the first limit messages when no anchor is provided", () => {
46
+ expect(sliceMessagesThroughAnchor(messages, undefined, 2)).toEqual([
47
+ messages[0],
48
+ messages[1],
49
+ ]);
62
50
  });
63
51
 
64
- it('broadcasts lab-response-failed even if marking the message as FAILED throws', async () => {
65
- const generationError = new Error('boom');
66
- const statusError = new Error('message missing');
52
+ test("returns the first limit messages when the anchor is not found", () => {
53
+ expect(sliceMessagesThroughAnchor(messages, "missing-anchor", 2)).toEqual([
54
+ messages[0],
55
+ messages[1],
56
+ ]);
57
+ });
67
58
 
68
- prismaMock.labChat.findUnique.mockRejectedValueOnce(generationError);
69
- prismaMock.message.update.mockRejectedValueOnce(statusError);
70
- pusherTriggerMock.mockResolvedValueOnce(undefined);
59
+ test("returns an empty array when given no messages", () => {
60
+ expect(sliceMessagesThroughAnchor([], "missing-anchor")).toEqual([]);
61
+ });
71
62
 
72
- await expect(
73
- generateAndSendLabResponse('lab-chat-1', 'teacher prompt', {
74
- classId: 'class-1',
75
- messageId: 'message-1',
76
- }),
77
- ).rejects.toThrow(generationError);
63
+ test("returns the anchored slice when the anchor is the first eligible message", () => {
64
+ expect(sliceMessagesThroughAnchor(messages, "anchor-first", 2)).toEqual([
65
+ messages[1],
66
+ messages[2],
67
+ ]);
68
+ });
78
69
 
79
- expect(prismaMock.message.update).toHaveBeenCalledWith({
80
- where: { id: 'message-1' },
81
- data: { status: 'FAILED' },
82
- });
83
- expect(teacherChannelMock).toHaveBeenCalledWith('class-1');
84
- expect(pusherTriggerMock).toHaveBeenCalledWith('teacher-class-1', 'lab-response-failed', {
85
- labChatId: 'lab-chat-1',
86
- messageId: 'message-1',
87
- error: 'AI response generation failed',
88
- });
89
- expect(loggerErrorMock).toHaveBeenCalledWith('Failed to set message status FAILED:', {
90
- error: statusError,
91
- labChatId: 'lab-chat-1',
92
- messageId: 'message-1',
93
- });
70
+ test("returns only the last message when the anchor is already the oldest", () => {
71
+ expect(sliceMessagesThroughAnchor(messages, "anchor-last", 3)).toEqual([
72
+ messages[3],
73
+ ]);
94
74
  });
95
75
  });
@@ -53,16 +53,6 @@ describe("Student Progress Router", () => {
53
53
  const futureDueDate = new Date();
54
54
  futureDueDate.setDate(futureDueDate.getDate() + 2);
55
55
 
56
- const futureAssignment = await user1Caller.assignment.create({
57
- classId: testClass.id,
58
- title: "Upcoming Quiz",
59
- instructions: "Prepare for the upcoming quiz.",
60
- dueDate: futureDueDate.toISOString(),
61
- maxGrade: 100,
62
- graded: true,
63
- type: "QUIZ",
64
- });
65
-
66
56
  const futureCompletedAssignment = await user1Caller.assignment.create({
67
57
  classId: testClass.id,
68
58
  title: "Completed Future Quiz",
@@ -110,17 +100,6 @@ describe("Student Progress Router", () => {
110
100
  expect(result.recommendations[0].assignmentId).toBe(assignment.id);
111
101
  expect(result.recommendations[0].reasons).toContain("Scored 50%");
112
102
 
113
- const futureRecommendation = result.recommendations.find(
114
- (recommendation) =>
115
- recommendation.assignmentId === futureAssignment.id,
116
- );
117
- expect(futureRecommendation?.reasons).toContain(
118
- "Upcoming graded assignment",
119
- );
120
- expect(futureRecommendation?.reasons).not.toContain(
121
- "Awaiting grade or feedback",
122
- );
123
-
124
103
  const completedFutureRecommendation = result.recommendations.find(
125
104
  (recommendation) =>
126
105
  recommendation.assignmentId === futureCompletedAssignment.id,
@@ -189,11 +168,7 @@ describe("Student Progress Router", () => {
189
168
  });
190
169
 
191
170
  expect(result.isFallback).toBe(true);
192
- expect(result.message).toContain("current overall progress");
193
- expect(result.message).toContain(
194
- "not enough graded work to determine a recent trend",
195
- );
196
- expect(result.message).not.toContain("missing or low-scoring");
171
+ expect(result.message).toContain("current overall grade");
197
172
  });
198
173
 
199
174
  test("returns a flagged fallback chat response for empty model output", async () => {
@@ -208,11 +183,7 @@ describe("Student Progress Router", () => {
208
183
  });
209
184
 
210
185
  expect(result.isFallback).toBe(true);
211
- expect(result.message).toContain("current overall progress");
212
- expect(result.message).toContain(
213
- "not enough graded work to determine a recent trend",
214
- );
215
- expect(result.message).not.toContain("missing or low-scoring");
186
+ expect(result.message).toContain("current overall grade");
216
187
  });
217
188
 
218
189
  test("prevents a student from chatting about another student's progress", async () => {
@@ -1,18 +1,126 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
 
3
- import { buildLabChatSystemPrompt } from "../../src/pipelines/labChatPrompt";
3
+ import {
4
+ buildLabChatResponseMessages,
5
+ buildLabChatSystemPrompt,
6
+ } from "../../src/pipelines/labChatPrompt";
7
+ import { labChatArrayFieldInstructions, labChatResponseFormat } from "../../src/pipelines/aiLabChatContract";
4
8
 
5
9
  describe("buildLabChatSystemPrompt", () => {
6
10
  test("requires arrays for worksheet and section structured output fields", () => {
7
11
  const prompt = buildLabChatSystemPrompt("Context");
8
12
 
9
- expect(prompt).toContain(
10
- '{ "text": string, "docs": null | array, "worksheetsToCreate": array, "sectionsToCreate": array, "assignmentsToCreate": null | array }',
11
- );
12
- expect(prompt).toContain('"worksheetsToCreate" must always be an array. Use [] when there are no worksheets to create.');
13
- expect(prompt).toContain('"sectionsToCreate" must always be an array. Use [] when there are no sections to create.');
13
+ expect(prompt).toContain(labChatResponseFormat);
14
+ expect(prompt).toContain(labChatArrayFieldInstructions);
14
15
  expect(prompt).toContain('Do not return null for "worksheetsToCreate" or "sectionsToCreate".');
15
16
  expect(prompt).not.toContain('"worksheetsToCreate": null | array');
16
17
  expect(prompt).not.toContain('"sectionsToCreate": null | array');
17
18
  });
18
19
  });
20
+
21
+ describe("buildLabChatResponseMessages", () => {
22
+ test("builds response messages from history without replaying the latest teacher turn", () => {
23
+ const messages = buildLabChatResponseMessages({
24
+ context: "Topic context",
25
+ classContext: "CLASS: Biology",
26
+ recentMessages: [
27
+ {
28
+ senderId: "teacher-1",
29
+ content: "Create a quiz on osmosis",
30
+ sender: {
31
+ username: "teacher",
32
+ profile: { displayName: "Ms Patel" },
33
+ },
34
+ },
35
+ {
36
+ senderId: "ai-user",
37
+ content: "I can do that.",
38
+ },
39
+ ],
40
+ isAIUser: (senderId) => senderId === "ai-user",
41
+ });
42
+
43
+ const teacherTurns = messages.filter(
44
+ (message) =>
45
+ message.role === "user" &&
46
+ typeof message.content === "string" &&
47
+ message.content.includes("Create a quiz on osmosis"),
48
+ );
49
+
50
+ expect(teacherTurns).toHaveLength(1);
51
+ expect(messages.at(-2)).toMatchObject({
52
+ role: "developer",
53
+ content: expect.stringContaining("CLASS CONTEXT"),
54
+ });
55
+ });
56
+
57
+ test("sorts timestamped history into chronological order before prompt assembly", () => {
58
+ const messages = buildLabChatResponseMessages({
59
+ context: "Topic context",
60
+ classContext: "CLASS: Biology",
61
+ recentMessages: [
62
+ {
63
+ id: "assistant-later",
64
+ senderId: "ai-user",
65
+ content: "Second response",
66
+ createdAt: new Date("2026-04-08T12:02:00Z"),
67
+ },
68
+ {
69
+ id: "teacher-earlier",
70
+ senderId: "teacher-1",
71
+ content: "First request",
72
+ createdAt: new Date("2026-04-08T12:01:00Z"),
73
+ sender: {
74
+ username: "teacher",
75
+ profile: { displayName: "Ms Patel" },
76
+ },
77
+ },
78
+ ],
79
+ isAIUser: (senderId) => senderId === "ai-user",
80
+ });
81
+
82
+ expect(messages[1]).toMatchObject({
83
+ role: "user",
84
+ content: "Ms Patel: First request",
85
+ });
86
+ expect(messages[2]).toMatchObject({
87
+ role: "assistant",
88
+ content: "Second response",
89
+ });
90
+ });
91
+
92
+ test("keeps original order among messages without timestamps", () => {
93
+ const messages = buildLabChatResponseMessages({
94
+ context: "Topic context",
95
+ classContext: "CLASS: Biology",
96
+ recentMessages: [
97
+ {
98
+ senderId: "teacher-1",
99
+ content: "First untimed request",
100
+ sender: {
101
+ username: "teacher",
102
+ profile: { displayName: "Ms Patel" },
103
+ },
104
+ },
105
+ {
106
+ senderId: "teacher-1",
107
+ content: "Second untimed request",
108
+ sender: {
109
+ username: "teacher",
110
+ profile: { displayName: "Ms Patel" },
111
+ },
112
+ },
113
+ ],
114
+ isAIUser: () => false,
115
+ });
116
+
117
+ expect(messages[1]).toMatchObject({
118
+ role: "user",
119
+ content: "Ms Patel: First untimed request",
120
+ });
121
+ expect(messages[2]).toMatchObject({
122
+ role: "user",
123
+ content: "Ms Patel: Second untimed request",
124
+ });
125
+ });
126
+ });