From f109b3cf38cb4784881a661e1c157dca6d64ce13 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 22:11:05 -0400 Subject: [PATCH] test(18-02): add failing integration tests for admin timezone endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - describe('admin timezone config') covers 8 cases: - GET and PUT 403 for non-admin authenticated user (T-18-03) - GET with no stored row returns 200 with isExplicitlySet: false - PUT America/Chicago then GET round-trip with isExplicitlySet: true - PUT UTC returns 200 (Pitfall 2) - PUT Not/AZone returns 400 and does not write to app_config (T-18-04) - POST seed when unset stores the value (D-02) - POST seed when already set does NOT overwrite (D-03) - appConfig imported from db/schema for per-test cleanup - afterEach removes household_timezone row to prevent test bleed - 6 new cases FAIL (404 — endpoints not yet implemented); 19 existing pass --- apps/api/tests/routes/admin.test.ts | 170 +++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 1 deletion(-) diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index 81d5248..979e92e 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -30,7 +30,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { db } from '../../src/db/client.js'; -import { users, memberCredentials, calendars } from '../../src/db/schema.js'; +import { users, memberCredentials, calendars, appConfig } from '../../src/db/schema.js'; // --------------------------------------------------------------------------- // CalDAV mock — intercept createFastmailClient so tests do not hit live Fastmail. @@ -585,3 +585,171 @@ describe('POST /api/me/credential', () => { expect(res.status).toBe(200); }); }); + +// =========================================================================== +// admin timezone config (Plan 18-02: D-01, D-02, D-03, D-04) +// =========================================================================== + +describe('admin timezone config', () => { + // Clean up household_timezone between tests to avoid bleed + afterEach(async () => { + await db.delete(appConfig).where(eq(appConfig.key, 'household_timezone')); + }); + + // ------------------------------------------------------------------------- + // GET /api/admin/config/timezone — access control + // ------------------------------------------------------------------------- + + it('GET returns 403 for a non-admin authenticated user (T-18-03)', async () => { + const nonAdminId = await seedUser('tz-non-admin-get', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); + expect(res.status).toBe(403); + }); + + // ------------------------------------------------------------------------- + // PUT /api/admin/config/timezone — access control + // ------------------------------------------------------------------------- + + it('PUT returns 403 for a non-admin authenticated user (T-18-03)', async () => { + const nonAdminId = await seedUser('tz-non-admin-put', false); + currentDevUserId = nonAdminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), + ); + expect(res.status).toBe(403); + }); + + // ------------------------------------------------------------------------- + // GET /api/admin/config/timezone — admin reads when no row is stored + // ------------------------------------------------------------------------- + + it('GET as admin with no stored row returns 200 with isExplicitlySet: false and a non-empty timezone', async () => { + const adminId = await seedUser('tz-admin-get-default', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); + expect(res.status).toBe(200); + const body = (await res.json()) as { timezone: string; isExplicitlySet: boolean }; + expect(typeof body.timezone).toBe('string'); + expect(body.timezone.length).toBeGreaterThan(0); + expect(body.isExplicitlySet).toBe(false); + }); + + // ------------------------------------------------------------------------- + // PUT then GET round-trip + // ------------------------------------------------------------------------- + + it('PUT America/Chicago then GET returns that value with isExplicitlySet: true', async () => { + const adminId = await seedUser('tz-admin-roundtrip', true); + currentDevUserId = adminId; + const app = await getApp(); + + const putRes = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), + ); + expect(putRes.status).toBe(200); + + const getRes = await app.fetch(jsonRequest('GET', '/api/admin/config/timezone')); + expect(getRes.status).toBe(200); + const body = (await getRes.json()) as { timezone: string; isExplicitlySet: boolean }; + expect(body.timezone).toBe('America/Chicago'); + expect(body.isExplicitlySet).toBe(true); + }); + + // ------------------------------------------------------------------------- + // PUT with UTC must succeed (Pitfall 2) + // ------------------------------------------------------------------------- + + it('PUT UTC returns 200 (Pitfall 2 — UTC must be accepted)', async () => { + const adminId = await seedUser('tz-admin-utc', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'UTC' }), + ); + expect(res.status).toBe(200); + }); + + // ------------------------------------------------------------------------- + // PUT with invalid IANA string returns 400 and writes nothing (T-18-04) + // ------------------------------------------------------------------------- + + it('PUT Not/AZone returns 400 and does not store a value (T-18-04)', async () => { + const adminId = await seedUser('tz-admin-invalid', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'Not/AZone' }), + ); + expect(res.status).toBe(400); + + // Verify no row was written to app_config + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + expect(row).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // POST /api/admin/config/timezone/seed — seeds when unset (D-02) + // ------------------------------------------------------------------------- + + it('POST seed when unset stores the value and returns ok (D-02)', async () => { + const adminId = await seedUser('tz-admin-seed-unset', true); + currentDevUserId = adminId; + const app = await getApp(); + + const res = await app.fetch( + jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Europe/London' }), + ); + expect(res.status).toBe(200); + + // Verify the value was stored + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + expect(row?.value).toBe('Europe/London'); + }); + + // ------------------------------------------------------------------------- + // POST /api/admin/config/timezone/seed — no-overwrite when already set (D-03) + // ------------------------------------------------------------------------- + + it('POST seed when already set does NOT overwrite the existing value (D-03)', async () => { + const adminId = await seedUser('tz-admin-seed-overwrite', true); + currentDevUserId = adminId; + const app = await getApp(); + + // First set a value via PUT + const putRes = await app.fetch( + jsonRequest('PUT', '/api/admin/config/timezone', { timezone: 'America/Chicago' }), + ); + expect(putRes.status).toBe(200); + + // Now attempt to seed a different value + const seedRes = await app.fetch( + jsonRequest('POST', '/api/admin/config/timezone/seed', { timezone: 'Asia/Tokyo' }), + ); + expect(seedRes.status).toBe(200); + + // The stored value must still be America/Chicago (no overwrite, D-03) + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + expect(row?.value).toBe('America/Chicago'); + }); +});