test(18-02): add failing integration tests for admin timezone endpoints

- 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
This commit is contained in:
Lucas Berger
2026-06-14 22:11:05 -04:00
parent ac60161726
commit f109b3cf38
+169 -1
View File
@@ -30,7 +30,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '../../src/db/client.js'; 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. // 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); 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');
});
});