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:
Lucas Berger
2026-06-04 09:50:16 -04:00
parent fb849605b1
commit 3f591566d1
22 changed files with 2923 additions and 0 deletions
+29
View File
@@ -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"]
+35
View File
@@ -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"
}
}
+22
View File
@@ -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)')
})
+22
View File
@@ -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)')
})
+24
View File
@@ -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)')
})
+29
View File
@@ -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)')
})
+16
View File
@@ -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)')
})
+59
View File
@@ -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`
+20
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
})
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#4A90D9" />
<title>FamilySync</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@familysync/pwa",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "5.101.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "5.0.14"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "8.0.16"
}
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.js'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/health': 'http://localhost:3000',
'/api': 'http://localhost:3000',
'/callback': 'http://localhost:3000',
},
},
})