@studious-lms/server 1.4.2 → 1.4.3

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 (145) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +67 -28
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/fileUpload.d.ts +10 -2
  6. package/dist/lib/fileUpload.d.ts.map +1 -1
  7. package/dist/lib/fileUpload.js +38 -6
  8. package/dist/lib/fileUpload.js.map +1 -1
  9. package/dist/middleware/auth.d.ts.map +1 -1
  10. package/dist/middleware/auth.js +18 -5
  11. package/dist/middleware/auth.js.map +1 -1
  12. package/dist/models/auth.d.ts +19 -14
  13. package/dist/models/auth.d.ts.map +1 -1
  14. package/dist/models/auth.js +53 -30
  15. package/dist/models/auth.js.map +1 -1
  16. package/dist/models/class.d.ts +3 -1
  17. package/dist/models/class.d.ts.map +1 -1
  18. package/dist/models/class.js +16 -4
  19. package/dist/models/class.js.map +1 -1
  20. package/dist/models/notification.d.ts +11 -10
  21. package/dist/models/notification.d.ts.map +1 -1
  22. package/dist/models/notification.js +13 -9
  23. package/dist/models/notification.js.map +1 -1
  24. package/dist/models/worksheet.d.ts +378 -7
  25. package/dist/models/worksheet.d.ts.map +1 -1
  26. package/dist/models/worksheet.js +168 -2
  27. package/dist/models/worksheet.js.map +1 -1
  28. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  29. package/dist/pipelines/aiLabChat.js +11 -3
  30. package/dist/pipelines/aiLabChat.js.map +1 -1
  31. package/dist/pipelines/aiLabChatContract.d.ts +3 -3
  32. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  33. package/dist/pipelines/aiNewtonChat.js +25 -14
  34. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  35. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.js +10 -3
  37. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  38. package/dist/routers/_app.d.ts +124 -18
  39. package/dist/routers/_app.d.ts.map +1 -1
  40. package/dist/routers/class.d.ts +2 -1
  41. package/dist/routers/class.d.ts.map +1 -1
  42. package/dist/routers/class.js +3 -3
  43. package/dist/routers/class.js.map +1 -1
  44. package/dist/routers/labChat.d.ts +6 -0
  45. package/dist/routers/labChat.d.ts.map +1 -1
  46. package/dist/routers/labChat.js +5 -3
  47. package/dist/routers/labChat.js.map +1 -1
  48. package/dist/routers/message.d.ts +31 -0
  49. package/dist/routers/message.d.ts.map +1 -1
  50. package/dist/routers/message.js +27 -4
  51. package/dist/routers/message.js.map +1 -1
  52. package/dist/routers/newtonChat.d.ts +6 -0
  53. package/dist/routers/newtonChat.d.ts.map +1 -1
  54. package/dist/routers/newtonChat.js +5 -4
  55. package/dist/routers/newtonChat.js.map +1 -1
  56. package/dist/routers/notifications.d.ts +9 -2
  57. package/dist/routers/notifications.d.ts.map +1 -1
  58. package/dist/routers/notifications.js +4 -4
  59. package/dist/routers/notifications.js.map +1 -1
  60. package/dist/routers/worksheet.d.ts +8 -6
  61. package/dist/routers/worksheet.d.ts.map +1 -1
  62. package/dist/routers/worksheet.js +18 -16
  63. package/dist/routers/worksheet.js.map +1 -1
  64. package/dist/services/auth.d.ts +1 -1
  65. package/dist/services/auth.d.ts.map +1 -1
  66. package/dist/services/auth.js +19 -12
  67. package/dist/services/auth.js.map +1 -1
  68. package/dist/services/class.d.ts +1 -1
  69. package/dist/services/class.d.ts.map +1 -1
  70. package/dist/services/class.js +6 -3
  71. package/dist/services/class.js.map +1 -1
  72. package/dist/services/file.d.ts.map +1 -1
  73. package/dist/services/file.js +7 -2
  74. package/dist/services/file.js.map +1 -1
  75. package/dist/services/labChat.d.ts +6 -0
  76. package/dist/services/labChat.d.ts.map +1 -1
  77. package/dist/services/labChat.js +30 -18
  78. package/dist/services/labChat.js.map +1 -1
  79. package/dist/services/message.d.ts +30 -0
  80. package/dist/services/message.d.ts.map +1 -1
  81. package/dist/services/message.js +97 -21
  82. package/dist/services/message.js.map +1 -1
  83. package/dist/services/newtonChat.d.ts +6 -1
  84. package/dist/services/newtonChat.d.ts.map +1 -1
  85. package/dist/services/newtonChat.js +36 -18
  86. package/dist/services/newtonChat.js.map +1 -1
  87. package/dist/services/notification.d.ts +12 -5
  88. package/dist/services/notification.d.ts.map +1 -1
  89. package/dist/services/notification.js +24 -9
  90. package/dist/services/notification.js.map +1 -1
  91. package/dist/services/studentProgress.d.ts.map +1 -1
  92. package/dist/services/studentProgress.js +6 -3
  93. package/dist/services/studentProgress.js.map +1 -1
  94. package/dist/services/worksheet.d.ts +15 -15
  95. package/dist/services/worksheet.d.ts.map +1 -1
  96. package/dist/services/worksheet.js +138 -54
  97. package/dist/services/worksheet.js.map +1 -1
  98. package/dist/trpc.d.ts.map +1 -1
  99. package/dist/trpc.js +11 -17
  100. package/dist/trpc.js.map +1 -1
  101. package/dist/utils/inference.d.ts +9 -1
  102. package/dist/utils/inference.d.ts.map +1 -1
  103. package/dist/utils/inference.js +138 -50
  104. package/dist/utils/inference.js.map +1 -1
  105. package/package.json +1 -1
  106. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  107. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  108. package/prisma/schema.prisma +27 -1
  109. package/scripts/test-pre-push.ts +6 -2
  110. package/src/index.ts +80 -28
  111. package/src/lib/fileUpload.ts +55 -5
  112. package/src/middleware/auth.ts +19 -4
  113. package/src/models/auth.ts +59 -29
  114. package/src/models/class.ts +15 -4
  115. package/src/models/notification.ts +13 -7
  116. package/src/models/worksheet.ts +202 -0
  117. package/src/pipelines/aiLabChat.ts +9 -1
  118. package/src/pipelines/aiNewtonChat.ts +24 -14
  119. package/src/pipelines/gradeWorksheet.ts +8 -0
  120. package/src/routers/class.ts +2 -2
  121. package/src/routers/labChat.ts +3 -1
  122. package/src/routers/message.ts +38 -1
  123. package/src/routers/newtonChat.ts +4 -2
  124. package/src/routers/notifications.ts +2 -2
  125. package/src/routers/worksheet.ts +31 -19
  126. package/src/services/auth.ts +26 -12
  127. package/src/services/class.ts +4 -1
  128. package/src/services/file.ts +6 -0
  129. package/src/services/labChat.ts +42 -18
  130. package/src/services/message.ts +163 -18
  131. package/src/services/newtonChat.ts +49 -18
  132. package/src/services/notification.ts +27 -8
  133. package/src/services/studentProgress.ts +4 -1
  134. package/src/services/worksheet.ts +191 -51
  135. package/src/trpc.ts +12 -18
  136. package/src/utils/inference.ts +209 -55
  137. package/tests/globalSetup.ts +13 -4
  138. package/tests/routers/auth.test.ts +120 -17
  139. package/tests/routers/class.test.ts +7 -1
  140. package/tests/routers/message.test.ts +236 -9
  141. package/tests/routers/newtonChat.test.ts +81 -0
  142. package/tests/routers/notification.test.ts +12 -0
  143. package/tests/routers/worksheet.test.ts +194 -0
  144. package/tests/setup.ts +22 -5
  145. package/tests/utils/inference.test.ts +218 -0
@@ -1,5 +1,36 @@
1
1
  import { test, expect, describe, beforeAll } from 'vitest';
2
2
  import { user1Caller, user2Caller, user3Caller } from '../setup';
3
+ import { prisma } from '../../src/lib/prisma';
4
+
5
+ const expectTRPCError = async (promise: Promise<unknown>, code: string) => {
6
+ await expect(promise).rejects.toMatchObject({ code });
7
+ };
8
+
9
+ const createAttachmentViaUpload = async (
10
+ caller: typeof user1Caller,
11
+ conversationId: string,
12
+ file: {
13
+ name: string;
14
+ type: string;
15
+ size: number;
16
+ },
17
+ ) => {
18
+ const [uploadTarget] = await caller.message.getUploadUrls({
19
+ conversationId,
20
+ files: [file],
21
+ });
22
+
23
+ await prisma.file.update({
24
+ where: { id: uploadTarget!.id },
25
+ data: {
26
+ uploadStatus: 'COMPLETED',
27
+ uploadedAt: new Date(),
28
+ uploadProgress: 100,
29
+ },
30
+ });
31
+
32
+ return uploadTarget!;
33
+ };
3
34
 
4
35
  describe('Message Router', () => {
5
36
  let conversationId: string;
@@ -46,28 +77,222 @@ describe('Message Router', () => {
46
77
  });
47
78
 
48
79
  test('should fail for non-member', async () => {
49
- await expect(
80
+ await expectTRPCError(
50
81
  user3Caller.message.send({
51
82
  conversationId,
52
83
  content: 'Unauthorized message',
53
84
  }),
54
- ).rejects.toThrow();
85
+ 'FORBIDDEN',
86
+ );
87
+ });
88
+
89
+ test('should create chat upload targets for conversation members', async () => {
90
+ const uploadTargets = await user1Caller.message.getUploadUrls({
91
+ conversationId,
92
+ files: [
93
+ {
94
+ name: 'chat-upload.pdf',
95
+ type: 'application/pdf',
96
+ size: 2048,
97
+ },
98
+ ],
99
+ });
100
+
101
+ expect(uploadTargets).toHaveLength(1);
102
+ expect(uploadTargets[0]?.id).toBeTruthy();
103
+ expect(uploadTargets[0]?.uploadSessionId).toBeTruthy();
104
+ expect(uploadTargets[0]?.uploadExpiresAt).toBeInstanceOf(Date);
105
+ expect(uploadTargets[0]!.uploadExpiresAt.getTime()).toBeGreaterThan(Date.now());
106
+ expect(uploadTargets[0]?.uploadUrl).toContain('/api/upload/');
107
+ expect(uploadTargets[0]?.uploadUrl).toContain('uploadSessionId=');
108
+ });
109
+
110
+ test('should reject upload targets for non-members', async () => {
111
+ await expectTRPCError(
112
+ user3Caller.message.getUploadUrls({
113
+ conversationId,
114
+ files: [
115
+ {
116
+ name: 'unauthorized-chat-upload.pdf',
117
+ type: 'application/pdf',
118
+ size: 2048,
119
+ },
120
+ ],
121
+ }),
122
+ 'FORBIDDEN',
123
+ );
124
+ });
125
+
126
+ test('should send a message with uploaded attachments', async () => {
127
+ const attachment = await createAttachmentViaUpload(user1Caller, conversationId, {
128
+ name: 'lesson-notes.pdf',
129
+ type: 'application/pdf',
130
+ size: 1024,
131
+ });
132
+
133
+ const result = await user1Caller.message.send({
134
+ conversationId,
135
+ content: 'Please review the attached file',
136
+ fileIds: [attachment.id],
137
+ });
138
+
139
+ expect(result.attachments).toHaveLength(1);
140
+ expect(result.attachments[0]?.id).toBe(attachment.id);
141
+ });
142
+
143
+ test('should reject reusing an already attached file', async () => {
144
+ const attachment = await createAttachmentViaUpload(user1Caller, conversationId, {
145
+ name: 'single-use-attachment.pdf',
146
+ type: 'application/pdf',
147
+ size: 1024,
148
+ });
149
+
150
+ await user1Caller.message.send({
151
+ conversationId,
152
+ content: 'First use of attachment',
153
+ fileIds: [attachment.id],
154
+ });
155
+
156
+ await expectTRPCError(
157
+ user1Caller.message.send({
158
+ conversationId,
159
+ content: 'Second use of attachment',
160
+ fileIds: [attachment.id],
161
+ }),
162
+ 'BAD_REQUEST',
163
+ );
164
+ });
165
+
166
+ test('should allow attachment-only messages', async () => {
167
+ const attachment = await createAttachmentViaUpload(user1Caller, conversationId, {
168
+ name: 'blank-message-upload.png',
169
+ type: 'image/png',
170
+ size: 512,
171
+ });
172
+
173
+ const result = await user1Caller.message.send({
174
+ conversationId,
175
+ content: '',
176
+ fileIds: [attachment.id],
177
+ });
178
+
179
+ expect(result.content).toBe('');
180
+ expect(result.attachments[0]?.id).toBe(attachment.id);
181
+ });
182
+
183
+ test('should reject files uploaded by another user', async () => {
184
+ const attachment = await createAttachmentViaUpload(user2Caller, conversationId, {
185
+ name: 'other-user.docx',
186
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
187
+ size: 1500,
188
+ });
189
+
190
+ await expectTRPCError(
191
+ user1Caller.message.send({
192
+ conversationId,
193
+ content: 'Trying to steal an attachment',
194
+ fileIds: [attachment.id],
195
+ }),
196
+ 'BAD_REQUEST',
197
+ );
198
+ });
199
+ });
200
+
201
+ describe('confirmUpload', () => {
202
+ test('should reject upload confirmation for non-members', async () => {
203
+ const [uploadTarget] = await user1Caller.message.getUploadUrls({
204
+ conversationId,
205
+ files: [
206
+ {
207
+ name: 'confirm-upload-non-member.pdf',
208
+ type: 'application/pdf',
209
+ size: 512,
210
+ },
211
+ ],
212
+ });
213
+
214
+ await expectTRPCError(
215
+ user3Caller.message.confirmUpload({
216
+ conversationId,
217
+ fileId: uploadTarget!.id,
218
+ uploadSuccess: false,
219
+ errorMessage: 'Unauthorized confirmation attempt',
220
+ }),
221
+ 'FORBIDDEN',
222
+ );
223
+ });
224
+
225
+ test('should mark uploads as failed when confirmation reports failure', async () => {
226
+ const [uploadTarget] = await user1Caller.message.getUploadUrls({
227
+ conversationId,
228
+ files: [
229
+ {
230
+ name: 'confirm-upload-failure.pdf',
231
+ type: 'application/pdf',
232
+ size: 1024,
233
+ },
234
+ ],
235
+ });
236
+
237
+ const result = await user1Caller.message.confirmUpload({
238
+ conversationId,
239
+ fileId: uploadTarget!.id,
240
+ uploadSuccess: false,
241
+ errorMessage: 'Simulated upload failure',
242
+ });
243
+
244
+ expect(result.success).toBe(true);
245
+ expect(result.message).toBe('Upload failed');
246
+
247
+ const fileRecord = await prisma.file.findUnique({
248
+ where: { id: uploadTarget!.id },
249
+ select: {
250
+ uploadStatus: true,
251
+ uploadError: true,
252
+ uploadProgress: true,
253
+ uploadedAt: true,
254
+ },
255
+ });
256
+
257
+ expect(fileRecord?.uploadStatus).toBe('FAILED');
258
+ expect(fileRecord?.uploadError).toBe('Simulated upload failure');
259
+ expect(fileRecord?.uploadProgress).toBe(0);
260
+ expect(fileRecord?.uploadedAt).toBeNull();
55
261
  });
56
262
  });
57
263
 
58
264
  describe('list', () => {
59
265
  test('should list messages in conversation', async () => {
266
+ const attachment = await createAttachmentViaUpload(user1Caller, conversationId, {
267
+ name: 'list-message-attachment.pdf',
268
+ type: 'application/pdf',
269
+ size: 768,
270
+ });
271
+
272
+ const seededMessage = await user1Caller.message.send({
273
+ conversationId,
274
+ content: 'Seeded list message',
275
+ fileIds: [attachment.id],
276
+ });
277
+
60
278
  const result = await user1Caller.message.list({ conversationId });
279
+ const listedMessage = result.messages.find(
280
+ (message) => message.id === seededMessage.id,
281
+ );
61
282
 
62
283
  expect(result.messages).toBeDefined();
63
284
  expect(Array.isArray(result.messages)).toBe(true);
64
- expect(result.messages.length).toBeGreaterThanOrEqual(2);
285
+ expect(listedMessage).toBeDefined();
286
+ expect(listedMessage?.content).toBe('Seeded list message');
287
+ expect(listedMessage?.attachments).toHaveLength(1);
288
+ expect(listedMessage?.attachments[0]?.id).toBe(attachment.id);
65
289
  });
66
290
 
67
291
  test('should fail for non-member', async () => {
68
- await expect(
292
+ await expectTRPCError(
69
293
  user3Caller.message.list({ conversationId }),
70
- ).rejects.toThrow();
294
+ 'FORBIDDEN',
295
+ );
71
296
  });
72
297
  });
73
298
 
@@ -82,12 +307,13 @@ describe('Message Router', () => {
82
307
  });
83
308
 
84
309
  test('should fail to update another user\'s message', async () => {
85
- await expect(
310
+ await expectTRPCError(
86
311
  user2Caller.message.update({
87
312
  messageId,
88
313
  content: 'Unauthorized edit',
89
314
  }),
90
- ).rejects.toThrow();
315
+ 'FORBIDDEN',
316
+ );
91
317
  });
92
318
  });
93
319
 
@@ -110,9 +336,10 @@ describe('Message Router', () => {
110
336
 
111
337
  describe('delete', () => {
112
338
  test('should fail to delete another user\'s message', async () => {
113
- await expect(
339
+ await expectTRPCError(
114
340
  user2Caller.message.delete({ messageId }),
115
- ).rejects.toThrow();
341
+ 'FORBIDDEN',
342
+ );
116
343
  });
117
344
 
118
345
  test('should delete own message', async () => {
@@ -0,0 +1,81 @@
1
+ import { beforeAll, describe, expect, test } from 'vitest';
2
+ import { prisma } from '../../src/lib/prisma';
3
+ import { user1Caller, user2Caller } from '../setup';
4
+
5
+ describe('Newton Chat Router', () => {
6
+ let testClass: any;
7
+ let assignment: any;
8
+ let submission: any;
9
+ let newtonChatId: string;
10
+ let conversationId: string;
11
+ let user2Id: string;
12
+
13
+ beforeAll(async () => {
14
+ testClass = await user1Caller.class.create({
15
+ name: 'Newton Chat Test Class',
16
+ subject: 'Physics',
17
+ section: '10th Grade',
18
+ });
19
+
20
+ const user2Profile = await user2Caller.user.getProfile();
21
+ user2Id = user2Profile.id;
22
+
23
+ await user1Caller.class.addStudent({
24
+ classId: testClass.id,
25
+ studentId: user2Id,
26
+ });
27
+
28
+ const dueDate = new Date();
29
+ dueDate.setDate(dueDate.getDate() + 7);
30
+
31
+ assignment = await user1Caller.assignment.create({
32
+ classId: testClass.id,
33
+ title: 'Newton Chat Assignment',
34
+ instructions: 'Ask Newton for help with this assignment.',
35
+ dueDate: dueDate.toISOString(),
36
+ type: 'HOMEWORK',
37
+ });
38
+
39
+ submission = await user2Caller.assignment.getSubmission({
40
+ classId: testClass.id,
41
+ assignmentId: assignment.id,
42
+ });
43
+
44
+ const tutorConversation = await user2Caller.newtonChat.getTutorConversation({
45
+ assignmentId: assignment.id,
46
+ classId: testClass.id,
47
+ });
48
+
49
+ newtonChatId = tutorConversation.newtonChatId;
50
+ conversationId = tutorConversation.conversationId;
51
+ });
52
+
53
+ test('rejects attachment-only Newton chat posts', async () => {
54
+ const [uploadTarget] = await user2Caller.message.getUploadUrls({
55
+ conversationId,
56
+ files: [
57
+ {
58
+ name: 'newton-attachment-only.pdf',
59
+ type: 'application/pdf',
60
+ size: 1024,
61
+ },
62
+ ],
63
+ });
64
+
65
+ await prisma.file.update({
66
+ where: { id: uploadTarget!.id },
67
+ data: {
68
+ uploadStatus: 'COMPLETED',
69
+ uploadedAt: new Date(),
70
+ },
71
+ });
72
+
73
+ await expect(
74
+ user2Caller.newtonChat.postToNewtonChat({
75
+ newtonChatId,
76
+ content: ' ',
77
+ fileIds: [uploadTarget!.id],
78
+ }),
79
+ ).rejects.toThrow();
80
+ });
81
+ });
@@ -40,6 +40,18 @@ describe('Notification Router', () => {
40
40
  expect(notification!.title).toBe('Test Notification');
41
41
  });
42
42
 
43
+ test('should not get another user\'s notification', async () => {
44
+ await expect(
45
+ user1Caller.notification.get({ id: notificationId })
46
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
47
+ });
48
+
49
+ test('should not mark another user\'s notification as read', async () => {
50
+ await expect(
51
+ user1Caller.notification.markAsRead({ id: notificationId })
52
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
53
+ });
54
+
43
55
  test('should mark notification as read', async () => {
44
56
  const notification = await user2Caller.notification.markAsRead({ id: notificationId });
45
57
 
@@ -0,0 +1,194 @@
1
+ import { beforeEach, describe, expect, test } from "vitest";
2
+ import { user1Caller, user2Caller, user3Caller } from "../setup";
3
+
4
+ const expectTRPCError = async (promise: Promise<unknown>, code: string) => {
5
+ await expect(promise).rejects.toMatchObject({ code });
6
+ };
7
+
8
+ describe("Worksheet Router Security", () => {
9
+ let testClass: any;
10
+ let worksheet: any;
11
+ let question: any;
12
+ let multipleChoiceQuestion: any;
13
+ let submission: any;
14
+ let worksheetResponse: any;
15
+ let progressId: string;
16
+
17
+ beforeEach(async () => {
18
+ testClass = await user1Caller.class.create({
19
+ name: `Worksheet Security ${Date.now()}`,
20
+ subject: "Physics",
21
+ section: "12th Grade",
22
+ });
23
+
24
+ const user2Profile = await user2Caller.user.getProfile();
25
+ await user1Caller.class.addStudent({
26
+ classId: testClass.id,
27
+ studentId: user2Profile.id,
28
+ });
29
+
30
+ worksheet = await user1Caller.worksheet.create({
31
+ classId: testClass.id,
32
+ name: "Security Worksheet",
33
+ });
34
+
35
+ question = await user1Caller.worksheet.addQuestion({
36
+ worksheetId: worksheet.id,
37
+ question: "What is force?",
38
+ answer: "Mass times acceleration",
39
+ points: 5,
40
+ type: "SHORT_ANSWER",
41
+ });
42
+
43
+ multipleChoiceQuestion = await user1Caller.worksheet.addQuestion({
44
+ worksheetId: worksheet.id,
45
+ question: "What is the SI unit of force?",
46
+ answer: "newton",
47
+ points: 1,
48
+ type: "MULTIPLE_CHOICE",
49
+ options: [
50
+ { id: "a", text: "Joule", isCorrect: false },
51
+ { id: "b", text: "Newton", isCorrect: true },
52
+ ],
53
+ });
54
+
55
+ const dueDate = new Date();
56
+ dueDate.setDate(dueDate.getDate() + 7);
57
+
58
+ const assignment = await user1Caller.assignment.create({
59
+ classId: testClass.id,
60
+ title: "Worksheet Assignment",
61
+ instructions: "Complete the worksheet.",
62
+ dueDate: dueDate.toISOString(),
63
+ acceptWorksheet: true,
64
+ gradeWithAI: true,
65
+ worksheetIds: [worksheet.id],
66
+ type: "HOMEWORK",
67
+ });
68
+
69
+ submission = await user2Caller.assignment.getSubmission({
70
+ classId: testClass.id,
71
+ assignmentId: assignment.id,
72
+ });
73
+
74
+ worksheetResponse = await user2Caller.worksheet.getWorksheetSubmission({
75
+ worksheetId: worksheet.id,
76
+ submissionId: submission.id,
77
+ });
78
+
79
+ const answered = await user2Caller.worksheet.answerQuestion({
80
+ worksheetResponseId: worksheetResponse.id,
81
+ questionId: question.id,
82
+ response: "Mass times acceleration",
83
+ });
84
+
85
+ progressId = answered.responses[0].id;
86
+ });
87
+
88
+ test("redacts worksheet answers and correctness metadata for students", async () => {
89
+ const studentWorksheet = await user2Caller.worksheet.getWorksheet({
90
+ worksheetId: worksheet.id,
91
+ });
92
+
93
+ const shortAnswerQuestion = studentWorksheet.questions.find(
94
+ (item: any) => item.id === question.id,
95
+ );
96
+ expect(shortAnswerQuestion.answer).toBe("");
97
+ expect(shortAnswerQuestion.markScheme).toBeNull();
98
+
99
+ const studentMcq = studentWorksheet.questions.find(
100
+ (item: any) => item.id === multipleChoiceQuestion.id,
101
+ );
102
+ expect(studentMcq.answer).toBe("");
103
+ expect(studentMcq.options).toEqual([
104
+ { id: "a", text: "Joule" },
105
+ { id: "b", text: "Newton" },
106
+ ]);
107
+ });
108
+
109
+ test("blocks cross-class worksheet reads by id", async () => {
110
+ await expectTRPCError(
111
+ user3Caller.worksheet.getWorksheet({ worksheetId: worksheet.id }),
112
+ "FORBIDDEN",
113
+ );
114
+ });
115
+
116
+ test("blocks cross-class worksheet submission reads by id", async () => {
117
+ await expectTRPCError(
118
+ user3Caller.worksheet.getWorksheetSubmission({
119
+ worksheetId: worksheet.id,
120
+ submissionId: submission.id,
121
+ }),
122
+ "FORBIDDEN",
123
+ );
124
+ });
125
+
126
+ test("blocks cross-class worksheet answer mutations", async () => {
127
+ await expectTRPCError(
128
+ user3Caller.worksheet.answerQuestion({
129
+ worksheetResponseId: worksheetResponse.id,
130
+ questionId: question.id,
131
+ response: "Unauthorized answer",
132
+ }),
133
+ "FORBIDDEN",
134
+ );
135
+ });
136
+
137
+ test("blocks students from teacher worksheet mutations", async () => {
138
+ await expectTRPCError(
139
+ user2Caller.worksheet.updateWorksheet({
140
+ worksheetId: worksheet.id,
141
+ name: "Student update",
142
+ }),
143
+ "FORBIDDEN",
144
+ );
145
+
146
+ await expectTRPCError(
147
+ user2Caller.worksheet.addQuestion({
148
+ worksheetId: worksheet.id,
149
+ question: "Student-created question",
150
+ answer: "Nope",
151
+ type: "SHORT_ANSWER",
152
+ }),
153
+ "FORBIDDEN",
154
+ );
155
+ });
156
+
157
+ test("blocks students from grading and feedback actions", async () => {
158
+ await expectTRPCError(
159
+ user2Caller.worksheet.gradeAnswer({
160
+ questionId: question.id,
161
+ responseId: progressId,
162
+ studentWorksheetResponseId: worksheetResponse.id,
163
+ isCorrect: true,
164
+ feedback: "Nice work",
165
+ points: 5,
166
+ }),
167
+ "FORBIDDEN",
168
+ );
169
+
170
+ await expectTRPCError(
171
+ user2Caller.worksheet.addComment({
172
+ responseId: progressId,
173
+ comment: "Teacher feedback",
174
+ }),
175
+ "FORBIDDEN",
176
+ );
177
+
178
+ await expectTRPCError(
179
+ user2Caller.worksheet.cancelGrading({
180
+ worksheetResponseId: worksheetResponse.id,
181
+ progressId,
182
+ }),
183
+ "FORBIDDEN",
184
+ );
185
+
186
+ await expectTRPCError(
187
+ user2Caller.worksheet.regradeQuestion({
188
+ worksheetResponseId: worksheetResponse.id,
189
+ progressId,
190
+ }),
191
+ "FORBIDDEN",
192
+ );
193
+ });
194
+ });
package/tests/setup.ts CHANGED
@@ -6,7 +6,7 @@ import { beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
6
6
  import { logger } from '../src/utils/logger';
7
7
  import { appRouter } from '../src/routers/_app';
8
8
  import { createTRPCContext } from '../src/trpc';
9
- import { Session } from '@prisma/client';
9
+ import { Session, SessionType } from '@prisma/client';
10
10
 
11
11
  // Load test environment variables
12
12
  config({ path: resolve(process.cwd(), '.env.test') });
@@ -35,9 +35,26 @@ beforeAll(async () => {
35
35
  throw new Error('Failed to get login tokens — ensure globalSetup ran');
36
36
  }
37
37
 
38
- session1 = await prisma.session.findFirst({ where: { userId: login1.user.id } }) as Session;
39
- session2 = await prisma.session.findFirst({ where: { userId: login2.user.id } }) as Session;
40
- session3 = await prisma.session.findFirst({ where: { userId: login3.user.id } }) as Session;
38
+ const authSession1 = await prisma.session.findFirst({
39
+ where: { userId: login1.user.id, type: SessionType.AUTH },
40
+ orderBy: { createdAt: 'desc' },
41
+ });
42
+ const authSession2 = await prisma.session.findFirst({
43
+ where: { userId: login2.user.id, type: SessionType.AUTH },
44
+ orderBy: { createdAt: 'desc' },
45
+ });
46
+ const authSession3 = await prisma.session.findFirst({
47
+ where: { userId: login3.user.id, type: SessionType.AUTH },
48
+ orderBy: { createdAt: 'desc' },
49
+ });
50
+
51
+ if (!authSession1 || !authSession2 || !authSession3) {
52
+ throw new Error('Failed to retrieve AUTH sessions for test users');
53
+ }
54
+
55
+ session1 = authSession1;
56
+ session2 = authSession2;
57
+ session3 = authSession3;
41
58
 
42
59
  verification1 = { verified: true };
43
60
  verification2 = { verified: true };
@@ -69,4 +86,4 @@ export let verification2: any;
69
86
  export let verification3: any;
70
87
  export let login1: any;
71
88
  export let login2: any;
72
- export let login3: any;
89
+ export let login3: any;