chore(01-01): scaffold monorepo, Docker Compose stack, and Vitest harness
- pnpm workspace with apps/api (Hono/Drizzle) and apps/pwa (Vite/React 19) - Pinned versions per RESEARCH: hono@4.12.23, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, zod@^3.25.0, node-cron@^4.2.1 - docker-compose.yml with mariadb:11 healthcheck, api depends_on service_healthy, redis stub - docker-compose.dev.yml overrides for local dev (bind mounts, exposed ports) - .env.example lists all env vars (DB_*, OIDC_*, APP_PASSWORD_ENCRYPTION_KEY) - .gitignore excludes .env (never commit secrets) - apps/api/vitest.config.ts with environment: node - Wave 0 test stubs: health, auth/user, broker/crypto, broker/sync, broker/poller
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
FROM base AS builder
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
# PWA static assets served from ./public (built separately)
|
||||
COPY apps/pwa/dist/ ./public/ 2>/dev/null || true
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
FROM base AS dev
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY tsconfig.json ./
|
||||
CMD ["pnpm", "dev"]
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@familysync/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch dist/index.js",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "2.0.4",
|
||||
"@hono/oidc-auth": "1.8.3",
|
||||
"@hono/zod-validator": "0.8.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"hono": "4.12.23",
|
||||
"ical.js": "2.2.1",
|
||||
"mysql2": "3.22.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"tsdav": "2.2.2",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Wave 0 stub — Auth: upsertUser color round-robin + identity stability
|
||||
*
|
||||
* These tests are RED stubs. Implementation lives in:
|
||||
* apps/api/src/auth/user.ts (Plan 02)
|
||||
*
|
||||
* Tests will be filled GREEN in Plan 02 when upsertUser is implemented.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('upsertUser', () => {
|
||||
it.todo('assigns a color from the palette on first login (Plan 02)')
|
||||
|
||||
it.todo('returns the same color on subsequent logins for the same oidc identity (Plan 02)')
|
||||
|
||||
it.todo('assigns distinct colors to two different users (Plan 02)')
|
||||
|
||||
it.todo('uses oidc_iss + oidc_sub as the composite identity key, not email (Plan 02)')
|
||||
|
||||
it.todo('returns the full user row including id, color, displayName (Plan 02)')
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Wave 0 stub — Broker: AES-GCM app-password encryption
|
||||
*
|
||||
* These tests are RED stubs. Implementation lives in:
|
||||
* apps/api/src/broker/crypto.ts (Plan 03)
|
||||
*
|
||||
* Tests will be filled GREEN in Plan 03 when crypto helpers are implemented.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('encryptPassword / decryptPassword', () => {
|
||||
it.todo('roundtrip: decrypt(encrypt(plaintext)) === plaintext (Plan 03)')
|
||||
|
||||
it.todo('different IVs produce different ciphertext for the same plaintext (Plan 03)')
|
||||
|
||||
it.todo('decrypting with a tampered authTag throws (Plan 03)')
|
||||
|
||||
it.todo('decrypting with a tampered ciphertext throws (Plan 03)')
|
||||
|
||||
it.todo('stored payload is valid JSON with iv, authTag, ciphertext fields (Plan 03)')
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Wave 0 stub — Broker: ctag polling + change detection
|
||||
*
|
||||
* These tests are RED stubs. Implementation lives in:
|
||||
* apps/api/src/broker/poller.ts (Plan 03)
|
||||
*
|
||||
* Tests will be filled GREEN in Plan 03 when the poller is implemented.
|
||||
*
|
||||
* Key behavior (D-13): ctag unchanged → no DB write (skip sync entirely)
|
||||
*/
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('broker poller', () => {
|
||||
it.todo('skips syncCalendar when ctag is unchanged (Plan 03)')
|
||||
|
||||
it.todo('calls syncCalendar when ctag changes (Plan 03)')
|
||||
|
||||
it.todo('calls syncCalendar when ctag was null (first sync) (Plan 03)')
|
||||
|
||||
it.todo('handles decryptPassword failure gracefully without crashing the poller (Plan 03)')
|
||||
|
||||
it.todo('processes all member credentials in a poll cycle (Plan 03)')
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Wave 0 stub — Broker: syncCalendar event upsert + all-day handling
|
||||
*
|
||||
* These tests are RED stubs. Implementation lives in:
|
||||
* apps/api/src/broker/sync.ts (Plan 03)
|
||||
*
|
||||
* Tests will be filled GREEN in Plan 03 when syncCalendar is implemented.
|
||||
*
|
||||
* Key behaviors to verify (D-13):
|
||||
* - All-day events: dtstart_date (DATE) set, dtstart_utc NULL, allDay=true
|
||||
* - Timed events: dtstart_utc (TIMESTAMP UTC) set, dtstart_date NULL, allDay=false
|
||||
* - UID used as idempotency key: second sync of same UID is an upsert, not duplicate
|
||||
*/
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('syncCalendar', () => {
|
||||
it.todo('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL (Plan 03)')
|
||||
|
||||
it.todo('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL (Plan 03)')
|
||||
|
||||
it.todo('sets allDay=true for all-day events, allDay=false for timed (Plan 03)')
|
||||
|
||||
it.todo('upserts on duplicate UID within the same calendar (Plan 03)')
|
||||
|
||||
it.todo('stores the raw VEVENT blob in rawVevent column (Plan 03)')
|
||||
|
||||
it.todo('updates the calendar ctag/syncToken after a successful sync (Plan 03)')
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Wave 0 stub — GET /health: 200 + real DB round-trip
|
||||
*
|
||||
* This test is a RED stub. Implementation lives in:
|
||||
* apps/api/src/routes/health.ts (Task 2)
|
||||
*
|
||||
* The test will be filled GREEN in Task 2 when the health route is implemented.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('GET /health', () => {
|
||||
it.todo('returns 200 with { ok: true, db: "up" } after a real DB round-trip (Task 2)')
|
||||
|
||||
it.todo('returns 503 if the DB round-trip throws (Task 2)')
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Test DB fixture helpers.
|
||||
*
|
||||
* For unit tests (Tasks 1-2 scope): provides mock/stub helpers so tests can run
|
||||
* without a real MariaDB connection. Plan 02/03 will fill in integration-style
|
||||
* fixtures that hit the Docker MariaDB (DB_HOST=127.0.0.1 from the dev compose override).
|
||||
*/
|
||||
|
||||
// Re-export vi for convenience in test files
|
||||
export { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Creates a minimal mock for the Drizzle `db` singleton.
|
||||
* Replace individual methods per test as needed.
|
||||
*
|
||||
* Plan 02 (auth) will extend this with a real test-schema fixture.
|
||||
* Plan 03 (broker) will extend with event-fixture helpers.
|
||||
*/
|
||||
export function createMockDb() {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
$returningId: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample VEVENT string for broker tests — a timed event (Plan 03).
|
||||
*/
|
||||
export const SAMPLE_VEVENT_TIMED = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//FamilySync//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:test-timed-event-001@familysync
|
||||
DTSTART:20260615T100000Z
|
||||
DTEND:20260615T110000Z
|
||||
SUMMARY:Test Timed Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
|
||||
/**
|
||||
* Sample VEVENT string for broker tests — an all-day event (Plan 03).
|
||||
*/
|
||||
export const SAMPLE_VEVENT_ALLDAY = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//FamilySync//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:test-allday-event-001@familysync
|
||||
DTSTART;VALUE=DATE:20260615
|
||||
DTEND;VALUE=DATE:20260616
|
||||
SUMMARY:Test All-Day Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user