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,249 @@
1
+ // Stripe webhook fulfillment with the injectable mock client. No network:
2
+ // the mock implements exactly the surface src/services/stripe.js touches.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import request from 'supertest';
6
+ import { createApp } from '../src/app.js';
7
+ import { createStripeService, fulfillCheckoutSession } from '../src/services/stripe.js';
8
+ import { createMailer } from '../src/services/mailer.js';
9
+ import { createUser } from '../src/services/auth.js';
10
+ import { openDb, setSetting } from '../src/db/index.js';
11
+
12
+ const VALID_SIG = 't=123,v1=goodsignature';
13
+
14
+ /** Mock of the stripe npm client: signature check + checkout session capture. */
15
+ function mockStripeClient() {
16
+ const created = [];
17
+ return {
18
+ created,
19
+ checkout: {
20
+ sessions: {
21
+ create: async (params) => {
22
+ created.push(params);
23
+ return { id: `cs_test_${created.length}`, url: 'https://checkout.stripe.test/session', ...params };
24
+ },
25
+ },
26
+ },
27
+ webhooks: {
28
+ constructEvent(rawBody, signature, secret) {
29
+ if (!secret) throw new Error('no webhook secret');
30
+ if (signature !== VALID_SIG) throw new Error('No signatures found matching the expected signature for payload');
31
+ return JSON.parse(rawBody.toString('utf8'));
32
+ },
33
+ },
34
+ };
35
+ }
36
+
37
+ function makeApp() {
38
+ const db = openDb(':memory:');
39
+ setSetting(db, 'studio_name', 'Stripe Test Studio');
40
+ setSetting(db, 'setup_complete', '1');
41
+ createUser(db, { email: 'owner@test.test', password: 'password123', role: 'owner' });
42
+ const client = mockStripeClient();
43
+ const stripeService = createStripeService({
44
+ env: { STRIPE_SECRET_KEY: 'sk_test_mock', STRIPE_WEBHOOK_SECRET: 'whsec_mock', STRIPE_PUBLISHABLE_KEY: 'pk_test_mock' },
45
+ client,
46
+ });
47
+ const mailer = createMailer({ env: {} });
48
+ const app = createApp({ db, mailer, stripeService, env: {} });
49
+ return { db, app, client };
50
+ }
51
+
52
+ function postEvent(app, event, sig = VALID_SIG) {
53
+ return request(app)
54
+ .post('/webhooks/stripe')
55
+ .set('stripe-signature', sig)
56
+ .set('content-type', 'application/json')
57
+ .send(JSON.stringify(event));
58
+ }
59
+
60
+ function checkoutCompleted(session) {
61
+ return { type: 'checkout.session.completed', data: { object: session } };
62
+ }
63
+
64
+ test('webhook rejects a bad signature with 400', async () => {
65
+ const { app } = makeApp();
66
+ const res = await postEvent(app, checkoutCompleted({ id: 'cs_x' }), 'bogus');
67
+ assert.equal(res.status, 400);
68
+ assert.match(res.body.error, /Signature verification failed/);
69
+ });
70
+
71
+ test('pack checkout fulfillment: creates client, pass and paid payment row', async () => {
72
+ const { db, app } = makeApp();
73
+ const productId = db.prepare(
74
+ "INSERT INTO pack_products (name, credits, price_cents, validity_days) VALUES ('10 Class Pack', 10, 150000, 180)"
75
+ ).run().lastInsertRowid;
76
+
77
+ const res = await postEvent(app, checkoutCompleted({
78
+ id: 'cs_pack_1',
79
+ amount_total: 150000,
80
+ currency: 'hkd',
81
+ metadata: { kind: 'pack', pack_product_id: String(productId), client_email: 'newbie@test.hk' },
82
+ }));
83
+ assert.equal(res.status, 200);
84
+ assert.equal(res.body.fulfilled, true);
85
+ assert.equal(res.body.kind, 'pack');
86
+
87
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get('newbie@test.hk');
88
+ assert.ok(client, 'client auto-created from checkout email');
89
+ const pass = db.prepare('SELECT * FROM passes WHERE client_id = ?').get(client.id);
90
+ assert.equal(pass.credits_total, 10);
91
+ assert.equal(pass.credits_remaining, 10);
92
+ assert.equal(pass.source, 'purchase');
93
+ assert.ok(pass.expires_on, 'validity_days produced an expiry date');
94
+ const payment = db.prepare('SELECT * FROM payments WHERE stripe_session_id = ?').get('cs_pack_1');
95
+ assert.equal(payment.amount_cents, 150000);
96
+ assert.equal(payment.method, 'stripe');
97
+ assert.equal(payment.status, 'paid');
98
+ assert.equal(payment.what, 'pack');
99
+ });
100
+
101
+ test('replayed webhook is a no-op (idempotent by stripe_session_id)', async () => {
102
+ const { db, app } = makeApp();
103
+ const productId = db.prepare(
104
+ "INSERT INTO pack_products (name, credits, price_cents) VALUES ('5 Pack', 5, 55000)"
105
+ ).run().lastInsertRowid;
106
+ const session = {
107
+ id: 'cs_replay',
108
+ amount_total: 55000,
109
+ currency: 'hkd',
110
+ metadata: { kind: 'pack', pack_product_id: String(productId), client_email: 'replay@test.hk' },
111
+ };
112
+ const first = await postEvent(app, checkoutCompleted(session));
113
+ assert.equal(first.body.fulfilled, true);
114
+ const second = await postEvent(app, checkoutCompleted(session));
115
+ assert.equal(second.status, 200, 'replay is acknowledged so Stripe stops retrying');
116
+ assert.equal(second.body.fulfilled, false);
117
+ assert.equal(second.body.reason, 'already_processed');
118
+
119
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get('replay@test.hk');
120
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM passes WHERE client_id = ?').get(client.id).c, 1, 'no duplicate pass');
121
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM payments').get().c, 1, 'no duplicate payment');
122
+ });
123
+
124
+ test('membership checkout fulfillment: activates membership with subscription id', async () => {
125
+ const { db, app } = makeApp();
126
+ const planId = db.prepare(
127
+ "INSERT INTO membership_plans (name, price_cents, unlimited, stripe_price_id) VALUES ('Unlimited Monthly', 128000, 1, 'price_mock')"
128
+ ).run().lastInsertRowid;
129
+
130
+ const res = await postEvent(app, checkoutCompleted({
131
+ id: 'cs_member_1',
132
+ amount_total: 128000,
133
+ currency: 'hkd',
134
+ subscription: 'sub_mock_1',
135
+ metadata: { kind: 'membership', plan_id: String(planId), client_email: 'member@test.hk' },
136
+ }));
137
+ assert.equal(res.status, 200);
138
+ assert.equal(res.body.fulfilled, true);
139
+ assert.equal(res.body.kind, 'membership');
140
+
141
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get('member@test.hk');
142
+ const m = db.prepare('SELECT * FROM memberships WHERE client_id = ?').get(client.id);
143
+ assert.equal(m.status, 'active');
144
+ assert.equal(m.unlimited, 1);
145
+ assert.equal(m.stripe_subscription_id, 'sub_mock_1');
146
+ const payment = db.prepare('SELECT * FROM payments WHERE stripe_session_id = ?').get('cs_member_1');
147
+ assert.equal(payment.what, 'membership');
148
+ assert.equal(payment.status, 'paid');
149
+ });
150
+
151
+ test('customer.subscription.deleted cancels the matching membership', async () => {
152
+ const { db, app } = makeApp();
153
+ const clientId = db.prepare(
154
+ "INSERT INTO clients (name, email, source) VALUES ('M', 'm@test.hk', 'self')"
155
+ ).run().lastInsertRowid;
156
+ db.prepare(
157
+ `INSERT INTO memberships (client_id, plan_name, status, started_on, stripe_subscription_id, unlimited)
158
+ VALUES (?, 'Unlimited', 'active', '2026-07-01', 'sub_gone', 1)`
159
+ ).run(clientId);
160
+
161
+ const res = await postEvent(app, { type: 'customer.subscription.deleted', data: { object: { id: 'sub_gone' } } });
162
+ assert.equal(res.status, 200);
163
+ assert.equal(res.body.fulfilled, true);
164
+ assert.equal(db.prepare('SELECT status FROM memberships WHERE client_id = ?').get(clientId).status, 'cancelled');
165
+ });
166
+
167
+ test('drop-in checkout marks the pending payment paid', async () => {
168
+ const { db, app } = makeApp();
169
+ const clientId = db.prepare(
170
+ "INSERT INTO clients (name, email, source) VALUES ('D', 'd@test.hk', 'self')"
171
+ ).run().lastInsertRowid;
172
+ const paymentId = db.prepare(
173
+ `INSERT INTO payments (client_id, amount_cents, currency, method, reference, what, status)
174
+ VALUES (?, 18000, 'HKD', 'other', 'drop-in', 'drop_in', 'pending')`
175
+ ).run(clientId).lastInsertRowid;
176
+
177
+ const res = await postEvent(app, checkoutCompleted({
178
+ id: 'cs_dropin_1',
179
+ amount_total: 18000,
180
+ currency: 'hkd',
181
+ metadata: { kind: 'drop_in', payment_id: String(paymentId), client_email: 'd@test.hk' },
182
+ }));
183
+ assert.equal(res.status, 200);
184
+ assert.equal(res.body.fulfilled, true);
185
+ const p = db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId);
186
+ assert.equal(p.status, 'paid');
187
+ assert.equal(p.method, 'stripe');
188
+ assert.equal(p.stripe_session_id, 'cs_dropin_1');
189
+ });
190
+
191
+ test('fulfillment failure returns 500 so Stripe retries', async () => {
192
+ const { app } = makeApp();
193
+ const res = await postEvent(app, checkoutCompleted({
194
+ id: 'cs_broken',
195
+ amount_total: 1000,
196
+ currency: 'hkd',
197
+ metadata: { kind: 'pack', pack_product_id: '9999', client_email: 'x@test.hk' },
198
+ }));
199
+ assert.equal(res.status, 500);
200
+ assert.match(res.body.error, /Unknown pack product/);
201
+ });
202
+
203
+ test('unhandled event types are acknowledged and ignored', async () => {
204
+ const { app } = makeApp();
205
+ const res = await postEvent(app, { type: 'invoice.paid', data: { object: {} } });
206
+ assert.equal(res.status, 200);
207
+ assert.equal(res.body.fulfilled, false);
208
+ assert.match(res.body.reason, /ignored event/);
209
+ });
210
+
211
+ test('createPackCheckout uses price_data fallback and pack metadata', async () => {
212
+ const { client } = { client: mockStripeClient() };
213
+ const svc = createStripeService({ env: { STRIPE_SECRET_KEY: 'sk_test_mock' }, client });
214
+ const session = await svc.createPackCheckout({
215
+ product: { id: 3, name: '10 Class Pack', price_cents: 150000, stripe_price_id: null },
216
+ clientEmail: 'buyer@test.hk',
217
+ baseUrl: 'http://studio.test',
218
+ currency: 'HKD',
219
+ });
220
+ assert.ok(session.url);
221
+ const params = client.created[0];
222
+ assert.equal(params.mode, 'payment');
223
+ assert.equal(params.line_items[0].price_data.unit_amount, 150000);
224
+ assert.equal(params.line_items[0].price_data.currency, 'hkd');
225
+ assert.equal(params.metadata.kind, 'pack');
226
+ assert.equal(params.metadata.pack_product_id, '3');
227
+ assert.match(params.success_url, /^http:\/\/studio\.test\/buy\/thanks/);
228
+ });
229
+
230
+ test('createMembershipCheckout requires a Stripe Price ID', async () => {
231
+ const client = mockStripeClient();
232
+ const svc = createStripeService({ env: { STRIPE_SECRET_KEY: 'sk_test_mock' }, client });
233
+ await assert.rejects(
234
+ () => svc.createMembershipCheckout({ plan: { id: 1, name: 'X', stripe_price_id: null }, clientEmail: 'a@b.c', baseUrl: 'http://x' }),
235
+ /no Stripe Price ID/
236
+ );
237
+ const session = await svc.createMembershipCheckout({
238
+ plan: { id: 1, name: 'Unlimited', stripe_price_id: 'price_123' },
239
+ clientEmail: 'a@b.c', baseUrl: 'http://studio.test',
240
+ });
241
+ assert.ok(session.url);
242
+ assert.equal(client.created[0].mode, 'subscription');
243
+ assert.equal(client.created[0].line_items[0].price, 'price_123');
244
+ });
245
+
246
+ test('fulfillCheckoutSession throws when the session has no email', () => {
247
+ const db = openDb(':memory:');
248
+ assert.throws(() => fulfillCheckoutSession(db, { id: 'cs_no_email', metadata: {} }), /no client email/);
249
+ });
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ // Static integrity checks over every EJS view: a mechanical edit once landed the CSRF
8
+ // hidden input INSIDE a form's action attribute, breaking submit URLs and leaking markup
9
+ // as visible text. Route tests miss this because they POST directly instead of
10
+ // submitting rendered forms — so the views themselves are the contract checked here.
11
+
12
+ const VIEWS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views');
13
+ const CSRF_INPUT = '<input type="hidden" name="_csrf" value="<%= csrfToken %>">';
14
+ // A <form ...> open tag; EJS blocks are consumed whole so their '>' does not end the tag.
15
+ const FORM_OPEN = /<form\b(?:<%[\s\S]*?%>|[^>])*>/g;
16
+
17
+ const ejsFiles = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
18
+ const p = path.join(dir, e.name);
19
+ return e.isDirectory() ? ejsFiles(p) : e.name.endsWith('.ejs') ? [p] : [];
20
+ });
21
+
22
+ for (const file of ejsFiles(VIEWS)) {
23
+ const rel = path.relative(VIEWS, file);
24
+ const src = fs.readFileSync(file, 'utf8');
25
+
26
+ test(`view integrity: ${rel}`, () => {
27
+ assert.ok(!/(?:action|href|value|name|class)="[^"]*<(?:input|button|select)/.test(src),
28
+ 'tag markup nested inside an attribute value');
29
+
30
+ for (const m of src.matchAll(FORM_OPEN)) {
31
+ const tag = m[0];
32
+ if (!/method\s*=\s*"post"/i.test(tag)) continue;
33
+ const after = src.slice(m.index + tag.length);
34
+ assert.ok(after.startsWith(CSRF_INPUT),
35
+ `POST form lacks the CSRF input immediately after its open tag: ${tag.slice(0, 80)}`);
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,392 @@
1
+ // Full-stack HTTP tests: setup wizard, auth, public booking round-trip with
2
+ // waiver, magic-link self-service, buy fallback, admin pages. No network.
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 { fileURLToPath } from 'node:url';
10
+ import { openDb, setSetting, getSetting } from '../src/db/index.js';
11
+ import { createApp } from '../src/app.js';
12
+ import { createMailer } from '../src/services/mailer.js';
13
+ import { makeMagicToken, verifyMagicToken, createUser } from '../src/services/auth.js';
14
+ import { makeClassType, makeInstance, makeClient, makePass, csrfToken } from './helpers.js';
15
+
16
+ const OUTBOX = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-outbox-'));
17
+
18
+ function makeApp({ setup = true } = {}) {
19
+ const db = openDb(':memory:');
20
+ if (setup) {
21
+ setSetting(db, 'studio_name', 'Web Test Studio');
22
+ setSetting(db, 'setup_complete', '1');
23
+ createUser(db, { email: 'owner@test.test', password: 'password123', role: 'owner' });
24
+ }
25
+ const mailer = createMailer({ env: {}, outboxDir: OUTBOX });
26
+ const app = createApp({ db, mailer, env: {} });
27
+ return { db, app };
28
+ }
29
+
30
+ /** Log the owner in on a cookie agent; returns the session's CSRF token for later POSTs. */
31
+ async function adminLogin(agent) {
32
+ const _csrf = await csrfToken(agent, '/admin/login');
33
+ const res = await agent.post('/admin/login').type('form')
34
+ .send({ email: 'owner@test.test', password: 'password123', _csrf });
35
+ assert.equal(res.status, 302);
36
+ return _csrf;
37
+ }
38
+
39
+ test('first-run gate redirects everything to /setup', async () => {
40
+ const { app } = makeApp({ setup: false });
41
+ const res = await request(app).get('/');
42
+ assert.equal(res.status, 302);
43
+ assert.equal(res.headers.location, '/setup');
44
+ const wizard = await request(app).get('/setup');
45
+ assert.equal(wizard.status, 200);
46
+ assert.match(wizard.text, /Welcome to Studio OS/);
47
+ });
48
+
49
+ test('setup wizard creates owner + settings and logs in', async () => {
50
+ const { db, app } = makeApp({ setup: false });
51
+ const agent = request.agent(app);
52
+ const _csrf = await csrfToken(agent, '/setup');
53
+ const res = await agent.post('/setup').type('form').send({
54
+ _csrf, studio_name: 'Harbour Yoga', timezone: 'Asia/Hong_Kong', currency: 'hkd',
55
+ email: 'owner@harbour.hk', password: 'supersecret', password2: 'supersecret',
56
+ seed_examples: '1',
57
+ });
58
+ assert.equal(res.status, 302);
59
+ assert.equal(res.headers.location, '/admin');
60
+ assert.equal(getSetting(db, 'setup_complete'), '1');
61
+ assert.equal(getSetting(db, 'studio_name'), 'Harbour Yoga');
62
+ assert.equal(getSetting(db, 'currency'), 'HKD');
63
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM class_types').get().c, 2, 'example types seeded');
64
+ const admin = await agent.get('/admin');
65
+ assert.equal(admin.status, 200, 'wizard session works');
66
+ // completed setup no longer reachable
67
+ const again = await request(app).get('/setup');
68
+ assert.equal(again.status, 302);
69
+ });
70
+
71
+ test('setup validation rejects bad input', async () => {
72
+ const { app } = makeApp({ setup: false });
73
+ const agent = request.agent(app);
74
+ const _csrf = await csrfToken(agent, '/setup');
75
+ const res = await agent.post('/setup').type('form').send({
76
+ _csrf, studio_name: 'X', timezone: 'Asia/Hong_Kong', currency: 'HKD',
77
+ email: 'bad-email', password: 'supersecret', password2: 'supersecret',
78
+ });
79
+ assert.equal(res.status, 400);
80
+ assert.match(res.text, /valid owner email/);
81
+ });
82
+
83
+ test('staff login/logout; admin requires auth', async () => {
84
+ const { app } = makeApp();
85
+ const anon = await request(app).get('/admin');
86
+ assert.equal(anon.status, 302);
87
+ assert.equal(anon.headers.location, '/admin/login');
88
+
89
+ const agent = request.agent(app);
90
+ const _csrf = await csrfToken(agent, '/admin/login');
91
+ const bad = await agent.post('/admin/login').type('form').send({ email: 'owner@test.test', password: 'wrong', _csrf });
92
+ assert.equal(bad.status, 401);
93
+ const good = await agent.post('/admin/login').type('form').send({ email: 'owner@test.test', password: 'password123', _csrf });
94
+ assert.equal(good.status, 302);
95
+ const dash = await agent.get('/admin');
96
+ assert.equal(dash.status, 200);
97
+ assert.match(dash.text, /Today's classes/);
98
+ await agent.post('/admin/logout').type('form').send({ _csrf });
99
+ const after = await agent.get('/admin');
100
+ assert.equal(after.status, 302);
101
+ });
102
+
103
+ test('public schedule renders instances', async () => {
104
+ const { db, app } = makeApp();
105
+ const type = makeClassType(db, { name: 'Aerial Yoga' });
106
+ makeInstance(db, type, { hoursFromNow: 24 });
107
+ const res = await request(app).get('/');
108
+ assert.equal(res.status, 200);
109
+ assert.match(res.text, /Aerial Yoga/);
110
+ assert.match(res.text, /spots? left/);
111
+ });
112
+
113
+ test('guest booking round-trip: waiver required, then booked; client + waiver stored', async () => {
114
+ const { db, app } = makeApp();
115
+ const type = makeClassType(db, { name: 'Boxing', capacity: 5 });
116
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
117
+
118
+ const agent = request.agent(app);
119
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
120
+
121
+ // missing waiver → 409 with message
122
+ const noWaiver = await agent.post(`/class/${inst}/book`).type('form')
123
+ .send({ name: 'Jane Chan', email: 'jane@test.hk', _csrf });
124
+ assert.equal(noWaiver.status, 409);
125
+ assert.match(noWaiver.text, /accept the waiver/);
126
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM bookings').get().c, 0);
127
+
128
+ // with waiver → booked
129
+ const ok = await agent.post(`/class/${inst}/book`).type('form')
130
+ .send({ name: 'Jane Chan', email: 'jane@test.hk', waiver_agree: '1', _csrf });
131
+ assert.equal(ok.status, 200);
132
+ assert.match(ok.text, /You're booked/);
133
+ const client = db.prepare('SELECT * FROM clients WHERE email = ?').get('jane@test.hk');
134
+ assert.ok(client, 'client created');
135
+ assert.equal(client.source, 'self');
136
+ assert.ok(client.waiver_signed_at, 'waiver recorded');
137
+ const booking = db.prepare('SELECT * FROM bookings WHERE client_id = ?').get(client.id);
138
+ assert.equal(booking.status, 'booked');
139
+ assert.equal(booking.paid_with, 'drop_in_manual');
140
+ // pending drop-in payment recorded
141
+ const payment = db.prepare('SELECT * FROM payments WHERE client_id = ?').get(client.id);
142
+ assert.equal(payment.status, 'pending');
143
+
144
+ // duplicate booking → friendly conflict
145
+ const dup = await agent.post(`/class/${inst}/book`).type('form')
146
+ .send({ name: 'Jane Chan', email: 'jane@test.hk', waiver_agree: '1', _csrf });
147
+ assert.equal(dup.status, 409);
148
+ assert.match(dup.text, /already have a booking/);
149
+ });
150
+
151
+ test('returning client with signed waiver books without checkbox, uses pack credit', async () => {
152
+ const { db, app } = makeApp();
153
+ const type = makeClassType(db);
154
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
155
+ const clientId = makeClient(db, { email: 'returning@test.hk', waiver: true });
156
+ makePass(db, clientId);
157
+
158
+ const agent = request.agent(app);
159
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
160
+ const res = await agent.post(`/class/${inst}/book`).type('form')
161
+ .send({ email: 'returning@test.hk', _csrf });
162
+ assert.equal(res.status, 200);
163
+ assert.match(res.text, /credit deducted from your class pack/);
164
+ });
165
+
166
+ test('full class books to waitlist via HTTP', async () => {
167
+ const { db, app } = makeApp();
168
+ const type = makeClassType(db, { capacity: 1 });
169
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
170
+ makeClient(db, { email: 'a@t.hk' });
171
+ const c = db.prepare('SELECT id FROM clients WHERE email=?').get('a@t.hk').id;
172
+ const { book } = await import('../src/services/booking.js');
173
+ book(db, c, inst);
174
+
175
+ const agent = request.agent(app);
176
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
177
+ const res = await agent.post(`/class/${inst}/book`).type('form')
178
+ .send({ email: 'b@t.hk', waiver_agree: '1', name: 'B', _csrf });
179
+ assert.equal(res.status, 200);
180
+ assert.match(res.text, /waitlist/i);
181
+ });
182
+
183
+ test('magic link: verify round-trip, view bookings, cancel within policy', async () => {
184
+ const { db, app } = makeApp();
185
+ const type = makeClassType(db, { name: 'Spin' });
186
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
187
+ const clientId = makeClient(db, { email: 'magic@test.hk' });
188
+ const passId = makePass(db, clientId);
189
+ const { book } = await import('../src/services/booking.js');
190
+ const { booking } = book(db, clientId, inst);
191
+
192
+ const token = makeMagicToken(db, clientId);
193
+ assert.equal(verifyMagicToken(db, token), clientId);
194
+ assert.equal(verifyMagicToken(db, token + 'x'), null, 'tampered token rejected');
195
+ assert.equal(verifyMagicToken(db, makeMagicToken(db, clientId, { now: Date.now() - 8 * 86400000 })), null, 'expired token rejected');
196
+
197
+ const me = await request(app).get(`/me?token=${encodeURIComponent(token)}`);
198
+ assert.equal(me.status, 200);
199
+ assert.match(me.text, /Spin/);
200
+
201
+ const agent = request.agent(app);
202
+ const _csrf = await csrfToken(agent, `/me?token=${encodeURIComponent(token)}`);
203
+ const cancel = await agent.post(`/me/cancel/${booking.id}`).type('form').send({ token, _csrf });
204
+ assert.equal(cancel.status, 302);
205
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id=?').get(booking.id).status, 'cancelled');
206
+ assert.equal(db.prepare('SELECT credits_remaining FROM passes WHERE id=?').get(passId).credits_remaining, 10, 'credit refunded');
207
+
208
+ const bad = await request(app).get('/me?token=garbage');
209
+ assert.equal(bad.status, 401);
210
+ });
211
+
212
+ test('magic link cannot cancel another client\'s booking', async () => {
213
+ const { db, app } = makeApp();
214
+ const type = makeClassType(db);
215
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
216
+ const victim = makeClient(db, { email: 'victim@test.hk' });
217
+ const attacker = makeClient(db, { email: 'attacker@test.hk' });
218
+ const { book } = await import('../src/services/booking.js');
219
+ const { booking } = book(db, victim, inst);
220
+
221
+ const agent = request.agent(app);
222
+ const _csrf = await csrfToken(agent, '/magic-link');
223
+ const res = await agent.post(`/me/cancel/${booking.id}`).type('form')
224
+ .send({ token: makeMagicToken(db, attacker), _csrf });
225
+ assert.equal(res.status, 404);
226
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id=?').get(booking.id).status, 'booked');
227
+ });
228
+
229
+ test('buy page renders and manual fallback works without Stripe', async () => {
230
+ const { db, app } = makeApp();
231
+ db.prepare("INSERT INTO pack_products (name, credits, price_cents) VALUES ('10 Pack', 10, 150000)").run();
232
+ const agent = request.agent(app);
233
+ const buy = await agent.get('/buy');
234
+ assert.equal(buy.status, 200);
235
+ assert.match(buy.text, /10 Pack/);
236
+ assert.match(buy.text, /pay at studio/i);
237
+ const _csrf = buy.text.match(/name="_csrf" value="([^"]+)"/)[1];
238
+
239
+ const packId = db.prepare('SELECT id FROM pack_products').get().id;
240
+ const res = await agent.post(`/buy/pack/${packId}`).type('form').send({ email: 'buyer@test.hk', _csrf });
241
+ assert.equal(res.status, 200);
242
+ assert.match(res.text, /Pay at the studio/i);
243
+ });
244
+
245
+ test('admin: rules CRUD generates instances; roster check-in works over HTTP', async () => {
246
+ const { db, app } = makeApp();
247
+ const agent = request.agent(app);
248
+ const _csrf = await adminLogin(agent);
249
+
250
+ const type = makeClassType(db, { name: 'HIIT' });
251
+ const rule = await agent.post('/admin/rules').type('form').send({
252
+ class_type_id: String(type), weekday: '2', start_time: '18:30', _csrf,
253
+ });
254
+ assert.equal(rule.status, 302);
255
+ const count = db.prepare('SELECT COUNT(*) c FROM class_instances').get().c;
256
+ assert.equal(count, 8, 'rule creation materialized 8 weeks');
257
+
258
+ // book someone in, then check them in via admin
259
+ const clientId = makeClient(db, { email: 'roster@test.hk' });
260
+ const instId = db.prepare('SELECT id FROM class_instances ORDER BY starts_at LIMIT 1').get().id;
261
+ const { book } = await import('../src/services/booking.js');
262
+ const { booking } = book(db, clientId, instId);
263
+
264
+ const roster = await agent.get(`/admin/instances/${instId}`);
265
+ assert.equal(roster.status, 200);
266
+ assert.match(roster.text, /roster@test.hk/);
267
+
268
+ const checkin = await agent.post(`/admin/bookings/${booking.id}/status`).type('form').send({ status: 'attended', _csrf });
269
+ assert.equal(checkin.status, 302);
270
+ assert.equal(db.prepare('SELECT status FROM bookings WHERE id=?').get(booking.id).status, 'attended');
271
+ });
272
+
273
+ test('admin: manual pass sale + payment, revenue report and CSV export', async () => {
274
+ const { db, app } = makeApp();
275
+ const agent = request.agent(app);
276
+ const _csrf = await adminLogin(agent);
277
+
278
+ const clientId = makeClient(db, { email: 'payer@test.hk' });
279
+ const sale = await agent.post(`/admin/clients/${clientId}/passes`).type('form').send({
280
+ name: '10-class pack', credits: '10', price: '1500', method: 'cash', record_payment: '1', _csrf,
281
+ });
282
+ assert.equal(sale.status, 302);
283
+ assert.equal(db.prepare('SELECT credits_remaining FROM passes WHERE client_id=?').get(clientId).credits_remaining, 10);
284
+ const pay = db.prepare('SELECT * FROM payments WHERE client_id=?').get(clientId);
285
+ assert.equal(pay.amount_cents, 150000);
286
+ assert.equal(pay.status, 'paid');
287
+
288
+ const reports = await agent.get('/admin/reports');
289
+ assert.equal(reports.status, 200);
290
+ assert.match(reports.text, /Revenue by month/);
291
+
292
+ const csv = await agent.get('/admin/reports.csv?report=revenue');
293
+ assert.equal(csv.status, 200);
294
+ assert.match(csv.headers['content-type'], /text\/csv/);
295
+ assert.match(csv.text, /month/);
296
+ });
297
+
298
+ test('admin: settings update + backup download', async () => {
299
+ const { db, app } = makeApp();
300
+ const agent = request.agent(app);
301
+ const _csrf = await adminLogin(agent);
302
+
303
+ const save = await agent.post('/admin/settings').type('form').send({
304
+ studio_name: 'Renamed Studio', cancellation_window_hours: '24', late_cancel_policy: 'refund', _csrf,
305
+ });
306
+ assert.equal(save.status, 302);
307
+ assert.equal(getSetting(db, 'studio_name'), 'Renamed Studio');
308
+ assert.equal(getSetting(db, 'cancellation_window_hours'), '24');
309
+ assert.equal(getSetting(db, 'late_cancel_policy'), 'refund');
310
+ });
311
+
312
+ test('admin: Mindbody import page dry-run previews, real run writes, re-run updates', async () => {
313
+ const { db, app } = makeApp();
314
+ const agent = request.agent(app);
315
+ const _csrf = await adminLogin(agent);
316
+
317
+ const csv = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'mindbody-clients.csv'), 'utf8');
318
+ const dry = await agent.post('/admin/import').type('form').send({ kind: 'clients', dry_run: '1', csv, _csrf });
319
+ assert.equal(dry.status, 200);
320
+ assert.match(dry.text, /dry run — nothing written/);
321
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 0);
322
+
323
+ const real = await agent.post('/admin/import').type('form').send({ kind: 'clients', csv, _csrf });
324
+ assert.equal(real.status, 200);
325
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 7);
326
+
327
+ const again = await agent.post('/admin/import').type('form').send({ kind: 'clients', csv, _csrf });
328
+ assert.equal(again.status, 200);
329
+ assert.match(again.text, /<strong>0<\/strong> created/);
330
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 7, 'idempotent via the web UI too');
331
+ });
332
+
333
+ test('CSRF: state-changing POST without a token → 403, nothing written', async () => {
334
+ const { db, app } = makeApp();
335
+ const type = makeClassType(db);
336
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
337
+
338
+ // no session at all
339
+ const bare = await request(app).post(`/class/${inst}/book`).type('form')
340
+ .send({ name: 'Evil', email: 'evil@test.hk', waiver_agree: '1' });
341
+ assert.equal(bare.status, 403);
342
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM bookings').get().c, 0);
343
+ assert.equal(db.prepare('SELECT COUNT(*) c FROM clients').get().c, 0);
344
+
345
+ // valid session, wrong token
346
+ const agent = request.agent(app);
347
+ await agent.get('/magic-link');
348
+ const wrong = await agent.post('/magic-link').type('form')
349
+ .send({ email: 'a@b.test', _csrf: 'not-the-real-token' });
350
+ assert.equal(wrong.status, 403);
351
+
352
+ // admin login is protected too — correct credentials without token still 403
353
+ const login = await request(app).post('/admin/login').type('form')
354
+ .send({ email: 'owner@test.test', password: 'password123' });
355
+ assert.equal(login.status, 403);
356
+ });
357
+
358
+ test('CSRF: POST with a valid session token works; Stripe webhook stays exempt', async () => {
359
+ const { app } = makeApp();
360
+ const agent = request.agent(app);
361
+ const _csrf = await csrfToken(agent, '/magic-link');
362
+ const ok = await agent.post('/magic-link').type('form').send({ email: 'nobody@test.hk', _csrf });
363
+ assert.equal(ok.status, 200);
364
+ // exempt from CSRF (signature-verified raw body instead): 501 unconfigured, not 403
365
+ const wh = await request(app).post('/webhooks/stripe').send({ some: 'payload' });
366
+ assert.equal(wh.status, 501);
367
+ });
368
+
369
+ test('webhook endpoint returns 501 when Stripe is unconfigured', async () => {
370
+ const { app } = makeApp();
371
+ const res = await request(app).post('/webhooks/stripe').send({ some: 'payload' });
372
+ assert.equal(res.status, 501);
373
+ });
374
+
375
+ test('emails are written to the outbox when SMTP is off', async () => {
376
+ const { db, app } = makeApp();
377
+ const type = makeClassType(db);
378
+ const inst = makeInstance(db, type, { hoursFromNow: 48 });
379
+ const before = fs.readdirSync(OUTBOX).length;
380
+ const agent = request.agent(app);
381
+ const _csrf = await csrfToken(agent, `/class/${inst}`);
382
+ await agent.post(`/class/${inst}/book`).type('form')
383
+ .send({ name: 'Mail Test', email: 'mail@test.hk', waiver_agree: '1', _csrf });
384
+ // mailer.send is async fire-and-forget; give it a tick
385
+ await new Promise((r) => setTimeout(r, 100));
386
+ const after = fs.readdirSync(OUTBOX).length;
387
+ assert.ok(after > before, 'an .eml file landed in the outbox');
388
+ const latest = fs.readdirSync(OUTBOX).sort().pop();
389
+ const content = fs.readFileSync(path.join(OUTBOX, latest), 'utf8');
390
+ assert.match(content, /To: mail@test.hk/);
391
+ assert.match(content, /\/me\?token=/);
392
+ });