docs(sprint-1-testplan): 27 test cases across UT/IT/E2E/MT mapped to A1-A6

Patrick Plate
2026-06-24 14:56:42 +02:00
parent 7167688de0
commit ec5cc6c151
+375
@@ -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 14) + [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 `<h1>Ideas</h1>`; "New idea" link visible.
### E2E-02 — Non-allowlisted user → rejection
- **Steps:** Same flow with a Google account whose email is NOT in plate-auth's allowlist.
- **Assert:** Redirected to `/login?error=access_denied` (or equivalent) with a visible message; NO `memberships` row created in DB.
### E2E-03 — `/manifest.json` is valid
- **Steps:** `GET /manifest.json`.
- **Assert:**
- HTTP 200, `content-type: application/manifest+json` (or `application/json`).
- JSON parses cleanly.
- Contains `name: "Sparkboard"`, `theme_color: "#ea580c"`, `display: "standalone"`.
### E2E-04 — `/sw.js` registers cleanly
- **Steps:** Navigate to `/`; wait for service worker registration via `navigator.serviceWorker.ready`.
- **Assert:**
- No console errors with severity error.
- `navigator.serviceWorker.controller` is non-null OR the registration promise resolved.
### E2E-05 — Create idea happy path
- **Steps:**
1. Auth as allowlisted user.
2. Click "New idea" → `/ideas/new`.
3. Fill title `"E2E test"`, body `"E2E body"`, submit.
4. Redirected to `/ideas`.
- **Assert:** `/ideas` shows `<li>` (or similar) containing `"E2E test"`.
### E2E-06 — Two users share an org
- **Steps:**
1. User A creates idea `"From A"`.
2. User B (different allowlisted account) loads `/ideas`.
- **Assert:** User B sees `"From A"` in the list (proves shared org via `FAMILY_SPARK_ID`).
---
## 7. Manual Tests
### MT-01 — iPhone Safari install
- **Device:** Patrick's iPhone.
- **Steps:**
1. Safari → `https://sparkboard.plate-software.de`.
2. Share menu → "Add to Home Screen".
3. Confirm icon + name (`Sparkboard`).
4. Launch from home screen.
- **Assert:** App launches in standalone mode (no Safari chrome); icon is Sparkboard's; title bar reflects the app.
### MT-02 — Android Chrome install
- **Device:** Any Android device.
- **Steps:**
1. Chrome → `https://sparkboard.plate-software.de`.
2. Accept "Install app" banner OR menu → "Install app".
3. Launch from launcher.
- **Assert:** Same as MT-01 (standalone, correct icon).
### MT-03 — 5th account off-allowlist rejection (visible error)
- **Steps:** Sign in with a Google account NOT on the allowlist.
- **Assert:** Plain-language rejection message; **not** a 500 stack trace; user can navigate back to `/login`.
### MT-04 — Gitea Actions deploy on `main`
- **Steps:**
1. Commit a trivial change to `main` (e.g., README typo fix).
2. Push.
3. Watch `.gitea/workflows/deploy.yml` run.
- **Assert:** Workflow run shows ✅ Success; `https://sparkboard.plate-software.de/` serves the latest commit.
### MT-05 — Smoke test
- **Steps:** Run `deploy/smoke-test.sh https://sparkboard.plate-software.de` from Patrick's laptop.
- **Assert:** Exit code 0; outputs confirmations for `/api/health`, `/login`, `/manifest.json`, `/sw.js`.
### MT-06 — All 4 humans sign in within one day
- **Steps:** All 4 family members sign in over a 24-hour window.
- **Assert:** `memberships` table contains 4 rows for `(SPARK_ORG, FAMILY_SPARK_ID)`. No errors in backend logs.
### MT-07 — README quickstart on fresh clone
- **Steps:**
1. `git clone <sparkboard repo>` on a clean machine (or after `docker system prune` + cleaned `~/.m2`).
2. Follow `README.md` step-by-step.
3. Reach the point where `http://localhost:3000` shows the login page.
- **Assert:** No undocumented steps required. Any deviation = README fix before Sprint 1 closes.
---
## 8. Test Data
### 8.1 Production seed (V2)
- One row in `spark_org`: `('00000000-0000-0000-0000-000000000001', 'Family Spark', 'SPARK_ORG', now())`.
- No seed for `ideas` in production.
- `memberships` rows created lazily via `SparkboardOnboardingHook` on first login.
### 8.2 Dev seed (R__dev_seed_ideas.sql, dev profile only)
- 5 dev-only ideas with placeholder text.
- Skipped in `prod` profile via Flyway placeholders or profile-conditional location.
### 8.3 Test fixtures
- `IdeaTestBuilder` (in `src/test/java`) for `@WebMvcTest` and `@SpringBootTest`.
- Playwright auth state: pre-authenticated session cookie OR mocked OAuth callback.
---
## 9. Environments
| Env | URL | Purpose |
|-----|-----|---------|
| Local dev | `http://localhost:3000` + `http://localhost:8080` | UT, IT, E2E |
| Gitea Actions CI | ephemeral docker-compose | UT, IT, E2E |
| TrueNAS production | `https://sparkboard.plate-software.de` | MT-01..MT-07 |
---
## 10. Coverage Mapping
Cross-check that every acceptance criterion has at least 2 covering tests (so a single test failure can't claim acceptance):
| Acceptance | Covered by |
|-----------|-----------|
| A1 — Allowlisted sign-in → `/ideas` | E2E-01, MT-06 |
| A2 — Non-allowlisted rejected | E2E-02, MT-03 |
| A3 — Membership auto-created | UT-06, UT-07, UT-08, IT-06, MT-06 |
| A4 — Idea CRUD-list | UT-01, UT-02, UT-03, UT-04, UT-05, IT-03, IT-04, IT-05, E2E-05, E2E-06 |
| A5 — PWA installable | E2E-03, E2E-04, MT-01, MT-02 |
| A6 — Gitea Actions deploy | MT-04, MT-05 |
**Coverage check:** ✅ Every A* has ≥ 2 tests. No orphan acceptances.
---
## 11. Out of Scope (Deferred)
| Item | Reason | Lands in |
|------|--------|----------|
| Edit / delete idea | Not in Sprint 1 plan | Sprint 2 testplan |
| Idea detail page | Not in Sprint 1 plan | Sprint 2 testplan |
| Status workflow tests | Status field is RAW-default-only in S1 | Sprint 2 testplan |
| Reactions, comments | Sprint 2/3 features | Sprint 2/3 testplan |
| Offline sync tests | sw.js is stub-only | Sprint 4 testplan |
| Push notification tests | Sprint 4 feature | Sprint 4 testplan |
| Native APK tests | Sprint 5 (Capacitor) | Sprint 5 testplan |
| Load / perf tests | 4 users, no SLA | Never (until Sparkboard scales beyond family) |
| Multi-org tests | Sparkboard is single-org by design | Never |
---
## 12. Open Questions (Test-Specific)
| ID | Question | Impact |
|----|----------|--------|
| QT-01 | Should E2E-01 use a real Google test account or a mocked OAuth callback? | If real, requires a Google test project. If mocked, doesn't validate the full plate-auth chain. **Lean: mocked in CI, real in MT-06.** |
| QT-02 | Do we run E2E in Gitea Actions or only locally? | Gitea Actions adds CI time but catches deploy regressions. **Lean: yes in CI.** |
| QT-03 | Should `R__dev_seed_ideas.sql` be guarded by a Flyway placeholder so it never runs in prod? | Yes — Flyway placeholder `${env}` or separate `db/migration-dev/` location. **Lean: separate dev-only location in `application-dev.yml`.** |
---
## 13. Cross-references
- [Sprint-1-Plan](Sprint-1-Plan.md) — chunks 14
- [Sprint-1-Assessment](Sprint-1-Assessment.md) — acceptance A1A6
- [Architecture](Architecture.md) — system diagrams
- [Open-Questions](Open-Questions.md) — Q01Q10
- [Integration-Guide](Integration-Guide.md) — plate-auth wire-up walkthrough
---
_End of Sprint 1 Testplan. Status: **Draft v1, awaiting GO from Patrick.**_