@studious-lms/server 1.4.1 → 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 (248) hide show
  1. package/.coderabbit.yaml +4 -1
  2. package/.env.example +6 -0
  3. package/.env.test.example +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +101 -76
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/config/cors.d.ts +16 -0
  8. package/dist/lib/config/cors.d.ts.map +1 -0
  9. package/dist/lib/config/cors.js +75 -0
  10. package/dist/lib/config/cors.js.map +1 -0
  11. package/dist/lib/config/env.d.ts +14 -0
  12. package/dist/lib/config/env.d.ts.map +1 -1
  13. package/dist/lib/config/env.js +9 -2
  14. package/dist/lib/config/env.js.map +1 -1
  15. package/dist/lib/fileUpload.d.ts +10 -2
  16. package/dist/lib/fileUpload.d.ts.map +1 -1
  17. package/dist/lib/fileUpload.js +38 -6
  18. package/dist/lib/fileUpload.js.map +1 -1
  19. package/dist/lib/prisma.d.ts +14 -2
  20. package/dist/lib/prisma.d.ts.map +1 -1
  21. package/dist/lib/prisma.js +27 -8
  22. package/dist/lib/prisma.js.map +1 -1
  23. package/dist/middleware/auth.d.ts.map +1 -1
  24. package/dist/middleware/auth.js +18 -5
  25. package/dist/middleware/auth.js.map +1 -1
  26. package/dist/middleware/security.d.ts.map +1 -1
  27. package/dist/middleware/security.js +3 -3
  28. package/dist/middleware/security.js.map +1 -1
  29. package/dist/models/agenda.d.ts +16 -16
  30. package/dist/models/announcement.d.ts +59 -23
  31. package/dist/models/announcement.d.ts.map +1 -1
  32. package/dist/models/assignment.d.ts +363 -276
  33. package/dist/models/assignment.d.ts.map +1 -1
  34. package/dist/models/attendance.d.ts +63 -21
  35. package/dist/models/attendance.d.ts.map +1 -1
  36. package/dist/models/auth.d.ts +115 -26
  37. package/dist/models/auth.d.ts.map +1 -1
  38. package/dist/models/auth.js +53 -30
  39. package/dist/models/auth.js.map +1 -1
  40. package/dist/models/class.d.ts +115 -65
  41. package/dist/models/class.d.ts.map +1 -1
  42. package/dist/models/class.js +16 -4
  43. package/dist/models/class.js.map +1 -1
  44. package/dist/models/comment.d.ts +52 -16
  45. package/dist/models/comment.d.ts.map +1 -1
  46. package/dist/models/conversation.d.ts +46 -16
  47. package/dist/models/conversation.d.ts.map +1 -1
  48. package/dist/models/event.d.ts +107 -53
  49. package/dist/models/event.d.ts.map +1 -1
  50. package/dist/models/file.d.ts +213 -165
  51. package/dist/models/file.d.ts.map +1 -1
  52. package/dist/models/folder.d.ts +161 -77
  53. package/dist/models/folder.d.ts.map +1 -1
  54. package/dist/models/labChat.d.ts +73 -31
  55. package/dist/models/labChat.d.ts.map +1 -1
  56. package/dist/models/marketing.d.ts +25 -7
  57. package/dist/models/marketing.d.ts.map +1 -1
  58. package/dist/models/message.d.ts +31 -13
  59. package/dist/models/message.d.ts.map +1 -1
  60. package/dist/models/newtonChat.d.ts +34 -10
  61. package/dist/models/newtonChat.d.ts.map +1 -1
  62. package/dist/models/notification.d.ts +29 -10
  63. package/dist/models/notification.d.ts.map +1 -1
  64. package/dist/models/notification.js +13 -9
  65. package/dist/models/notification.js.map +1 -1
  66. package/dist/models/section.d.ts +71 -23
  67. package/dist/models/section.d.ts.map +1 -1
  68. package/dist/models/user.d.ts +27 -9
  69. package/dist/models/user.d.ts.map +1 -1
  70. package/dist/models/worksheet.d.ts +606 -106
  71. package/dist/models/worksheet.d.ts.map +1 -1
  72. package/dist/models/worksheet.js +168 -2
  73. package/dist/models/worksheet.js.map +1 -1
  74. package/dist/pipelines/aiLabChat.d.ts +22 -2
  75. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  76. package/dist/pipelines/aiLabChat.js +133 -95
  77. package/dist/pipelines/aiLabChat.js.map +1 -1
  78. package/dist/pipelines/aiLabChatContract.d.ts +25 -25
  79. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  80. package/dist/pipelines/aiNewtonChat.js +25 -14
  81. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  82. package/dist/pipelines/gradeWorksheet.d.ts +8 -8
  83. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  84. package/dist/pipelines/gradeWorksheet.js +12 -5
  85. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  86. package/dist/pipelines/labChatPrompt.d.ts +27 -0
  87. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  88. package/dist/pipelines/labChatPrompt.js +143 -69
  89. package/dist/pipelines/labChatPrompt.js.map +1 -1
  90. package/dist/routers/_app.d.ts +1579 -1257
  91. package/dist/routers/_app.d.ts.map +1 -1
  92. package/dist/routers/agenda.d.ts +16 -16
  93. package/dist/routers/announcement.d.ts +19 -19
  94. package/dist/routers/assignment.d.ts +307 -291
  95. package/dist/routers/assignment.d.ts.map +1 -1
  96. package/dist/routers/assignment.js +3 -2
  97. package/dist/routers/assignment.js.map +1 -1
  98. package/dist/routers/attendance.d.ts +7 -7
  99. package/dist/routers/auth.d.ts +1 -1
  100. package/dist/routers/class.d.ts +79 -72
  101. package/dist/routers/class.d.ts.map +1 -1
  102. package/dist/routers/class.js +3 -3
  103. package/dist/routers/class.js.map +1 -1
  104. package/dist/routers/comment.d.ts +6 -6
  105. package/dist/routers/conversation.d.ts +11 -11
  106. package/dist/routers/event.d.ts +35 -35
  107. package/dist/routers/file.d.ts +12 -12
  108. package/dist/routers/folder.d.ts +54 -54
  109. package/dist/routers/labChat.d.ts +18 -12
  110. package/dist/routers/labChat.d.ts.map +1 -1
  111. package/dist/routers/labChat.js +5 -3
  112. package/dist/routers/labChat.js.map +1 -1
  113. package/dist/routers/marketing.d.ts +2 -2
  114. package/dist/routers/message.d.ts +33 -2
  115. package/dist/routers/message.d.ts.map +1 -1
  116. package/dist/routers/message.js +27 -4
  117. package/dist/routers/message.js.map +1 -1
  118. package/dist/routers/newtonChat.d.ts +7 -1
  119. package/dist/routers/newtonChat.d.ts.map +1 -1
  120. package/dist/routers/newtonChat.js +5 -4
  121. package/dist/routers/newtonChat.js.map +1 -1
  122. package/dist/routers/notifications.d.ts +13 -6
  123. package/dist/routers/notifications.d.ts.map +1 -1
  124. package/dist/routers/notifications.js +4 -4
  125. package/dist/routers/notifications.js.map +1 -1
  126. package/dist/routers/section.d.ts +7 -7
  127. package/dist/routers/studentProgress.d.ts +86 -0
  128. package/dist/routers/studentProgress.d.ts.map +1 -1
  129. package/dist/routers/studentProgress.js +14 -4
  130. package/dist/routers/studentProgress.js.map +1 -1
  131. package/dist/routers/user.d.ts +1 -1
  132. package/dist/routers/worksheet.d.ts +64 -62
  133. package/dist/routers/worksheet.d.ts.map +1 -1
  134. package/dist/routers/worksheet.js +18 -16
  135. package/dist/routers/worksheet.js.map +1 -1
  136. package/dist/seedDatabase.d.ts +1 -1
  137. package/dist/services/agenda.d.ts +16 -16
  138. package/dist/services/announcement.d.ts +8 -8
  139. package/dist/services/assignment.d.ts +299 -283
  140. package/dist/services/assignment.d.ts.map +1 -1
  141. package/dist/services/assignment.js +24 -5
  142. package/dist/services/assignment.js.map +1 -1
  143. package/dist/services/attendance.d.ts +7 -7
  144. package/dist/services/auth.d.ts +2 -2
  145. package/dist/services/auth.d.ts.map +1 -1
  146. package/dist/services/auth.js +19 -12
  147. package/dist/services/auth.js.map +1 -1
  148. package/dist/services/class.d.ts +74 -68
  149. package/dist/services/class.d.ts.map +1 -1
  150. package/dist/services/class.js +6 -3
  151. package/dist/services/class.js.map +1 -1
  152. package/dist/services/comment.d.ts +6 -6
  153. package/dist/services/conversation.d.ts +11 -11
  154. package/dist/services/event.d.ts +31 -31
  155. package/dist/services/file.d.ts +12 -12
  156. package/dist/services/file.d.ts.map +1 -1
  157. package/dist/services/file.js +7 -2
  158. package/dist/services/file.js.map +1 -1
  159. package/dist/services/folder.d.ts +52 -52
  160. package/dist/services/labChat.d.ts +18 -12
  161. package/dist/services/labChat.d.ts.map +1 -1
  162. package/dist/services/labChat.js +30 -18
  163. package/dist/services/labChat.js.map +1 -1
  164. package/dist/services/marketing.d.ts +2 -2
  165. package/dist/services/message.d.ts +30 -0
  166. package/dist/services/message.d.ts.map +1 -1
  167. package/dist/services/message.js +97 -21
  168. package/dist/services/message.js.map +1 -1
  169. package/dist/services/newtonChat.d.ts +6 -1
  170. package/dist/services/newtonChat.d.ts.map +1 -1
  171. package/dist/services/newtonChat.js +36 -18
  172. package/dist/services/newtonChat.js.map +1 -1
  173. package/dist/services/notification.d.ts +16 -9
  174. package/dist/services/notification.d.ts.map +1 -1
  175. package/dist/services/notification.js +24 -9
  176. package/dist/services/notification.js.map +1 -1
  177. package/dist/services/section.d.ts +6 -6
  178. package/dist/services/studentProgress.d.ts +75 -0
  179. package/dist/services/studentProgress.d.ts.map +1 -1
  180. package/dist/services/studentProgress.js +300 -107
  181. package/dist/services/studentProgress.js.map +1 -1
  182. package/dist/services/worksheet.d.ts +64 -64
  183. package/dist/services/worksheet.d.ts.map +1 -1
  184. package/dist/services/worksheet.js +138 -54
  185. package/dist/services/worksheet.js.map +1 -1
  186. package/dist/trpc.d.ts.map +1 -1
  187. package/dist/trpc.js +11 -17
  188. package/dist/trpc.js.map +1 -1
  189. package/dist/utils/inference.d.ts +9 -12
  190. package/dist/utils/inference.d.ts.map +1 -1
  191. package/dist/utils/inference.js +135 -95
  192. package/dist/utils/inference.js.map +1 -1
  193. package/package.json +1 -1
  194. package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
  195. package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
  196. package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
  197. package/prisma/schema.prisma +41 -1
  198. package/scripts/test-pre-push.ts +6 -2
  199. package/src/index.ts +119 -79
  200. package/src/lib/config/cors.ts +96 -0
  201. package/src/lib/config/env.ts +12 -1
  202. package/src/lib/fileUpload.ts +55 -5
  203. package/src/lib/prisma.ts +25 -6
  204. package/src/middleware/auth.ts +19 -4
  205. package/src/middleware/security.ts +1 -1
  206. package/src/models/auth.ts +59 -29
  207. package/src/models/class.ts +15 -4
  208. package/src/models/notification.ts +13 -7
  209. package/src/models/worksheet.ts +202 -0
  210. package/src/pipelines/aiLabChat.ts +183 -104
  211. package/src/pipelines/aiNewtonChat.ts +24 -14
  212. package/src/pipelines/gradeWorksheet.ts +10 -2
  213. package/src/pipelines/labChatPrompt.ts +196 -68
  214. package/src/routers/assignment.ts +1 -0
  215. package/src/routers/class.ts +2 -2
  216. package/src/routers/labChat.ts +3 -1
  217. package/src/routers/message.ts +38 -1
  218. package/src/routers/newtonChat.ts +4 -2
  219. package/src/routers/notifications.ts +2 -2
  220. package/src/routers/studentProgress.ts +25 -1
  221. package/src/routers/worksheet.ts +31 -19
  222. package/src/services/assignment.ts +30 -2
  223. package/src/services/auth.ts +26 -12
  224. package/src/services/class.ts +4 -1
  225. package/src/services/file.ts +6 -0
  226. package/src/services/labChat.ts +42 -18
  227. package/src/services/message.ts +163 -18
  228. package/src/services/newtonChat.ts +49 -18
  229. package/src/services/notification.ts +27 -8
  230. package/src/services/studentProgress.ts +425 -121
  231. package/src/services/worksheet.ts +191 -51
  232. package/src/trpc.ts +12 -18
  233. package/src/utils/inference.ts +205 -112
  234. package/tests/globalSetup.ts +13 -4
  235. package/tests/lib/cors.test.ts +103 -0
  236. package/tests/pipelines/aiLabChat.test.ts +64 -84
  237. package/tests/routers/auth.test.ts +120 -17
  238. package/tests/routers/class.test.ts +7 -1
  239. package/tests/routers/message.test.ts +236 -9
  240. package/tests/routers/newtonChat.test.ts +81 -0
  241. package/tests/routers/notification.test.ts +12 -0
  242. package/tests/routers/studentProgress.test.ts +2 -31
  243. package/tests/routers/worksheet.test.ts +194 -0
  244. package/tests/setup.ts +22 -5
  245. package/tests/utils/aiLabChatPrompt.test.ts +114 -6
  246. package/tests/utils/inference.test.ts +218 -0
  247. package/tests/utils/studentProgress.test.ts +361 -0
  248. package/vitest.unit.config.ts +1 -0
@@ -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
  });
@@ -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 () => {