@studious-lms/server 1.27.0 → 1.28.0
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -4
- package/dist/index.js.map +1 -1
- package/dist/instrument.js +9 -4
- package/dist/instrument.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +15 -0
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +43 -2
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +2 -2
- package/dist/pipelines/generateVisualization.d.ts +33 -0
- package/dist/pipelines/generateVisualization.d.ts.map +1 -0
- package/dist/pipelines/generateVisualization.js +79 -0
- package/dist/pipelines/generateVisualization.js.map +1 -0
- package/dist/routers/_app.d.ts +891 -53
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/admin.d.ts +297 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/admin.js +43 -2
- package/dist/routers/admin.js.map +1 -1
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +16 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/module.d.ts +1 -1
- package/dist/routers/visualization.d.ts +124 -0
- package/dist/routers/visualization.d.ts.map +1 -0
- package/dist/routers/visualization.js +41 -0
- package/dist/routers/visualization.js.map +1 -0
- package/dist/services/adminClass.d.ts +153 -0
- package/dist/services/adminClass.d.ts.map +1 -0
- package/dist/services/adminClass.js +196 -0
- package/dist/services/adminClass.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/adminInstitution.js +11 -4
- package/dist/services/adminInstitution.js.map +1 -1
- package/dist/services/adminMetrics.d.ts +8 -0
- package/dist/services/adminMetrics.d.ts.map +1 -1
- package/dist/services/adminMetrics.js +30 -3
- package/dist/services/adminMetrics.js.map +1 -1
- package/dist/services/adminSpam.d.ts +76 -0
- package/dist/services/adminSpam.d.ts.map +1 -0
- package/dist/services/adminSpam.js +163 -0
- package/dist/services/adminSpam.js.map +1 -0
- package/dist/services/adminSql.d.ts +36 -0
- package/dist/services/adminSql.d.ts.map +1 -0
- package/dist/services/adminSql.js +172 -0
- package/dist/services/adminSql.js.map +1 -0
- package/dist/services/adminTelemetry.d.ts +56 -0
- package/dist/services/adminTelemetry.d.ts.map +1 -0
- package/dist/services/adminTelemetry.js +147 -0
- package/dist/services/adminTelemetry.js.map +1 -0
- package/dist/services/adminUser.d.ts +45 -0
- package/dist/services/adminUser.d.ts.map +1 -1
- package/dist/services/adminUser.js +48 -11
- package/dist/services/adminUser.js.map +1 -1
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +10 -4
- package/dist/services/auth.js.map +1 -1
- package/dist/services/fees.d.ts.map +1 -1
- package/dist/services/fees.js +7 -3
- package/dist/services/fees.js.map +1 -1
- package/dist/services/module.d.ts.map +1 -1
- package/dist/services/module.js +23 -4
- package/dist/services/module.js.map +1 -1
- package/dist/services/visualization.d.ts +72 -0
- package/dist/services/visualization.d.ts.map +1 -0
- package/dist/services/visualization.js +122 -0
- package/dist/services/visualization.js.map +1 -0
- package/dist/utils/sentryTrpc.d.ts +3 -0
- package/dist/utils/sentryTrpc.d.ts.map +1 -0
- package/dist/utils/sentryTrpc.js +26 -0
- package/dist/utils/sentryTrpc.js.map +1 -0
- package/package.json +1 -1
- package/prisma/migrations/20261025120000_add_visualizations/migration.sql +27 -0
- package/prisma/schema.prisma +24 -0
- package/src/index.ts +30 -4
- package/src/instrument.ts +8 -2
- package/src/lib/fileUpload.ts +49 -0
- package/src/pipelines/generateVisualization.ts +107 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/admin.ts +72 -0
- package/src/routers/class.ts +14 -1
- package/src/routers/visualization.ts +60 -0
- package/src/services/adminClass.ts +222 -0
- package/src/services/adminInstitution.ts +9 -2
- package/src/services/adminMetrics.ts +35 -0
- package/src/services/adminSpam.ts +224 -0
- package/src/services/adminSql.ts +214 -0
- package/src/services/adminTelemetry.ts +194 -0
- package/src/services/adminUser.ts +61 -9
- package/src/services/auth.ts +8 -2
- package/src/services/fees.ts +5 -1
- package/src/services/module.ts +29 -2
- package/src/services/visualization.ts +147 -0
- package/src/utils/sentryTrpc.ts +25 -0
- package/tests/unit/lib/fileUploadSession.test.ts +67 -0
- package/tests/unit/services/adminClass.test.ts +102 -0
- package/tests/unit/services/adminSpam.test.ts +108 -0
- package/tests/unit/services/adminSql.test.ts +56 -0
- package/tests/unit/services/adminTelemetry.test.ts +47 -0
- package/tests/unit/services/adminUser.test.ts +2 -0
- package/tests/unit/services/visualization.test.ts +130 -0
- package/tests/unit/utils/sentryTrpc.test.ts +15 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const prismaMocks = vi.hoisted(() => ({
|
|
4
|
+
class: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn() },
|
|
5
|
+
submission: { groupBy: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../../../src/lib/prisma.js', () => ({
|
|
9
|
+
prisma: prismaMocks,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getClassForAdmin,
|
|
14
|
+
listClassesForAdmin,
|
|
15
|
+
} from '../../../src/services/adminClass.js';
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('listClassesForAdmin', () => {
|
|
22
|
+
test('filters empty classes and exposes the last assignment timestamp', async () => {
|
|
23
|
+
const createdAt = new Date('2026-09-01T00:00:00Z');
|
|
24
|
+
prismaMocks.class.findMany.mockResolvedValue([
|
|
25
|
+
{ id: 'c1', name: 'Bio', assignments: [{ createdAt }] },
|
|
26
|
+
{ id: 'c2', name: 'Art', assignments: [] },
|
|
27
|
+
]);
|
|
28
|
+
prismaMocks.class.count.mockResolvedValue(2);
|
|
29
|
+
|
|
30
|
+
const result = await listClassesForAdmin({
|
|
31
|
+
page: 0,
|
|
32
|
+
pageSize: 25,
|
|
33
|
+
membership: 'empty',
|
|
34
|
+
sort: 'students',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(prismaMocks.class.findMany).toHaveBeenCalledWith(
|
|
38
|
+
expect.objectContaining({
|
|
39
|
+
where: { AND: [{ students: { none: {} } }] },
|
|
40
|
+
orderBy: [{ students: { _count: 'desc' } }, { name: 'asc' }],
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
expect(result.items[0]).toMatchObject({ lastAssignmentAt: createdAt });
|
|
44
|
+
expect(result.items[1]).toMatchObject({ lastAssignmentAt: null });
|
|
45
|
+
expect(result.items[0]).not.toHaveProperty('assignments');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getClassForAdmin', () => {
|
|
50
|
+
test('scopes roster submission counts to the class and flags suspected members', async () => {
|
|
51
|
+
prismaMocks.class.findUnique.mockResolvedValue({
|
|
52
|
+
id: 'c1',
|
|
53
|
+
name: 'Bio',
|
|
54
|
+
teachers: [
|
|
55
|
+
{
|
|
56
|
+
id: 't1',
|
|
57
|
+
username: 'ada',
|
|
58
|
+
email: 'ada@example.com',
|
|
59
|
+
verified: true,
|
|
60
|
+
_count: { teacherIn: 1, studentIn: 0, submissions: 0 },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
students: [
|
|
64
|
+
{
|
|
65
|
+
id: 's1',
|
|
66
|
+
username: 'devin-bot',
|
|
67
|
+
email: 'bot@riverside.edu',
|
|
68
|
+
verified: false,
|
|
69
|
+
_count: { teacherIn: 0, studentIn: 1, submissions: 4 },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
assignments: [],
|
|
73
|
+
announcements: [],
|
|
74
|
+
_count: { students: 1, teachers: 1 },
|
|
75
|
+
});
|
|
76
|
+
prismaMocks.submission.groupBy.mockResolvedValue([
|
|
77
|
+
{ studentId: 's1', _count: { _all: 2 } },
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const cls = await getClassForAdmin('c1');
|
|
81
|
+
|
|
82
|
+
expect(prismaMocks.submission.groupBy).toHaveBeenCalledWith(
|
|
83
|
+
expect.objectContaining({ where: { assignment: { classId: 'c1' } } }),
|
|
84
|
+
);
|
|
85
|
+
expect(cls.students[0]).toMatchObject({
|
|
86
|
+
submissionsInClass: 2,
|
|
87
|
+
signals: { suspected: true },
|
|
88
|
+
});
|
|
89
|
+
expect(cls.teachers[0]).toMatchObject({
|
|
90
|
+
submissionsInClass: 0,
|
|
91
|
+
signals: { suspected: false },
|
|
92
|
+
});
|
|
93
|
+
expect(cls.suspectedMembers).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('throws NOT_FOUND for a missing class', async () => {
|
|
97
|
+
prismaMocks.class.findUnique.mockResolvedValue(null);
|
|
98
|
+
await expect(getClassForAdmin('nope')).rejects.toMatchObject({
|
|
99
|
+
code: 'NOT_FOUND',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../../../src/lib/prisma.js', () => ({
|
|
4
|
+
prisma: { user: { count: vi.fn(), findMany: vi.fn() }, $queryRaw: vi.fn() },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
classifyEmail,
|
|
9
|
+
classifyUser,
|
|
10
|
+
suspectedUserWhere,
|
|
11
|
+
} from '../../../src/services/adminSpam.js';
|
|
12
|
+
|
|
13
|
+
const activeCounts = { teacherIn: 1, studentIn: 0, submissions: 3 };
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
delete process.env.ADMIN_SPAM_DOMAINS;
|
|
17
|
+
delete process.env.ADMIN_SPAM_BOT_PATTERNS;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('classifyUser', () => {
|
|
21
|
+
test('flags the blocked riverside.edu domain and its subdomains', () => {
|
|
22
|
+
expect(
|
|
23
|
+
classifyUser({
|
|
24
|
+
username: 'jane',
|
|
25
|
+
email: 'Jane@Riverside.edu',
|
|
26
|
+
verified: true,
|
|
27
|
+
_count: activeCounts,
|
|
28
|
+
}).reasons,
|
|
29
|
+
).toEqual(['BLOCKED_DOMAIN']);
|
|
30
|
+
|
|
31
|
+
expect(
|
|
32
|
+
classifyUser({
|
|
33
|
+
username: 'jane',
|
|
34
|
+
email: 'jane@mail.riverside.edu',
|
|
35
|
+
verified: true,
|
|
36
|
+
_count: activeCounts,
|
|
37
|
+
}).suspected,
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('does not flag domains that merely end with the blocked one', () => {
|
|
42
|
+
expect(
|
|
43
|
+
classifyUser({
|
|
44
|
+
username: 'jane',
|
|
45
|
+
email: 'jane@notriverside.edu',
|
|
46
|
+
verified: true,
|
|
47
|
+
_count: activeCounts,
|
|
48
|
+
}).suspected,
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('flags bot accounts by username or email pattern', () => {
|
|
53
|
+
expect(
|
|
54
|
+
classifyUser({
|
|
55
|
+
username: 'devin-test-1',
|
|
56
|
+
email: 'real@school.org',
|
|
57
|
+
verified: true,
|
|
58
|
+
_count: activeCounts,
|
|
59
|
+
}).reasons,
|
|
60
|
+
).toContain('BOT_PATTERN');
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
classifyUser({
|
|
64
|
+
username: 'ada',
|
|
65
|
+
email: 'ada+devin@school.org',
|
|
66
|
+
verified: true,
|
|
67
|
+
_count: activeCounts,
|
|
68
|
+
}).reasons,
|
|
69
|
+
).toContain('BOT_PATTERN');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('flags unverified accounts with no classes and no submissions', () => {
|
|
73
|
+
expect(
|
|
74
|
+
classifyUser({
|
|
75
|
+
username: 'ghost',
|
|
76
|
+
email: 'ghost@school.org',
|
|
77
|
+
verified: false,
|
|
78
|
+
_count: { teacherIn: 0, studentIn: 0, submissions: 0 },
|
|
79
|
+
}).reasons,
|
|
80
|
+
).toEqual(['NEVER_VERIFIED_INACTIVE']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('leaves real accounts alone', () => {
|
|
84
|
+
expect(
|
|
85
|
+
classifyUser({
|
|
86
|
+
username: 'ada',
|
|
87
|
+
email: 'ada@school.org',
|
|
88
|
+
verified: true,
|
|
89
|
+
_count: activeCounts,
|
|
90
|
+
}),
|
|
91
|
+
).toEqual({ suspected: false, reasons: [] });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('rules are overridable through env', () => {
|
|
95
|
+
process.env.ADMIN_SPAM_DOMAINS = 'spam.test';
|
|
96
|
+
expect(classifyEmail('a@spam.test').suspected).toBe(true);
|
|
97
|
+
expect(classifyEmail('a@riverside.edu').suspected).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('suspectedUserWhere', () => {
|
|
102
|
+
test('never matches on the inactivity heuristic, only on rule matches', () => {
|
|
103
|
+
const where = JSON.stringify(suspectedUserWhere());
|
|
104
|
+
expect(where).toContain('riverside.edu');
|
|
105
|
+
expect(where).toContain('devin');
|
|
106
|
+
expect(where).not.toContain('verified');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
assertReadOnlySql,
|
|
4
|
+
buildBoundedQuery,
|
|
5
|
+
serializeValue,
|
|
6
|
+
} from '../../../src/services/adminSql.js';
|
|
7
|
+
|
|
8
|
+
describe('assertReadOnlySql', () => {
|
|
9
|
+
it('accepts read statements and strips the trailing semicolon', () => {
|
|
10
|
+
expect(assertReadOnlySql('SELECT 1;')).toBe('SELECT 1');
|
|
11
|
+
expect(assertReadOnlySql(' with t as (select 1) select * from t ')).toBe(
|
|
12
|
+
'with t as (select 1) select * from t',
|
|
13
|
+
);
|
|
14
|
+
expect(assertReadOnlySql('EXPLAIN SELECT * FROM "User"')).toBe('EXPLAIN SELECT * FROM "User"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects writes, DDL and data-modifying CTEs', () => {
|
|
18
|
+
expect(() => assertReadOnlySql('DELETE FROM "User"')).toThrow(/only/i);
|
|
19
|
+
expect(() => assertReadOnlySql('DROP TABLE "User"')).toThrow(/only/i);
|
|
20
|
+
expect(() =>
|
|
21
|
+
assertReadOnlySql('WITH x AS (DELETE FROM "User" RETURNING id) SELECT * FROM x'),
|
|
22
|
+
).toThrow(/DELETE/);
|
|
23
|
+
expect(() => assertReadOnlySql('SELECT 1; DROP TABLE "User"')).toThrow(/single statement/i);
|
|
24
|
+
expect(() => assertReadOnlySql('SELECT pg_sleep(60)')).toThrow(/PG_SLEEP/);
|
|
25
|
+
expect(() => assertReadOnlySql(' ')).toThrow(/empty/i);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('does not flag keywords inside strings, comments or identifiers', () => {
|
|
29
|
+
expect(assertReadOnlySql("SELECT * FROM \"User\" WHERE username = 'delete-me'")).toContain(
|
|
30
|
+
'delete-me',
|
|
31
|
+
);
|
|
32
|
+
expect(assertReadOnlySql('SELECT 1 -- drop table users')).toBe('SELECT 1 -- drop table users');
|
|
33
|
+
expect(assertReadOnlySql('SELECT "settings" FROM "User" OFFSET 5')).toContain('OFFSET');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('buildBoundedQuery', () => {
|
|
38
|
+
it('wraps row-returning statements in a capped subquery', () => {
|
|
39
|
+
expect(buildBoundedQuery('SELECT * FROM "User"', 10)).toBe(
|
|
40
|
+
'SELECT * FROM (SELECT * FROM "User") AS admin_sql_console LIMIT 10',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('leaves EXPLAIN and SHOW untouched', () => {
|
|
45
|
+
expect(buildBoundedQuery('EXPLAIN SELECT 1', 10)).toBe('EXPLAIN SELECT 1');
|
|
46
|
+
expect(buildBoundedQuery('SHOW statement_timeout', 10)).toBe('SHOW statement_timeout');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('serializeValue', () => {
|
|
51
|
+
it('converts values the JSON transformer cannot handle', () => {
|
|
52
|
+
expect(serializeValue(12n)).toBe('12');
|
|
53
|
+
expect(serializeValue(new Date('2026-01-02T03:04:05.000Z'))).toBe('2026-01-02T03:04:05.000Z');
|
|
54
|
+
expect(serializeValue({ nested: [1n, null] })).toEqual({ nested: ['1', null] });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const prismaMocks = vi.hoisted(() => ({
|
|
4
|
+
$queryRaw: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('../../../src/lib/prisma.js', () => ({
|
|
8
|
+
prisma: prismaMocks,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { getActivitySeriesForAdmin } from '../../../src/services/adminTelemetry.js';
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('getActivitySeriesForAdmin', () => {
|
|
18
|
+
test('zero-fills every day in the window and keeps them ordered', async () => {
|
|
19
|
+
prismaMocks.$queryRaw.mockResolvedValue([]);
|
|
20
|
+
|
|
21
|
+
const { points } = await getActivitySeriesForAdmin({ days: 5 });
|
|
22
|
+
|
|
23
|
+
expect(points).toHaveLength(5);
|
|
24
|
+
expect(points.map((p) => p.date)).toEqual([...points.map((p) => p.date)].sort());
|
|
25
|
+
expect(points.every((p) => p.signups === 0 && p.logins === 0)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('maps counted rows onto their day', async () => {
|
|
29
|
+
const today = new Date();
|
|
30
|
+
today.setUTCHours(0, 0, 0, 0);
|
|
31
|
+
const key = today.toISOString().slice(0, 10);
|
|
32
|
+
|
|
33
|
+
// Same shape for every series query; only signups are asserted.
|
|
34
|
+
prismaMocks.$queryRaw.mockResolvedValue([{ day: today, total: 3n }]);
|
|
35
|
+
|
|
36
|
+
const { points } = await getActivitySeriesForAdmin({ days: 3 });
|
|
37
|
+
const todayPoint = points.find((p) => p.date === key);
|
|
38
|
+
|
|
39
|
+
expect(todayPoint).toMatchObject({ signups: 3, submissions: 3 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('clamps the window to at most 90 days', async () => {
|
|
43
|
+
prismaMocks.$queryRaw.mockResolvedValue([]);
|
|
44
|
+
const { points } = await getActivitySeriesForAdmin({ days: 500 });
|
|
45
|
+
expect(points).toHaveLength(90);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
|
|
|
3
3
|
|
|
4
4
|
const prismaMocks = vi.hoisted(() => ({
|
|
5
5
|
user: { findUnique: vi.fn() },
|
|
6
|
+
class: { findMany: vi.fn() },
|
|
6
7
|
}));
|
|
7
8
|
|
|
8
9
|
vi.mock('../../../src/lib/prisma.js', () => ({
|
|
@@ -13,6 +14,7 @@ import { getUserForAdmin } from '../../../src/services/adminUser.js';
|
|
|
13
14
|
|
|
14
15
|
beforeEach(() => {
|
|
15
16
|
vi.clearAllMocks();
|
|
17
|
+
prismaMocks.class.findMany.mockResolvedValue([]);
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
describe('getUserForAdmin', () => {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { TRPCError } from "@trpc/server";
|
|
3
|
+
|
|
4
|
+
const { prismaMocks, inferenceMock, teacherMock } = vi.hoisted(() => ({
|
|
5
|
+
prismaMocks: {
|
|
6
|
+
class: { findUnique: vi.fn() },
|
|
7
|
+
visualization: {
|
|
8
|
+
findFirst: vi.fn(),
|
|
9
|
+
findMany: vi.fn(),
|
|
10
|
+
create: vi.fn(),
|
|
11
|
+
update: vi.fn(),
|
|
12
|
+
delete: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
courseModuleItem: { deleteMany: vi.fn() },
|
|
15
|
+
$transaction: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
inferenceMock: vi.fn(),
|
|
18
|
+
teacherMock: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("../../../src/lib/prisma.js", () => ({ prisma: prismaMocks }));
|
|
22
|
+
vi.mock("../../../src/utils/inference.js", () => ({ inference: inferenceMock }));
|
|
23
|
+
vi.mock("../../../src/models/class.js", () => ({ isTeacherInClass: teacherMock }));
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createVisualization,
|
|
27
|
+
deleteVisualization,
|
|
28
|
+
getVisualization,
|
|
29
|
+
listVisualizations,
|
|
30
|
+
reviseVisualizationRecord,
|
|
31
|
+
} from "../../../src/services/visualization.js";
|
|
32
|
+
|
|
33
|
+
const HTML = "<!DOCTYPE html><html><body><canvas></canvas></body></html>";
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
prismaMocks.class.findUnique.mockResolvedValue({
|
|
38
|
+
name: "Physics 10",
|
|
39
|
+
subject: "Physics",
|
|
40
|
+
schoolId: "s1",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("listVisualizations / getVisualization", () => {
|
|
45
|
+
test("students only see published visualisations", async () => {
|
|
46
|
+
teacherMock.mockResolvedValue(null);
|
|
47
|
+
prismaMocks.visualization.findMany.mockResolvedValue([]);
|
|
48
|
+
await listVisualizations("u1", "c1");
|
|
49
|
+
expect(prismaMocks.visualization.findMany.mock.calls[0][0].where).toEqual({
|
|
50
|
+
classId: "c1",
|
|
51
|
+
published: true,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("teachers see drafts too", async () => {
|
|
56
|
+
teacherMock.mockResolvedValue({ id: "c1" });
|
|
57
|
+
prismaMocks.visualization.findMany.mockResolvedValue([]);
|
|
58
|
+
await listVisualizations("t1", "c1");
|
|
59
|
+
expect(prismaMocks.visualization.findMany.mock.calls[0][0].where).toEqual({
|
|
60
|
+
classId: "c1",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("students get NOT_FOUND for drafts", async () => {
|
|
65
|
+
teacherMock.mockResolvedValue(null);
|
|
66
|
+
prismaMocks.visualization.findFirst.mockResolvedValue(null);
|
|
67
|
+
await expect(getVisualization("u1", "c1", "v1")).rejects.toBeInstanceOf(TRPCError);
|
|
68
|
+
expect(prismaMocks.visualization.findFirst.mock.calls[0][0].where).toEqual({
|
|
69
|
+
id: "v1",
|
|
70
|
+
classId: "c1",
|
|
71
|
+
published: true,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("createVisualization", () => {
|
|
77
|
+
test("generates via inference and stores an unpublished draft", async () => {
|
|
78
|
+
inferenceMock.mockResolvedValue({ title: "Projectile motion", html: HTML });
|
|
79
|
+
prismaMocks.visualization.create.mockImplementation(({ data }) => ({ id: "v1", ...data }));
|
|
80
|
+
|
|
81
|
+
const viz = await createVisualization("t1", { classId: "c1", prompt: "projectile sliders" });
|
|
82
|
+
|
|
83
|
+
expect(inferenceMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
const [messages, , options] = inferenceMock.mock.calls[0];
|
|
85
|
+
expect(messages[1].content).toContain("projectile sliders");
|
|
86
|
+
expect(messages[1].content).toContain("Subject: Physics");
|
|
87
|
+
expect(options.attribution).toEqual({ userId: "t1", classId: "c1", schoolId: "s1" });
|
|
88
|
+
expect(viz).toMatchObject({ title: "Projectile motion", html: HTML, createdById: "t1" });
|
|
89
|
+
expect(prismaMocks.visualization.create.mock.calls[0][0].data.published).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("rejects output that isn't an HTML document", async () => {
|
|
93
|
+
inferenceMock.mockResolvedValue({ title: "x", html: "just text" });
|
|
94
|
+
await expect(
|
|
95
|
+
createVisualization("t1", { classId: "c1", prompt: "anything" }),
|
|
96
|
+
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" });
|
|
97
|
+
expect(prismaMocks.visualization.create).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("reviseVisualizationRecord", () => {
|
|
102
|
+
test("sends current html and saves the revision", async () => {
|
|
103
|
+
prismaMocks.visualization.findFirst.mockResolvedValue({
|
|
104
|
+
id: "v1",
|
|
105
|
+
title: "Waves",
|
|
106
|
+
html: HTML,
|
|
107
|
+
prompt: "waves",
|
|
108
|
+
});
|
|
109
|
+
inferenceMock.mockResolvedValue({ title: "Waves", html: HTML.replace("canvas", "svg") });
|
|
110
|
+
prismaMocks.visualization.update.mockResolvedValue({ id: "v1" });
|
|
111
|
+
|
|
112
|
+
await reviseVisualizationRecord("t1", { classId: "c1", id: "v1", instruction: "add damping" });
|
|
113
|
+
|
|
114
|
+
expect(inferenceMock.mock.calls[0][0][1].content).toContain(HTML);
|
|
115
|
+
expect(prismaMocks.visualization.update.mock.calls[0][0].data.prompt).toBe(
|
|
116
|
+
"waves\n\nadd damping",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("deleteVisualization", () => {
|
|
122
|
+
test("removes attached module items", async () => {
|
|
123
|
+
prismaMocks.visualization.findFirst.mockResolvedValue({ id: "v1" });
|
|
124
|
+
prismaMocks.$transaction.mockResolvedValue([]);
|
|
125
|
+
await deleteVisualization({ classId: "c1", id: "v1" });
|
|
126
|
+
expect(prismaMocks.courseModuleItem.deleteMany).toHaveBeenCalledWith({
|
|
127
|
+
where: { itemType: "VISUALIZATION", itemId: "v1" },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isReportableTrpcError } from '../../../src/utils/sentryTrpc.js';
|
|
3
|
+
|
|
4
|
+
describe('isReportableTrpcError', () => {
|
|
5
|
+
it('reports server-side failures', () => {
|
|
6
|
+
expect(isReportableTrpcError({ code: 'INTERNAL_SERVER_ERROR' })).toBe(true);
|
|
7
|
+
expect(isReportableTrpcError({ code: 'TIMEOUT' })).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('ignores expected client errors', () => {
|
|
11
|
+
for (const code of ['BAD_REQUEST', 'UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND'] as const) {
|
|
12
|
+
expect(isReportableTrpcError({ code })).toBe(false);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
});
|