@studious-lms/server 1.4.2 → 1.4.4

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 (152) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.vscode/settings.json +3 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +68 -29
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/fileUpload.d.ts +10 -2
  7. package/dist/lib/fileUpload.d.ts.map +1 -1
  8. package/dist/lib/fileUpload.js +38 -6
  9. package/dist/lib/fileUpload.js.map +1 -1
  10. package/dist/middleware/auth.d.ts.map +1 -1
  11. package/dist/middleware/auth.js +18 -5
  12. package/dist/middleware/auth.js.map +1 -1
  13. package/dist/models/auth.d.ts +19 -14
  14. package/dist/models/auth.d.ts.map +1 -1
  15. package/dist/models/auth.js +53 -30
  16. package/dist/models/auth.js.map +1 -1
  17. package/dist/models/class.d.ts +3 -1
  18. package/dist/models/class.d.ts.map +1 -1
  19. package/dist/models/class.js +16 -4
  20. package/dist/models/class.js.map +1 -1
  21. package/dist/models/notification.d.ts +11 -10
  22. package/dist/models/notification.d.ts.map +1 -1
  23. package/dist/models/notification.js +13 -9
  24. package/dist/models/notification.js.map +1 -1
  25. package/dist/models/worksheet.d.ts +379 -7
  26. package/dist/models/worksheet.d.ts.map +1 -1
  27. package/dist/models/worksheet.js +168 -2
  28. package/dist/models/worksheet.js.map +1 -1
  29. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  30. package/dist/pipelines/aiLabChat.js +11 -3
  31. package/dist/pipelines/aiLabChat.js.map +1 -1
  32. package/dist/pipelines/aiLabChatContract.d.ts +3 -3
  33. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  34. package/dist/pipelines/aiNewtonChat.js +25 -14
  35. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  36. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  37. package/dist/pipelines/gradeWorksheet.js +10 -3
  38. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  39. package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
  40. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
  41. package/dist/pipelines/pdfToWorksheet.js +155 -0
  42. package/dist/pipelines/pdfToWorksheet.js.map +1 -0
  43. package/dist/routers/_app.d.ts +264 -16
  44. package/dist/routers/_app.d.ts.map +1 -1
  45. package/dist/routers/class.d.ts +2 -1
  46. package/dist/routers/class.d.ts.map +1 -1
  47. package/dist/routers/class.js +3 -3
  48. package/dist/routers/class.js.map +1 -1
  49. package/dist/routers/labChat.d.ts +6 -0
  50. package/dist/routers/labChat.d.ts.map +1 -1
  51. package/dist/routers/labChat.js +5 -3
  52. package/dist/routers/labChat.js.map +1 -1
  53. package/dist/routers/message.d.ts +31 -0
  54. package/dist/routers/message.d.ts.map +1 -1
  55. package/dist/routers/message.js +27 -4
  56. package/dist/routers/message.js.map +1 -1
  57. package/dist/routers/newtonChat.d.ts +6 -0
  58. package/dist/routers/newtonChat.d.ts.map +1 -1
  59. package/dist/routers/newtonChat.js +5 -4
  60. package/dist/routers/newtonChat.js.map +1 -1
  61. package/dist/routers/notifications.d.ts +9 -2
  62. package/dist/routers/notifications.d.ts.map +1 -1
  63. package/dist/routers/notifications.js +4 -4
  64. package/dist/routers/notifications.js.map +1 -1
  65. package/dist/routers/worksheet.d.ts +78 -5
  66. package/dist/routers/worksheet.d.ts.map +1 -1
  67. package/dist/routers/worksheet.js +126 -17
  68. package/dist/routers/worksheet.js.map +1 -1
  69. package/dist/services/auth.d.ts +1 -1
  70. package/dist/services/auth.d.ts.map +1 -1
  71. package/dist/services/auth.js +19 -12
  72. package/dist/services/auth.js.map +1 -1
  73. package/dist/services/class.d.ts +1 -1
  74. package/dist/services/class.d.ts.map +1 -1
  75. package/dist/services/class.js +6 -3
  76. package/dist/services/class.js.map +1 -1
  77. package/dist/services/file.d.ts.map +1 -1
  78. package/dist/services/file.js +7 -2
  79. package/dist/services/file.js.map +1 -1
  80. package/dist/services/labChat.d.ts +6 -0
  81. package/dist/services/labChat.d.ts.map +1 -1
  82. package/dist/services/labChat.js +30 -18
  83. package/dist/services/labChat.js.map +1 -1
  84. package/dist/services/message.d.ts +30 -0
  85. package/dist/services/message.d.ts.map +1 -1
  86. package/dist/services/message.js +97 -21
  87. package/dist/services/message.js.map +1 -1
  88. package/dist/services/newtonChat.d.ts +6 -1
  89. package/dist/services/newtonChat.d.ts.map +1 -1
  90. package/dist/services/newtonChat.js +36 -18
  91. package/dist/services/newtonChat.js.map +1 -1
  92. package/dist/services/notification.d.ts +12 -5
  93. package/dist/services/notification.d.ts.map +1 -1
  94. package/dist/services/notification.js +24 -9
  95. package/dist/services/notification.js.map +1 -1
  96. package/dist/services/studentProgress.d.ts.map +1 -1
  97. package/dist/services/studentProgress.js +6 -3
  98. package/dist/services/studentProgress.js.map +1 -1
  99. package/dist/services/worksheet.d.ts +36 -15
  100. package/dist/services/worksheet.d.ts.map +1 -1
  101. package/dist/services/worksheet.js +186 -54
  102. package/dist/services/worksheet.js.map +1 -1
  103. package/dist/trpc.d.ts.map +1 -1
  104. package/dist/trpc.js +11 -17
  105. package/dist/trpc.js.map +1 -1
  106. package/dist/utils/inference.d.ts +10 -1
  107. package/dist/utils/inference.d.ts.map +1 -1
  108. package/dist/utils/inference.js +186 -50
  109. package/dist/utils/inference.js.map +1 -1
  110. package/package.json +1 -1
  111. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  112. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  113. package/prisma/schema.prisma +27 -1
  114. package/scripts/test-pre-push.ts +6 -2
  115. package/src/index.ts +83 -30
  116. package/src/lib/fileUpload.ts +55 -5
  117. package/src/middleware/auth.ts +19 -4
  118. package/src/models/auth.ts +59 -29
  119. package/src/models/class.ts +15 -4
  120. package/src/models/notification.ts +13 -7
  121. package/src/models/worksheet.ts +203 -0
  122. package/src/pipelines/aiLabChat.ts +9 -1
  123. package/src/pipelines/aiNewtonChat.ts +24 -14
  124. package/src/pipelines/gradeWorksheet.ts +8 -0
  125. package/src/pipelines/pdfToWorksheet.ts +177 -0
  126. package/src/routers/class.ts +2 -2
  127. package/src/routers/labChat.ts +3 -1
  128. package/src/routers/message.ts +38 -1
  129. package/src/routers/newtonChat.ts +4 -2
  130. package/src/routers/notifications.ts +2 -2
  131. package/src/routers/worksheet.ts +174 -19
  132. package/src/services/auth.ts +26 -12
  133. package/src/services/class.ts +4 -1
  134. package/src/services/file.ts +6 -0
  135. package/src/services/labChat.ts +42 -18
  136. package/src/services/message.ts +163 -18
  137. package/src/services/newtonChat.ts +49 -18
  138. package/src/services/notification.ts +27 -8
  139. package/src/services/studentProgress.ts +4 -1
  140. package/src/services/worksheet.ts +252 -51
  141. package/src/trpc.ts +12 -18
  142. package/src/utils/inference.ts +280 -56
  143. package/tests/globalSetup.ts +13 -4
  144. package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
  145. package/tests/routers/auth.test.ts +120 -17
  146. package/tests/routers/class.test.ts +7 -1
  147. package/tests/routers/message.test.ts +236 -9
  148. package/tests/routers/newtonChat.test.ts +81 -0
  149. package/tests/routers/notification.test.ts +12 -0
  150. package/tests/routers/worksheet.test.ts +194 -0
  151. package/tests/setup.ts +22 -5
  152. package/tests/utils/inference.test.ts +255 -0
@@ -1,12 +1,58 @@
1
1
  // @note: some sloppy impl
2
2
 
3
+ import { randomUUID } from 'crypto';
4
+ import { SessionType } from '@prisma/client';
3
5
  import { test, expect, describe } from 'vitest';
4
6
  import { appRouter } from '../../src/routers/_app';
5
7
  import { createTRPCContext } from '../../src/trpc';
6
8
  import { prisma } from '../../src/lib/prisma';
7
- import { caller, login1, login2, login3, session1, session2, session3, user1Caller, user2Caller, user3Caller, verification1, verification2, verification3 } from '../setup';
9
+ import {
10
+ caller,
11
+ login1,
12
+ login2,
13
+ login3,
14
+ session1,
15
+ session2,
16
+ session3,
17
+ user1Caller,
18
+ user2Caller,
19
+ user3Caller,
20
+ verification1,
21
+ verification2,
22
+ verification3,
23
+ } from '../setup';
8
24
  import { expectUnauth } from '../helpers';
9
25
 
26
+ type TokenHeaderSource = 'x-user' | 'authorization';
27
+
28
+ const createCallerForToken = async (
29
+ token: string,
30
+ source: TokenHeaderSource = 'x-user'
31
+ ) => {
32
+ const headers =
33
+ source === 'x-user'
34
+ ? { 'x-user': token }
35
+ : { authorization: `Bearer ${token}` };
36
+
37
+ const ctx = await createTRPCContext({
38
+ req: {
39
+ headers,
40
+ } as any,
41
+ res: {} as any,
42
+ });
43
+
44
+ return appRouter.createCaller(ctx);
45
+ };
46
+
47
+ const expectTokenRejected = async (token: string) => {
48
+ for (const source of ['x-user', 'authorization'] as const) {
49
+ const protectedCaller = await createCallerForToken(token, source);
50
+ await expect(protectedCaller.auth.check()).rejects.toThrow(
51
+ 'Invalid or expired session'
52
+ );
53
+ }
54
+ };
55
+
10
56
  describe('Auth Router', () => {
11
57
  test('check returns user when authenticated', async () => {
12
58
  const result = await user1Caller.auth.check();
@@ -15,8 +61,7 @@ describe('Auth Router', () => {
15
61
  expect(result.user.username).toBe('testuser1');
16
62
  });
17
63
 
18
- test('check fails when unauthenticated', () =>
19
- expectUnauth((c) => c.auth.check()));
64
+ test('check fails when unauthenticated', () => expectUnauth((c) => c.auth.check()));
20
65
 
21
66
  test('resendVerificationEmail succeeds for existing user', async () => {
22
67
  await caller.auth.register({
@@ -39,16 +84,77 @@ describe('Auth Router', () => {
39
84
  expect(result.success).toBe(true);
40
85
  });
41
86
 
87
+ test('expired auth sessions are rejected', async () => {
88
+ const expiredSession = await prisma.session.create({
89
+ data: {
90
+ id: randomUUID(),
91
+ type: SessionType.AUTH,
92
+ userId: login1.user.id,
93
+ expiresAt: new Date(Date.now() - 60_000),
94
+ },
95
+ });
96
+
97
+ await expectTokenRejected(expiredSession.id);
98
+ });
99
+
100
+ test('password reset tokens cannot authenticate API calls', async () => {
101
+ const resetToken = await prisma.session.create({
102
+ data: {
103
+ id: randomUUID(),
104
+ type: SessionType.PASSWORD_RESET,
105
+ userId: login1.user.id,
106
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
107
+ },
108
+ });
109
+
110
+ await expectTokenRejected(resetToken.id);
111
+ });
112
+
113
+ test('email verification tokens cannot authenticate API calls', async () => {
114
+ const verificationToken = await prisma.session.create({
115
+ data: {
116
+ id: randomUUID(),
117
+ type: SessionType.EMAIL_VERIFICATION,
118
+ userId: login1.user.id,
119
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
120
+ },
121
+ });
122
+
123
+ await expectTokenRejected(verificationToken.id);
124
+ });
125
+
126
+ test('class invite tokens cannot authenticate API calls', async () => {
127
+ const inviteClass = await prisma.class.create({
128
+ data: {
129
+ id: randomUUID(),
130
+ name: 'Auth Invite Guard',
131
+ subject: 'Security',
132
+ section: 'A',
133
+ teachers: { connect: { id: login1.user.id } },
134
+ },
135
+ });
136
+
137
+ const inviteToken = await prisma.session.create({
138
+ data: {
139
+ id: randomUUID(),
140
+ type: SessionType.CLASS_INVITE,
141
+ classId: inviteClass.id,
142
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000),
143
+ },
144
+ });
145
+
146
+ await expectTokenRejected(inviteToken.id);
147
+ });
148
+
42
149
  test('resetPassword works with valid token', async () => {
43
150
  await caller.auth.requestPasswordReset({ email: 'test@studious.sh' });
44
- // Reset tokens expire within 2hr; login sessions expire in 30 days
45
151
  const twoHoursFromNow = new Date(Date.now() + 2 * 60 * 60 * 1000);
46
152
  const resetSession = await prisma.session.findFirst({
47
153
  where: {
48
154
  userId: login1.user.id,
49
- classId: null,
155
+ type: SessionType.PASSWORD_RESET,
50
156
  expiresAt: { lte: twoHoursFromNow },
51
- createdAt: { gte: new Date(Date.now() - 60_000) }, // created in last minute
157
+ createdAt: { gte: new Date(Date.now() - 60_000) },
52
158
  },
53
159
  orderBy: { createdAt: 'desc' },
54
160
  });
@@ -71,7 +177,7 @@ describe('Auth Router', () => {
71
177
  const restoreSession = await prisma.session.findFirst({
72
178
  where: {
73
179
  userId: login1.user.id,
74
- classId: null,
180
+ type: SessionType.PASSWORD_RESET,
75
181
  expiresAt: { lte: twoHoursFromNow },
76
182
  createdAt: { gte: new Date(Date.now() - 60_000) },
77
183
  },
@@ -95,6 +201,7 @@ describe('Auth Router', () => {
95
201
  test('email verification works', async () => {
96
202
  expect(verification1).toBeDefined();
97
203
  expect(verification2).toBeDefined();
204
+ expect(verification3).toBeDefined();
98
205
  });
99
206
 
100
207
  test('login returns valid tokens', async () => {
@@ -141,9 +248,7 @@ describe('Auth Router', () => {
141
248
  });
142
249
 
143
250
  test('verify fails with invalid token', async () => {
144
- await expect(
145
- caller.auth.verify({ token: 'invalid-token-12345' })
146
- ).rejects.toThrow();
251
+ await expect(caller.auth.verify({ token: 'invalid-token-12345' })).rejects.toThrow();
147
252
  });
148
253
 
149
254
  test('resetPassword fails with invalid token', async () => {
@@ -161,11 +266,9 @@ describe('Auth Router', () => {
161
266
  expect(logout).toBeDefined();
162
267
  expect(logout.success).toBe(true);
163
268
 
164
- const ctx = await createTRPCContext({
165
- req: { headers: { authorization: `Bearer ${login3.token}`, 'x-user': login3.token } } as any,
166
- res: {} as any,
167
- });
168
- const router = appRouter.createCaller(ctx);
169
- await expect(router.user.getProfile()).rejects.toThrow('Invalid or expired session');
269
+ for (const source of ['x-user', 'authorization'] as const) {
270
+ const router = await createCallerForToken(login3.token, source);
271
+ await expect(router.user.getProfile()).rejects.toThrow('Invalid or expired session');
272
+ }
170
273
  });
171
- });
274
+ });
@@ -96,6 +96,12 @@ describe('Class Router', () => {
96
96
  expect(classData.class.name).toBe('Test Class 1');
97
97
  });
98
98
 
99
+ test('should forbid non-members from getting class data', async () => {
100
+ await expect(
101
+ user3Caller.class.get({ classId: testClass.id })
102
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
103
+ });
104
+
99
105
  test('should fail to get non-existent class', () =>
100
106
  expectRejects(() => user1Caller.class.get({ classId: 'non-existent-id' })));
101
107
 
@@ -349,4 +355,4 @@ describe('Class Router', () => {
349
355
  ).rejects.toThrow();
350
356
  });
351
357
  });
352
- });
358
+ });
@@ -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