@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.
- package/.coderabbit.yaml +4 -1
- package/.vscode/settings.json +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -29
- package/dist/index.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/models/auth.d.ts +19 -14
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +3 -1
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/notification.d.ts +11 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/worksheet.d.ts +379 -7
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +11 -3
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +3 -3
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +10 -3
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
- package/dist/pipelines/pdfToWorksheet.js +155 -0
- package/dist/pipelines/pdfToWorksheet.js.map +1 -0
- package/dist/routers/_app.d.ts +264 -16
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/class.d.ts +2 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/labChat.d.ts +6 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/message.d.ts +31 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +6 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +9 -2
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/worksheet.d.ts +78 -5
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +126 -17
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/auth.d.ts +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +1 -1
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/labChat.d.ts +6 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +12 -5
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +6 -3
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +36 -15
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +186 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +10 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +186 -50
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +27 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +83 -30
- package/src/lib/fileUpload.ts +55 -5
- package/src/middleware/auth.ts +19 -4
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +203 -0
- package/src/pipelines/aiLabChat.ts +9 -1
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +8 -0
- package/src/pipelines/pdfToWorksheet.ts +177 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/worksheet.ts +174 -19
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +4 -1
- package/src/services/worksheet.ts +252 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +280 -56
- package/tests/globalSetup.ts +13 -4
- package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- 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 {
|
|
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
|
-
|
|
155
|
+
type: SessionType.PASSWORD_RESET,
|
|
50
156
|
expiresAt: { lte: twoHoursFromNow },
|
|
51
|
-
createdAt: { gte: new Date(Date.now() - 60_000) },
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
80
|
+
await expectTRPCError(
|
|
50
81
|
user3Caller.message.send({
|
|
51
82
|
conversationId,
|
|
52
83
|
content: 'Unauthorized message',
|
|
53
84
|
}),
|
|
54
|
-
|
|
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(
|
|
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
|
|
292
|
+
await expectTRPCError(
|
|
69
293
|
user3Caller.message.list({ conversationId }),
|
|
70
|
-
|
|
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
|
|
310
|
+
await expectTRPCError(
|
|
86
311
|
user2Caller.message.update({
|
|
87
312
|
messageId,
|
|
88
313
|
content: 'Unauthorized edit',
|
|
89
314
|
}),
|
|
90
|
-
|
|
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
|
|
339
|
+
await expectTRPCError(
|
|
114
340
|
user2Caller.message.delete({ messageId }),
|
|
115
|
-
|
|
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
|
|