diff --git a/Sprint-1-Testplan.md b/Sprint-1-Testplan.md new file mode 100644 index 0000000..12f1c78 --- /dev/null +++ b/Sprint-1-Testplan.md @@ -0,0 +1,375 @@ +# Sprint 1 Testplan — "Spark" + +**Status:** Draft v1 +**Sprint:** 1 — "Spark" +**Date:** 2026-06-24 +**Owner:** Patrick + Roo-Planner +**Basis:** [Sprint-1-Plan](Sprint-1-Plan.md) (chunks 1–4) + [Sprint-1-Assessment](Sprint-1-Assessment.md) + +--- + +## 1. Scope + +This testplan covers the **walking-skeleton MVP** for Sparkboard: plate-auth wire-up, single Idea entity CRUD-list, PWA install, and Gitea-Actions deploy to TrueNAS. Tests are organised by type: + +- **UT** — Unit tests (JUnit 5 + Mockito on backend, Vitest on frontend) +- **IT** — Integration tests (`@SpringBootTest` with Testcontainers Postgres on backend) +- **E2E** — End-to-end tests (Playwright against `docker-compose up`) +- **MT** — Manual tests (executed by Patrick on real hardware or on `https://sparkboard.plate-software.de`) + +Tests **not** in scope: plate-auth's own internals (those are covered by the plate-auth library's own test suite). Sparkboard's testplan only verifies the contract surface and Sparkboard-specific code. + +--- + +## 2. Test Overview + +| ID | Title | Type | Acceptance | +|----|-------|------|-----------| +| UT-01 | `IdeaService.create` persists with default status RAW and timestamps | UT | A4 | +| UT-02 | `IdeaService.listForOrg` returns newest first | UT | A4 | +| UT-03 | `IdeaController` rejects empty title (400) | UT | A4 | +| UT-04 | `IdeaController` rejects title > 200 chars (400) | UT | A4 | +| UT-05 | `IdeaController` injects `@CurrentUser` as author | UT | A4 | +| UT-06 | `SparkboardOnboardingHook` writes ADMIN row when user email is in `sparkboard.admins[]` | UT | A3 | +| UT-07 | `SparkboardOnboardingHook` writes MEMBER row when user email is NOT in `sparkboard.admins[]` | UT | A3 | +| UT-08 | `SparkboardOnboardingHook` is idempotent (running twice = one row) | UT | A3 | +| UT-09 | `SparkboardAdminProperties` binds `sparkboard.admins[]` from YAML | UT | A3 | +| UT-10 | Frontend `lib/api.ts` forwards cookies on server-component calls | UT | A4 | +| IT-01 | Sparkboard Flyway migrations run cleanly on empty DB | IT | A3, A4 | +| IT-02 | Sparkboard + plate-auth migrations coexist (two history tables) | IT | A3 | +| IT-03 | `POST /api/ideas` → 200 with authenticated context, idea persisted | IT | A4 | +| IT-04 | `GET /api/ideas` → 200 with `[idea]` returned for the org | IT | A4 | +| IT-05 | `POST /api/ideas` → 401 without auth | IT | A4 | +| IT-06 | `OnboardingHook` triggers on first plate-auth login (integration with real `MembershipService`) | IT | A3 | +| E2E-01 | Allowlisted user → sign-in → `/ideas` happy path | E2E | A1, A4 | +| E2E-02 | Non-allowlisted user → sign-in attempt → rejection screen | E2E | A2 | +| E2E-03 | `/manifest.json` serves valid PWA manifest with `theme_color #ea580c` | E2E | A5 | +| E2E-04 | `/sw.js` registers without console errors | E2E | A5 | +| E2E-05 | Logged-in user creates idea → redirected to `/ideas` → idea visible | E2E | A4 | +| E2E-06 | Two different allowlisted users see each other's ideas (shared org) | E2E | A4 | +| MT-01 | iPhone Safari → "Add to Home Screen" → standalone PWA launches | MT | A5 | +| MT-02 | Android Chrome → install prompt → standalone PWA launches | MT | A5 | +| MT-03 | 5th account (off allowlist) sign-in attempt produces visible error | MT | A2 | +| MT-04 | `git push origin main` triggers `.gitea/workflows/deploy.yml` → green | MT | A6 | +| MT-05 | `smoke-test.sh` against `https://sparkboard.plate-software.de` exits 0 | MT | A6 | +| MT-06 | All 4 humans on allowlist can sign in over the course of one day | MT | A1, A3 | +| MT-07 | README quickstart works on a fresh `git clone` on a clean machine | MT | DoD §10.5 | + +**Total: 27 test cases** (10 UT + 6 IT + 6 E2E + 7 MT). + +--- + +## 3. Unit Tests (Backend) + +### UT-01 — IdeaService.create persists with default status and timestamps + +- **Class:** `de.plate.sparkboard.idea.IdeaServiceTest` +- **Setup:** Mock `IdeaRepository.save` to return its input. +- **Given:** `CreateIdeaRequest("My idea", "Body", null)` and a known `userId`/`orgId`. +- **When:** `ideaService.create(request, userId, orgId)` +- **Then:** + - Returned `Idea` has `status = IdeaStatus.RAW`. + - `createdAt` is set (within last 1 sec). + - `updatedAt == createdAt`. + - `authorId == userId`, `orgId == orgId`. + - `IdeaRepository.save` called exactly once. + +### UT-02 — IdeaService.listForOrg returns newest first + +- **Given:** Repo returns 3 ideas with `createdAt` of T-2h, T-1h, T-0. +- **When:** `ideaService.listForOrg(orgId)` +- **Then:** Result list is `[T-0, T-1h, T-2h]` (newest first). + +### UT-03 — IdeaController rejects empty title (400) + +- **Class:** `IdeaControllerTest` using `@WebMvcTest`. +- **When:** `POST /api/ideas` with body `{"title": "", "body": null}` and valid auth. +- **Then:** HTTP 400, validation error mentions `title`. + +### UT-04 — IdeaController rejects title > 200 chars (400) + +- **When:** `POST /api/ideas` with `title = "x".repeat(201)`. +- **Then:** HTTP 400, validation error mentions `size` or `length`. + +### UT-05 — IdeaController injects @CurrentUser as author + +- **When:** Authenticated POST with `userId = U` and `FAMILY_SPARK_ID`. +- **Then:** Captured `IdeaService.create` argument has `authorId == U`. + +### UT-06 — SparkboardOnboardingHook ADMIN path + +- **Class:** `de.plate.sparkboard.auth.SparkboardOnboardingHookTest` +- **Given:** `sparkboard.admins[] = ["patrick@plate-software.de"]`, login event for `patrick@plate-software.de`. +- **When:** `hook.onFirstLogin(authenticatedUser)` +- **Then:** `MembershipService.upsert(userId, "SPARK_ORG", FAMILY_SPARK_ID, "ADMIN")` called once. + +### UT-07 — SparkboardOnboardingHook MEMBER path + +- **Given:** `sparkboard.admins[] = ["patrick@plate-software.de"]`, login event for `kid@example.com`. +- **Then:** `MembershipService.upsert(userId, "SPARK_ORG", FAMILY_SPARK_ID, "MEMBER")` called once. + +### UT-08 — Hook idempotency + +- **When:** `hook.onFirstLogin(user)` called twice with same user. +- **Then:** `MembershipService.upsert` called twice with identical arguments; no exception. (`upsert` semantics, not duplicate insert.) + +### UT-09 — SparkboardAdminProperties binding + +- **Class:** `SparkboardAdminPropertiesTest` using `ApplicationContextRunner`. +- **Given:** YAML `sparkboard.admins: [a@x.de, b@y.de]`. +- **Then:** `properties.admins() == List.of("a@x.de", "b@y.de")`. + +--- + +## 4. Unit Tests (Frontend) + +### UT-10 — lib/api.ts forwards cookies on server-component calls + +- **Tool:** Vitest + `next/headers` mock. +- **Given:** `cookies()` returns `[{name: "next-auth.session-token", value: "abc"}]`. +- **When:** `listIdeas()` is called from a server component. +- **Then:** The underlying `fetch` is called with `headers.Cookie === "next-auth.session-token=abc"`. + +--- + +## 5. Integration Tests (Backend) + +### IT-01 — Sparkboard Flyway migrations run cleanly + +- **Class:** `SparkboardFlywayMigrationTest` using Testcontainers Postgres. +- **When:** Spring Boot starts with `spring.flyway.locations=classpath:db/migration`. +- **Then:** + - `flyway_schema_history` table exists. + - `spark_org` table exists with 1 seeded row (`'00000000-0000-0000-0000-000000000001'`, name = "Family Spark"). + - `ideas` table exists with all columns from V1. + - No errors in startup log. + +### IT-02 — Sparkboard + plate-auth migrations coexist + +- **Class:** `DualFlywayHistoryTest`. +- **Setup:** Boot with both `plate-auth-starter` (config: `plate.auth.flyway.history-table = flyway_schema_history_auth`) and Sparkboard migrations. +- **Then:** + - Both `flyway_schema_history` AND `flyway_schema_history_auth` tables exist. + - Migrations from both projects applied without collision. + - `auth_identities` (plate-auth) and `ideas` (Sparkboard) both queryable. + +### IT-03 — POST /api/ideas → 200 with authenticated context + +- **Class:** `IdeaApiIntegrationTest` using `@SpringBootTest(WebEnvironment.RANDOM_PORT)` + a stubbed JWT principal. +- **Given:** Mock authenticated user with `userId = U`. +- **When:** `POST /api/ideas { "title": "T", "body": "B" }`. +- **Then:** + - HTTP 200, body is the created `IdeaDto`. + - Postgres now contains one `ideas` row with `author_id = U`, `org_id = FAMILY_SPARK_ID`, `title = "T"`. + +### IT-04 — GET /api/ideas → 200 with `[idea]` + +- **Given:** One `ideas` row seeded with `org_id = FAMILY_SPARK_ID`. +- **When:** `GET /api/ideas` with auth. +- **Then:** HTTP 200, body is a JSON array of length 1 with matching `title`. + +### IT-05 — POST /api/ideas → 401 without auth + +- **When:** `POST /api/ideas` with no `Authorization` header and no session cookie. +- **Then:** HTTP 401, no DB write. + +### IT-06 — OnboardingHook triggers on first plate-auth login (real wiring) + +- **Class:** `OnboardingHookIntegrationTest`. +- **Setup:** Boot full Spring context with `SparkboardOnboardingHook` registered as a bean. +- **When:** Call `AuthService.handleFirstLogin(authenticatedUser)` (the plate-auth integration point). +- **Then:** + - `memberships` table contains a row with `(user_id, 'SPARK_ORG', FAMILY_SPARK_ID, 'MEMBER')`. + - Second invocation does not add a duplicate row. + +--- + +## 6. End-to-End Tests (Playwright) + +> **Target:** `docker-compose up` in CI; smoke-tested against TrueNAS deploy. + +### E2E-01 — Allowlisted user → sign-in → `/ideas` + +- **Steps:** + 1. Navigate to `/`. + 2. Click "Sign in with Google". + 3. Use a mocked OAuth callback OR a real test-google account on allowlist. + 4. Land on `/ideas`. +- **Assert:** URL is `/ideas`; page renders `