studio-os 0.2.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.
Files changed (86) hide show
  1. package/.dockerignore +7 -0
  2. package/.env.example +34 -0
  3. package/CHANGELOG.md +86 -0
  4. package/Dockerfile +25 -0
  5. package/LICENSE +21 -0
  6. package/PROGRESS.md +60 -0
  7. package/README.md +253 -0
  8. package/SPEC.md +130 -0
  9. package/docker-compose.yml +26 -0
  10. package/docs/screenshots/admin-client.png +0 -0
  11. package/docs/screenshots/admin-dashboard.png +0 -0
  12. package/docs/screenshots/admin-reports.png +0 -0
  13. package/docs/screenshots/admin-roster.png +0 -0
  14. package/docs/screenshots/admin-schedule.png +0 -0
  15. package/docs/screenshots/public-booking.png +0 -0
  16. package/docs/screenshots/public-buy.png +0 -0
  17. package/docs/screenshots/public-schedule.png +0 -0
  18. package/package.json +54 -0
  19. package/public/css/custom.css +40 -0
  20. package/public/js/icon.svg +4 -0
  21. package/public/js/sw.js +23 -0
  22. package/public/manifest.json +17 -0
  23. package/public/vendor/htmx.min.js +1 -0
  24. package/public/vendor/pico.min.css +4 -0
  25. package/scripts/import-mindbody.mjs +109 -0
  26. package/scripts/seed.mjs +147 -0
  27. package/src/app.js +141 -0
  28. package/src/db/002-instructor-role.sql +26 -0
  29. package/src/db/index.js +82 -0
  30. package/src/db/schema.sql +164 -0
  31. package/src/lib/ratelimit.js +50 -0
  32. package/src/lib/time.js +87 -0
  33. package/src/routes/admin.js +692 -0
  34. package/src/routes/auth.js +34 -0
  35. package/src/routes/instructor.js +82 -0
  36. package/src/routes/me.js +101 -0
  37. package/src/routes/public.js +215 -0
  38. package/src/routes/setup.js +55 -0
  39. package/src/routes/webhooks.js +28 -0
  40. package/src/server.js +29 -0
  41. package/src/services/auth.js +81 -0
  42. package/src/services/booking.js +261 -0
  43. package/src/services/importer.js +201 -0
  44. package/src/services/mailer.js +92 -0
  45. package/src/services/schedule.js +71 -0
  46. package/src/services/stripe.js +169 -0
  47. package/test/booking.test.js +346 -0
  48. package/test/fixtures/mindbody-clients.csv +9 -0
  49. package/test/fixtures/mindbody-passes.csv +8 -0
  50. package/test/helpers.js +59 -0
  51. package/test/importer.test.js +173 -0
  52. package/test/instructor.test.js +217 -0
  53. package/test/ratelimit.test.js +123 -0
  54. package/test/schedule.test.js +102 -0
  55. package/test/stripe.test.js +249 -0
  56. package/test/views.test.js +38 -0
  57. package/test/web.test.js +392 -0
  58. package/views/admin/class_types.ejs +45 -0
  59. package/views/admin/client.ejs +149 -0
  60. package/views/admin/clients.ejs +33 -0
  61. package/views/admin/dashboard.ejs +55 -0
  62. package/views/admin/import.ejs +39 -0
  63. package/views/admin/instructor_classes.ejs +21 -0
  64. package/views/admin/instructor_new.ejs +15 -0
  65. package/views/admin/instructors.ejs +49 -0
  66. package/views/admin/products.ejs +83 -0
  67. package/views/admin/reports.ejs +34 -0
  68. package/views/admin/roster.ejs +58 -0
  69. package/views/admin/rules.ejs +71 -0
  70. package/views/admin/schedule.ejs +50 -0
  71. package/views/admin/settings.ejs +53 -0
  72. package/views/error.ejs +7 -0
  73. package/views/instructor/roster.ejs +27 -0
  74. package/views/instructor/schedule.ejs +21 -0
  75. package/views/login.ejs +11 -0
  76. package/views/partials/foot.ejs +11 -0
  77. package/views/partials/head.ejs +54 -0
  78. package/views/public/book_result.ejs +26 -0
  79. package/views/public/buy.ejs +37 -0
  80. package/views/public/buy_manual.ejs +17 -0
  81. package/views/public/buy_thanks.ejs +7 -0
  82. package/views/public/class.ejs +29 -0
  83. package/views/public/magic_request.ejs +21 -0
  84. package/views/public/me.ejs +47 -0
  85. package/views/public/schedule.ejs +49 -0
  86. package/views/setup.ejs +53 -0
@@ -0,0 +1,173 @@
1
+ // Mindbody importer: CSV parsing, fixture import, idempotency, mapping
2
+ // overrides, dry-run, and the CLI script end-to-end. No network.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { openDb } from '../src/db/index.js';
11
+ import { parseCsv, importClients, importPasses } from '../src/services/importer.js';
12
+ import { testDb } from './helpers.js';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const FIXTURES = path.join(__dirname, 'fixtures');
16
+ const clientsCsv = fs.readFileSync(path.join(FIXTURES, 'mindbody-clients.csv'), 'utf8');
17
+ const passesCsv = fs.readFileSync(path.join(FIXTURES, 'mindbody-passes.csv'), 'utf8');
18
+
19
+ test('parseCsv handles quoted fields, embedded commas and doubled quotes', () => {
20
+ const rows = parseCsv('A,B\n"x, y","he said ""hi"""\nplain,2');
21
+ assert.equal(rows.length, 2);
22
+ assert.equal(rows[0].A, 'x, y');
23
+ assert.equal(rows[0].B, 'he said "hi"');
24
+ assert.equal(rows[1].A, 'plain');
25
+ });
26
+
27
+ test('clients fixture import: creates valid rows, skips missing email, normalizes', () => {
28
+ const db = testDb();
29
+ const r = importClients(db, clientsCsv);
30
+ assert.equal(r.created, 7, 'seven rows have valid emails');
31
+ assert.equal(r.skipped, 1, 'David Ng has no email');
32
+ assert.equal(r.updated, 0);
33
+
34
+ // quoted comma inside a name survives
35
+ const jason = db.prepare('SELECT * FROM clients WHERE email = ?').get('jason.obrien@example.com');
36
+ assert.equal(jason.name, "Jason O'Brien, Jr.");
37
+ assert.equal(jason.source, 'import');
38
+
39
+ // uppercase email in the export is stored lowercase
40
+ const mei = db.prepare('SELECT * FROM clients WHERE email = ?').get('mei.cheung@example.com');
41
+ assert.ok(mei, 'MEI.CHEUNG@Example.com normalized to lowercase');
42
+
43
+ // waiver column mapped: Yes → signed, No → not signed
44
+ const wing = db.prepare('SELECT * FROM clients WHERE email = ?').get('wingyan.chan@example.com');
45
+ assert.ok(wing.waiver_signed_at, 'Liability Waiver=Yes sets waiver_signed_at');
46
+ const siufung = db.prepare('SELECT * FROM clients WHERE email = ?').get('siufung.lee@example.com');
47
+ assert.equal(siufung.waiver_signed_at, null, 'Liability Waiver=No leaves waiver unsigned');
48
+ });
49
+
50
+ test('clients import is idempotent by email: re-run updates, never duplicates', () => {
51
+ const db = testDb();
52
+ importClients(db, clientsCsv);
53
+ const before = db.prepare('SELECT COUNT(*) c FROM clients').get().c;
54
+
55
+ const r2 = importClients(db, clientsCsv);
56
+ assert.equal(r2.created, 0);
57
+ assert.equal(r2.updated, 7);
58
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, before, 'no duplicates');
59
+
60
+ // an updated export (new phone) updates in place, and blank columns do not wipe data
61
+ const changed = 'First Name,Last Name,Email,Mobile Phone\nWing Yan,Chan,wingyan.chan@example.com,+852 5555 0000\n';
62
+ const r3 = importClients(db, changed);
63
+ assert.equal(r3.updated, 1);
64
+ const wing = db.prepare('SELECT * FROM clients WHERE email = ?').get('wingyan.chan@example.com');
65
+ assert.equal(wing.phone, '+852 5555 0000');
66
+ assert.match(wing.notes, /morning classes/, 'notes kept when re-import omits the column');
67
+ });
68
+
69
+ test('clients dry-run reports work but writes nothing', () => {
70
+ const db = testDb();
71
+ const r = importClients(db, clientsCsv, { dryRun: true });
72
+ assert.equal(r.created, 7);
73
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 0, 'dry run wrote nothing');
74
+ });
75
+
76
+ test('passes fixture import: attaches by email, parses money/dates, auto-creates unknown clients', () => {
77
+ const db = testDb();
78
+ importClients(db, clientsCsv);
79
+ const r = importPasses(db, passesCsv);
80
+ assert.equal(r.created, 6);
81
+ assert.equal(r.skipped, 1, 'Walk In row has no email');
82
+ assert.equal(r.clientsCreated, 1, 'Priya Sharma not in clients export → bare client created');
83
+
84
+ const wing = db.prepare('SELECT * FROM clients WHERE email = ?').get('wingyan.chan@example.com');
85
+ const pass = db.prepare('SELECT * FROM passes WHERE client_id = ?').get(wing.id);
86
+ assert.equal(pass.credits_remaining, 7);
87
+ assert.equal(pass.credits_total, 10);
88
+ assert.equal(pass.expires_on, '2026-10-31', 'M/D/YYYY normalized');
89
+ assert.equal(pass.price_paid_cents, 150000, '"$1,500.00" parsed to cents');
90
+ assert.equal(pass.source, 'import');
91
+
92
+ // ISO expiration passes through
93
+ const emily = db.prepare('SELECT * FROM clients WHERE email = ?').get('emily.tsang@example.com');
94
+ const epass = db.prepare('SELECT * FROM passes WHERE client_id = ?').get(emily.id);
95
+ assert.equal(epass.expires_on, '2026-12-31');
96
+
97
+ // two different pricing options for the same client → two passes
98
+ const kaming = db.prepare('SELECT * FROM clients WHERE email = ?').get('kaming.wong@example.com');
99
+ const kpasses = db.prepare('SELECT * FROM passes WHERE client_id = ? ORDER BY name').all(kaming.id);
100
+ assert.equal(kpasses.length, 2);
101
+ assert.equal(kpasses.find((p) => p.name === '5 Class Intro Pack').credits_remaining, 0);
102
+
103
+ const priya = db.prepare('SELECT * FROM clients WHERE email = ?').get('priya.sharma@example.com');
104
+ assert.equal(priya.name, 'Priya Sharma');
105
+ assert.equal(priya.source, 'import');
106
+ });
107
+
108
+ test('passes import is idempotent: re-run updates remaining, never duplicates', () => {
109
+ const db = testDb();
110
+ importClients(db, clientsCsv);
111
+ importPasses(db, passesCsv);
112
+ const before = db.prepare('SELECT COUNT(*) c FROM passes').get().c;
113
+
114
+ const r2 = importPasses(db, passesCsv);
115
+ assert.equal(r2.created, 0);
116
+ assert.equal(r2.updated, 6);
117
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes').get().c, before, 'no duplicate passes');
118
+
119
+ // a newer export with fewer remaining credits updates the same pass row
120
+ const newer = 'Client,Email,Pricing Option,Remaining,Total,Expiration,Price Paid\n'
121
+ + 'Wing Yan Chan,wingyan.chan@example.com,10 Class Pack,5,10,10/31/2026,"$1,500.00"\n';
122
+ const r3 = importPasses(db, newer);
123
+ assert.equal(r3.updated, 1);
124
+ assert.equal(r3.created, 0);
125
+ const wing = db.prepare('SELECT * FROM clients WHERE email = ?').get('wingyan.chan@example.com');
126
+ const pass = db.prepare("SELECT * FROM passes WHERE client_id = ? AND name = '10 Class Pack'").get(wing.id);
127
+ assert.equal(pass.credits_remaining, 5);
128
+ });
129
+
130
+ test('column mapping overrides let non-standard exports import', () => {
131
+ const db = testDb();
132
+ const csv = 'Vorname,Nachname,E-Mail-Adresse\nHans,Muller,hans@example.com\n';
133
+ const r = importClients(db, csv, {
134
+ mapping: {
135
+ firstName: ['Vorname'], lastName: ['Nachname'], email: ['E-Mail-Adresse'],
136
+ phone: [], notes: [], waiverSigned: [],
137
+ },
138
+ });
139
+ assert.equal(r.created, 1);
140
+ assert.equal(db.prepare('SELECT name FROM clients WHERE email = ?').get('hans@example.com').name, 'Hans Muller');
141
+ });
142
+
143
+ test('CLI script: dry-run writes nothing, real run imports, re-run is idempotent', () => {
144
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-import-'));
145
+ const dbPath = path.join(dir, 'studio.db');
146
+ const script = path.join(__dirname, '..', 'scripts', 'import-mindbody.mjs');
147
+ const run = (extra) => spawnSync(process.execPath, [
148
+ script,
149
+ '--clients', path.join(FIXTURES, 'mindbody-clients.csv'),
150
+ '--passes', path.join(FIXTURES, 'mindbody-passes.csv'),
151
+ '--db', dbPath, ...extra,
152
+ ], { encoding: 'utf8' });
153
+
154
+ const dry = run(['--dry-run']);
155
+ assert.equal(dry.status, 0, dry.stderr);
156
+ assert.match(dry.stdout, /DRY RUN/);
157
+ let db = openDb(dbPath);
158
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 0, 'dry run wrote nothing');
159
+ db.close();
160
+
161
+ const real = run([]);
162
+ assert.equal(real.status, 0, real.stderr);
163
+ assert.match(real.stdout, /7 created/);
164
+ const rerun = run([]);
165
+ assert.equal(rerun.status, 0, rerun.stderr);
166
+ assert.match(rerun.stdout, /0 created, 7 updated/);
167
+
168
+ db = openDb(dbPath);
169
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 8, '7 from clients + Priya');
170
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes').get().c, 6);
171
+ db.close();
172
+ fs.rmSync(dir, { recursive: true, force: true });
173
+ });
@@ -0,0 +1,217 @@
1
+ // Instructor role (v0.2.0): migration, admin account management + class
2
+ // assignment, portal route guards, roster scoping, check-in. No network.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import request from 'supertest';
6
+ import { openDb, setSetting } from '../src/db/index.js';
7
+ import { createApp } from '../src/app.js';
8
+ import { createMailer } from '../src/services/mailer.js';
9
+ import { createUser } from '../src/services/auth.js';
10
+ import { makeClassType, makeInstance, makeClient, csrfToken } from './helpers.js';
11
+
12
+ function makeApp() {
13
+ const db = openDb(':memory:');
14
+ setSetting(db, 'studio_name', 'Instructor Test Studio');
15
+ setSetting(db, 'setup_complete', '1');
16
+ createUser(db, { email: 'owner@test.test', password: 'password123', role: 'owner' });
17
+ const app = createApp({ db, mailer: createMailer({ env: {} }), env: {} });
18
+ return { db, app };
19
+ }
20
+
21
+ function makeInstructor(db, { email = 'teach@test.test', password = 'teachpass1', name = 'Kim Lee' } = {}) {
22
+ return createUser(db, { email, password, name, role: 'instructor' });
23
+ }
24
+
25
+ function assign(db, instructorId, classId) {
26
+ db.prepare('INSERT INTO instructor_class_assignments (instructor_id, class_id) VALUES (?, ?)')
27
+ .run(instructorId, classId);
28
+ }
29
+
30
+ async function login(agent, email, password) {
31
+ const _csrf = await csrfToken(agent, '/admin/login');
32
+ const res = await agent.post('/admin/login').type('form').send({ email, password, _csrf });
33
+ return { res, _csrf };
34
+ }
35
+
36
+ test('migration v2: instructor role accepted, assignments table exists, owner intact', () => {
37
+ const { db } = makeApp();
38
+ const id = makeInstructor(db);
39
+ assert.equal(db.prepare('SELECT role FROM users WHERE id = ?').get(id).role, 'instructor');
40
+ assert.equal(db.prepare("SELECT role FROM users WHERE email = 'owner@test.test'").get().role, 'owner');
41
+ assert.throws(() => db.prepare("UPDATE users SET role = 'bogus' WHERE id = ?").run(id), /CHECK/);
42
+ const type = makeClassType(db);
43
+ const inst = makeInstance(db, type);
44
+ assign(db, id, inst);
45
+ assert.ok(db.prepare('SELECT 1 FROM instructor_class_assignments WHERE instructor_id = ? AND class_id = ?').get(id, inst));
46
+ });
47
+
48
+ test('instructor login redirects to /instructor/schedule; /login aliases the form', async () => {
49
+ const { db, app } = makeApp();
50
+ makeInstructor(db);
51
+ const alias = await request(app).get('/login');
52
+ assert.equal(alias.status, 302);
53
+ assert.equal(alias.headers.location, '/admin/login');
54
+
55
+ const agent = request.agent(app);
56
+ const { res } = await login(agent, 'teach@test.test', 'teachpass1');
57
+ assert.equal(res.status, 302);
58
+ assert.equal(res.headers.location, '/instructor/schedule');
59
+
60
+ // owner still lands on /admin
61
+ const admin = request.agent(app);
62
+ const { res: res2 } = await login(admin, 'owner@test.test', 'password123');
63
+ assert.equal(res2.headers.location, '/admin');
64
+ });
65
+
66
+ test('instructor cannot access /admin/* (403); anonymous still 302 to login', async () => {
67
+ const { db, app } = makeApp();
68
+ makeInstructor(db);
69
+ const agent = request.agent(app);
70
+ const { _csrf } = await login(agent, 'teach@test.test', 'teachpass1');
71
+
72
+ for (const path of ['/admin', '/admin/clients', '/admin/schedule', '/admin/settings', '/admin/reports']) {
73
+ const res = await agent.get(path);
74
+ assert.equal(res.status, 403, `${path} should 403 for instructor`);
75
+ }
76
+ const post = await agent.post('/admin/settings').type('form').send({ studio_name: 'Hacked', _csrf });
77
+ assert.equal(post.status, 403);
78
+
79
+ const anon = await request(app).get('/admin');
80
+ assert.equal(anon.status, 302);
81
+ assert.equal(anon.headers.location, '/admin/login');
82
+ });
83
+
84
+ test('portal guards: anonymous → login; admin → back to /admin', async () => {
85
+ const { app } = makeApp();
86
+ const anon = await request(app).get('/instructor/schedule');
87
+ assert.equal(anon.status, 302);
88
+ assert.equal(anon.headers.location, '/admin/login');
89
+
90
+ const admin = request.agent(app);
91
+ await login(admin, 'owner@test.test', 'password123');
92
+ const res = await admin.get('/instructor/schedule');
93
+ assert.equal(res.status, 302);
94
+ assert.equal(res.headers.location, '/admin');
95
+ });
96
+
97
+ test('schedule shows only assigned upcoming classes', async () => {
98
+ const { db, app } = makeApp();
99
+ const me = makeInstructor(db);
100
+ const mine = makeClassType(db, { name: 'Assigned Aerial' });
101
+ const other = makeClassType(db, { name: 'Unassigned Boxing' });
102
+ const myClass = makeInstance(db, mine, { hoursFromNow: 24 });
103
+ makeInstance(db, other, { hoursFromNow: 24 });
104
+ assign(db, me, myClass);
105
+
106
+ const agent = request.agent(app);
107
+ await login(agent, 'teach@test.test', 'teachpass1');
108
+ const res = await agent.get('/instructor/schedule');
109
+ assert.equal(res.status, 200);
110
+ assert.match(res.text, /Assigned Aerial/);
111
+ assert.doesNotMatch(res.text, /Unassigned Boxing/);
112
+ });
113
+
114
+ test('roster: 200 for assigned class, 403 for unassigned', async () => {
115
+ const { db, app } = makeApp();
116
+ const me = makeInstructor(db);
117
+ const type = makeClassType(db, { name: 'Spin' });
118
+ const myClass = makeInstance(db, type, { hoursFromNow: 24 });
119
+ const otherClass = makeInstance(db, type, { hoursFromNow: 48 });
120
+ assign(db, me, myClass);
121
+ const clientId = makeClient(db, { name: 'Rider One', email: 'rider@test.hk' });
122
+ const { book } = await import('../src/services/booking.js');
123
+ book(db, clientId, myClass);
124
+
125
+ const agent = request.agent(app);
126
+ await login(agent, 'teach@test.test', 'teachpass1');
127
+
128
+ const ok = await agent.get(`/instructor/classes/${myClass}/roster`);
129
+ assert.equal(ok.status, 200);
130
+ assert.match(ok.text, /Rider One/);
131
+ assert.match(ok.text, /booked/);
132
+ assert.doesNotMatch(ok.text, /rider@test.hk/, 'client emails are not exposed to instructors');
133
+
134
+ const forbidden = await agent.get(`/instructor/classes/${otherClass}/roster`);
135
+ assert.equal(forbidden.status, 403);
136
+ });
137
+
138
+ test('check-in: works for assigned class, 403 unassigned, 404 booking from another class', async () => {
139
+ const { db, app } = makeApp();
140
+ const me = makeInstructor(db);
141
+ const type = makeClassType(db, { name: 'HIIT' });
142
+ const myClass = makeInstance(db, type, { hoursFromNow: 24 });
143
+ const otherClass = makeInstance(db, type, { hoursFromNow: 48 });
144
+ assign(db, me, myClass);
145
+ const c1 = makeClient(db, { email: 'a@test.hk' });
146
+ const c2 = makeClient(db, { email: 'b@test.hk' });
147
+ const { book } = await import('../src/services/booking.js');
148
+ const { booking: myBooking } = book(db, c1, myClass);
149
+ const { booking: otherBooking } = book(db, c2, otherClass);
150
+
151
+ const agent = request.agent(app);
152
+ const { _csrf } = await login(agent, 'teach@test.test', 'teachpass1');
153
+
154
+ const ok = await agent.post(`/instructor/classes/${myClass}/checkin/${myBooking.id}`).type('form').send({ _csrf });
155
+ assert.equal(ok.status, 302);
156
+ assert.equal(ok.headers.location, `/instructor/classes/${myClass}/roster`);
157
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(myBooking.id).status, 'attended');
158
+ const roster = await agent.get(`/instructor/classes/${myClass}/roster`);
159
+ assert.match(roster.text, /checked-in/);
160
+
161
+ const forbidden = await agent.post(`/instructor/classes/${otherClass}/checkin/${otherBooking.id}`).type('form').send({ _csrf });
162
+ assert.equal(forbidden.status, 403);
163
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id = ?').get(otherBooking.id).status, 'booked');
164
+
165
+ const wrongClass = await agent.post(`/instructor/classes/${myClass}/checkin/${otherBooking.id}`).type('form').send({ _csrf });
166
+ assert.equal(wrongClass.status, 404);
167
+ });
168
+
169
+ test('admin UI: create instructor login, assign + unassign classes via checkboxes', async () => {
170
+ const { db, app } = makeApp();
171
+ const type = makeClassType(db, { name: 'Yin' });
172
+ const ci1 = makeInstance(db, type, { hoursFromNow: 24 });
173
+ const ci2 = makeInstance(db, type, { hoursFromNow: 48 });
174
+
175
+ const agent = request.agent(app);
176
+ const { _csrf } = await login(agent, 'owner@test.test', 'password123');
177
+
178
+ // create: bad password rejected, good one lands on the assignment page
179
+ const bad = await agent.post('/admin/instructors/new').type('form')
180
+ .send({ name: 'New Teach', email: 'nt@test.test', password: 'short', _csrf });
181
+ assert.equal(bad.status, 400);
182
+ const good = await agent.post('/admin/instructors/new').type('form')
183
+ .send({ name: 'New Teach', email: 'nt@test.test', password: 'longenough1', _csrf });
184
+ assert.equal(good.status, 302);
185
+ const user = db.prepare("SELECT * FROM users WHERE email = 'nt@test.test'").get();
186
+ assert.equal(user.role, 'instructor');
187
+ assert.equal(good.headers.location, `/admin/instructors/${user.id}/classes`);
188
+ const dup = await agent.post('/admin/instructors/new').type('form')
189
+ .send({ name: 'Dup', email: 'nt@test.test', password: 'longenough1', _csrf });
190
+ assert.equal(dup.status, 400);
191
+ assert.match(dup.text, /already has an account/);
192
+
193
+ // listed on the instructors page
194
+ const list = await agent.get('/admin/instructors');
195
+ assert.match(list.text, /nt@test.test/);
196
+
197
+ // assign both, then keep only one — unchecked box is removed
198
+ const both = await agent.post(`/admin/instructors/${user.id}/classes`).type('form')
199
+ .send({ class_ids: [String(ci1), String(ci2)], _csrf });
200
+ assert.equal(both.status, 302);
201
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM instructor_class_assignments WHERE instructor_id = ?').get(user.id).c, 2);
202
+
203
+ const one = await agent.post(`/admin/instructors/${user.id}/classes`).type('form')
204
+ .send({ class_ids: String(ci1), _csrf });
205
+ assert.equal(one.status, 302);
206
+ const left = db.prepare('SELECT class_id FROM instructor_class_assignments WHERE instructor_id = ?').all(user.id);
207
+ assert.deepEqual(left.map((r) => r.class_id), [ci1]);
208
+
209
+ // form page renders with the surviving box checked
210
+ const page = await agent.get(`/admin/instructors/${user.id}/classes`);
211
+ assert.equal(page.status, 200);
212
+ assert.match(page.text, new RegExp(`value="${ci1}" checked`));
213
+
214
+ // unknown account id → 404
215
+ const missing = await agent.get('/admin/instructors/9999/classes');
216
+ assert.equal(missing.status, 404);
217
+ });
@@ -0,0 +1,123 @@
1
+ // Rate limiter tests: fixed-window trigger + reset via an injected fake
2
+ // clock (no sleeping), and X-Forwarded-For handling with/without TRUST_PROXY.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import request from 'supertest';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { openDb, setSetting } from '../src/db/index.js';
10
+ import { createApp } from '../src/app.js';
11
+ import { createMailer } from '../src/services/mailer.js';
12
+ import { createUser } from '../src/services/auth.js';
13
+ import { csrfToken, makeClassType, makeInstance } from './helpers.js';
14
+
15
+ const OUTBOX = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-rl-outbox-'));
16
+ const WINDOW = 15 * 60 * 1000;
17
+
18
+ function makeApp({ env = {}, now } = {}) {
19
+ const db = openDb(':memory:');
20
+ setSetting(db, 'studio_name', 'RL Studio');
21
+ setSetting(db, 'setup_complete', '1');
22
+ createUser(db, { email: 'owner@test.test', password: 'password123', role: 'owner' });
23
+ const mailer = createMailer({ env: {}, outboxDir: OUTBOX });
24
+ const app = createApp({ db, mailer, env, now });
25
+ return { db, app };
26
+ }
27
+
28
+ test('/magic-link: 5 per 15 min, 6th → 429, next window resets', async () => {
29
+ let t = 10 * WINDOW; // arbitrary fixed start
30
+ const { app } = makeApp({ now: () => t });
31
+ const agent = request.agent(app);
32
+ const _csrf = await csrfToken(agent, '/magic-link');
33
+
34
+ for (let i = 1; i <= 5; i++) {
35
+ const res = await agent.post('/magic-link').type('form').send({ email: `x${i}@t.test`, _csrf });
36
+ assert.equal(res.status, 200, `request ${i} allowed`);
37
+ }
38
+ const blocked = await agent.post('/magic-link').type('form').send({ email: 'x6@t.test', _csrf });
39
+ assert.equal(blocked.status, 429);
40
+ assert.match(blocked.text, /Too many link requests/);
41
+ assert.equal(blocked.headers['retry-after'], String(WINDOW / 1000));
42
+
43
+ // advance the clock into the next window — allowed again
44
+ t += WINDOW;
45
+ const fresh = await agent.post('/magic-link').type('form').send({ email: 'x7@t.test', _csrf });
46
+ assert.equal(fresh.status, 200);
47
+ });
48
+
49
+ test('/admin/login: 10 per 15 min, wrong passwords count, 11th → 429', async () => {
50
+ let t = 10 * WINDOW;
51
+ const { app } = makeApp({ now: () => t });
52
+ const agent = request.agent(app);
53
+ const _csrf = await csrfToken(agent, '/admin/login');
54
+
55
+ for (let i = 1; i <= 10; i++) {
56
+ const res = await agent.post('/admin/login').type('form')
57
+ .send({ email: 'owner@test.test', password: 'wrong', _csrf });
58
+ assert.equal(res.status, 401, `attempt ${i} rejected but not throttled`);
59
+ }
60
+ const blocked = await agent.post('/admin/login').type('form')
61
+ .send({ email: 'owner@test.test', password: 'password123', _csrf });
62
+ assert.equal(blocked.status, 429, 'even correct credentials are throttled');
63
+ assert.match(blocked.text, /Too many login attempts/);
64
+ });
65
+
66
+ test('booking POSTs: 30 per 15 min shared across /class/:id/book', async () => {
67
+ let t = 10 * WINDOW;
68
+ const { db, app } = makeApp({ now: () => t });
69
+ const type = makeClassType(db, { capacity: 100 });
70
+ const instA = makeInstance(db, type, { hoursFromNow: 48 });
71
+ const instB = makeInstance(db, type, { hoursFromNow: 72 });
72
+ const agent = request.agent(app);
73
+ const _csrf = await csrfToken(agent, `/class/${instA}`);
74
+
75
+ // 30 posts (invalid email → 400, but each one counts) split across two
76
+ // instances: the bucket is per route PATTERN, not per concrete URL.
77
+ for (let i = 1; i <= 30; i++) {
78
+ const inst = i % 2 ? instA : instB;
79
+ const res = await agent.post(`/class/${inst}/book`).type('form').send({ email: 'nope', _csrf });
80
+ assert.equal(res.status, 400, `request ${i} allowed through the limiter`);
81
+ }
82
+ const blocked = await agent.post(`/class/${instA}/book`).type('form')
83
+ .send({ email: 'real@t.test', waiver_agree: '1', _csrf });
84
+ assert.equal(blocked.status, 429);
85
+ assert.match(blocked.text, /Too many booking attempts/);
86
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM bookings').get().c, 0);
87
+
88
+ t += WINDOW;
89
+ const fresh = await agent.post(`/class/${instA}/book`).type('form')
90
+ .send({ name: 'Real', email: 'real@t.test', waiver_agree: '1', _csrf });
91
+ assert.equal(fresh.status, 200, 'window reset allows booking again');
92
+ });
93
+
94
+ test('X-Forwarded-For is ignored unless TRUST_PROXY is set', async () => {
95
+ let t = 10 * WINDOW;
96
+ // TRUST_PROXY unset: spoofed XFF headers cannot dodge the limit
97
+ const { app } = makeApp({ now: () => t });
98
+ const agent = request.agent(app);
99
+ const _csrf = await csrfToken(agent, '/magic-link');
100
+ for (let i = 1; i <= 5; i++) {
101
+ await agent.post('/magic-link').type('form')
102
+ .set('X-Forwarded-For', `1.2.3.${i}`).send({ email: 'a@t.test', _csrf });
103
+ }
104
+ const blocked = await agent.post('/magic-link').type('form')
105
+ .set('X-Forwarded-For', '9.9.9.9').send({ email: 'a@t.test', _csrf });
106
+ assert.equal(blocked.status, 429, 'spoofed XFF still shares the socket-IP bucket');
107
+
108
+ // TRUST_PROXY=1: distinct forwarded IPs get distinct buckets
109
+ const trusted = makeApp({ env: { TRUST_PROXY: '1' }, now: () => t });
110
+ const agent2 = request.agent(trusted.app);
111
+ const csrf2 = await csrfToken(agent2, '/magic-link');
112
+ for (let i = 1; i <= 5; i++) {
113
+ const res = await agent2.post('/magic-link').type('form')
114
+ .set('X-Forwarded-For', '1.1.1.1').send({ email: 'a@t.test', _csrf: csrf2 });
115
+ assert.equal(res.status, 200);
116
+ }
117
+ const sameIp = await agent2.post('/magic-link').type('form')
118
+ .set('X-Forwarded-For', '1.1.1.1').send({ email: 'a@t.test', _csrf: csrf2 });
119
+ assert.equal(sameIp.status, 429, 'same forwarded IP is limited');
120
+ const otherIp = await agent2.post('/magic-link').type('form')
121
+ .set('X-Forwarded-For', '2.2.2.2').send({ email: 'a@t.test', _csrf: csrf2 });
122
+ assert.equal(otherIp.status, 200, 'different forwarded IP has its own bucket');
123
+ });
@@ -0,0 +1,102 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { testDb, makeClassType } from './helpers.js';
4
+ import { generateInstances, upcomingInstances } from '../src/services/schedule.js';
5
+ import { zonedToUtc, localDateStr, utcToZoned } from '../src/lib/time.js';
6
+
7
+ function makeRule(db, typeId, { weekday = 1, startTime = '09:00', capacityOverride = null, activeFrom = null, activeUntil = null } = {}) {
8
+ return db.prepare(
9
+ 'INSERT INTO schedule_rules (class_type_id, weekday, start_time, capacity_override, active_from, active_until) VALUES (?, ?, ?, ?, ?, ?)'
10
+ ).run(typeId, weekday, startTime, capacityOverride, activeFrom, activeUntil).lastInsertRowid;
11
+ }
12
+
13
+ test('zonedToUtc converts Hong Kong wall time to UTC (-8h, no DST)', () => {
14
+ const d = zonedToUtc('Asia/Hong_Kong', 2026, 8, 3, 9, 0);
15
+ assert.equal(d.toISOString(), '2026-08-03T01:00:00.000Z');
16
+ const p = utcToZoned('Asia/Hong_Kong', d);
17
+ assert.equal(p.hh, 9);
18
+ assert.equal(p.weekday, 1); // Monday
19
+ });
20
+
21
+ test('generator materializes 8 weeks of weekly instances, idempotently', () => {
22
+ const db = testDb();
23
+ const type = makeClassType(db, { capacity: 12 });
24
+ makeRule(db, type, { weekday: 1, startTime: '09:00' });
25
+
26
+ const created = generateInstances(db);
27
+ assert.equal(created, 8); // one Monday per week over 8 weeks
28
+
29
+ const again = generateInstances(db);
30
+ assert.equal(again, 0, 'second run creates nothing');
31
+
32
+ const rows = db.prepare('SELECT * FROM class_instances ORDER BY starts_at').all();
33
+ assert.equal(rows.length, 8);
34
+ for (const r of rows) {
35
+ const p = utcToZoned('Asia/Hong_Kong', r.starts_at);
36
+ assert.equal(p.weekday, 1, 'lands on Monday in studio tz');
37
+ assert.equal(p.hh, 9, 'at 09:00 studio time');
38
+ assert.equal(r.capacity, 12, 'inherits class type capacity');
39
+ }
40
+ });
41
+
42
+ test('capacity_override wins over class type capacity', () => {
43
+ const db = testDb();
44
+ const type = makeClassType(db, { capacity: 12 });
45
+ makeRule(db, type, { capacityOverride: 4 });
46
+ generateInstances(db);
47
+ const row = db.prepare('SELECT capacity FROM class_instances LIMIT 1').get();
48
+ assert.equal(row.capacity, 4);
49
+ });
50
+
51
+ test('active_from / active_until bound the generated range', () => {
52
+ const db = testDb();
53
+ const type = makeClassType(db);
54
+ const tz = 'Asia/Hong_Kong';
55
+ const today = localDateStr(tz);
56
+ // only active for the next ~2 weeks
57
+ const until = new Date(Date.now() + 14 * 86400000).toISOString().slice(0, 10);
58
+ makeRule(db, type, { weekday: 3, activeFrom: today, activeUntil: until });
59
+ generateInstances(db);
60
+ const rows = db.prepare('SELECT starts_at FROM class_instances').all();
61
+ assert.ok(rows.length >= 1 && rows.length <= 3, `expected ~2 instances, got ${rows.length}`);
62
+ });
63
+
64
+ test('inactive rules and inactive class types generate nothing', () => {
65
+ const db = testDb();
66
+ const type = makeClassType(db);
67
+ const rule = makeRule(db, type);
68
+ db.prepare('UPDATE schedule_rules SET active = 0 WHERE id = ?').run(rule);
69
+ assert.equal(generateInstances(db), 0);
70
+
71
+ db.prepare('UPDATE schedule_rules SET active = 1 WHERE id = ?').run(rule);
72
+ db.prepare('UPDATE class_types SET active = 0 WHERE id = ?').run(type);
73
+ assert.equal(generateInstances(db), 0);
74
+ });
75
+
76
+ test('deleting future instances then regenerating restores them (idempotent by rule+time)', () => {
77
+ const db = testDb();
78
+ const type = makeClassType(db);
79
+ makeRule(db, type);
80
+ generateInstances(db);
81
+ db.prepare('DELETE FROM class_instances').run();
82
+ const recreated = generateInstances(db);
83
+ assert.equal(recreated, 8);
84
+ });
85
+
86
+ test('upcomingInstances filters by type and hides cancelled', () => {
87
+ const db = testDb();
88
+ const yoga = makeClassType(db, { name: 'Yoga' });
89
+ const boxing = makeClassType(db, { name: 'Boxing' });
90
+ makeRule(db, yoga, { weekday: 2 });
91
+ makeRule(db, boxing, { weekday: 4 });
92
+ generateInstances(db);
93
+
94
+ const all = upcomingInstances(db, { days: 14 });
95
+ const yogaOnly = upcomingInstances(db, { days: 14, classTypeId: yoga });
96
+ assert.ok(all.length > yogaOnly.length);
97
+ assert.ok(yogaOnly.every((r) => r.class_name === 'Yoga'));
98
+
99
+ db.prepare("UPDATE class_instances SET status='cancelled' WHERE id = ?").run(all[0].id);
100
+ const after = upcomingInstances(db, { days: 14 });
101
+ assert.equal(after.length, all.length - 1);
102
+ });