diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48bb561 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,274 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # --------------------------------------------------------------------------------------------- + # Go server — mirrors gate-build.sh "go-build" / "go-vet". + # --------------------------------------------------------------------------------------------- + go-build: + name: Go build (server) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + # Pinned from the module itself (`go 1.25.0`) so CI can never drift from go.mod. + go-version-file: server/go.mod + # setup-go's built-in cache covers both the module cache (GOMODCACHE) and the compiler + # build cache (GOCACHE), keyed on go.sum. + cache: true + cache-dependency-path: server/go.sum + + - name: go build + working-directory: server + run: go build -o /dev/null ./cmd/... ./internal/... ./tests/... + + - name: go vet + working-directory: server + run: go vet ./cmd/... ./internal/... ./tests/... + + - name: go build (CGO_ENABLED=0) — NFR-005 + working-directory: server + + env: + CGO_ENABLED: '0' + run: go build -o /dev/null ./cmd/... ./internal/... ./tests/... + + # --------------------------------------------------------------------------------------------- + # Web UI — mirrors gate-build.sh "web-install" / "web-build" (+ gate-lint.sh's cheap checks). + # --------------------------------------------------------------------------------------------- + web-build: + name: Web build (server/web) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: server/web + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + # package.json engines is a floor (">=20.19.0"), not a pin. 24 is the current LTS and the + # major developers run locally; bump deliberately, not implicitly. + node-version: '24' + cache: npm + cache-dependency-path: server/web/package-lock.json + + - name: npm ci + run: npm ci + + - name: Type-check + # vue-tsc failures are compile errors, not style opinions — they fail the job. + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Lint (advisory) + # eslint + prettier. Reported, not enforced: no gates are enforced until D1.14. + continue-on-error: true + run: npm run lint + + + openapi-drift: + name: OpenAPI drift (P-4/NFR-032/NFR-033) + runs-on: ubuntu-latest + + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache: true + cache-dependency-path: server/go.sum + + - name: Set up Node + uses: actions/setup-node@v4 + with: + # For `npx @redocly/cli lint`, invoked by gate-contract-test.sh's openapi-lint leg. + node-version: '24' + cache: npm + cache-dependency-path: server/web/package-lock.json + + - name: Contract test gate (openapi-lint + openapi-drift + contract-tests) + run:../scripts/gate-contract-test.sh + + - name: NFR-033 additive-only check (pull_request only) + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + git fetch --depth=1 origin "${{ github.event.pull_request.base.sha }}" + git show "${{ github.event.pull_request.base.sha }}:server/api/openapi.yaml" > /tmp/base-openapi.yaml + ../scripts/check-additive-api.sh /tmp/base-openapi.yaml server/api/openapi.yaml + + - name: NFR-033 additive-only check skipped (no base ref on this event) + + if: github.event_name != 'pull_request' + run: echo "::notice::NFR-033 additive-only check skipped — no PR base ref on a '${{ github.event_name }}' event. The openapi-drift step above still ran." + + compose-smoke: + name: Compose smoke (NFR-026) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build image (tagged to match compose.yaml's pin — see job comment above) + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: ghcr.io/ankek/hho:0.1.0 + cache-from: type=gha,scope=compose-smoke + cache-to: type=gha,mode=max,scope=compose-smoke + + - name: Run compose-smoke gate (NFR-026) + run: ./scripts/gate-compose-smoke.sh + + - name: Upload gate logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: compose-smoke-gate-logs + path: .tmp/current/gates/ + if-no-files-found: ignore + retention-days: 7 + + go-coverage: + name: Go coverage >= 70% (NFR-030) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache: true + cache-dependency-path: server/go.sum + + - name: Go coverage gate (race + 70% threshold — NFR-030) + run:../scripts/gate-go-coverage.sh + + - name: Upload coverage profile + if: always() + uses: actions/upload-artifact@v4 + with: + name: go-coverage-profile + path: .tmp/current/gates/go-coverage.out + if-no-files-found: ignore + retention-days: 7 + + + go-govulncheck: + name: govulncheck (NFR-015) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache: true + cache-dependency-path: server/go.sum + + - name: govulncheck gate (NFR-015) + run: ./scripts/gate-govulncheck.sh + + - name: Upload govulncheck logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: govulncheck-logs + path: .tmp/current/gates/govulncheck*.log + if-no-files-found: ignore + retention-days: 7 + + image-size: + name: Image size < 40 MB (NFR-003) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: hho:gate + cache-from: type=gha,scope=image-size + cache-to: type=gha,mode=max,scope=image-size + + - name: Image-size gate (NFR-003) + run: ./scripts/gate-image-size.sh + + - name: Upload gate logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: image-size-gate-logs + path: .tmp/current/gates/ + if-no-files-found: ignore + retention-days: 7 + + latency: + name: p95 API latency < 100ms (NFR-002) + runs-on: ubuntu-latest + + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache: true + cache-dependency-path: server/go.sum + + - name: p95 API latency gate (NFR-002/SC-003) + run:../scripts/gate-latency.sh + + - name: Upload gate logs and JSON result + + if: always() + uses: actions/upload-artifact@v4 + with: + name: latency-gate-logs + path: .tmp/current/gates/ + if-no-files-found: ignore + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..5ba2cf3 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,83 @@ +name: Docker publish + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker: + name: Multi-arch image (linux/amd64 + linux/arm64) + runs-on: ubuntu-latest + + timeout-minutes: 30 + permissions: + contents: read + + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/ankek/hho + flavor: | + latest=false + tags: | + type=semver,pattern={{version}} + type=sha,prefix=sha-,format=short + type=ref,event=branch + type=ref,event=pr + + - name: Build (and publish on a version tag) + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64,linux/arm64 + build-args: | + VERSION=${{ startsWith(github.ref, 'refs/tags/v') && steps.meta.outputs.version || 'dev' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + push: ${{ startsWith(github.ref, 'refs/tags/v') }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: ${{ startsWith(github.ref, 'refs/tags/v') }} + sbom: ${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Publish summary + if: always() + run: | + if [ "${{ startsWith(github.ref, 'refs/tags/v') }}" = "true" ]; then + echo "### Published \`ghcr.io/ankek/hho\`" >> "$GITHUB_STEP_SUMMARY" + echo "Tags: ${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY" + else + echo "### Verify-only build (no push)" >> "$GITHUB_STEP_SUMMARY" + echo "Built \`linux/amd64\` + \`linux/arm64\` from Dockerfile; nothing pushed. Publishing happens only on a \`vX.Y.Z\` tag push." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..58e66d5 --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,61 @@ +name: Release binaries + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: release-binaries-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + goreleaser: + name: Build and publish raw binaries (goreleaser) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: server/web/package-lock.json + + - name: Build web UI + working-directory: server/web + run: | + npm ci + npm run build + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache: true + cache-dependency-path: server/go.sum + + - name: Run goreleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish summary + if: always() + run: | + echo "### Published raw binaries for tag \`${{ github.ref_name }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 -- see the release's assets and \`checksums.txt\`." >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 11ba129..cc8b054 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# HHO — Household Objects +# HHO - Household Objects HHO is a **self-hosted home inventory and organization system**. You run it on your own hardware (a Raspberry Pi, a NAS, a home server), and it helps a household track what it owns: @@ -7,18 +7,18 @@ warranty. No cloud account, no subscription, no external service required to ope HHO ships as two coupled products : -- **HHO Server** ( this one repository, `server/`) — a single Go binary / container. SQLite storage, an embedded - Vue 3 web UI, and a documented public REST API. This is the whole product for a browser-only - household. -- **HHO Android app** (`https://github.com/ankek/household-objects-android/android-app/`) — native Kotlin + Jetpack Compose, offline-first, - barcode-driven stock control for standing in front of a shelf with no signal. Syncs to the - server. *(Not yet functional — see [Project status](#project-status) below.)* +- **HHO Server** ( this one repository, `server/`) - a single Go binary / container. SQLite storage, an embedded + Vue 3 web UI, and a documented public REST API. This is the whole product for a browser-only + household. +- **HHO Android app** (`https://github.com/ankek/household-objects-android/android-app/`) - native Kotlin + Jetpack Compose, offline-first, + barcode-driven stock control for standing in front of a shelf with no signal. Syncs to the + server. *(Not yet functional - see [Project status](#project-status) below.)* -Licensed **AGPL-3.0-only**. Please check [License](#license). +Licensed **AGPL-3.0-only**. Please check [License](#license). ## Project status -This project is under development — the HHO Server MVP +This project is under development - the HHO Server MVP ## Quickstart (server) @@ -28,21 +28,21 @@ Requires Docker. docker compose up -d ``` -That's it — the committed [`compose.yaml`](compose.yaml) pulls a pinned multi-arch image +That's it - the committed [`compose.yaml`](compose.yaml) pulls a pinned multi-arch image (`linux/amd64` + `linux/arm64`), creates a named data volume, and starts the server with every setting at its working default (no `.env` file needed). Within about a minute the container reports healthy; visit the web UI and complete the one-time registration to create your first -user and household group — every registration after that first one is disabled unless you opt in +user and household group - every registration after that first one is disabled unless you opt in (see below). -For anything beyond this — the bind-mount alternative and its `chown 65532:65532` step, the full -configuration reference, reverse-proxy/TLS notes, and upgrading — see the +For anything beyond this - the bind-mount alternative and its `chown 65532:65532` step, the full +configuration reference, reverse-proxy/TLS notes, and upgrading - see the [deploy guide](docs/deploying.md), and the [backup and restore runbook](docs/backup-restore.md) for protecting your data. ### Configuration -The server starts with **zero required configuration** — every environment variable below is +The server starts with **zero required configuration** - every environment variable below is optional and already shown at its default in `compose.yaml`: | Variable | Default | Purpose | @@ -50,7 +50,7 @@ optional and already shown at its default in `compose.yaml`: | `HHO_DATA_DIR` | `/data` | Where the database, attachments, and backups live | | `HHO_ADDR` | `:7745` | Listen address inside the container | | `HHO_REGISTRATION_OPEN` | `false` | Allow a *second* household to self-register (the first ever registration is always allowed) | -| `HHO_TRUST_PROXY_HEADERS` | `false` | Derive the rate-limit key from `X-Forwarded-For` — only behind a trusted reverse proxy | +| `HHO_TRUST_PROXY_HEADERS` | `false` | Derive the rate-limit key from `X-Forwarded-For` - only behind a trusted reverse proxy | | `HHO_MAX_ATTACHMENT_SIZE_BYTES` | `26214400` (25 MiB) | Per-upload size ceiling | | `HHO_ATTACHMENT_RECLAIM_INTERVAL_SECONDS` | `3600` | How often orphaned attachments are swept | | `HHO_MIN_CLIENT_VERSION` | unset | Reject API clients below this version | @@ -66,35 +66,35 @@ automatically inside your one mounted volume. docker compose exec hho hho backup --output /data/backups/manual.tar.gz ``` -produces one archive containing both the database snapshot and the attachments tree — safe to +produces one archive containing both the database snapshot and the attachments tree - safe to run against a live server. The server also takes one automatically every 24 hours, keeping the -last 7, and an authenticated owner can pull the same archive from `GET /api/v1/backup` — the +last 7, and an authenticated owner can pull the same archive from `GET /api/v1/backup` - the **Download backup** link on the web UI's Settings screen. Restoring is CLI-only. Restoring is a CLI operation against a **stopped** server, and never deletes what it replaces. The -full procedure — including getting backups off the box, which scheduled backups alone do not do — +full procedure - including getting backups off the box, which scheduled backups alone do not do - is in the [backup and restore runbook](docs/backup-restore.md). ## Architecture -- **Server** — Go 1.25, `chi` router, SQLite via the CGO-free `modernc.org/sqlite` driver, - `sqlc`-generated data access, `goose` migrations, structured logging. Builds as a single static - binary with no external services (no Postgres, Redis, S3, or required reverse proxy). CI-gated - at <50 MB idle RSS and a <40 MB container image. -- **Web UI** — Vue 3 (Composition API) + Pinia + Vite, built and embedded directly into the Go - binary via `embed.FS`. There is no separate frontend deployment. -- **Android app** — Kotlin, Jetpack Compose, Material 3, Hilt for DI (package `dev.hho.android`). - Designed offline-first around a durable local mirror and outbox. Not yet implemented, track progress in `https://github.com/ankek/household-objects-android/android-app/`. -- **API contract** — [`server/api/openapi.yaml`](server/api/openapi.yaml) is the single source of - truth both the server and the Android client are built against; the running server also serves - it at `/api/v1/openapi.yaml`. CI fails on drift between the document and the implementation. -- **Sync protocol** — the [sync protocol spec](docs/sync-protocol.md) defines the device-to-server - replication contract (`/sync/pull`, `/sync/push`) that Phase 3/4 Android sync builds against; - both routes are declared and mounted today but answer `501 Not Implemented` until then. +- **Server** - Go 1.25, `chi` router, SQLite via the CGO-free `modernc.org/sqlite` driver, + `sqlc`-generated data access, `goose` migrations, structured logging. Builds as a single static + binary with no external services (no Postgres, Redis, S3, or required reverse proxy). CI-gated + at <50 MB idle RSS and a <40 MB container image. +- **Web UI** - Vue 3 (Composition API) + Pinia + Vite, built and embedded directly into the Go + binary via `embed.FS`. There is no separate frontend deployment. +- **Android app** - Kotlin, Jetpack Compose, Material 3, Hilt for DI (package `dev.hho.android`). + Designed offline-first around a durable local mirror and outbox. Not yet implemented, track progress in `https://github.com/ankek/household-objects-android/android-app/`. +- **API contract** - [`server/api/openapi.yaml`](server/api/openapi.yaml) is the single source of + truth both the server and the Android client are built against; the running server also serves + it at `/api/v1/openapi.yaml`. CI fails on drift between the document and the implementation. +- **Sync protocol** - the [sync protocol spec](docs/sync-protocol.md) defines the device-to-server + replication contract (`/sync/pull`, `/sync/push`) that Phase 3/4 Android sync builds against; + both routes are declared and mounted today but answer `501 Not Implemented` until then. ## License -HHO and all components are licensed under the **GNU Affero General Public License v3.0 only** (`AGPL-3.0-only`) — see +HHO and all components are licensed under the **GNU Affero General Public License v3.0 only** (`AGPL-3.0-only`) - see [`LICENSE`](LICENSE). The AGPL's network-use clause (section 13) is the deliberate reason it was chosen over plain GPL: if you run a modified version of HHO as a network service, you must offer its source to your users. diff --git a/scripts/gate-compose-smoke.sh b/scripts/gate-compose-smoke.sh new file mode 100755 index 0000000..a163968 --- /dev/null +++ b/scripts/gate-compose-smoke.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +source "$(dirname "$0")/_common.sh" +[ -f compose.yaml ] || [ -f docker-compose.yml ] || { echo "RESULT: fail: no compose file yet — NFR-026 gate cannot run (do not treat as pass)"; exit 2; } +have docker || { echo "RESULT: fail: docker not available"; exit 1; } +DC="docker compose"; $DC version >/dev/null 2>&1 || DC="docker-compose" + +cleanup() { $DC down -v >/dev/null 2>&1 || true; } +trap cleanup EXIT + +wait_healthy() { + for _ in $(seq 1 60); do + st=$($DC ps --format json 2>/dev/null | grep -o '"Health":"[a-z]*"' | head -1 | cut -d'"' -f4) + [ "$st" = "healthy" ] && return 0 + sleep 1 + done + return 1 +} + +run "compose-up" $DC up -d || { finish; } + +wait_healthy && RAN+=("healthcheck") || FAILED+=("healthcheck: not healthy within 60s") + +PORT="${HHO_PORT:-7745}" +run "smoke-request" bash -c "curl -fsS http://localhost:${PORT}/api/v1/status >/dev/null" || true + +VOL=$(docker volume ls -q --filter name=hho-data | head -1) +if [ -z "$VOL" ]; then + FAILED+=("data-layout: no hho-data volume found") +else + + EXPECTED_OWNER="65532:65532" + run "data-layout" docker run --rm -e EXPECTED_OWNER="$EXPECTED_OWNER" -v "$VOL":/data busybox sh -c ' + set -e + for d in /data/db /data/attachments /data/backups /data/tmp; do + [ -d "$d" ] || { echo "missing: $d"; exit 1; } + owner=$(stat -c "%u:%g" "$d") + echo "$d -> owner $owner" + [ "$owner" = "$EXPECTED_OWNER" ] || { echo "wrong ownership: $d is $owner, expected $EXPECTED_OWNER"; exit 1; } + done + ' || true +fi + +# +CANARY_TOKEN="gate-canary-$$-$(date +%s%N)" +docker run --rm -v "$VOL":/data busybox sh -c "echo '$CANARY_TOKEN' > /data/db/.gate-canary" >/dev/null 2>&1 +SCHEMA_BEFORE=$(curl -fsS "http://localhost:${PORT}/api/v1/status" 2>/dev/null | grep -o '"schema_version":[0-9]*' | head -1) +echo "--- restart-over-existing-volume: baseline before restart — canary planted, $SCHEMA_BEFORE" + +if [ -z "$SCHEMA_BEFORE" ]; then + FAILED+=("restart-over-existing-volume: could not capture pre-restart baseline (schema='$SCHEMA_BEFORE')") +else + # A PLAIN down: no `-v`. The volume and its contents must survive exactly like an operator's + # `docker compose down` (no flag) or a host reboot would — only `down -v`, exercised separately + # below, is destructive. + $DC down >/dev/null 2>&1 + VOL_SURVIVED=$(docker volume ls -q --filter name=hho-data | head -1) + if [ "$VOL_SURVIVED" != "$VOL" ]; then + FAILED+=("restart-over-existing-volume: volume did not survive a plain 'down' (expected $VOL, got '${VOL_SURVIVED:-}')") + else + $DC up -d >/dev/null 2>&1 + if ! wait_healthy; then + FAILED+=("restart-over-existing-volume: second 'up' over the pre-existing volume did not become healthy within 60s") + else + HHO_DB_PRESENT=false + docker run --rm -v "$VOL":/data busybox test -f /data/db/hho.db && HHO_DB_PRESENT=true + CANARY_AFTER=$(docker run --rm -v "$VOL":/data busybox cat /data/db/.gate-canary 2>/dev/null || echo "") + SCHEMA_AFTER=$(curl -fsS "http://localhost:${PORT}/api/v1/status" 2>/dev/null | grep -o '"schema_version":[0-9]*' | head -1) + echo "--- restart-over-existing-volume: after restart — hho.db present=$HHO_DB_PRESENT canary='$CANARY_AFTER' $SCHEMA_AFTER" + if $HHO_DB_PRESENT && [ "$CANARY_AFTER" = "$CANARY_TOKEN" ] && [ "$SCHEMA_AFTER" = "$SCHEMA_BEFORE" ]; then + RAN+=("restart-over-existing-volume") + else + FAILED+=("restart-over-existing-volume: data did not survive the restart — hho.db present=$HHO_DB_PRESENT, canary expected='$CANARY_TOKEN' got='$CANARY_AFTER', schema before='$SCHEMA_BEFORE' after='$SCHEMA_AFTER'") + fi + fi + fi +fi + +$DC down -v >/dev/null 2>&1 +VOL_AFTER=$(docker volume ls -q --filter name=hho-data | head -1) +if [ -z "$VOL_AFTER" ]; then + RAN+=("down-v-removes-volume") + echo "--- down-v-removes-volume: confirmed no hho-data volume remains" +else + FAILED+=("down-v-removes-volume: volume '$VOL_AFTER' still exists after 'down -v'") +fi + +finish diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 22363f2..6468814 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -1,3 +1,4 @@ + openapi: 3.1.0 info: title: Household Objects — HHO Ecosystem API @@ -7,228 +8,28 @@ info: group per SOW deliverable, in the same commit as the handler it describes (constitution P-4, FR-081). Nothing in this document exists that the router does not currently mount; nothing the router mounts is missing from this document. - description: >- - This version of the document covers D1.3's slice of the surface (first-run registration, - password login, session/device-token administration, and group invites) plus D1.5's slice: - core item CRUD (`/items`), the three 1:0..1 detail blocks (`warranty`/`sale`/`purchase`), item - identifications, group-level custom-field definitions, per-item custom-field values, the - per-group detail-visibility settings those definitions and blocks are gated behind, and the - append-only stock-adjustment history (FR-010..FR-021, T051–T060, T062). See - `specs/001-hho-ecosystem/contracts/README.md` for the full endpoint-group map and which SOW - deliverable authors each remaining group. Most of the remaining domain groups (`/import/*`, - `/reports/*`, `/status` beyond the liveness shape below) are deliberately absent here and are - each other tasks' job to add, in their own commit, alongside their own handler. `/backup` is - no longer among them: T113 adds its one admin endpoint below (FR-132's admin-endpoint half). - FR-132's OTHER half — the `hho backup` CLI subcommand — and FR-133/134's restore and - scheduling are separate, later tasks with no HTTP surface of their own to document here. - `/export/items.csv` is no longer among them either: T147d adds its one member-readable - endpoint below (FR-050's export half, D2.1) — the import round trip (D2.2, FR-051–FR-053) and - the Homebox-compatible import adapter (D2.3, FR-054) remain separate, later tasks with their - own `/import/*` routes not yet documented here. - - D2.5's label-sheet printing pair is documented below too: `POST /labels/qr/batch` (T151d, - FR-070..FR-073) turns a caller-held list of item ids into QR SVG markup and label text in one - round trip, and `GET /i/{token}` (T151c, FR-073/FR-074, A123) is the deep-link resolver every - QR code and label this API prints ultimately points at. `GET /i/{token}` is the one operation - in this document NOT reachable under this document's own `servers[0].url` base (`/api/v1`): - its path item declares its OWN `servers` override (the OpenAPI 3.1 Path Item Object's own - mechanism for exactly this) because `qrlabels.ItemURL` encodes `"/i/"` - verbatim into every printed label, so this one route can never move under a version prefix - (see that path item's own comment). D2.6's Avery-grid sheet layout is the Vue client's own - job, not part of this document. - - D1.9's `/sync/pull` and `/sync/push` (T093) are the one deliberate exception to "documented - means implemented": both routes are mounted and answer with the request/response envelope - below, which IS the final FR-090..FR-096 contract (pinned by - `specs/001-hho-ecosystem/contracts/sync-protocol.md`) — but every request today answers - `501 Not Implemented` unconditionally, because spec.md's Phase 1 boundary allows `/sync/*` to - ship with "contract only, routes declared, may be unimplemented." Real pull and push - semantics are D3.1 (T155) and D4.1 (T164, idempotent by `mutation_id`)'s own jobs, replacing - the stub's body rather than extending it. See the `sync` tag description below. - - D1.8's `/attachments/*` group is now fully present as of T104a: `POST - /items/{itemID}/attachments` (upload, T084), `GET /items/{itemID}/attachments` (list an item's - live attachments, T104a — discovered gathering context for T105's gallery UI: FR-041's "grouped - by category" needs a way to enumerate what to group), the two streamed, authenticated download - routes -- `GET /items/{itemID}/attachments/{attachmentID}` (the original bytes) and - `GET /items/{itemID}/attachments/{attachmentID}/thumbnail` (the generated preview, T085/T086) - -- and `DELETE /items/{itemID}/attachments/{attachmentID}` (T087, FR-045's delete half) are all - documented below, each in its own commit alongside the handler that implements it (P-4). The - periodic orphan-reclamation half of FR-045 (T088) is a background maintenance pass, not a route, - and has no surface on this document at all. - - D1.6's `/locations/*` and `/labels/*` were added by the Thin MVP's gap G1. Both had been - MOUNTED since T065/T067 but never documented, which is the precise failure mode P-4's - same-commit rule exists to prevent — 9 live routes that no generated client could see. See - `api-structure.md` for the Thin MVP scope and its remaining gaps. - - Every error response on every operation in this document uses the single `Problem` schema - (RFC 9457, `application/problem+json`). Two authentication schemes exist across the whole API: - a session cookie (`SessionCookie`) and an `Authorization: Bearer` device token - (`DeviceToken`) — see the Security Schemes section below for exactly which routes accept which. - - D1.5 introduces this document's first owner/member authorization ASYMMETRIES within an - otherwise uniformly-authenticated surface: `POST`/`PUT`/`DELETE /custom-field-defs{,/...}` and - `PUT /groups/detail-visibility` are owner-gated (403 for a member, A101.1/A99) because a field - DEFINITION or a visibility TOGGLE reshapes what the whole household sees — a group admin - setting in FR-007's own words. Their `GET` siblings, and every operation under `/items` - (including its warranty/sale/purchase blocks, its identifications and its own custom-field - VALUES), are member-writable: item data is "full CRUD on group data," which FR-007 assigns to - every member, not just the owner. Each operation below restates which side of that split it is - on. license: name: AGPL-3.0-only identifier: AGPL-3.0-only servers: - url: /api/v1 - description: >- - URL-path major versioning (FR-080). A breaking change is never applied to this path; it - ships as /api/v2 instead (NFR-033). tags: - name: status - description: Liveness (D1.12 owns the finished endpoint; this document only covers its current shape). - name: meta - description: >- - About this document and this server's build, rather than any group's data (FR-081, D1.9, - T094) — neither a liveness probe nor a domain group, so it gets its own tag rather than - sitting under `status`. - name: auth - description: Registration, login, session and device-token administration (FR-001–FR-004, D1.3). - name: invites - description: Group invite issuance, listing, revocation and redemption (FR-006, D1.3). - name: items - description: >- - Core item CRUD, the three 1:0..1 detail blocks (warranty/sale/purchase) as sub-resources, - item identifications, item custom-field VALUES and the append-only stock-adjustment history - (FR-010–FR-021, D1.5). Every route here is member-writable — none of it is owner-gated - (FR-007's "full CRUD on group data"). - name: custom-field-defs - description: >- - Group-level custom-field DEFINITIONS (FR-017, D1.5) — the group-wide schema `items`' own - custom-field values are optionally recorded against. Writes are owner-gated (A101.1); the - list is member-readable. - name: locations - description: >- - The group's location tree (FR-030, D1.6, T065). A `parent_id` hierarchy with rename and move - as one operation. Member-writable throughout — a location is group data, and FR-007 gives - every member full CRUD on that. The FR-030 delete-guard is not yet implemented; see - `DELETE /locations/{locationID}`. - name: labels - description: >- - Flat, group-scoped, colour-tagged labels (FR-031, D1.6, T067) — the second organising axis - beside the location tree. Member-writable. Item↔label ASSIGNMENT (FR-032) lives under - `/items/{itemID}/labels` — the edge is addressed by its two ends, so `PUT` is idempotent by - construction and a client never needs the assignment row's own id. - name: groups - description: >- - Per-group settings. This document covers only the detail-block visibility toggle (FR-011, - D1.5, T055); the registration-reopen toggle FR-005/FR-007 also assign to this prefix has no - HTTP route of its own (see `groups.RegistrationGate`'s doc) and is not part of this document. - `PUT` is owner-gated (A99); `GET` is member-readable. - name: attachments - description: >- - Files attached to an item (FR-040, FR-041, FR-042, FR-043, FR-044, FR-045, D1.8). This - document covers upload (T084), streamed, authenticated download of the original bytes and of - a generated thumbnail (T085/T086), and delete (T087, FR-045's own delete half — the periodic - orphan-reclamation half is a background maintenance pass, T088, not a route). Member-writable, - the same FR-007 classification every other `/items/{itemID}/...` sub-resource in this - document carries. Every route in this group verifies the caller's own group ownership BEFORE - a single byte is streamed OR removed (FR-042/FR-045, NFR-017) — a static-file handler over the - attachments tree is structurally impossible in this API's shape, not merely avoided by - convention. - name: sync - description: >- - Device-to-server replication (FR-090–FR-096, D3.1/D4.1). `POST /sync/pull` and - `POST /sync/push`'s request/response envelopes documented here ARE the final contract - (T093, D1.9) — but both routes answer `501 Not Implemented` unconditionally today, per - spec.md's Phase 1 boundary ("contract only, routes declared, may be unimplemented"). Real - pull lands at D3.1 (T155); real push, idempotent by `mutation_id`, lands at D4.1 (T164). - Group-scoped like every other route in this document, NOT owner-gated: `device_id` - identifies the caller's OWN device within their own group, exactly as - `POST /auth/device-tokens` already does, so any member may sync their own device. - name: backup - description: >- - FR-132's admin-endpoint half: `GET /backup` streams one gzip-compressed tar archive - containing a compacted database snapshot plus the whole attachments tree (T112, T113). - Owner-only (FR-007 names "backup" explicitly among the owner role's admin actions), and - deliberately NOT scoped to the caller's own group: the archive is a snapshot of the WHOLE - database, every group included, by FR-132's own design. FR-132's other half (an `hho backup` - CLI subcommand producing the identical archive format) has no HTTP surface and is not part - of this document. - name: export - description: >- - D2.1's CSV export half (FR-050, T147a/T147b): `GET /export/items.csv` streams the caller's - own group's full inventory as one native HHO CSV file. Member-readable, NOT owner-gated — - contrast `/backup` above, which is both owner-only and deliberately cross-group; this - endpoint is neither, because FR-007 classifies an item export as reading item data a member - can already see through every other route in this document, only reshaped into one file. - D2.2's import round trip (the `import` tag below) and D2.3's Homebox adapter are separate - tasks with their own `/import/*` routes, documented under their own tag rather than this one - — reading a group's data out as CSV and writing a group's data back in from CSV are opposite - directions with different failure shapes (per-row validation, staged-session lifecycle), - not one bidirectional resource. - - D2.4's Bill-of-Materials export (FR-063, T150c) also sits under this tag: `GET /export/bom` - is a different SELECTION of the same underlying item data — by location, by label, or by - explicit item id, never the whole group — streamed as CSV only, with no `?format=` toggle - (contrast `/reports/*` below, where every report offers both JSON and CSV on one route). - Member-readable for the identical FR-007 reason `/export/items.csv` is. - name: import - description: >- - D2.2's CSV import round trip (FR-051–FR-053, T148c/T148d/T148e): stage a native-format file - (`POST /import/native/upload`), preview how every row would classify without writing anything - (`GET /import/{importID}/preview`), then commit it in one all-or-nothing transaction - (`POST /import/{importID}/commit`). Member-writable, the same FR-007 classification every - `/items/{itemID}/...` write already carries — importing item data is not a group-wide admin - setting the way `/custom-field-defs` writes are. - - **A staged import is ephemeral, filesystem-backed state, not a durable resource.** The - uploaded bytes live under a per-group temp directory that a server restart empties (A127); - the matching `import_sessions` row is sync infrastructure, not one of P-2's six domain - primitives (it carries no `version`/`updated_at`/`change_seq`). A `staged` session answers - 404 the moment either its `expires_at` has passed or its file is gone (A127) — the two cases - are indistinguishable on the wire — and a session that has already reached `committed` - answers 409 on every subsequent read or write, never a stale re-preview of bytes that may no - longer match what was actually written. - - **Preview and commit classify every row identically**, by construction (both routes share - one internal classification path): each row lands in exactly one of `create`, `update`, - `unchanged` or `error`. `unchanged` means the proposed values are byte-identical to the - item's current stored values (A122.3) — committing such a row bumps neither `version` nor - `change_seq`. Preview only ever reads; it writes nothing, not even on a lazy session cleanup - of the import's own row (never item data). Commit's own all-or-nothing rule (FR-053): if ANY - row classifies `error`, the entire request is rejected before a transaction ever opens and - the response mirrors preview's own per-row shape; otherwise every `create`/`update` row is - applied inside one transaction, and the staged file is removed only once that transaction has - durably succeeded. - - D2.3's Homebox adapter (`POST /import/homebox/upload`, a separate, later task) reuses this - same preview/commit pipeline unchanged under its own `/import/homebox/*` path, per FR-054's - "clearly-labelled, separate path" requirement — not part of this document yet. - name: reports - description: >- - D2.4's four fixed, parameterized reports (FR-060–FR-062, T150a/T150b): valuation, warranty - expiring, purchases in a date range, and item count by location. No report builder, no raw - SQL exposed to a caller — each is a closed, named computation in `server/internal/reporting` - (D-062). Every report accepts a shared `?format=csv` toggle (default `json`) on the SAME - route rather than a second endpoint per report, so a plain link works from the web UI - (FR-061, FR-062). Member-readable, NOT owner-gated — the identical FR-007 argument `export` - above makes: a report is a different SHAPE of data every member can already read through - `/items` and its sub-resources, not a wider one. Money fields are always `*_minor` integers - on the wire, matching every detail-block price already in this document. - name: labels-qr - description: >- - D2.5's label-sheet printing pair: `POST /labels/qr/batch` (T151d) turns a caller-held list of - item ids into QR SVG markup and label text in one round trip, tenant-scoped, regardless of - sheet size; `GET /i/{token}` (T151c, A123) is the deep-link resolver every QR code and label - this API ever prints ultimately points at — a UUID token redirects to the SPA's own - tenant-scoped item page, a short_code token resolves only inside the caller's own group. - Distinct from the flat, colour-tagged `labels` resource above: this is a stateless - rendering/redirect operation over items and tokens, not CRUD on that resource — see - `labels_qr_batch.go`'s and `resolve.go`'s own file docs for the "these are two unrelated - features that happen to share the English word 'label'" argument in full. D2.6's actual - Avery-grid sheet layout (`LabelSheetView.vue`) is the Vue client's own job, not part of this - document. paths: /status: @@ -238,19 +39,6 @@ paths: operationId: getStatus tags: [status] summary: Liveness, build version and schema version. - description: >- - Unauthenticated. Reports liveness only, not readiness — it issues no database query. Polled - with no credentials by the container healthcheck (FR-140), so this response is published to - anyone who can reach the port; nothing here may ever grow a field that is not safe for that - audience (NFR-018). - - NOTE (T043 reconciliation): this route is mounted unconditionally in `mountV1` but is not - one of D1.3's deliverables — it predates D1.3 (mounted at T027) and its doc-comment - (`statusHandler`) names T121/D1.12 as the task that finishes it. It is documented here, - minimally and in its current shape only, because it is a *mounted* route this task's brief - did not list and P-4 requires every mounted route to have a matching document entry; T093's - consolidation (or D1.12 itself) should feel free to extend this operation's description as - the endpoint grows, the same way every other deliverable in this document is expected to. security: [] responses: "200": @@ -260,10 +48,7 @@ paths: schema: $ref: "#/components/schemas/StatusResponse" default: - description: >- - An unexpected server-side fault (e.g. the response could not be marshalled). Not a - documented status this handler ever chooses deliberately — included only so this - operation is not falsely read as infallible. + description: '' content: application/problem+json: schema: @@ -278,18 +63,6 @@ paths: operationId: getOpenAPISpec tags: [meta] summary: This document, served from the running instance (FR-081). - description: >- - Unauthenticated, mounted beside `/status` (D1.9, T094). Public for the same reason - `/auth/register`/`/auth/login`/`/invites/redeem` below are: an integrator generating a - client has no session yet, and this document describes the API rather than any group's - data. The bytes served are `server/api/openapi.yaml` compiled into the binary at build - time (`go:embed`) — never read from disk at request time, so the response is byte-for-byte - identical to this checked-in file for the life of the running process, including inside - the distroless production image, which ships no source tree to read it from. - - Static per build: the response carries an `ETag` derived from the served bytes and a - `Cache-Control` that allows revalidation, unlike `/status`'s `no-store` (this document - cannot go stale mid-request the way a liveness/schema-version answer could). security: [] responses: "200": @@ -298,17 +71,8 @@ paths: application/yaml: schema: type: string - description: >- - Opaque YAML text, not a $ref to a structured schema — the document describing - itself has no separate schema of its own to point to, and typing it as anything - narrower would just be restating "this is an OpenAPI document" in a second, - driftable place. default: - description: >- - An unexpected server-side fault. Not a documented status this handler ever chooses - deliberately — the embedded bytes cannot fail to marshal, so this exists only so the - operation is not falsely read as infallible, the same reasoning `/status`'s own - `default` response states. + description: '' content: application/problem+json: schema: @@ -323,12 +87,6 @@ paths: operationId: registerFirstRun tags: [auth] summary: First-run self-service registration (FR-001). - description: >- - Unauthenticated. Creates the first group and its owner account. Returns no session — a - client calls `POST /auth/login` next with the credentials it just chose. Refused with 409 - once a group already exists, unless self-service registration has been explicitly reopened - (FR-005; the reopened-registration policy is enforced by a layer in front of this seam, not - by this operation's shape). security: [] requestBody: required: true @@ -344,26 +102,19 @@ paths: schema: $ref: "#/components/schemas/RegisterResponse" "400": - description: >- - The request body was not valid JSON, or the username/password failed this API's own - validation rules. `detail` is populated (the caller wrote the body being rejected). + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - Registration is closed — a group already exists and self-service registration has not - been reopened (FR-005). No `detail`: the conflicting state is a fact about the server, - disclosed to a caller who may not be authenticated at all. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "429": - description: >- - Refused by the per-source-IP registration rate limiter (NFR-012). No `detail`. - `Retry-After` names how long to wait, in whole seconds. + description: '' headers: Retry-After: schema: @@ -383,13 +134,6 @@ paths: operationId: login tags: [auth] summary: Password login, session issuance (FR-002). - description: >- - Unauthenticated. On success, mints a session and sets it as an `HttpOnly; Secure; - SameSite=Lax` cookie (NFR-013) — the raw session token never appears in the response body. - An unknown username, a wrong password, and every other credential failure answer the - identical 401 (uniform-failure contract; see the `Unauthorized` problem type's meaning - below) — there is no separate 400 for a malformed credential pair, only for a body that - cannot be parsed as JSON at all. security: [] requestBody: required: true @@ -402,10 +146,6 @@ paths: description: Login succeeded. The session cookie is set via `Set-Cookie`. headers: Set-Cookie: - description: >- - The session cookie (`hho_session`), `HttpOnly; Secure; SameSite=Lax`, valid for the - session's TTL (NFR-013). Secure is unconditional, including for local plain-HTTP - development — see `loginHandler`'s doc comment for the consequence. schema: type: string content: @@ -419,10 +159,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "401": - description: >- - Invalid credentials. Covers an unknown username, a wrong password, and every other way - the credential can fail — deliberately one answer for all of them, with no `detail`, so - this response can never be used to enumerate which usernames are registered. + description: '' headers: WWW-Authenticate: schema: @@ -433,10 +170,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "429": - description: >- - Refused by the per-account or per-source-IP login rate limiter (NFR-012). No `detail` — - which bucket tripped is never disclosed. `Retry-After` names how long to wait, in whole - seconds. + description: '' headers: Retry-After: schema: @@ -456,10 +190,6 @@ paths: operationId: listSessions tags: [auth] summary: List the caller's own web sessions (FR-004). - description: >- - Authenticated by either credential (session cookie or device token) — listing carries no - self-propagation risk, unlike device-token issuance below. Scoped to the caller's own - identity within their own group; never returns another user's sessions. security: - SessionCookie: [] - DeviceToken: [] @@ -482,11 +212,6 @@ paths: operationId: revokeSession tags: [auth] summary: Revoke one of the caller's own sessions (FR-004). - description: >- - Authenticated by either credential. Idempotent: revoking an already-revoked id answers the - identical 204 the first call did. Revoking the session currently authenticating this very - request is allowed and handled with no special case; the caller's own Set-Cookie is not - proactively cleared in the response. security: - SessionCookie: [] - DeviceToken: [] @@ -498,10 +223,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such session id. Returned identically for an id that never existed, one from another - tenant, and one belonging to a different user in the caller's *own* group (FR-008, - NFR-010, T038) — never 403, and never a `detail` distinguishing the three. + description: '' content: application/problem+json: schema: @@ -509,6 +231,28 @@ paths: "426": $ref: "#/components/responses/UpgradeRequired" + /auth/me: + parameters: + - $ref: "#/components/parameters/ClientVersion" + get: + operationId: getCurrentUser + tags: [auth] + summary: The caller's own identity (T154c). + security: + - SessionCookie: [] + - DeviceToken: [] + responses: + "200": + description: The caller's own identity. + content: + application/json: + schema: + $ref: "#/components/schemas/LoginResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "426": + $ref: "#/components/responses/UpgradeRequired" + /auth/logout: parameters: - $ref: "#/components/parameters/ClientVersion" @@ -516,27 +260,11 @@ paths: operationId: logout tags: [auth] summary: End the calling session (G5, FR-004). - description: >- - Revokes the session that authenticates *this very request* and clears the cookie, without - the caller first having to list its sessions and work out which entry is itself. - - Cookie-only, never a bearer device token — the same narrower gate - `POST /auth/device-tokens` uses, and for a related reason: the credential being ended is - the address of the thing to end, so a request that presents no cookie names nothing this - operation could act on. An Android client ends its own credential with - `DELETE /auth/device-tokens/{deviceTokenID}`, which does take an id. - - Idempotent in the only sense available to it: a cookie that no longer authenticates never - reaches this handler at all (401), and a cookie that does is revoked exactly once. The - response always clears `hho_session` — including on the 401 — so a client holding a stale - cookie is left in a clean state either way. security: - SessionCookie: [] responses: "204": - description: >- - The session no longer authenticates. `Set-Cookie` clears `hho_session` with - `Max-Age=0`. + description: '' headers: Set-Cookie: description: Clears `hho_session`. @@ -554,9 +282,6 @@ paths: operationId: listDeviceTokens tags: [auth] summary: List the caller's own registered devices (FR-004). - description: >- - Authenticated by either credential — listing carries no self-propagation risk, unlike - issuance below. security: - SessionCookie: [] - DeviceToken: [] @@ -575,16 +300,6 @@ paths: operationId: issueDeviceToken tags: [auth] summary: Mint a device token for the caller (FR-003). - description: >- - Authenticated by the session cookie ONLY — never by an existing device token, and never by - the general composed authenticator. A device token cannot mint another (A47): a request - presenting only `Authorization: Bearer` has no cookie for this narrower gate to find, and is - answered 401 before this operation is ever reached. This is the one route in this document - whose accepted credential is a strict subset of "either credential". - - The minted token appears in the response body exactly once, here, and nowhere else — an - Android client has no cookie jar to receive it via `Set-Cookie`, so the JSON body is the - only channel this credential can travel over. security: - SessionCookie: [] requestBody: @@ -601,17 +316,13 @@ paths: schema: $ref: "#/components/schemas/DeviceTokenIssueResponse" "400": - description: >- - The request body was not valid JSON, or `device_label` was empty or too long. `detail` - is populated. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "401": - description: >- - No valid session cookie was presented. A bearer device token alone answers this same - 401, by design (see this operation's description). + description: '' headers: WWW-Authenticate: schema: @@ -631,10 +342,6 @@ paths: operationId: revokeDeviceToken tags: [auth] summary: Revoke one of the caller's own device tokens (FR-004). - description: >- - Authenticated by either credential. This is the only terminator a device token ever gets — - device tokens carry no `expires_at` (FR-003). Idempotent: revoking an already-revoked id - answers the identical 204 the first call did. security: - SessionCookie: [] - DeviceToken: [] @@ -646,10 +353,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such device token id. Returned identically for an id that never existed, one from - another tenant, and one belonging to a different user in the caller's own group - (FR-008, NFR-010) — never 403. + description: '' content: application/problem+json: schema: @@ -664,12 +368,6 @@ paths: operationId: listInvites tags: [invites] summary: List every invite issued inside the caller's group (FR-006). Owner-only. - description: >- - Owner-only (A55, FR-007) — a member presenting either valid credential gets 403, not 404: - `TenantScope` has already bound this route's resource to the caller's own group, so there is - no tenant-existence oracle at stake here, only a role fact (A54). Lists every invite in the - group regardless of who issued it (A63) — an invite is a shared administrative record, not a - personal credential the way a session or device token is. security: - SessionCookie: [] - DeviceToken: [] @@ -690,13 +388,6 @@ paths: operationId: createInvite tags: [invites] summary: Issue a new invite token for the caller's group (FR-006). Owner-only. - description: >- - Owner-only (A55). Takes no request body: the group and the issuer are both already known - from the authenticated identity. The minted token appears in the response body exactly - once, here, and nowhere else — the invite recipient has no prior relationship with this - server, so a channel outside HTTP entirely (whatever the client UI uses to deliver the link) - is the only way this value can ever reach them. Invite TTL is 24 hours (A61), not - configurable. security: - SessionCookie: [] - DeviceToken: [] @@ -721,15 +412,6 @@ paths: operationId: revokeInvite tags: [invites] summary: Revoke one invite issued inside the caller's group (FR-006). Owner-only. - description: >- - Owner-only (A55). Scoped to the group, not to the caller: any owner may revoke any invite in - their own group, including one a different owner issued (A63) — unlike session/device-token - revocation, which is bound to the caller's own identity. Revocation is represented by - forcing the invite's `expires_at` to the revoke instant (A62); a revoked and a - naturally-expired invite are therefore indistinguishable in every response that describes - one, including this operation's own 404. Idempotent: revoking an already-unredeemable - invite (already revoked, already expired, already used) answers the identical 204 the first - call did. security: - SessionCookie: [] - DeviceToken: [] @@ -743,9 +425,7 @@ paths: "403": $ref: "#/components/responses/Forbidden" "404": - description: >- - No such invite id, in the caller's own group or otherwise. Returned identically for an - unknown id and one belonging to another tenant (FR-008, NFR-010) — never a `detail`. + description: '' content: application/problem+json: schema: @@ -760,24 +440,6 @@ paths: operationId: redeemInvite tags: [invites] summary: Redeem a bearer invite token into new group membership (FR-006). - description: >- - Unauthenticated (A65) — deliberately NOT part of the owner-only `/invites` group above, - despite sharing its path prefix: a redeemer holds neither a session nor a role, only the - invite token itself. This asymmetry is intentional: `GET`/`POST /invites` are owner-only; - `POST /invites/redeem` is public. - - Every negative outcome *about the token* — unknown, expired, revoked, or already redeemed — - answers the identical 404 with no `detail` (A64, A67): these four are deliberately - indistinguishable, the same NFR-010 treatment every other foreign/unknown id gets in this - API. A username collision is the one outcome this operation DOES distinguish, as 409, - because it describes the caller's own chosen input, not a fact about the invite's state - (A67). - - Does not auto-issue a session (A66): the response mirrors `POST /auth/register`'s shape - exactly (`group_id`, `user_id`, `username`) with no session-related field at all. A client - calls `POST /auth/login` next with the credentials it just chose. FR-005's - registration-closed toggle does not gate this route — invites remain the way to join an - existing group precisely when self-service registration is closed. security: [] requestBody: required: true @@ -787,41 +449,31 @@ paths: $ref: "#/components/schemas/InviteRedeemRequest" responses: "201": - description: >- - Redeemed. The invite's group has a new member. No session is issued — see this - operation's description (A66). + description: '' content: application/json: schema: $ref: "#/components/schemas/InviteRedeemResponse" "400": - description: >- - The request body was not valid JSON, the token was missing, or the chosen - username/password failed this API's own validation rules. `detail` is populated. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "404": - description: >- - The presented token is not currently redeemable: unknown, expired, revoked, or already - redeemed. All four are deliberately indistinguishable (A64, A67) — no `detail`. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - The chosen username is already taken. Distinguishable from the 404 above on purpose - (A67): this describes the caller's own input, not the invite's state. No `detail`. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "429": - description: >- - Refused by the per-source-IP or per-token-hash redemption rate limiter (A68, NFR-012). - No `detail`. `Retry-After` names how long to wait, in whole seconds. + description: '' headers: Retry-After: schema: @@ -841,13 +493,6 @@ paths: operationId: createItem tags: [items] summary: Create an item (FR-010). - description: >- - Member-writable — mounted on the general authenticated group, not behind - [middleware.RequireOwner]; nothing in FR-010..FR-021 restricts item management to the owner - role (items.go's own file doc). `name` is the item's only required field - (`items.ErrNameRequired`) — FR-010 forbids any other field being mandatory. `id`, - `short_code`, `created_at`, `updated_at` and `version` are server-assigned; a caller sending - them has no effect. security: - SessionCookie: [] - DeviceToken: [] @@ -878,58 +523,6 @@ paths: operationId: listItems tags: [items] summary: List and search the caller's group's live items (FR-020, FR-021, FR-033). - description: >- - Member-readable, like every route in this group. Most recently updated first by default - (`ix_items_group_updated`), or by `sort` when given. `limit`/`offset` bound the page; an - out-of-range or unparsable value is a 400 rather than a silent clamp (A91) — default - `limit` is 50, maximum 200, neither pinned by a requirement. Tombstoned items (FR-019) are - never listed. - - **Filtering and search (G4, T077)** are ADDITIVE query parameters on this same operation — - no new route, no breaking change. A request that sends none of them behaves exactly as it - did before they existed, and that equivalence is a guarantee rather than an accident: the - server takes the unfiltered code path when no filter and no `sort` is set. - - Filters COMBINE with AND. `?q=drill&label_id=fragile&location_id=garage` means "matching - *drill*, tagged fragile, and in the garage". `limit`/`offset` then paginate the FILTERED, - SORTED set, and the response shape is unchanged. - - **Sorting and pagination (T078).** `sort` picks one column from a closed set — `name`, - `quantity`, `created_at`, `updated_at` — optionally prefixed with `-` for descending - (`sort=-created_at`); an absent `sort` keeps the default order stated above. Every page, - under every sort and every filter combination, additionally breaks ties on the item's `id` - (ascending) so that rows sharing the same sort value never drop or repeat across a - limit/offset walk — see `sort`'s own parameter description for what an unrecognised token - does, which is NOT the same rule the filters above follow. - - Pagination on this operation is bounded OFFSET, not a cursor: FR-020 asks for "cursor or - offset ... with a bounded page size", and a page size bounded to 200 (A91) makes the - classic cursor argument against OFFSET — SQLite still walking every skipped row on a deep - page — negligible at this API's actual scale (a household inventory, "hundreds, not - millions, of items", the same A91 sizing `limit`'s own default and maximum). A dedicated - sync client (Phase 3, FR-090/094-096) has its own cursor-shaped `/sync` envelope and is not - a consumer of this operation's pagination. If a group's live item count grows enough that - deep-offset paging measurably regresses this endpoint's p95 (NFR-002), that is the trigger - to revisit this decision — not a hypothetical one traded away today. - - **`q` is a search box, not a query language.** Its value is treated as literal text: - whitespace-separated words are ANDed, and the last word matches as a prefix so results - narrow as a person types. Punctuation is literal too — searching `SN-999` finds the serial - number rather than being read as FTS5's NOT operator, which is what raw input would mean - and what would otherwise answer 500 to an ordinary search. The cost, stated plainly: - FTS5 operator syntax is NOT available. `drill OR kettle` looks for items containing all - three literal words. - - `q` searches an item's name, description, its identifications, and the notes on its - warranty/sale/purchase blocks — the four columns `items_fts` indexes (FR-021). - - **Every filter parameter is best-effort, never a 400 (T077).** A value naming something - that does not exist, or that does not parse, narrows the result — to empty, in the worst - case — rather than failing the request: a stale bookmark, a label deleted on another - device, or one bad chip in a client's own filter bar must not turn into a broken page. This - governs `custom_field`, `warranty_status`, `created_from`/`created_to`/`updated_from`/ - `updated_to` exactly as it already governs `location_id`/`label_id`/`descendants` above — - only `limit`/`offset`, which are not filters, keep the strict A91 400. security: - SessionCookie: [] - DeviceToken: [] @@ -938,28 +531,15 @@ paths: in: query schema: type: string - description: >- - Free-text search over name, description, identifications and detail-block notes. - Literal text, not an expression — see this operation's description. Whitespace-only is - treated as absent. - name: location_id in: query schema: type: string - description: >- - Restrict to items in this location. By itself this means items DIRECTLY in it; add - `descendants=true` to include the whole subtree. An id that does not resolve is not an - error — it simply matches nothing, because a filter naming an unknown value has an - empty answer, and a 404 here would turn a stale bookmark into a broken page. - name: descendants in: query schema: type: boolean default: false - description: >- - Roll `location_id` up over its whole subtree. Ignored without `location_id`. The walk - descends THROUGH tombstoned intermediate locations to reach live ones beneath them - (A108) but never treats a tombstone as a match itself. examples: subtree: value: true @@ -970,12 +550,6 @@ paths: type: array items: type: string - description: >- - Restrict to items carrying this label. **Repeatable, and repeats mean AND** — an item - must carry every label named. Labels here are properties an item has ("fragile", "lent - out"), so a second filter chip narrowing rather than widening is what a person expects; - a client wanting OR issues one request per label. A repeated identical value counts - once. An id that does not resolve matches nothing, as with `location_id`. - name: custom_field in: query explode: true @@ -983,53 +557,16 @@ paths: type: array items: type: string - description: >- - Restrict to items whose custom fields (FR-016) contain this `name:value` pair. - **Repeatable, and repeats mean AND**, the same default `label_id` uses and for the same - reason. Only the FIRST `:` splits name from value, so a value may itself contain `:`; - a custom field NAME containing `:` cannot be targeted through this parameter (not - supported — see FR-016's typed EAV storage). - - item_custom_fields is a TYPED column set (`text_value`/`number_value`/`bool_value`/ - `date_value`, one populated per row, `field_type`-driven) but this parameter's value is - plain text with no declared type, because a filter chip built from "a value the user - typed" has no type of its own to send. The server tries the value against every column - it could plausibly be — literal text, literal date, and, only when the value itself - parses as a number or a boolean, that column too — and a value that cannot become a - given column's type never reaches a comparison against it: this is deliberately how a - type mismatch stays a normal "matches nothing" rather than a 500. - - An entry with no `:` at all is malformed and is DROPPED — individually, leaving any - other `custom_field` entries in the same request intact — rather than 400ing the whole - list. `custom_field=Colour:` (empty value) is not malformed: it is a legitimate filter - for a field recorded with an empty string. - name: warranty_status in: query schema: type: string enum: [none, any, active, expired, lifetime] - description: >- - Restrict by the item's warranty (FR-012) status. `none` — no live warranty block. - `any` — a warranty block exists, regardless of shape (an undated one is `any` but - neither `active` nor `expired`). `active` — a lifetime warranty, OR one whose recorded - expiry has not yet passed and whose recorded start (if any) already has. `expired` — a - non-lifetime warranty whose recorded expiry has passed. `lifetime` — - `is_lifetime = true`, regardless of any expiry also recorded alongside it (a lifetime - product warranty can still carry an accessory-cover expiry, per `CreateWarrantyRequest` - 's own doc). "Passed"/"has arrived" compare against the SERVER's current UTC calendar - day against `starts_on`/`expires_on`, which — like every warranty date on this API — - carry no time zone of their own (A93): a warranty starts and expires on a day as - printed on a receipt, not at an instant. A value outside this enum is ignored — the - request behaves as though `warranty_status` were absent — not a 400. - name: created_from in: query schema: type: integer format: int64 - description: >- - Only items created at or after this instant (INCLUSIVE), Unix milliseconds (A51) — the - same unit `Item.created_at` already uses on the wire. An unparsable value is ignored, - not a 400. - name: created_to in: query schema: @@ -1041,9 +578,6 @@ paths: schema: type: integer format: int64 - description: >- - Only items last updated at or after this instant (INCLUSIVE), Unix milliseconds (A51). - See `created_from` for the parsing rule. - name: updated_to in: query schema: @@ -1055,20 +589,6 @@ paths: schema: type: string pattern: '^-?(name|quantity|created_at|updated_at)$' - description: >- - Order the result by one column from a closed set — `name`, `quantity`, `created_at`, - `updated_at` — ascending by default, or descending with a leading `-` - (`sort=-created_at`). Absent, this operation keeps its existing default order (most - recently updated first). Every ORDER BY this parameter can produce additionally breaks - ties on the item's `id` (ascending), so two items sharing the sorted column's value - still page deterministically — never dropped, never repeated, regardless of how many - rows tie. - - Unlike every filter parameter above, a value that is not exactly one of the four - column names, optionally `-`-prefixed, is a 400 — `sort`, like `limit`/`offset`, is - not a filter: it decides which rows land on which PAGE, so silently substituting a - different order than the one requested would be a lie about what was returned, the - identical reasoning `limit`/`offset` are held to above. examples: name_ascending: value: name @@ -1097,10 +617,7 @@ paths: schema: $ref: "#/components/schemas/ItemListResponse" "400": - description: >- - `limit`/`offset` was present but not a valid integer in its allowed range, or `sort` - named something other than the closed set of allowed columns (each optionally - `-`-prefixed). `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1117,9 +634,6 @@ paths: operationId: getItem tags: [items] summary: Get one item (FR-020). - description: >- - Member-readable. [storage.ErrNotFound] renders as 404 with no detail — an unknown, - tombstoned or foreign-group item id are one indistinguishable answer (FR-008/NFR-010). security: - SessionCookie: [] - DeviceToken: [] @@ -1135,9 +649,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such item. Returned identically for an unknown id, a tombstoned one (FR-019) and one - belonging to another tenant (FR-008, NFR-010) — never a `detail`. + description: '' content: application/problem+json: schema: @@ -1148,12 +660,6 @@ paths: operationId: updateItem tags: [items] summary: Replace an item's writable fields (FR-090's optimistic concurrency). - description: >- - Member-writable, the identical posture create and delete on this resource have. Whole-body - replace, not a partial patch: an omitted field clears the column. `version` is REQUIRED - (distinct from the read shape, where it is optional and informational) — omitting it and - sending `0` are the identical failure (`items.ErrVersionRequired`). A `version` that no - longer matches the row's current one is a 409, never a silent last-write-wins. security: - SessionCookie: [] - DeviceToken: [] @@ -1173,9 +679,7 @@ paths: schema: $ref: "#/components/schemas/Item" "400": - description: >- - The request body was not valid JSON, `name` was empty, or `version` was omitted. - `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1183,17 +687,13 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such item. Returned identically for an unknown id, a tombstoned one and one - belonging to another tenant — never a `detail`. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - `version` no longer matches the item's current one — the caller's view is stale. No - `detail`. + description: '' content: application/problem+json: schema: @@ -1204,16 +704,6 @@ paths: operationId: deleteItem tags: [items] summary: Soft-delete an item and cascade the tombstone to its satellite rows (FR-019, A107). - description: >- - Member-writable — NOT behind [middleware.RequireOwner], despite deleting a resource: FR-019 - governs the same item-management surface FR-010/FR-020/FR-090 already made member-writable - (itemDeleteHandler's own doc). Tombstones the item (`deleted_at`) and, in the SAME - transaction, cascades that tombstone to the item's warranty/sale/purchase/identifications/ - custom-fields (A107) — none of those five satellite rows survives a parent delete as a live - row. NOT idempotent the way `DELETE /auth/sessions/{sessionID}` is: a repeat call answers - 404, not a second 204 (A93) — [storage.ErrNotFound] is the one answer every "nothing here - for you" case gets, and inventing a 204 for an id that no longer names a live item would - answer "successfully deleted" for another tenant's item id. security: - SessionCookie: [] - DeviceToken: [] @@ -1225,9 +715,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such live item. Returned identically for an unknown id, an already-tombstoned one - (a repeat delete, A93) and one belonging to another tenant — never a `detail`. + description: '' content: application/problem+json: schema: @@ -1242,15 +730,6 @@ paths: operationId: createItemWarranty tags: [items] summary: Create the item's warranty detail block (FR-012). - description: >- - Member-writable, like every route in this group — nothing in FR-012 makes a warranty block - owner-only. 1:0..1 with the item (`ux_item_warranty_group_item`); the block has no id of - its own, addressed entirely by its parent item's id (warranty.go's own file doc). No field - is required (FR-011's opt-in design). `starts_on`/`expires_on` are zero-padded ISO-8601 - calendar days (`YYYY-MM-DD`) or empty — anything else is a 400 (A93.1); `is_lifetime` and an - `expires_on` may both be set (A93.2, e.g. a lifetime product warranty whose registration - cover still lapses). Cross-group-404 shape: an unknown, tombstoned or foreign-group - `{itemID}` is 404, never a 409 or a 500. security: - SessionCookie: [] - DeviceToken: [] @@ -1270,9 +749,7 @@ paths: schema: $ref: "#/components/schemas/WarrantyBlock" "400": - description: >- - The request body was not valid JSON, or a date was not `YYYY-MM-DD` (A93.1). `detail` - is populated. + description: '' content: application/problem+json: schema: @@ -1286,9 +763,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - The item already has a live warranty block (the relationship is 1:0..1) — use `PUT` to - change it. No `detail`. + description: '' content: application/problem+json: schema: @@ -1299,12 +774,6 @@ paths: operationId: getItemWarranty tags: [items] summary: Get the item's warranty detail block (FR-012). - description: >- - Member-readable, like every route in this group. [storage.ErrNotFound] renders as 404 with - no detail, and deliberately collapses two cases into that one answer: an unknown/ - tombstoned/foreign-group item, AND a visible item with no warranty block yet — a distinct - status for the second would answer "does that item exist" for any guessed id - (FR-008/NFR-010). security: - SessionCookie: [] - DeviceToken: [] @@ -1320,9 +789,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such item, or the item has no warranty block (indistinguishable, see this - operation's description). No `detail`. + description: '' content: application/problem+json: schema: @@ -1333,12 +800,6 @@ paths: operationId: updateItemWarranty tags: [items] summary: Replace the item's warranty detail block (FR-012, FR-090's optimistic concurrency). - description: >- - Member-writable. Whole-block replace, not a partial patch — an omitted field clears the - column. `version` is REQUIRED, the identical split `PUT /items/{itemID}` draws. A `version` - that no longer matches the row's current one is a 409. `storage.ErrNotFound` covers an - unknown/tombstoned/foreign-group item and a visible item with no block, identically to the - `GET` above. security: - SessionCookie: [] - DeviceToken: [] @@ -1358,9 +819,7 @@ paths: schema: $ref: "#/components/schemas/WarrantyBlock" "400": - description: >- - The request body was not valid JSON, `version` was omitted, or a date was not - `YYYY-MM-DD`. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1385,14 +844,6 @@ paths: operationId: deleteItemWarranty tags: [items] summary: Delete the item's warranty detail block (FR-012). - description: >- - Member-writable. Tombstoned (`deleted_at`), never destroyed — the partial unique index is - scoped to `deleted_at IS NULL`, so the item may be given a new warranty block afterwards. No - expected version, unlike `PUT` (A93.3): a delete writes no field values to guard, and a - body on `DELETE` is not reliable transport for one. NOT idempotent: a repeat call answers - 404, not a second 204 — the same A93 asymmetry `DELETE /items/{itemID}` documents, and for - the same reason (inventing a 204 for a block that no longer exists would answer - "successfully deleted" for another tenant's item id). security: - SessionCookie: [] - DeviceToken: [] @@ -1404,9 +855,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such item, or the item has no live warranty block (including a repeat delete). No - `detail`. + description: '' content: application/problem+json: schema: @@ -1421,14 +870,6 @@ paths: operationId: createItemSale tags: [items] summary: Create the item's sold-to detail block (FR-013). - description: >- - Member-writable, the identical posture the warranty block above has — nothing in FR-013 - makes a sold-to block owner-only. 1:0..1 with the item, following warranty's own shape - (this block has no id of its own). No field is required. `sold_on` is a zero-padded - ISO-8601 calendar day or empty, else 400. `sale_price_minor` is an integer minor-unit - amount (A96) — meaningful at zero (an item given away, not sold), never negative - (`items.ErrSalePriceNegative`). Cross-group-404 shape, identical to the warranty block's - own. security: - SessionCookie: [] - DeviceToken: [] @@ -1448,9 +889,7 @@ paths: schema: $ref: "#/components/schemas/SaleBlock" "400": - description: >- - The request body was not valid JSON, `sold_on` was not `YYYY-MM-DD`, or - `sale_price_minor` was negative. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1464,8 +903,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - The item already has a live sale block (1:0..1) — use `PUT` to change it. No `detail`. + description: '' content: application/problem+json: schema: @@ -1476,10 +914,6 @@ paths: operationId: getItemSale tags: [items] summary: Get the item's sold-to detail block (FR-013). - description: >- - Member-readable. [storage.ErrNotFound] renders as 404, collapsing an unknown/tombstoned/ - foreign-group item and a visible item with no sale block into one answer, the identical - FR-008/NFR-010 reason the warranty block's own `GET` gives. security: - SessionCookie: [] - DeviceToken: [] @@ -1506,9 +940,6 @@ paths: operationId: updateItemSale tags: [items] summary: Replace the item's sold-to detail block (FR-013, FR-090's optimistic concurrency). - description: >- - Member-writable. Whole-block replace; `version` is REQUIRED, and a stale one is a 409, the - identical contract the warranty block's `PUT` documents. security: - SessionCookie: [] - DeviceToken: [] @@ -1528,9 +959,7 @@ paths: schema: $ref: "#/components/schemas/SaleBlock" "400": - description: >- - The request body was not valid JSON, `version` was omitted, `sold_on` was not - `YYYY-MM-DD`, or `sale_price_minor` was negative. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1555,9 +984,6 @@ paths: operationId: deleteItemSale tags: [items] summary: Delete the item's sold-to detail block (FR-013). - description: >- - Member-writable. Tombstoned, never destroyed. No expected version (A93.3, the same - argument A96 follows). NOT idempotent: a repeat call answers 404, not a second 204. security: - SessionCookie: [] - DeviceToken: [] @@ -1584,13 +1010,6 @@ paths: operationId: createItemPurchase tags: [items] summary: Create the item's purchased-from detail block (FR-014). - description: >- - Member-writable, the identical posture the warranty and sale blocks above have. 1:0..1 - with the item; no id of its own. No field is required. `purchased_on` is a zero-padded - ISO-8601 calendar day or empty, else 400. `purchase_price_minor` is an integer minor-unit - amount (A96) — meaningful at zero (an item received as a gift), never negative - (`items.ErrPurchasePriceNegative`). Cross-group-404 shape, identical to the other two - blocks'. security: - SessionCookie: [] - DeviceToken: [] @@ -1610,9 +1029,7 @@ paths: schema: $ref: "#/components/schemas/PurchaseBlock" "400": - description: >- - The request body was not valid JSON, `purchased_on` was not `YYYY-MM-DD`, or - `purchase_price_minor` was negative. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1626,9 +1043,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - The item already has a live purchase block (1:0..1) — use `PUT` to change it. No - `detail`. + description: '' content: application/problem+json: schema: @@ -1639,10 +1054,6 @@ paths: operationId: getItemPurchase tags: [items] summary: Get the item's purchased-from detail block (FR-014). - description: >- - Member-readable. [storage.ErrNotFound] renders as 404, collapsing an unknown/tombstoned/ - foreign-group item and a visible item with no purchase block into one answer, the identical - FR-008/NFR-010 reason the other two blocks' own `GET` gives. security: - SessionCookie: [] - DeviceToken: [] @@ -1669,9 +1080,6 @@ paths: operationId: updateItemPurchase tags: [items] summary: Replace the item's purchased-from detail block (FR-014, FR-090's optimistic concurrency). - description: >- - Member-writable. Whole-block replace; `version` is REQUIRED, and a stale one is a 409, the - identical contract the other two blocks' `PUT` document. security: - SessionCookie: [] - DeviceToken: [] @@ -1691,9 +1099,7 @@ paths: schema: $ref: "#/components/schemas/PurchaseBlock" "400": - description: >- - The request body was not valid JSON, `version` was omitted, `purchased_on` was not - `YYYY-MM-DD`, or `purchase_price_minor` was negative. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1718,9 +1124,6 @@ paths: operationId: deleteItemPurchase tags: [items] summary: Delete the item's purchased-from detail block (FR-014). - description: >- - Member-writable. Tombstoned, never destroyed. No expected version (A93.3). NOT idempotent: - a repeat call answers 404, not a second 204. security: - SessionCookie: [] - DeviceToken: [] @@ -1747,14 +1150,6 @@ paths: operationId: listItemIdentifications tags: [items] summary: List an item's identifications (FR-015). - description: >- - Member-readable. Cross-group-404 shape, not the collection-absence shape `GET /items` uses: - this route names an EXISTING parent (the item), so an unknown/tombstoned/foreign-group - `{itemID}` is a 404. An item the caller CAN see with zero rows is an ordinary 200 with an - empty array — "no rows yet" is this relationship's normal resting state, never folded into - 404 (A100.1). Ordered by `created_at ASC` (insertion order); takes no `limit`/`offset` — a - single item's identifications are a handful of rows, not the unbounded set `GET /items` - paginates (A100.1). security: - SessionCookie: [] - DeviceToken: [] @@ -1781,15 +1176,6 @@ paths: operationId: createItemIdentification tags: [items] summary: Add an identification row to an item (FR-015). - description: >- - Member-writable, like every route in this group — item data, not a group setting, the - opposite default from `/custom-field-defs` below (A101.1). Cross-group-404 shape, like the - `GET` above. Both `kind` and `value` are required on every row (A100.2, no FR-011-style - opt-in escape hatch: a row's only two content columns are these, so a row with neither - would be a "searchable" row with nothing to search for). `kind` must be one of migration - 0001's five CHECK tokens: `serial`, `model`, `asset_tag`, `barcode`, `other` - (`items.ErrIdentificationKindInvalid`). No 409: FR-015 is 0..N, so a second row never - conflicts with an existing one. security: - SessionCookie: [] - DeviceToken: [] @@ -1809,9 +1195,7 @@ paths: schema: $ref: "#/components/schemas/Identification" "400": - description: >- - The request body was not valid JSON, `kind` was not one of the five allowed tokens, or - `value` was empty. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1834,13 +1218,6 @@ paths: operationId: updateItemIdentification tags: [items] summary: Replace one identification row (FR-090's optimistic concurrency, T056). - description: >- - Member-writable. Both `itemID` and `identificationID` are checked: [storage.ErrNotFound] - covers an unknown/tombstoned/foreign-group item, an unknown/foreign-group row, AND a row - that belongs to a DIFFERENT item in the caller's own group (identifications.go's own file - doc, "The route with two ids") — that last case collapses into the identical 404 the rest - get, the same existence-oracle rule applied to a second wildcard. `version` is REQUIRED, - and a stale one is a 409. security: - SessionCookie: [] - DeviceToken: [] @@ -1861,9 +1238,7 @@ paths: schema: $ref: "#/components/schemas/Identification" "400": - description: >- - The request body was not valid JSON, `version` was omitted, `kind` was invalid, or - `value` was empty. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -1871,10 +1246,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such row for this item — unknown item, unknown row, foreign-tenant either, or a row - belonging to a different item in the caller's own group, all indistinguishable. No - `detail`. + description: '' content: application/problem+json: schema: @@ -1891,11 +1263,6 @@ paths: operationId: deleteItemIdentification tags: [items] summary: Delete one identification row (T056). - description: >- - Member-writable. Tombstoned, never destroyed, the same treatment the three detail blocks - give their own rows. No expected version (A93). NOT idempotent: a repeat call answers 404, - including for a row that belongs to a different item — [storage.ErrNotFound] is the one - answer every "nothing here for you" case gets. security: - SessionCookie: [] - DeviceToken: [] @@ -1908,9 +1275,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such live row for this item (unknown, already tombstoned, foreign-tenant, or - belonging to a different item). No `detail`. + description: '' content: application/problem+json: schema: @@ -1925,10 +1290,6 @@ paths: operationId: listItemCustomFields tags: [items] summary: List an item's custom-field values (FR-016). - description: >- - Member-readable. The identical cross-group-404 shape `GET .../identifications` documents: - an unknown/tombstoned/foreign-group `{itemID}` is 404; a visible item with zero rows is an - ordinary 200 with an empty array. security: - SessionCookie: [] - DeviceToken: [] @@ -1955,22 +1316,6 @@ paths: operationId: createItemCustomField tags: [items] summary: Add a custom-field value to an item (FR-016). - description: >- - Member-writable — item data, the SAME default `/items/{itemID}/identifications` has and - the OPPOSITE of `/custom-field-defs`' owner-gated writes below: a VALUE is item data even - though a DEFINITION is a group setting (A101.1, item_custom_fields.go's own file doc). - `name` and `field_type` are required; `field_type` is one of `text`, `number`, `boolean`, - `date` (`items.ErrCustomFieldTypeInvalid`). Exactly one of `text_value`/`number_value`/ - `bool_value`/`date_value` must be set, matching `field_type` - (`items.ErrCustomFieldValueInvalid`) — there is no schema-level CHECK backing this - invariant, so this operation is the only place it is enforced. `field_def_id`, when - non-empty, is resolved against `/custom-field-defs` in the caller's own group: an unknown - or soft-deleted id is a 400 (`items.ErrCustomFieldDefNotFound`, A104's conservative "reject - a write against a removed definition"), and a `field_type` that disagrees with the resolved - definition's own is a 400 (`items.ErrCustomFieldTypeMismatch`, A103.2). An empty - `field_def_id` means an ad hoc row with no definition (A103.1) — `name`/`field_type` are - then authoritative on the row itself, a snapshot that does not follow a later rename of the - definition it started from. No 409: FR-016 is 0..N. security: - SessionCookie: [] - DeviceToken: [] @@ -1990,11 +1335,7 @@ paths: schema: $ref: "#/components/schemas/ItemCustomField" "400": - description: >- - The request body was not valid JSON, `name` was empty, `field_type` was not one of the - four allowed tokens, the exactly-one-value-column invariant was violated, `field_def_id` - did not resolve to a live definition in the caller's own group, or `field_type` - disagreed with the resolved definition's. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -2017,12 +1358,6 @@ paths: operationId: updateItemCustomField tags: [items] summary: Replace one custom-field value row (FR-090's optimistic concurrency, T058). - description: >- - Member-writable. Both `itemID` and `customFieldID` are checked, the identical two-id - contract `PUT .../identifications/{identificationID}` documents, including a row belonging - to a different item in the caller's own group collapsing into the same 404. Same - `field_def_id`/`field_type`/value-column validation as the `POST` above. `version` is - REQUIRED, and a stale one is a 409. security: - SessionCookie: [] - DeviceToken: [] @@ -2043,9 +1378,7 @@ paths: schema: $ref: "#/components/schemas/ItemCustomField" "400": - description: >- - The request body was not valid JSON, `version` was omitted, or any of the `POST` - operation's validation rules failed. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -2053,10 +1386,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such row for this item — unknown item, unknown row, foreign-tenant either, or a row - belonging to a different item in the caller's own group, all indistinguishable. No - `detail`. + description: '' content: application/problem+json: schema: @@ -2073,9 +1403,6 @@ paths: operationId: deleteItemCustomField tags: [items] summary: Delete one custom-field value row (T058). - description: >- - Member-writable. Tombstoned, never destroyed. No expected version (A93). NOT idempotent: a - repeat call answers 404, including for a row that belongs to a different item. security: - SessionCookie: [] - DeviceToken: [] @@ -2088,9 +1415,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such live row for this item (unknown, already tombstoned, foreign-tenant, or - belonging to a different item). No `detail`. + description: '' content: application/problem+json: schema: @@ -2105,14 +1430,6 @@ paths: operationId: listCustomFieldDefs tags: [custom-field-defs] summary: List the caller's group's custom-field definitions (FR-017). - description: >- - Member-readable — mounted on the general authenticated group, NOT behind - [middleware.RequireOwner], the opposite of the writes below: every member needs the current - definitions to render an item's data-entry form (A101.1). Collection shape, not - cross-group-404: there is no id in this route to be wrong about, so the isolation property - is "another group's definitions are absent from this list" (the same property `GET /items` - carries). Duplicate `name` values are permitted — no unique index backs FR-017's - "consistent naming" (A101.2). security: - SessionCookie: [] - DeviceToken: [] @@ -2131,14 +1448,6 @@ paths: operationId: createCustomFieldDef tags: [custom-field-defs] summary: Create a group-level custom-field definition (FR-017). Owner-gated. - description: >- - Owner-gated (A101.1) — 403 for a member: a field DEFINITION reshapes the data-entry form - for every item in the household, a group ADMIN SETTING in FR-007's own words, unlike an - item custom-field VALUE (member-writable, above). Id-less-create shape, like `POST /items`: - the row lands in the caller's own group structurally, with no group id in the request for a - caller to name. `name` and `field_type` are both required; `field_type` is one of `text`, - `number`, `boolean`, `date` (`customfields.ErrFieldTypeInvalid`). No 409: FR-017 is 0..N - and duplicate names are permitted (A101.2). security: - SessionCookie: [] - DeviceToken: [] @@ -2156,9 +1465,7 @@ paths: schema: $ref: "#/components/schemas/CustomFieldDef" "400": - description: >- - The request body was not valid JSON, `name` was empty, or `field_type` was not one of - the four allowed tokens. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -2177,12 +1484,6 @@ paths: operationId: updateCustomFieldDef tags: [custom-field-defs] summary: Replace a custom-field definition (FR-090's optimistic concurrency, T057). Owner-gated. - description: >- - Owner-gated (A101.1) — 403 for a member. Cross-group-404 shape: `{fieldDefID}` names an - existing resource, so an unknown, tombstoned or foreign-group id is 404. `version` is - REQUIRED, and a stale one is a 409. This does not touch, cascade to, or invalidate any - `item_custom_fields` row that already references this definition (T057's scope; see A102 - for the read-side consequence and A104 for the write-side one). security: - SessionCookie: [] - DeviceToken: [] @@ -2202,9 +1503,7 @@ paths: schema: $ref: "#/components/schemas/CustomFieldDef" "400": - description: >- - The request body was not valid JSON, `version` was omitted, `name` was empty, or - `field_type` was not one of the four allowed tokens. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -2231,13 +1530,6 @@ paths: operationId: deleteCustomFieldDef tags: [custom-field-defs] summary: Delete a custom-field definition (T057). Owner-gated. - description: >- - Owner-gated (A101.1) — 403 for a member. Tombstoned, never destroyed (FR-019's rule). No - expected version (A93). NOT idempotent: a repeat call answers 404, not a second 204. - Existing `item_custom_fields` rows that already reference this definition are unaffected — - the foreign key is never violated by a soft delete, and A102's conservative default keeps - those values rendering; new writes against the now-deleted definition are rejected by - `/items/{itemID}/custom-fields`' own `POST`/`PUT` (A104), not by this route. security: - SessionCookie: [] - DeviceToken: [] @@ -2251,9 +1543,7 @@ paths: "403": $ref: "#/components/responses/Forbidden" "404": - description: >- - No such live definition — unknown, already tombstoned (a repeat delete), or another - tenant's. No `detail`. + description: '' content: application/problem+json: schema: @@ -2268,12 +1558,6 @@ paths: operationId: getGroupDetailVisibility tags: [groups] summary: Get the caller's group's detail-block visibility state (FR-011). - description: >- - Member-readable — mounted on the general authenticated group, NOT behind - [middleware.RequireOwner], the opposite of the `PUT` below: every member needs the current - visibility state to render item screens correctly (A99). No group identifier or resource id - in the path — the resource IS the caller's own group, already the whole of what the bound - [storage.Scope] identifies. security: - SessionCookie: [] - DeviceToken: [] @@ -2287,10 +1571,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No visibility row for the caller's group. Unreachable in practice for a session bound - to a live group (every group gets one at creation) — documented defensively, the same - way every other `Get` in this document is. No `detail`. + description: '' content: application/problem+json: schema: @@ -2303,12 +1584,6 @@ paths: summary: >- Replace the caller's group's detail-block visibility state (FR-011, FR-090's optimistic concurrency). Owner-gated. - description: >- - Owner-gated (A99) — 403 for a member: toggling what the whole household sees is a group - ADMIN SETTING in FR-007's own words ("owner: ... admin settings"), the opposite default - from the member-writable `/items/{itemID}/warranty|sale|purchase` blocks these three flags - gate. Whole-state replace, not a partial patch — an omitted flag is `false` (hidden). - `version` is REQUIRED, and a stale one is a 409. security: - SessionCookie: [] - DeviceToken: [] @@ -2357,15 +1632,6 @@ paths: operationId: listGroupMembers tags: [groups] summary: List every live member of the caller's group (FR-105, D1.17, A115). Member-readable. - description: >- - Member-readable — mounted on the general authenticated group, NOT behind - `RequireOwner`, the opposite default from `GET /invites` despite both listing people: a - household's own membership roster (FR-105's group-members screen) is shared group state - every member's own screens need, not an admin action the way issuing or revoking an invite - is (FR-007). Discloses each member's username, role and joined-at instant only — never a - password hash or any other credential-shaped column. No group identifier or resource id in - the path — the resource IS the caller's own group, already the whole of what the bound - session identifies. Ordered oldest-joined first. security: - SessionCookie: [] - DeviceToken: [] @@ -2388,12 +1654,6 @@ paths: operationId: listItemStockAdjustments tags: [items] summary: List an item's stock-adjustment history (FR-018). - description: >- - Member-readable. The identical cross-group-404 shape `GET .../custom-fields` documents: an - unknown/tombstoned/foreign-group `{itemID}` is 404; a visible item with zero adjustments - yet is an ordinary 200 with an empty array. Append-only (FR-018, A105): no `PUT`/`DELETE` - exists for this resource anywhere in this document, and there is no `{stockAdjustmentID}` - wildcard in its address at all — a correction is a new adjustment row, not an edit. security: - SessionCookie: [] - DeviceToken: [] @@ -2420,17 +1680,6 @@ paths: operationId: createItemStockAdjustment tags: [items] summary: Record a stock adjustment against an item (FR-018). - description: >- - Member-writable, like every route in this group — adjusting stock is item data, not a - group setting. Cross-group-404 shape, like the `GET` above. Applies `delta` to the item's - `quantity` in the SAME transaction as the history row it writes; `resulting_quantity` on - the response is the quantity after this adjustment. No shape validation beyond JSON - decoding itself: `reason`/`note` carry no CHECK-constraint-backed enumeration. No 409: the - history is append-only, so a new row never conflicts with an existing one. **Not the only - way to move stock**: `PUT /items/{itemID}` also replaces `quantity` wholesale and leaves no - history row (A105, an open design tension) — a client that wants a complete adjustment - trail should never send `quantity` on that route and should move stock through this one - instead. security: - SessionCookie: [] - DeviceToken: [] @@ -2474,20 +1723,6 @@ paths: operationId: listItemLabels tags: [items, labels] summary: List the labels attached to an item. - description: >- - Returns whole labels — names and colours — not label ids, because a client rendering an - item's tags needs both and resolving ids against a separately-fetched label list is an N+1 - the server-side join exists to avoid. Ordered by label name, matching `GET /labels`, so an - item's tags read in the same order as the picker that assigned them. - - An unknown, tombstoned or foreign `{itemID}` answers **404**, NOT an empty list: "this item - has no tags" and "this item is not yours" are different facts, and answering the second - with the first would quietly tell a caller their id was acceptable. - - A label that has itself been soft-deleted is absent from this list, even though the - assignment row still exists (A110). `DELETE /labels/{labelID}` deliberately does not cascade - to assignments — they are filtered out here, at read time — which keeps a label delete O(1) - rather than O(items tagged) and means a restored label brings its assignments back. security: - SessionCookie: [] - DeviceToken: [] @@ -2518,24 +1753,6 @@ paths: operationId: attachItemLabel tags: [items, labels] summary: Attach a label to an item. - description: >- - Member-writable. Takes **no request body**: both operands are in the path, and there is no - third fact about an assignment to send. - - **Idempotent.** Attaching a label that is already attached answers 204 and changes nothing - — that is what `PUT` on an edge means ("let this edge exist"), and a caller who says it - twice has not made an error worth a 409. Re-attaching a previously *detached* label revives - the existing assignment row rather than inserting a second one, so toggling a tag in a loop - does not grow the sync delta. - - **No `version`.** Every other `PUT` in this document requires one (FR-090). An assignment - has no fields, and the only two states it has are the ones the method names, so there is - nothing a stale version could protect. - - **Both path ids are scope-checked independently.** A foreign `{labelID}` answers 404 even - when `{itemID}` is the caller's own, and vice versa — and the two cases are - indistinguishable in the response, so a caller cannot learn which of the two ids named a - real row in a group they may not be in. security: - SessionCookie: [] - DeviceToken: [] @@ -2545,9 +1762,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - The item or the label is unknown, tombstoned or another tenant's. One answer for all - four causes, with no `detail` — which of the two ids was at fault is not disclosed. + description: '' content: application/problem+json: schema: @@ -2558,15 +1773,6 @@ paths: operationId: detachItemLabel tags: [items, labels] summary: Detach a label from an item. - description: >- - Removes the assignment. The label itself and the item itself are untouched. - - **Not idempotent**, unlike the `PUT` above: detaching a label that is not attached answers - 404, matching every other `DELETE` in this document (A93). The asymmetry follows the - methods' own semantics — `PUT` asserts a state and is naturally repeatable, `DELETE` names a - thing and that thing is either there or it is not. Replay-safety for an offline client that - sends the same detach twice is the sync envelope's job via its `mutation_id` idempotency - key (FR-096), not this surface's. security: - SessionCookie: [] - DeviceToken: [] @@ -2576,9 +1782,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - The item or the label is unknown, tombstoned or another tenant's, or the label is not - currently attached to this item. One answer for every cause, with no `detail`. + description: '' content: application/problem+json: schema: @@ -2594,16 +1798,6 @@ paths: operationId: listItemAttachments tags: [items, attachments] summary: List an item's live attachments (FR-041, T104a). - description: >- - Member-readable. The identical cross-group-404 shape `GET .../custom-fields` documents: an - unknown/tombstoned/foreign-group `{itemID}` is 404; a visible item with zero attachments is - an ordinary 200 with an empty array. - - LIVE attachments only — a tombstoned row is never returned, unlike the server's internal - reclamation sweep, which deliberately sees both. Thumbnail bytes are never resolved or - embedded in this response: each element carries `has_thumbnail` exactly as `POST`'s own - 201 response does, and a client fetches a thumbnail separately through the existing - `GET .../attachments/{attachmentID}/thumbnail` route, addressed by that element's own `id`. security: - SessionCookie: [] - DeviceToken: [] @@ -2628,34 +1822,6 @@ paths: operationId: uploadItemAttachment tags: [items, attachments] summary: Upload a file attached to an item (FR-040, FR-041, FR-044, T084). - description: >- - Member-writable, the same FR-007 classification every other `/items/{itemID}/...` - sub-resource in this document carries — not owner-gated. Cross-group-404 shape, like - `POST /items/{itemID}/warranty`: `{itemID}` names an existing parent going in, so an - unknown/tombstoned/foreign-group id is a 404, and a cross-group upload attempt writes - nothing to disk (the parent-item check runs before a single byte of the file is read). - - **`multipart/form-data`, not JSON** — the only operation in this document that is. Two - parts: a `category` field (see `AttachmentCreateRequest` below) and a `file` part carrying - the bytes and a filename. Both may arrive in either order. - - **The `file` part's own declared `Content-Type` header is never trusted (FR-044).** The - server sniffs the real type from the bytes themselves - ([RFC 9110](https://mimesniff.spec.whatwg.org/)-style magic-byte detection) and stores - that; `content_type` in the response reflects the SNIFFED value, which may differ from - whatever the uploading client declared. - - **Size is capped, and the cap is operator-configurable (FR-044)** — `HHO_MAX_ATTACHMENT_SIZE_BYTES`, - 25 MiB by default. An upload over that limit is refused with `413`, not `400`: the request - is well-formed and would have succeeded at a smaller size. The connection itself carries a - second, independent, slightly larger cap to leave room for multipart's own framing, so an - upload sized exactly at the configured limit is never rejected for the wrong reason. - - `has_thumbnail` in the response reports whether a preview was generated for this upload - (T085) — `true` only for the three image formats this server decodes for thumbnailing - (`image/jpeg`, `image/png`, `image/webp`); `false` for every other content type AND for an - image this server failed to decode or that exceeded the thumbnailing pixel ceiling. When - `true`, `GET /items/{itemID}/attachments/{attachmentID}/thumbnail` (T086, below) serves it. security: - SessionCookie: [] - DeviceToken: [] @@ -2667,6 +1833,12 @@ paths: $ref: "#/components/schemas/AttachmentCreateRequest" encoding: file: + + + + + + contentType: "*/*" responses: "201": @@ -2676,10 +1848,7 @@ paths: schema: $ref: "#/components/schemas/Attachment" "400": - description: >- - The request was not valid `multipart/form-data`, carried no `file` part, or `category` - was not one of the five allowed tokens (`image`, `manual`, `warranty`, `receipt`, - `general`). `detail` is populated. + description: '' content: application/problem+json: schema: @@ -2693,9 +1862,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "413": - description: >- - The file exceeds the configured maximum attachment size. No `detail` — the configured - limit is not published on this response; see FR-044. + description: '' content: application/problem+json: schema: @@ -2717,32 +1884,6 @@ paths: operationId: downloadItemAttachment tags: [items, attachments] summary: Download an attachment's original bytes (FR-042, NFR-017, T086). - description: >- - Streamed, never buffered — the response body is the file's own bytes, read directly off - the server's filesystem and copied to the connection in bounded chunks, regardless of the - attachment's own size. Member-writable, the same FR-007 classification every other - `/items/{itemID}/...` sub-resource in this document carries — not owner-gated. - - **Authenticated and group-checked on EVERY request, before a single byte is read (FR-042, - NFR-017).** `{itemID}`/`{attachmentID}` are resolved through the caller's OWN group - binding; an unknown, tombstoned, or another tenant's item or attachment — indistinguishably - — is a `404` with no `detail`, and the server's filesystem is never touched for that - request. There is no unauthenticated or static-file route anywhere in this API that can - reach an attachment's bytes. - - **`Content-Disposition` depends on the stored `content_type` (FR-042).** The three image - formats this server already decodes for thumbnailing (`image/jpeg`, `image/png`, - `image/webp`) are served `inline`, so a client can render them directly (an `` tag, a - lightbox). Every OTHER content type — including `image/svg+xml`, which this server never - decodes and which may embed executable script — is served `attachment`, forcing a download - rather than inline rendering. This is a deliberate defence against a stored-content - attachment being rendered in the same browser origin this API's own web client shares. - `X-Content-Type-Options: nosniff` is set on every response regardless. - - **Range requests are supported.** A client resuming an interrupted download, or an image - viewer probing only a file's header, may send a `Range` header and receive a `206 Partial - Content` response; conditional requests (`If-Modified-Since`) are honoured against the - underlying file's own last-modified time. security: - SessionCookie: [] - DeviceToken: [] @@ -2775,24 +1916,6 @@ paths: operationId: deleteItemAttachment tags: [items, attachments] summary: Delete one attachment (FR-045's delete half, T087). - description: >- - Member-writable, the same FR-007 classification every other `/items/{itemID}/...` - sub-resource in this document carries. Tombstoned (`deleted_at`), never destroyed, matching - FR-019's rule and the same treatment every other 0..N child row in this document gets. No - expected `version` — a delete has no field values to lose to a concurrent edit, the - identical A93 reasoning `DELETE /items/{itemID}/identifications/{identificationID}` states - for its own row. NOT idempotent: a repeat call answers `404`, including for a row that - belongs to a different item in the caller's own group — [storage.ErrNotFound] is the one - answer every "nothing here for you" case gets. - - **The row and the bytes are two separate things (FR-045).** This route tombstones the - metadata row synchronously, on this request, and best-effort removes the underlying file - (and, if one was generated, its thumbnail) from the server's filesystem in the SAME - request — but a filesystem fault during that best-effort removal does NOT fail this - response: the row is already gone from every other route's point of view by the time the - unlink is even attempted, and a periodic reclamation pass (FR-045's other half, not - reachable over HTTP) is the durable backstop for whatever bytes this request could not - remove synchronously. security: - SessionCookie: [] - DeviceToken: [] @@ -2810,9 +1933,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such live attachment for this item (unknown, already tombstoned, foreign-tenant, or - belonging to a different item). No `detail`. + description: '' content: application/problem+json: schema: @@ -2834,23 +1955,6 @@ paths: operationId: downloadItemAttachmentThumbnail tags: [items, attachments] summary: Download an attachment's generated thumbnail (FR-042, FR-043, NFR-017, T085/T086). - description: >- - Streamed, exactly like the original's own download route immediately above, and sharing - that route's identical group check (FR-042, NFR-017): `{itemID}`/`{attachmentID}` are - resolved through the caller's OWN group binding before this route's filesystem access, and - an unknown, tombstoned or another tenant's attachment answers `404` indistinguishably from - one with no thumbnail at all (see the `404` response below). Member-writable, same - FR-007 classification as the rest of this tag. - - A thumbnail, when one exists, is ALWAYS `image/jpeg` regardless of the original's own - `content_type` (T085 always encodes as JPEG), and is always served with - `Content-Disposition: inline` (JPEG is always in the inline-safe set — see the sibling - download route's own description). - - `has_thumbnail` on the `Attachment` schema (returned by upload and by a future GET on the - attachment itself) tells a client whether this route is worth calling at all — a `false` - value means either the original was not an image this server decodes, or thumbnailing was - attempted and failed (an undecodable or oversized source, FR-043). security: - SessionCookie: [] - DeviceToken: [] @@ -2872,10 +1976,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such attachment on this item (unknown, tombstoned or another tenant's), OR a real - attachment with no thumbnail (thumbnailing never ran or failed) — the same status for - both, with no `detail`, so neither is distinguishable from the other on the wire. + description: '' content: application/problem+json: schema: @@ -2890,17 +1991,6 @@ paths: operationId: createLocation tags: [locations] summary: Create a location. - description: >- - Member-writable — FR-007 assigns full CRUD on group data to every member, and a location is - group data. The id-less-create shape: no existing resource is named in the PATH, even - though the BODY may optionally name a parent (`parent_id`) — the same distinction - `POST /items` draws (`location_id` in the body, no `{id}` in the path). - - The row lands in the CALLER's group structurally, not by validation: the scoped - `LocationRepository` declares no method taking a group identifier, so there is no group id - for a caller to name. A `parent_id` that does not resolve to a live location in the - caller's own group answers **400**, never a 404 — the 404 status on this route is reserved - for a path segment, and no path segment here can be wrong. security: - SessionCookie: [] - DeviceToken: [] @@ -2918,9 +2008,7 @@ paths: schema: $ref: "#/components/schemas/Location" "400": - description: >- - `name` was empty, the body was not valid JSON, or `parent_id` did not resolve to a live - location in the caller's own group. + description: '' content: application/problem+json: schema: @@ -2933,15 +2021,6 @@ paths: operationId: listLocations tags: [locations] summary: List every location in the group. - description: >- - Returns the whole tree FLAT; the client folds it into a tree using each row's own - `parent_id`. No tree assembly happens server-side, and there is no `limit`/`offset` — a - household's location tree is tens of rows, not the unbounded set `GET /items` paginates. - - The collection isolation shape: there is no id in this route to be wrong about, so the - tenant property is stated over the RESPONSE — another group's locations are absent from - this listing. That is not a weaker claim than the cross-group 404; it is the only form that - claim can take where the request names no id. security: - SessionCookie: [] - DeviceToken: [] @@ -2964,32 +2043,6 @@ paths: operationId: getLocationTree tags: [locations] summary: The location hierarchy as a nested tree, with roll-up item counts (G6, FR-033). - description: >- - `GET /locations` returns the tree FLAT and leaves assembly to the client. This returns it - NESTED and, more importantly, carries the two counts a client cannot compute from the flat - list without also fetching every item: how many items sit directly in each node, and how - many sit anywhere in its subtree. - - It is a separate operation rather than a `?tree=true` flag on `GET /locations` because the - response is a genuinely different shape — nested and count-bearing. Overloading one - operation with two response schemas is exactly what makes a generated client awkward, and - FR-080's additive-only rule would then have no way to evolve one shape without touching the - other. - - **`item_count` versus `total_item_count`.** The first counts items whose `location_id` IS - this node. The second adds every descendant's, at any depth. Both are needed and neither - implies the other: a person deciding whether "Garage" is worth opening wants the roll-up, - and a person about to delete it needs the direct count, because that is what the - delete-guard refuses on and what `reassign_to` would move. - - **Ordering.** Siblings come back ordered by name then id — the same order `GET /locations` - uses, applied at each level. Roots are the top-level array. - - **Tombstones.** Deleted locations are absent, and their items are not counted anywhere. A - live location whose parent was deleted separately (possible only for a delete that - predates G2's guard, or an operator's SQL) surfaces as a ROOT rather than vanishing: a - node reachable from no root would be invisible in this response and therefore - unrecoverable through the UI, which is worse than showing it in a slightly wrong place. security: - SessionCookie: [] - DeviceToken: [] @@ -3026,9 +2079,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such location — unknown, tombstoned or another tenant's, indistinguishably. No - `detail`. + description: '' content: application/problem+json: schema: @@ -3039,16 +2090,6 @@ paths: operationId: updateLocation tags: [locations] summary: Rename and/or move a location. - description: >- - One operation covers BOTH rename and move: `name` and `parent_id` are simply the two - writable fields, and a request that changes only one still sends both. `version` is - REQUIRED (FR-090). - - Status mapping for the tree-specific failures is deliberate. A `parent_id` that does not - resolve, and a `parent_id` that would create a **cycle**, both answer **400**: each is a - malformed request — a reference to a parent that cannot legally receive this location — - not a missing resource (404 is reserved for `{locationID}` itself) and not a concurrency - race (409 is reserved for a stale `version` on the row being edited). security: - SessionCookie: [] - DeviceToken: [] @@ -3066,9 +2107,7 @@ paths: schema: $ref: "#/components/schemas/Location" "400": - description: >- - `name` was empty, `version` was absent or zero, the body was not valid JSON, - `parent_id` did not resolve, or `parent_id` would create a cycle in the tree. + description: '' content: application/problem+json: schema: @@ -3082,9 +2121,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - The supplied `version` is not the row's current one — another writer got there first. - Re-read and retry. + description: '' content: application/problem+json: schema: @@ -3095,48 +2132,6 @@ paths: operationId: deleteLocation tags: [locations] summary: Soft-delete a location. - description: >- - Tombstoned (`deleted_at`), never destroyed — the same rule items themselves follow - (FR-019), and what makes offline sync convergent (P-2). Takes no expected `version` (A93). - Not idempotent: a second call answers 404, because `not found` is the single answer every - "nothing here for you" case gets. - - **The delete-guard (G2, FR-030).** A location that still holds live child locations or live - items is REFUSED with **409** and the problem type `urn:hho:problem:location-not-empty`, - rather than tombstoned. Deleting "Garage" must not silently strand the drill inside it: an - item whose `location_id` points at a tombstone is not addressable through the tree and not - reachable by any location filter, so a permissive delete turns a rename-shaped mistake into - lost inventory. - - The 409 body carries `child_count` and `item_count` — the two numbers a client needs to - offer "this holds 3 boxes and 12 items; move them where?" — as EXTRA members alongside the - standard `Problem` fields. This is the one problem type in the API with extra members, and - they are safe to disclose here in a way a 404's would not be: the caller has already been - proven to own this location by the 404 that a foreign id gets *first*. - - **Reassignment.** Pass `?reassign_to=` to delete anyway, moving what the location directly - holds: - - * `?reassign_to=` — direct children are re-parented to that location and direct - items move into it, atomically, in the same transaction as the tombstone. - - * `?reassign_to=` (present, EMPTY) — children become roots and items become unplaced - (`location_id` NULL). Explicitly asking for the root is distinct from omitting the - parameter: the empty value says "detach", absence says "refuse if non-empty". - - Reassignment is **shallow by construction**: only rows that directly reference the deleted - location move. Deeper descendants keep pointing at their own parents, which have themselves - been re-parented, so the subtree survives with its internal shape intact and only its - attachment point changed. - - `reassign_to` naming the location being deleted, one of its own descendants, or an id that - does not resolve in the caller's group answers **400** — each would either orphan the - subtree or move rows into a row that is about to become a tombstone. - - Still soft (`deleted_at`), never destroyed — the same rule items follow (FR-019), and what - makes offline sync convergent (P-2). Still takes no expected `version` (A93). Still not - idempotent: a second call answers 404, because "not found" is the single answer every - "nothing here for you" case gets. security: - SessionCookie: [] - DeviceToken: [] @@ -3144,13 +2139,9 @@ paths: - $ref: "#/components/parameters/ReassignTo" responses: "204": - description: >- - Deleted. If `reassign_to` was supplied, the direct children and direct items moved in - the same transaction. + description: '' "400": - description: >- - `reassign_to` did not resolve to a live location in this group, or named the location - being deleted or one of its own descendants. + description: '' content: application/problem+json: schema: @@ -3164,9 +2155,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - The location still holds live children or items and no `reassign_to` was supplied - (G2, FR-030). + description: '' content: application/problem+json: schema: @@ -3181,10 +2170,6 @@ paths: operationId: listLabels tags: [labels] summary: List every label in the group. - description: >- - Member-readable: every member needs the current labels in order to tag an item with one. - The collection isolation shape — another group's labels are absent from this listing. No - `limit`/`offset`, for the same reason `GET /locations` has none. security: - SessionCookie: [] - DeviceToken: [] @@ -3203,12 +2188,6 @@ paths: operationId: createLabel tags: [labels] summary: Create a label. - description: >- - Member-writable. `name` and `color` are both required. - - Unlike custom-field DEFINITIONS — where A101.2 permits duplicate names — a name collision - with a different live label in the same group answers **409**, not silent acceptance - (A109). Two labels a human cannot tell apart are a data-entry accident, not a use case. security: - SessionCookie: [] - DeviceToken: [] @@ -3250,9 +2229,6 @@ paths: operationId: updateLabel tags: [labels] summary: Rename and/or recolour a label. - description: >- - One operation covers both rename and recolour; `version` is REQUIRED (FR-090). A rename - onto another live label's name answers 409, exactly as create does (A109). security: - SessionCookie: [] - DeviceToken: [] @@ -3284,9 +2260,7 @@ paths: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - Either the supplied `version` is stale, or the new `name` collides with another live - label in this group. Both are 409; the `type` distinguishes them. + description: '' content: application/problem+json: schema: @@ -3297,17 +2271,6 @@ paths: operationId: deleteLabel tags: [labels] summary: Soft-delete a label. - description: >- - Tombstoned, never destroyed. No expected `version` (A93). Not idempotent: a second call - answers 404. - - **What this does not do (A110).** It does not touch the assignment rows referencing this - label — no cascade. The label simply stops appearing in every item's tag list, because - `GET /items/{itemID}/labels` joins live-to-live and filters it out at read time. That keeps - this delete O(1) rather than O(items tagged), and means a label restored from a backup - brings its assignments back with it instead of having silently lost them. Contrast - `DELETE /items/{itemID}`, which DOES cascade to its own assignments: a deleted item has no - tags in any sense, whereas a deleted label is a row that may legitimately come back. security: - SessionCookie: [] - DeviceToken: [] @@ -3332,39 +2295,6 @@ paths: operationId: syncPull tags: [sync] summary: Pull the caller's group's changes since a watermark (FR-090, FR-095). - description: >- - **Implemented (D3.1, T155d).** This route's request/response envelope, pinned by - `specs/001-hho-ecosystem/contracts/sync-protocol.md`, is now the operation's real - behaviour, not merely a declared contract in front of a stub. - - Ordered by the caller's group's `change_seq` column, non-decreasing (never guaranteed - strictly ascending — see the next paragraph), paged by `limit` (FR-090). `changes[]` - covers the closed sync entity set (assumption A7): items and their detail blocks, - identifications, custom fields, stock adjustments, locations, labels, item-label links, - and attachment METADATA only — never bytes (FR-096). `tombstones[]` carries - `{ entity_type, id, deleted_at }` and nothing else. - - `limit` bounds `changes[]` and `tombstones[]` TOGETHER, not `changes[]` alone. A single - write (an item delete's cascade, for instance) can tombstone several rows sharing ONE - `change_seq` value exactly as easily as a create/update can produce several live rows - sharing one, and a page never splits such a group across two responses: every entry - sharing one `change_seq` value is always emitted together, in the same page. Because of - that guarantee, a page MAY exceed `limit` — the one documented exception is when a single - `change_seq` group is itself larger than `limit`, in which case the whole group is still - emitted in one page (rather than an empty, unservable one) and the next page resumes - strictly after it. - - A `since` watermark that predates the tombstone garbage-collection horizon cannot be - served correctly (the tombstones the client would need to reconcile its mirror are - already gone) and answers the `cursor_too_old` shape instead of the normal envelope - (FR-095); the client MUST discard its local mirror and re-pull from `since: 0`. `since: 0` - is always a valid full resync and never itself answers `cursor_too_old`, no matter how - much tombstone history has since been garbage-collected — see `SyncCursorTooOld`'s own - description. - - `mutation_id` retention (FR-091, restated fully on `POST /sync/push`) is what makes a - client's own pushed writes safe to observe again on a later pull without double-applying - them locally — that reconciliation is entirely the CLIENT's job, not this endpoint's. security: - SessionCookie: [] - DeviceToken: [] @@ -3376,9 +2306,7 @@ paths: $ref: "#/components/schemas/SyncPullRequest" responses: "200": - description: >- - Either the normal pull envelope or the `cursor_too_old` shape (FR-095) — see - `SyncPullResponse`'s `oneOf` and this operation's own description. + description: '' content: application/json: schema: @@ -3401,25 +2329,6 @@ paths: operationId: syncPush tags: [sync] summary: Push the caller's device's queued mutations (FR-091, FR-092, FR-093). - description: >- - **Phase 1 status (T093, D1.9).** This route is mounted and the request/response envelope - below is the final FR-091..FR-093 contract, pinned by - `specs/001-hho-ecosystem/contracts/sync-protocol.md` — but the current implementation - answers `501 Not Implemented` unconditionally. Real push semantics (idempotency lookup, - field-level merge, conflict recording and `change_seq` allocation) ship at D4.1 (T164), - replacing this stub's body rather than extending it. - - Idempotent by `mutation_id` per `(group_id, mutation_id)` — a retried push carrying an - already-seen `mutation_id` is answered in `skipped`, never reapplied (FR-091). A - `mutation_id` is retained at least as long as the tombstone retention window (default 90 - days) so a very late retry is still recognised. - - Merge is field-level: two mutations touching DIFFERENT fields of the same entity both - apply. Only a same-field divergence — this push's `base_version` for that specific field - does not match the server's stored value for it — writes a `conflicts` entry; the - server's stored value always wins (FR-092, FR-093). Ordering authority is exclusively - server-side (`change_seq` allocation at write time); a mutation's own `base_version` is - never trusted for ordering, only for detecting the divergence above. security: - SessionCookie: [] - DeviceToken: [] @@ -3431,29 +2340,19 @@ paths: $ref: "#/components/schemas/SyncPushRequest" responses: "200": - description: >- - Not reachable in Phase 1: see the `501` response below, which is this operation's - actual answer today. + description: '' content: application/json: schema: $ref: "#/components/schemas/SyncPushResponse" "400": - description: The request body was not valid JSON, or a mutation entry failed validation. `detail` is populated. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "401": $ref: "#/components/responses/Unauthorized" - "501": - description: >- - Phase 1's actual, unconditional answer (T093) — see this operation's own description. - No `detail`. - content: - application/problem+json: - schema: - $ref: "#/components/schemas/Problem" "426": $ref: "#/components/responses/UpgradeRequired" @@ -3464,47 +2363,12 @@ paths: operationId: getBackup tags: [backup] summary: Stream one whole-database backup archive (FR-132, T112, T113). Owner-only. - description: >- - Owner-only (FR-007 names "backup" explicitly among the owner role's admin actions) — a - member presenting either valid credential gets `403`, not `404`: exactly like every other - owner-only operation in this document (`/invites`, `/custom-field-defs`, - `/groups/detail-visibility`), `TenantScope` has already authenticated the caller, so there - is no tenant-existence oracle at stake, only a role fact (A54). Unlike ALL of those, - though, this operation is NOT scoped to the caller's own group at all: the archive is a - snapshot of the WHOLE database, every group included, as of the instant the request is - served — FR-132's own design, not a narrower admin action this document could scope down - further. - - **Streamed, never buffered (NFR-020).** The response body is a single gzip-compressed tar - stream — `manifest.json` first, then the compacted database snapshot (`db/hho.db`), then - every attachment file across every group, in `filepath.WalkDir`'s own lexical order — read - directly off the server's storage and filesystem and copied to the connection in - bounded-size chunks regardless of how large the database or the attachments tree is. This - endpoint has no `Content-Length`: the archive's final size is not known before the stream - completes. - - **A failure after streaming has begun cannot change this response's status code or body.** - If archive assembly fails partway through, the connection simply ends before the gzip/tar - stream's own natural end — an incomplete download, indistinguishable in effect from any - other connection that closed early, which every HTTP client already knows how to detect - (a short or corrupt stream) and, if it chooses, retry. The one failure this operation can - still answer as an ordinary `500` with a `Problem` body is a server-side misconfiguration - detected before any byte is written. - - FR-132's other half — an `hho backup` CLI subcommand producing the identical archive - format — is a separate task with no HTTP surface; restoring an archive (FR-133) and - scheduling automatic backups (FR-134) are separate tasks again, neither reachable over - HTTP today. security: - SessionCookie: [] - DeviceToken: [] responses: "200": - description: >- - The backup archive, streamed: a gzip-compressed tar stream (`manifest.json`, - `db/hho.db`, then `attachments//` for every attachment in every - group). `Content-Disposition` names a timestamped `hho-backup-.tar.gz` - filename. + description: '' content: application/gzip: schema: @@ -3524,48 +2388,12 @@ paths: operationId: exportItemsCSV tags: [export] summary: Stream the caller's group's full item export as native HHO CSV (FR-050, T147b). Member-readable. - description: >- - Member-readable, mounted on the general authenticated group exactly like `GET /items` and - `GET /locations` — NOT owner-gated, unlike `GET /backup` above. FR-007 classifies an item - export as reading item data every member can already see through every other route in - this document; this endpoint is a different SHAPE of the same read (one CSV file instead - of N JSON responses), not a wider one. Full group export, no filters, no pagination - parameters in v1 (FR-050, D2.1) — a re-export after a few edits is the ordinary round-trip - workflow this format exists to support. - - **Bounded assembly, then a genuinely streamed body.** The native CSV column schema (core - item fields, dynamic `cf:` custom-field columns, warranty/purchase/sale detail-block - columns, pipe-delimited labels/identifications, `category:filename:sha256` attachment - references — never attachment BYTES) is `server/internal/importexport`'s own scope (T147a). - Building that column set requires scanning every item's custom-field values up front, so - this handler assembles the full row set in memory before writing a single byte — bounded - by a household's own item count ("hundreds, not millions", per plan.md's sizing), never by - attachment bytes — and then streams the actual CSV to the connection one row at a time, - the same "assemble bounded, then stream" shape `GET /backup` uses for its own, much larger - archive. - - **Mid-stream failures cannot change this response's status code or body.** Every failure - this handler can produce during its OWN reads (a repository fault, most plausibly a - database error) happens entirely before any header is written and is answered as an - ordinary `500` with a `Problem` body. The one failure that can still occur AFTER headers - (and possibly some row bytes) have reached the client is the CSV writer's own `io.Writer` - fault — a client disconnecting mid-download, or any other write fault on the connection — - which this handler logs and stops writing for, exactly like `GET /backup`'s identical - post-header failure path: an incomplete download, indistinguishable from any other - connection that closed early. security: - SessionCookie: [] - DeviceToken: [] responses: "200": - description: >- - The group's full item export, streamed as one native HHO CSV file (FR-050, D2.1). - `Content-Disposition` names a `hho-items-.csv` filename (a plain date, not a - full timestamp like `GET /backup`'s own filename — a same-day repeat export is the - ordinary workflow this format supports, and the `id` column, not the filename, is the - external key a re-importer relies on). `Cache-Control: private, no-store` and - `X-Content-Type-Options: nosniff` are set, the same pair `GET /backup` sets and for the - identical reasons. + description: '' content: text/csv: schema: @@ -3583,18 +2411,6 @@ paths: operationId: getReportValuation tags: [reports] summary: Total item value grouped by location or by label (FR-060, D2.4). - description: >- - Member-readable, mounted beside the other three `/reports/*` routes and `GET - /export/items.csv` — NOT owner-gated (this document's `reports` tag description makes the - FR-007 argument in full). - - `group_by` is REQUIRED — `location` or `label`, no default — because guessing which of two - materially different reports a caller meant would be worse than asking again; an absent or - unrecognised value answers `400`. Grouping by `label` counts an item once PER label it - carries (an item with two labels appears in two groups), matching how `GET - /items?label_id=` itself treats labels as independent axes, not a partition. A value's - `total_value_minor` is `0` for a group whose items carry no purchase block at all (FR-011 - makes that block opt-in) — never null, never an absent row. security: - SessionCookie: [] - DeviceToken: [] @@ -3606,17 +2422,12 @@ paths: schema: type: string enum: [location, label] - description: >- - Which axis to group by. Required — an absent or unrecognised value answers `400` - rather than defaulting to either axis. examples: by_location: value: location responses: "200": - description: >- - One row per group (`?format=json`, the default) or the same rows as CSV - (`?format=csv`) — see `format`'s own description. + description: '' content: application/json: schema: @@ -3634,9 +2445,7 @@ paths: type: string format: binary "400": - description: >- - `group_by` was absent or was not exactly `location` or `label`, or `format` named - something other than `json`/`csv`. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -3653,17 +2462,6 @@ paths: operationId: getReportWarrantyExpiring tags: [reports] summary: Items whose warranty expires within a window (FR-060, D2.4). - description: >- - Member-readable, mounted beside the other three `/reports/*` routes. See the `reports` tag - description for the FR-007 argument. - - `within_days` is OPTIONAL, defaulting to `30`. `0` is a legal, if narrow, window ("expiring - today, and nothing later"); a NEGATIVE value is a `400` (there is no sensible "expiring - before today" reading), as is a value that does not parse as an integer. A warranty whose - expiry has already passed as of the server's current UTC calendar day is never included — - this report is forward-looking only. `days_remaining` compares against the SAME calendar - day/time-zone-free rule every warranty date on this API already follows (A93): a warranty - expires on a day as printed on a receipt, not at an instant. security: - SessionCookie: [] - DeviceToken: [] @@ -3674,17 +2472,12 @@ paths: schema: type: integer default: 30 - description: >- - How many days out to look, inclusive. Defaults to 30. `0` is legal ("expiring today - only"). A negative or unparsable value is a `400`. examples: two_weeks: value: 14 responses: "200": - description: >- - One row per item whose warranty expires within the window (`?format=json`, the - default) or the same rows as CSV (`?format=csv`). + description: '' content: application/json: schema: @@ -3702,9 +2495,7 @@ paths: type: string format: binary "400": - description: >- - `within_days` was present but not a valid, non-negative integer, or `format` named - something other than `json`/`csv`. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -3721,15 +2512,6 @@ paths: operationId: getReportPurchases tags: [reports] summary: Items purchased within a date range (FR-060, D2.4). - description: >- - Member-readable, mounted beside the other three `/reports/*` routes. See the `reports` tag - description for the FR-007 argument. - - `from` and `to` are BOTH REQUIRED (ISO-8601 `YYYY-MM-DD`, inclusive on both ends) — there - is no default range, unlike `within_days` above. Either missing answers `400` with a - message naming which one is absent. A present but malformed value, or a range with `from` - after `to`, is also a `400`. Rows are drawn from each item's purchase block's own - `purchased_on` date, the same field `GET /items/{itemID}/purchase` already returns. security: - SessionCookie: [] - DeviceToken: [] @@ -3749,16 +2531,12 @@ paths: required: true schema: type: string - description: >- - End of the range, inclusive, ISO-8601 `YYYY-MM-DD`. Required. Must not be before `from`. examples: default: value: "2026-12-31" responses: "200": - description: >- - One row per item purchased within the range (`?format=json`, the default) or the same - rows as CSV (`?format=csv`). + description: '' content: application/json: schema: @@ -3777,9 +2555,7 @@ paths: type: string format: binary "400": - description: >- - `from` and/or `to` was missing, one was not a real ISO-8601 calendar day, `from` was - after `to`, or `format` named something other than `json`/`csv`. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -3796,22 +2572,6 @@ paths: operationId: getReportItemCountByLocation tags: [reports] summary: Live item count per location, reusing the location tree's descendant rollup (FR-060, D2.4). - description: >- - Member-readable, mounted beside the other three `/reports/*` routes. See the `reports` tag - description for the FR-007 argument. - - Takes no parameter beyond the shared `format` — this report has nothing else to narrow by. - Reuses `GET /locations/tree`'s own descendant-rollup computation (T069), so `item_count` - here is the ROLLED-UP total: this location's own live items PLUS every live descendant's, - at any depth — the same figure `total_item_count` carries on `GET /locations/tree`, not the - direct-only count. A caller wanting the direct-only count reads `item_count` off - `GET /locations/tree` instead, which carries both. - - Every live location appears exactly once, in the tree's own pre-order (a parent before its - children), including locations holding nothing at all, at `item_count: 0`. Items with no - location at all are counted nowhere here: this report groups LOCATIONS, and an unplaced - item belongs to none — unlike `/reports/valuation`, whose `group_by=location` mode does - carry an "Unassigned" bucket. security: - SessionCookie: [] - DeviceToken: [] @@ -3819,9 +2579,7 @@ paths: - $ref: "#/components/parameters/ReportFormat" responses: "200": - description: >- - One row per location (`?format=json`, the default) or the same rows as CSV - (`?format=csv`). + description: '' content: application/json: schema: @@ -3855,38 +2613,6 @@ paths: operationId: exportBoM tags: [export] summary: Stream a Bill-of-Materials CSV for a selection of the caller's own items (FR-063, D2.4). - description: >- - Member-readable, mounted beside `GET /export/items.csv` — NOT owner-gated, the identical - FR-007 argument that route's own description makes. Unlike that route, this one is a - SELECTION: a caller names WHICH items they want a parts list for, and gets back exactly - those, never the whole group's inventory. CSV only (FR-063) — there is no `?format=json` - toggle on this route, unlike every `/reports/*` operation above. - - **Three mutually exclusive selection modes.** `location_id`, `label_id` and `item_id` are - each repeatable, but at MOST ONE of the three may be supplied per request — sending values - for two or more of them is a `400`, since there is no coherent single reading of "items in - location A AND carrying label B" as a mutually exclusive mode. Supplying NONE of the three - is valid, and answers an empty export: a header row with zero data rows, never an error and - never "the whole group's inventory" — see each parameter's own description. - - **`label_id` here is OR, unlike `GET /items?label_id=`'s AND.** `GET /items`'s own - `label_id` filter is a narrowing filter bar, where a second repeated value means "must - carry this label TOO" (AND). This route's `label_id` answers a different question — - "belongs to any of these labelled sets" — so `label_id=fragile&label_id=lent-out` selects - the UNION of items carrying *either* label, not their intersection. `location_id`'s - repeats are OR in both places (`GET /items?location_id=` restricts to items directly in ANY - of the given locations already), so only `label_id`'s semantics differ between the two - routes — a client that assumes AND here, by analogy with `GET /items`, will silently - under-select. - - **Row shape and running total.** Each row carries the item's own `quantity`, its purchase - block's own recorded `value_minor` (`0` if the item has no purchase block — FR-011 makes - that block opt-in; never a per-unit price multiplied by quantity), its pipe-delimited - `identifications` (`kind:value` pairs), and `running_total_minor`: a per-ROW accumulating - sum of `value_minor` down the sheet in the order rows are written (ascending item id) — not - a single grand-total footer row. The whole selection's total is visible at the LAST row. - Rows are ordered by item id ascending, so a repeated export of an unchanged selection is - byte-identical. security: - SessionCookie: [] - DeviceToken: [] @@ -3898,11 +2624,6 @@ paths: type: array items: type: string - description: >- - Selection mode 1 of 3: items directly in ANY of these locations (OR). Mutually - exclusive with `label_id` and `item_id` — supplying more than one of the three kinds is - a `400`. An id that does not resolve simply contributes nothing to the selection, the - same "unknown value has an empty answer" rule `GET /items`'s own `location_id` follows. - name: label_id in: query explode: true @@ -3910,12 +2631,6 @@ paths: type: array items: type: string - description: >- - Selection mode 2 of 3: items carrying ANY of these labels (OR) — see this operation's - own description, "`label_id` here is OR, unlike `GET /items?label_id=`'s AND," for why - this route's semantics deliberately differ from that one's identically-named parameter. - Mutually exclusive with `location_id` and `item_id`. An id that does not resolve - contributes nothing to the selection. - name: item_id in: query explode: true @@ -3923,29 +2638,16 @@ paths: type: array items: type: string - description: >- - Selection mode 3 of 3: exactly these item ids, explicitly. Mutually exclusive with - `location_id` and `label_id`. An id that does not resolve, is tombstoned, or belongs to - another group is silently dropped from the selection rather than failing the whole - request (a foreign id is indistinguishable from an unknown one — see P-3). responses: "200": - description: >- - The BoM CSV for the resolved selection: `item_id, item_name, location_id, - location_name, quantity, value_minor, identifications, running_total_minor`. A header - row only, with zero data rows, when no selection parameter was supplied. - `Content-Disposition` names an `hho-report-bom-.csv` filename. - `Cache-Control: private, no-store` and `X-Content-Type-Options: nosniff` are set, the - same pair `GET /export/items.csv` sets and for the identical reasons. + description: '' content: text/csv: schema: type: string format: binary "400": - description: >- - More than one of `location_id`/`label_id`/`item_id` was supplied. `detail` names all - three parameters. + description: '' content: application/problem+json: schema: @@ -3962,34 +2664,6 @@ paths: operationId: importNativeUpload tags: [import] summary: Stage a native-format CSV file for import (FR-051, T148c, D2.2). - description: >- - Member-writable, the same FR-007 classification `POST /items/{itemID}/attachments` - already carries — not owner-gated. The first of D2.2's three-step round trip; see the - `import` tag description for the shape of the whole flow. - - **The request body IS the file — raw bytes, not `multipart/form-data`.** Unlike - `POST /items/{itemID}/attachments` (the only `multipart/form-data` operation in this - document), this route carries exactly one thing and nothing alongside it, so the body is - the CSV file directly, the write-side mirror of `GET /export/items.csv`'s own response - body. - - **Validation at this step is header-only.** The uploaded bytes are checked for well-formed - CSV framing and a recognised header row; per-row content (required fields, value formats, - create-vs-update resolution) is NOT evaluated here — that is `GET - /import/{importID}/preview`'s own job, on the very next call. A file that is not - well-formed CSV at all, or whose header row this server does not recognise, is rejected - with `400` before anything is staged. - - **Size is capped at 10 MiB by default** — there is no `HHO_MAX_ATTACHMENT_SIZE_BYTES`-style - operator-facing environment variable for this route yet (a future task may add one without - changing this contract). An upload over the limit is refused with `413`, not `400`: the - request is well-formed and would have succeeded at a smaller size. - - On success, the file is written to a per-group temporary location and a new - `import_sessions` row is created in `staged` status with its own `expires_at`. A server - restart empties this temporary storage (A127): `import_id` returned here can go on to 404 - on `GET .../preview`/`POST .../commit` even before `expires_at` is reached, if the server - restarted in between. security: - SessionCookie: [] - DeviceToken: [] @@ -4008,9 +2682,7 @@ paths: schema: $ref: "#/components/schemas/ImportUploadResponse" "400": - description: >- - The body was not well-formed CSV, or its header row was not recognised as a native HHO - import. `detail` is populated. + description: '' content: application/problem+json: schema: @@ -4018,10 +2690,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "413": - description: >- - The file exceeds the 10 MiB default upload limit. No `detail` — the configured limit - is not published on this response, the identical shape `POST - /items/{itemID}/attachments`'s own `413` documents. + description: '' content: application/problem+json: schema: @@ -4037,25 +2706,6 @@ paths: operationId: getImportPreview tags: [import] summary: Classify every row of a staged import without writing anything (FR-052, T148d, D2.2). - description: >- - Member-readable, mirroring `POST /import/native/upload`'s own member classification. The - second of D2.2's three-step round trip. Re-parses the staged file fresh from disk on every - call — nothing about a parsed row is cached in the database (see the `import` tag - description). - - Every row is classified exactly one of `create` (no matching `id`), `update` (a matching - `id` whose proposed values differ from what is currently stored), `unchanged` (a matching - `id` whose proposed values are byte-identical to what is currently stored, A122.3), or - `error` (a per-row validation failure, reported with a line number). `changes` on an - `update` row lists which of `name`, `description`, `quantity`, `location_id`, `labels`, - `identifications`, `warranty`, `purchase`, `sale`, `custom_fields` differ — never - `short_code`, `location_path` or `attachments`, none of which import ever compares or - writes (A128 covers `attachments` specifically: this format carries attachment REFERENCES - only, never bytes, so a spreadsheet edit to that column can never add or remove an actual - uploaded file). - - `POST /import/{importID}/commit` classifies every row through this exact same internal - path, so the two responses can never disagree about what a row will do. security: - SessionCookie: [] - DeviceToken: [] @@ -4098,19 +2748,13 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such staged import — unknown, another tenant's, past its `expires_at`, or its staged - file is gone across a server restart (A127), indistinguishably. No `detail`. The - session row, if any, is deleted as part of answering this response (lazy cleanup). + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - This import was already committed (`POST /import/{importID}/commit`'s own terminal - state) — a real import_id, but not a state this route can preview. No `detail`. - Nothing about the committed session is deleted. + description: '' content: application/problem+json: schema: @@ -4126,35 +2770,12 @@ paths: operationId: postImportCommit tags: [import] summary: Apply a staged import's rows in one all-or-nothing transaction (FR-053, T148e, D2.2). - description: >- - Member-writable. The third and final step of D2.2's round trip. Classifies the staged file - through the exact same path `GET /import/{importID}/preview` uses, then, per FR-053's - all-or-nothing rule: - - 1. If ANY row classifies `error`, the whole request is rejected with `422` before a - transaction is ever opened, carrying the identical per-row shape `GET .../preview` - returns — `422`, not a `Problem` body, since a flat `detail` string cannot carry a - per-row error list, and the request itself is well-formed; it is the STAGED FILE'S - CONTENT that fails a semantic check. - 2. Otherwise every `create`/`update` row is applied inside ONE database transaction; an - `unchanged` row is skipped and bumps neither `version` nor `change_seq` (A122.3). A - failure partway through the transaction rolls back every row in this commit, including - ones that would otherwise have succeeded. - - On success, the staged file is removed only once the transaction has durably committed — - never before, and a removal failure afterward does not turn a successful commit into an - error response (nothing ever reads a committed session's staged file again). The session - moves to `committed`; a second commit attempt against the same `import_id` — including one - racing this same request — answers `409`, never a second application of the same rows. security: - SessionCookie: [] - DeviceToken: [] responses: "200": - description: >- - Every non-`error` row was applied (or, for `unchanged`, skipped) inside one - transaction. `created_items` maps each `create` row's own CSV line to the fresh item id - it produced, so a UI can link them. + description: '' content: application/json: schema: @@ -4173,28 +2794,19 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "404": - description: >- - No such staged import — unknown, another tenant's, past its `expires_at`, or its staged - file is gone across a server restart (A127), indistinguishably. No `detail`. The - session row, if any, is deleted as part of answering this response (lazy cleanup). + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "409": - description: >- - This import was already committed — either resolved before classification ran (a - second call against an already-`committed` session), or lost a race against a - concurrent commit of the same `import_id` during the transaction itself. No `detail`. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/Problem" "422": - description: >- - At least one row in the staged file classified `error` (FR-053 rule 1) — no transaction - was opened. The body carries the same per-row shape `GET .../preview` returns, so a - client can show exactly which rows failed and why. + description: '' content: application/json: schema: @@ -4209,34 +2821,6 @@ paths: operationId: createLabelsQRBatch tags: [labels-qr] summary: Render QR SVG markup and label text for a batch of the caller's own items (D2.5, T151d). - description: >- - Member-writable, mounted on the general authenticated group beside `/export/items.csv` - above — NOT owner-gated: nothing in FR-070..FR-073 restricts label printing to the owner - role, the same member-writable default every `/items`-adjacent route in this document - already takes. - - The one job this endpoint does: turn a list of item ids the caller already has (from the - item list/filter UI it is printing from) into the QR SVG markup and label text for each - one, in a single database round trip regardless of how many ids `item_ids` holds — plan.md's - own D2.5 words, "one round trip regardless of sheet size." Avery-preset grid layout, print - CSS and page breaks are the Vue client's own job (D2.6, `LabelSheetView.vue`), not this - response's concern; the QR payload each `qr_svg` encodes is `GET /i/{token}`'s own item - deep link, below. - - **A foreign or nonexistent id is silently OMITTED from `items`, never a per-id error - (P-3).** An id belonging to another group and an id that does not exist anywhere are - indistinguishable on this response — both simply do not appear — for the identical - "404-never-403" reason `GET /i/{token}` below already documents: failing the whole request - for one stale or foreign id would force a caller printing 30 labels to lose all 30 over one - ordinary race (an item deleted between the caller's list fetch and this request), and - naming WHICH id was rejected would itself be the cross-tenant confirmation P-3 forbids. - `items` therefore preserves `item_ids`'s own order for whichever ids DID resolve, but its - length may be less than `item_ids`'s own — a client must not assume the two arrays line up - index-for-index. - - `item_ids` must hold at least 1 and at most 200 ids (the same ceiling `GET /items`'s own - `limit` query parameter uses) — an empty array or one naming more than 200 ids answers - `400` before any database read. security: - SessionCookie: [] - DeviceToken: [] @@ -4252,9 +2836,7 @@ paths: item_ids: ["0191b1b2-2e3e-7c9a-8f2e-1a2b3c4d5e6f"] responses: "200": - description: >- - One entry per resolvable id in `item_ids`, in that array's own order — foreign or - nonexistent ids are omitted, never signalled (see this operation's own description). + description: '' content: application/json: schema: @@ -4279,52 +2861,40 @@ paths: $ref: "#/components/responses/UpgradeRequired" /i/{token}: + + + + + + + + + + + + + + servers: - url: / - description: >- - The server's literal root -- overrides this document's own top-level `/api/v1` base for - this ONE path only, per the OpenAPI 3.1 Path Item Object's own `servers` field. parameters: - $ref: "#/components/parameters/ResolveToken" get: operationId: resolveItemToken tags: [labels-qr] summary: Resolve a printed QR code or short_code deep link to the item it names (A123, D2.5). - description: >- - The resolve half of the QR/deep-link pair `POST /labels/qr/batch` above prints: - `qrlabels.ItemURL` encodes `"/i/"` verbatim into every QR symbol and label - this API ever prints. Mounted unversioned, on the server's root -- see this path item's own - `servers` override above. - - **Two token shapes, two different resolution rules, decided only after authentication is - checked (A123).** Exactly one authentication attempt runs before the token's shape is even - inspected: - - - No credential at all (no session cookie, no device-token bearer header): `302` to - `/login?next=`, for EITHER token shape below. No database - read runs on this path at all -- this is why this operation is `security: []`: an - unauthenticated caller is never refused with `401`, only redirected to sign in. - - A UUID token (the only shape a QR code ever encodes): `302` to `/items/{id}`, the SPA's - own already-tenant-scoped item page. This branch never itself reads the items table -- - `GET /items/{itemID}` does the real lookup and 404s under P-3 exactly as every other - tenant-scoped route already does. - - A non-UUID token is resolved as a `short_code`, ONLY inside the caller's own - authenticated group. A code belonging to another group answers `404`, indistinguishable - from one that does not exist anywhere -- never a hint that the code belongs to someone - else's household. - - Every redirect (`302`, never `301`: the target for this exact path can change from one - request to the next -- an unauthenticated retry after logging in reaches the opposite - branch) and the `404` both carry `Cache-Control: no-store`: the target depends on the - caller's own authentication state and, on the short-code branch, on a database read, so an - intermediary caching either would serve a stale or wrong answer to a second caller. security: [] + + + + + + + + responses: "302": - description: >- - Redirect. `Location` is `/login?next=...` for an unauthenticated caller (either token - shape), or `/items/{id}` for an authenticated caller (a UUID token verbatim, lower-case - and hyphenated, or the item id a short_code resolved to inside the caller's own group). + description: '' headers: Location: schema: @@ -4334,10 +2904,7 @@ paths: type: string enum: ["no-store"] "404": - description: >- - An authenticated caller's non-UUID token did not resolve to a live item inside their - OWN group -- unknown anywhere, tombstoned, or belonging to another tenant, all - indistinguishable (A123). No `detail`. + description: '' headers: Cache-Control: schema: @@ -4354,19 +2921,9 @@ components: type: apiKey in: cookie name: hho_session - description: >- - The web session cookie minted by `POST /auth/login` (NFR-013): `HttpOnly; Secure; - SameSite=Lax`. Consulted by the session authenticator half of the composed authentication - chain (T035/T038), and — alone, never combined with a device token — by - `POST /auth/device-tokens` (T037). DeviceToken: type: http scheme: bearer - description: >- - An `Authorization: Bearer ` device credential minted by `POST /auth/device-tokens` - (FR-003, T037). Long-lived by design (no `expires_at`); ended only by explicit revocation. - Accepted everywhere `SessionCookie` is, EXCEPT `POST /auth/device-tokens` itself (A47: a - device token cannot mint another). parameters: SessionID: @@ -4396,11 +2953,6 @@ components: required: true schema: type: string - description: >- - The staged import's own id, as returned by `POST /import/native/upload`'s `import_id`. - camelCase in the path (`{importID}`), matching every other path parameter in this - document, even though `plan.md`'s own prose names this route `/import/{import_id}/...` — - see `GET /import/{importID}/preview`'s own description for why. ItemID: name: itemID in: path @@ -4414,29 +2966,18 @@ components: required: true schema: type: string - description: >- - The identification row's opaque id, as returned by `GET`/`POST - /items/{itemID}/identifications`. Scoped to its parent item as well as to the caller's - group — a row that belongs to a different item in the caller's own group answers the same - 404 as an unknown or foreign-tenant one (identifications.go's own file doc). CustomFieldID: name: customFieldID in: path required: true schema: type: string - description: >- - The item custom-field value row's opaque id, as returned by `GET`/`POST - /items/{itemID}/custom-fields`. Scoped to its parent item as well as to the caller's group, - the identical treatment `identificationID` gets. FieldDefID: name: fieldDefID in: path required: true schema: type: string - description: >- - The custom-field definition's opaque id, as returned by `GET`/`POST /custom-field-defs`. LocationID: name: locationID @@ -4444,8 +2985,6 @@ components: required: true schema: type: string - description: >- - The location's opaque id, as returned by `POST /locations` or `GET /locations`. ReassignTo: name: reassign_to in: query @@ -4453,25 +2992,12 @@ components: allowEmptyValue: true schema: type: string - description: >- - Where to move what this location directly holds, so the delete can proceed (G2, FR-030). - - Three states, and they are three, not two. ABSENT: refuse with 409 if the location holds - anything. PRESENT and EMPTY (`?reassign_to=`): detach — direct children become roots, - direct items become unplaced. PRESENT with a location id: re-parent the direct children - under it and move the direct items into it. - - The empty value is meaningful, which is why `allowEmptyValue` is set: "move everything to - the root" and "do not delete a non-empty location" are different requests, and a client - must be able to say the first. LabelID: name: labelID in: path required: true schema: type: string - description: >- - The label's opaque id, as returned by `POST /labels` or `GET /labels`. ClientVersion: name: X-HHO-Client-Version in: header @@ -4479,29 +3005,12 @@ components: schema: type: string pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" - description: >- - FR-083's minimum-supported-client-version handshake (A114, T097): `MAJOR.MINOR.PATCH`, - e.g. `"1.4.2"`. Applies to every operation in this document, not only writes — see the - `426` response each operation carries. - - Optional, and absent is compliant, not refused (A114 decision 2): the in-binary web SPA is - always in lockstep with this server and sends none, as does the FR-140 container - healthcheck and any integrator using plain `curl`. The handshake constrains a caller that - *makes* a version claim; one that makes none is the pre-handshake status quo. A malformed - value (not three dot-separated non-negative integers) answers `400`. A parseable value - below the server's configured minimum (`HHO_MIN_CLIENT_VERSION`; unset by default, meaning - no minimum) answers `426` — see the `UpgradeRequired` response. ResolveToken: name: token in: path required: true schema: type: string - description: >- - Either an item's UUID id (the only shape a QR code ever encodes) or a `short_code` (A123). - `GET /i/{token}` tells the two apart by shape alone — a value that parses as a UUID is - always treated as one — and resolves each under its own rule; see that operation's own - description. ReportFormat: name: format in: query @@ -4510,34 +3019,15 @@ components: type: string enum: [json, csv] default: json - description: >- - Every `/reports/*` operation shares this parameter (D2.4). Defaults to `json`. An - unrecognised value (anything other than exactly `json` or `csv`) is a `400` — there is no - sensible narrower reading of a format token a caller did not name, the same rule this - document's filter parameters do NOT follow (contrast `GET /items`'s own filters, which - narrow silently rather than reject). responses: UpgradeRequired: - description: >- - The caller's `X-HHO-Client-Version` claim parsed successfully but is below the minimum - this server currently accepts (FR-083, A114, T097). Returned on EVERY operation in this - document, not only writes — a client old enough to write incorrectly is old enough to - misread a response it does not understand, so one rule for the whole surface is also the - only version a client has to hold in its head. `minimum_version` names what to upgrade to. - - Answered BEFORE authentication runs: an outdated client is told to upgrade whether or not - its credentials are valid, so this can be the response to a request presenting no - credentials at all. + description: '' content: application/problem+json: schema: $ref: "#/components/schemas/UpgradeRequiredProblem" Unauthorized: - description: >- - No valid credential was presented on a route that requires one — a missing cookie/header, a - malformed one, or one that failed to authenticate for any reason (expired, revoked, - unknown). One answer for every cause, with no `detail`, so this response can never be used - to distinguish "never had an account" from "credential expired." + description: '' headers: WWW-Authenticate: schema: @@ -4548,10 +3038,7 @@ components: schema: $ref: "#/components/schemas/Problem" Forbidden: - description: >- - The caller is authenticated and their resource is known to belong to their own group, but - they lack the [Owner] role this route requires (A54, FR-007). No `detail`: "you are not the - owner" is the entire fact this response ever conveys. + description: '' content: application/problem+json: schema: @@ -4560,21 +3047,11 @@ components: schemas: Problem: type: object - description: >- - RFC 9457 problem details, `application/problem+json`. The only error body this API emits, - on every route in this document. `detail` is present ONLY where populating it cannot narrow - what the caller is allowed to know — see each operation's response description for whether - a given status carries one here. `NotFound`, `Unauthorized` and `Forbidden` responses in - this document are `detail`-less on every occurrence, by construction, never merely by - convention. required: [type, title, status] properties: type: type: string format: uri - description: >- - A stable, machine-readable problem kind, e.g. "urn:hho:problem:not-found". Clients - switch on this, never on `title`. title: type: string description: Short human-readable summary, fixed per `type`. @@ -4583,14 +3060,8 @@ components: description: Repeats the HTTP status code. detail: type: string - description: >- - Optional human-readable elaboration. Present only on responses that document it (see - this schema's top-level description). request_id: type: string - description: >- - Correlates this response with the server's log line for the same request. Omitted when - no request id was available (a handler exercised with no middleware chain above it). LocationTreeResponse: type: object @@ -4604,8 +3075,6 @@ components: LocationTreeNode: type: object - description: >- - One node of the location tree: a `Location`'s own fields, the two counts, and its children. required: [id, name, created_at, updated_at, version, item_count, total_item_count, children] properties: id: @@ -4629,15 +3098,9 @@ components: item_count: type: integer format: int64 - description: >- - Live items whose `location_id` is THIS node. The number the delete-guard refuses on and - that `reassign_to` would move. total_item_count: type: integer format: int64 - description: >- - `item_count` plus every descendant's, at any depth. Always greater than or equal to - `item_count`. children: type: array description: Child nodes, ordered by name then id. Empty, never null, on a leaf. @@ -4645,20 +3108,6 @@ components: $ref: "#/components/schemas/LocationTreeNode" LocationNotEmptyProblem: - description: >- - The 409 a delete-guard refusal answers (G2, FR-030): a `Problem` whose `type` is - `urn:hho:problem:location-not-empty`, plus the two counts a client needs to explain the - refusal to a person and offer somewhere to move things. - - This and `UpgradeRequiredProblem` below are the only two problems in this API carrying - extra members. This one is safe in a way a 404's would not be: a caller reaching this - response has already been proven to own the location, because a foreign id answers 404 - before the guard is ever consulted. The counts describe the caller's own household to the - caller. - - Both counts are DIRECT, not recursive — they count what this location itself holds, which - is exactly the set `reassign_to` would move. A recursive count would name rows the delete - does not touch. allOf: - $ref: "#/components/schemas/Problem" - type: object @@ -4674,14 +3123,6 @@ components: description: Live items whose `location_id` is this location. UpgradeRequiredProblem: - description: >- - The 426 FR-083's minimum-supported-client-version handshake answers with (A114, T097): a - `Problem` whose `type` is `urn:hho:problem:upgrade-required`, plus the minimum version a - refused caller must be at or above. - - A field, not a sentence folded into `detail`: a generated Kotlin client that wants to - compare `minimum_version` against its own build needs a value in a known field, not a - human-readable string to parse back out of prose. allOf: - $ref: "#/components/schemas/Problem" - type: object @@ -4689,9 +3130,6 @@ components: properties: minimum_version: type: string - description: >- - The lowest `MAJOR.MINOR.PATCH` this server currently accepts - (`HHO_MIN_CLIENT_VERSION`, as configured by the operator). StatusResponse: type: object @@ -4743,8 +3181,6 @@ components: LoginResponse: type: object required: [group_id, user_id, username, role] - description: >- - The session token itself never appears here — it travels only in the Set-Cookie header. properties: group_id: type: string @@ -4780,9 +3216,6 @@ components: revoked_at: type: integer format: int64 - description: >- - Unix milliseconds (A51). Present only when `revoked` is true — omitted entirely rather - than sent as 0, since 0 is itself a valid (if absurd) instant. SessionListResponse: type: object @@ -4796,9 +3229,6 @@ components: DeviceTokenListItem: type: object required: [id, device_label, created_at, revoked] - description: >- - Never carries the device token or its hash. Unlike a session, carries no user_agent or - created_from_ip (the device_tokens table has neither column). properties: id: type: string @@ -4834,10 +3264,6 @@ components: DeviceTokenIssueResponse: type: object required: [id, device_label, token] - description: >- - The ONLY response in this document, besides invite issuance, that carries a raw credential. - `token` appears here exactly once and is never returned again by any other operation (not - even the list above, which has no token/hash field at all). properties: id: type: string @@ -4850,11 +3276,6 @@ components: InviteListItem: type: object required: [id, created_by_user_id, created_at, expires_at, redeemed] - description: >- - Never carries the invite token or its hash. Carries no computed "revoked"/"expired" - boolean either: a revoked and a naturally-expired invite are indistinguishable in this - schema by design (A62) — a client compares `expires_at` and `redeemed` against its own - clock, the same two facts redemption itself checks (A64). properties: id: type: string @@ -4867,9 +3288,6 @@ components: expires_at: type: integer format: int64 - description: >- - Unix milliseconds (A51). Also the field a revocation is represented through (A62): a - revoked invite has this instant forced to the moment of revocation. redeemed: type: boolean redeemed_at: @@ -4892,9 +3310,6 @@ components: InviteCreateResponse: type: object required: [id, token, expires_at] - description: >- - `token` appears here exactly once and nowhere else in this document — the invite list above - carries no token/hash field. properties: id: type: string @@ -4922,10 +3337,6 @@ components: InviteRedeemResponse: type: object required: [group_id, user_id, username] - description: >- - Deliberately the same shape as RegisterResponse (A66): account and membership creation, not - login. No field here suggests the caller is authenticated — a client calls - `POST /auth/login` next. properties: group_id: type: string @@ -4936,10 +3347,6 @@ components: Item: type: object - description: >- - The wire shape of an item, used for `POST`/`PUT`/`GET` alike (T051). `id`, `short_code`, - `created_at` and `updated_at` are server-assigned and read-only; `version` round-trips on - read so a client has something to send back on its next `PUT` (FR-090). required: [name] properties: id: @@ -4970,10 +3377,6 @@ components: ItemUpdateRequest: type: object - description: >- - `PUT /items/{itemID}`'s wire shape. Whole-body replace: an omitted field clears the column. - `version` is REQUIRED here (unlike [Item], where it is informational) — omitted and zero - are the identical failure (`items.ErrVersionRequired`). required: [name, version] properties: name: @@ -5000,12 +3403,6 @@ components: WarrantyBlock: type: object - description: >- - The wire shape of an item's warranty detail block, used for `POST`/`PUT`/`GET` responses. - No `id`: the block is addressed entirely by its parent item and nothing in this API accepts - a warranty id (warranty.go's own file doc). `is_lifetime` has no omitted-false case — it is - always present, unlike every other field here — because it is a plain boolean column, not a - tri-state. required: [is_lifetime] properties: item_id: @@ -5040,10 +3437,6 @@ components: WarrantyCreateRequest: type: object - description: >- - `POST /items/{itemID}/warranty`'s wire shape. No field is required (FR-011's opt-in - design) — `item_id`/`created_at`/`updated_at`/`version` are outbound-only and never - accepted here; the parent item comes from the URL. properties: holder: type: string @@ -5062,9 +3455,6 @@ components: WarrantyUpdateRequest: type: object - description: >- - `PUT /items/{itemID}/warranty`'s wire shape: [WarrantyCreateRequest] plus a REQUIRED - `version` (FR-090). Whole-block replace: an omitted field clears the column. required: [version] properties: holder: @@ -5087,11 +3477,6 @@ components: SaleBlock: type: object - description: >- - The wire shape of an item's sold-to detail block, used for `POST`/`PUT`/`GET` responses. - No `id`, for the identical reason [WarrantyBlock] has none. `sale_price_minor` has no - omitted-zero case: it is meaningful at zero (an item given away, not sold), an integer - minor-unit amount (A96). required: [sale_price_minor] properties: item_id: @@ -5137,9 +3522,6 @@ components: SaleUpdateRequest: type: object - description: >- - `PUT /items/{itemID}/sale`'s wire shape: [SaleCreateRequest] plus a REQUIRED `version` - (FR-090). required: [version] properties: buyer_name: @@ -5158,11 +3540,6 @@ components: PurchaseBlock: type: object - description: >- - The wire shape of an item's purchased-from detail block, used for `POST`/`PUT`/`GET` - responses. No `id`, for the identical reason [SaleBlock] has none. `purchase_price_minor` - has no omitted-zero case: it is meaningful at zero (an item received as a gift), an integer - minor-unit amount (A96). required: [purchase_price_minor] properties: item_id: @@ -5212,9 +3589,6 @@ components: PurchaseUpdateRequest: type: object - description: >- - `PUT /items/{itemID}/purchase`'s wire shape: [PurchaseCreateRequest] plus a REQUIRED - `version` (FR-090). required: [version] properties: vendor: @@ -5235,10 +3609,6 @@ components: Identification: type: object - description: >- - The wire shape of one item identification row (FR-015), used for `POST`/`PUT` responses - and as `IdentificationListResponse`'s element type. Unlike the 1:0..1 detail blocks it DOES - carry an `id`: the row must be individually addressable for `PUT`/`DELETE` to name it. required: [kind, value] properties: id: @@ -5265,10 +3635,6 @@ components: IdentificationCreateRequest: type: object - description: >- - `POST /items/{itemID}/identifications`'s wire shape. Both fields are required (A100.2) — - unlike the detail blocks, an identification row's only two content columns are these, so a - row with neither would carry nothing to search for. required: [kind, value] properties: kind: @@ -5279,9 +3645,6 @@ components: IdentificationUpdateRequest: type: object - description: >- - `PUT /items/{itemID}/identifications/{identificationID}`'s wire shape: - [IdentificationCreateRequest] plus a REQUIRED `version` (FR-090). required: [kind, value, version] properties: kind: @@ -5304,10 +3667,6 @@ components: CustomFieldDef: type: object - description: >- - The wire shape of one group-level custom-field definition (FR-017), used for - `GET`/`POST`/`PUT` responses and as `CustomFieldDefListResponse`'s element type. Carries an - `id`, like [Identification] and unlike the detail blocks, so `PUT`/`DELETE` can address it. required: [name, field_type, display_order] properties: id: @@ -5335,9 +3694,6 @@ components: CustomFieldDefCreateRequest: type: object - description: >- - `POST /custom-field-defs`'s wire shape. `name` and `field_type` are required; duplicate - `name` values within a group are permitted (A101.2, no unique index). required: [name, field_type, display_order] properties: name: @@ -5351,9 +3707,6 @@ components: CustomFieldDefUpdateRequest: type: object - description: >- - `PUT /custom-field-defs/{fieldDefID}`'s wire shape: [CustomFieldDefCreateRequest] plus a - REQUIRED `version` (FR-090). required: [name, field_type, display_order, version] properties: name: @@ -5379,14 +3732,6 @@ components: ItemCustomField: type: object - description: >- - The wire shape of one item custom-field VALUE row (FR-016), used for `POST`/`PUT` - responses and as `ItemCustomFieldListResponse`'s element type — the item-side counterpart - to [CustomFieldDef]'s group-level DEFINITIONS. `name`/`field_type` are the row's OWN - snapshot, authoritative even when `field_def_id` is set (A103.1): renaming a definition is - non-retroactive. Exactly one of `text_value`/`number_value`/`bool_value`/`date_value` is - ever set, matching `field_type`; the other three are OMITTED from the response entirely - (never sent as `null`) when unset. required: [name, field_type] properties: id: @@ -5395,9 +3740,6 @@ components: type: string field_def_id: type: string - description: >- - Present only when this row is standardized against a group-level definition (A103.1). - Absent for an ad hoc row. name: type: string field_type: @@ -5426,14 +3768,6 @@ components: ItemCustomFieldCreateRequest: type: object - description: >- - `POST /items/{itemID}/custom-fields`'s wire shape. `field_def_id` is present on every - request (an empty string is the "ad hoc, no definition" sentinel, A103.1) — it is not - omittable the way it is on the response. `text_value`/`number_value`/`bool_value`/ - `date_value` are likewise all present on every request, each nullable: exactly one carries - a non-null value, matching `field_type`, and the other three are explicit `null` - (`items.ErrCustomFieldValueInvalid`) — there is no CHECK-constraint backstop for this - invariant, so this operation is the only enforcement point. required: [field_def_id, name, field_type, text_value, number_value, bool_value, date_value] properties: field_def_id: @@ -5456,9 +3790,6 @@ components: ItemCustomFieldUpdateRequest: type: object - description: >- - `PUT /items/{itemID}/custom-fields/{customFieldID}`'s wire shape: - [ItemCustomFieldCreateRequest] plus a REQUIRED `version` (FR-090). required: [field_def_id, name, field_type, text_value, number_value, bool_value, date_value, version] properties: @@ -5493,12 +3824,6 @@ components: StockAdjustment: type: object - description: >- - The wire shape of one stock-adjustment history row (FR-018), used for `POST`'s response - and as `StockAdjustmentListResponse`'s element type. Carries an `id` for a client's own - bookkeeping, even though no route in this document ever addresses one again by it — the - history is append-only (A105), with no `PUT`/`DELETE` and no `{stockAdjustmentID}` wildcard - anywhere. required: [delta, resulting_quantity] properties: id: @@ -5528,16 +3853,9 @@ components: version: type: integer format: int64 - description: >- - Round-tripped for consistency with every other row type in this document, though - nothing ever sends it back — this resource has no `PUT` for FR-090 to apply to. StockAdjustmentCreateRequest: type: object - description: >- - `POST /items/{itemID}/stock-adjustments`'s wire shape (FR-018). No field carries a - CHECK-constraint-backed enumeration to validate beyond what JSON decoding itself already - guarantees. required: [delta, reason, note] properties: delta: @@ -5560,11 +3878,6 @@ components: GroupVisibility: type: object - description: >- - The wire shape of the caller's group's detail-block progressive-disclosure state (FR-011), - used for `GET`/`PUT` responses. No group identifier and no resource id: the resource IS the - caller's own group. All three flags are always present, including when `false` — omitting - a `false` one would make "hidden" and "the server did not say" indistinguishable. required: [warranty_visible, sale_visible, purchase_visible] properties: warranty_visible: @@ -5579,9 +3892,6 @@ components: GroupVisibilityUpdateRequest: type: object - description: >- - `PUT /groups/detail-visibility`'s wire shape: the three flags plus a REQUIRED `version` - (FR-090). Whole-state replace: an omitted flag is `false` (hidden). required: [warranty_visible, sale_visible, purchase_visible, version] properties: warranty_visible: @@ -5596,10 +3906,6 @@ components: GroupMember: type: object - description: >- - One entry of `GET /groups/members`'s response (FR-105, D1.17): a household member's - username, role and joined-at instant. Never carries a password hash or any other - credential-shaped field. required: [id, username, role, joined_at] properties: id: @@ -5625,9 +3931,6 @@ components: Location: type: object - description: >- - A node in the group's location tree. `parent_id` absent means a root. `id`, `created_at`, - `updated_at` and `version` are server-assigned; a caller sending them has no effect. required: [id, name, created_at, updated_at, version] properties: id: @@ -5636,10 +3939,6 @@ components: type: string parent_id: type: string - description: >- - The parent location's id, omitted entirely when this location is a root. Always resolves - within the caller's own group — a location can never be parented into another tenant's - tree, because the id would not resolve there at all. created_at: type: integer format: int64 @@ -5651,9 +3950,6 @@ components: version: type: integer format: int64 - description: >- - Round-tripped on read so a client has something to send back as - `LocationUpdateRequest.version` on its next PUT (FR-090). LocationCreateRequest: type: object @@ -5664,34 +3960,21 @@ components: type: string parent_id: type: string - description: >- - Optional. Omitted or empty creates a root. A value that does not resolve to a live - location in the caller's own group is a 400, not a 404. LocationUpdateRequest: type: object - description: >- - `PUT /locations/{locationID}`'s wire shape — rename and move in one operation. `version` is - REQUIRED: zero and omitted are indistinguishable on the wire and both are rejected, so that - a client which simply forgot the field can never be read as one that raced and lost. required: [name, version] properties: name: type: string parent_id: type: string - description: >- - Omitted or empty moves this location to the root. A value naming this location itself, - or any of its own descendants, is a cycle and answers 400. version: type: integer format: int64 LocationListResponse: type: object - description: >- - The whole tree, flat. Wrapped in an object rather than returned as a bare array so the - response can grow a sibling field (a count, a cursor) additively within v1 (FR-080). required: [locations] properties: locations: @@ -5701,8 +3984,6 @@ components: Label: type: object - description: >- - A flat, group-scoped, colour-tagged label. No hierarchy — that is what locations are for. required: [id, name, color, created_at, updated_at, version] properties: id: @@ -5736,9 +4017,6 @@ components: LabelUpdateRequest: type: object - description: >- - `PUT /labels/{labelID}`'s wire shape. `version` is REQUIRED, for the identical reason - `LocationUpdateRequest.version` is. required: [name, color, version] properties: name: @@ -5767,15 +4045,9 @@ components: type: array items: type: string - description: >- - 1 to 200 item ids (see that operation's own description for the bound and for what - happens to an id that does not resolve). LabelsQRBatchItem: type: object - description: >- - One resolvable entry of `POST /labels/qr/batch`'s `items` array — an item's label-sheet - fields plus its rendered QR code. required: [id, short_code, name, qr_svg] properties: id: @@ -5786,11 +4058,6 @@ components: type: string qr_svg: type: string - description: >- - An inline, self-contained SVG document (no embedded fonts or scripts, viewBox-scaled) - encoding this item's `GET /i/{token}` deep link. The client lays this markup directly - into the chosen Avery preset's grid cell — there is no separate image fetch, so a sheet - of N items costs this one response, not N additional round trips for N QR images. LabelsQRBatchResponse: type: object @@ -5803,17 +4070,10 @@ components: ReportValuationRow: type: object - description: >- - One group of `GET /reports/valuation`'s result (D2.4). `group_key`/`group_label` name a - location id/name or a label id/name, depending on `group_by`. required: [group_key, group_label, item_count, total_value_minor] properties: group_key: type: string - description: >- - A location id (`group_by=location`) or a label id (`group_by=label`). Empty string for - the "no location"/"no label" bucket, when `group_by=location` and items exist with no - `location_id` set. group_label: type: string description: The location's or label's own name, for display without a second lookup. @@ -5824,9 +4084,6 @@ components: total_value_minor: type: integer format: int64 - description: >- - Sum of every item in this group's own purchase block's recorded price. `0` for a group - whose items carry no purchase block (FR-011's opt-in detail block), never a missing row. ReportValuationResponse: type: object @@ -5849,14 +4106,8 @@ components: type: string expires_on: type: string - description: >- - ISO-8601 calendar day (`YYYY-MM-DD`), no time zone — the same "a day as printed on a - receipt" rule every warranty date on this API already follows (A93). days_remaining: type: integer - description: >- - Days from the server's current UTC calendar day to `expires_on`, inclusive of today - (`0` means expiring today). ReportWarrantyExpiringResponse: type: object @@ -5882,9 +4133,6 @@ components: description: ISO-8601 calendar day (`YYYY-MM-DD`), the item's purchase block's own `purchased_on`. vendor: type: string - description: >- - The purchase block's own recorded vendor. Empty string, never absent, when no vendor - was recorded. purchase_price_minor: type: integer format: int64 @@ -5912,10 +4160,6 @@ components: item_count: type: integer format: int64 - description: >- - This location's own live items PLUS every live descendant's, at any depth — the - rolled-up total (T069), never the direct-only count. Always greater than or equal to - the direct count. See this operation's own description. ReportItemCountByLocationResponse: type: object @@ -5929,18 +4173,6 @@ components: Attachment: type: object - description: >- - The wire shape of one uploaded attachment (FR-040, FR-041, FR-042, FR-043, FR-044), - returned by `POST /items/{itemID}/attachments`. Carries an `id`: the two GET routes above - (T086) and a future DELETE (T087) address a row by it. - - Deliberately absent: `group_id` (the caller's own tenant, redundant on every response this - API gives), the server-internal filesystem storage path, and `thumbnail_path` itself — - none of the three is something a client needs to reference. A client that only needs to - know WHETHER a preview exists reads `has_thumbnail` instead, and always fetches the - thumbnail (when present) through the fixed - `GET /items/{itemID}/attachments/{attachmentID}/thumbnail` route, addressed by this row's - own `id`. required: [id, item_id, category, original_filename, content_type, size_bytes, sha256, created_at, updated_at, version, has_thumbnail] properties: id: @@ -5955,11 +4187,6 @@ components: description: The client-declared filename, kept for display only. content_type: type: string - description: >- - Server-side sniffed from the uploaded bytes' own magic numbers (FR-044) — NEVER the - value, if any, the uploading client's `file` part declared in its own `Content-Type` - header. See this path's own operation description for why that value is never even - read. size_bytes: type: integer format: int64 @@ -5977,24 +4204,11 @@ components: version: type: integer format: int64 - description: >- - Round-tripped for the same FR-090 reason every other create response carries one, even - though this document ships no `PUT` for an attachment yet. has_thumbnail: type: boolean - description: >- - Whether `GET /items/{itemID}/attachments/{attachmentID}/thumbnail` (FR-043, T085/T086) - will succeed for this attachment. `true` only for the three image content types this - server decodes for thumbnailing (`image/jpeg`, `image/png`, `image/webp`); `false` for - every other content type AND for an image this server failed to decode or that - exceeded the thumbnailing pixel ceiling. AttachmentCreateRequest: type: object - description: >- - `POST /items/{itemID}/attachments`'s wire shape — the `multipart/form-data` fields - alongside the `file` part itself (which this schema, being JSON Schema, cannot describe; - see the operation's own `requestBody.content` encoding for the file part). required: [category, file] properties: category: @@ -6022,35 +4236,15 @@ components: properties: device_id: type: string - description: >- - The caller's own device, as returned by `POST /auth/device-tokens`. Identifies which - device is pulling, not which credential authenticated the request — the two may be the - same device but this field is never inferred from the credential. since: type: integer format: int64 - description: >- - The watermark the caller last successfully applied — the group's `change_seq` value to - resume strictly after. `0` requests a full pull from the beginning, and is always - valid: it never answers `cursor_too_old` (FR-095), regardless of how much tombstone - history has since been garbage-collected — see `SyncCursorTooOld`'s own description. limit: type: integer minimum: 1 maximum: 500 - description: >- - The maximum number of `changes[]` AND `tombstones[]` entries, COUNTED TOGETHER, to - return in one page (FR-090; T155d's own judgment call, following A91's `GET /items` - page-size precedent, scaled up for a route spanning eleven tables at once) — a page may - exceed this bound only when a single `change_seq` group (rows written in the same - transaction, e.g. a delete's cascade) is itself larger than `limit`; see this - operation's own description. SyncPullResponse: - description: >- - `POST /sync/pull`'s 200 body is one of two shapes (FR-095): the normal pull envelope when - `since` is still within the tombstone retention window, or the `cursor_too_old` shape when - it is not. oneOf: - $ref: "#/components/schemas/SyncPullResult" - $ref: "#/components/schemas/SyncCursorTooOld" @@ -6062,10 +4256,6 @@ components: properties: changes: type: array - description: >- - One entry per changed row in the closed sync entity set (assumption A7). Each entry's - per-entity-type field shape is a discriminated union pinned at D3.1 (assumption A140, - T155a) — see `SyncChangeEntry`. items: $ref: "#/components/schemas/SyncChangeEntry" tombstones: @@ -6082,33 +4272,6 @@ components: description: Whether another page remains beyond `limit` at `next_watermark`. SyncChangeEntry: - description: >- - One row from the closed sync entity set (assumption A7, pinned at A140/D3.1, T155a): a - discriminated union on `entity_type`, `{ entity_type, id, group_change_seq, data }`. - `group_change_seq` is this row's own `change_seq` value in the caller's group (the same - value the containing `changes[]` array is ordered by, FR-090). `data` reuses each entity's - EXISTING REST response schema verbatim (`Item`, `WarrantyBlock`, `SaleBlock`, - `PurchaseBlock`, `Identification`, `ItemCustomField`, `StockAdjustment`, `Location`, - `Label`, `Attachment`) rather than a second, independently-maintained field set — a change - to one of those schemas and a change to its sync payload are now structurally the same - schema, so they cannot drift apart. The one exception is `item_label`: no existing REST - response schema covers an item-label ASSIGNMENT row (`PUT`/`DELETE - /items/{itemID}/labels/{labelID}` take and return no body — see this document's `labels` - tag description), so its `data` is the new, deliberately minimal `ItemLabelAssignment` - schema instead (see that schema's own description for why). `attachment`'s `data` is the - existing `Attachment` schema unmodified: it already carries metadata only and has never - carried bytes (FR-096) — the file itself is fetched separately, by `attachmentID`. - - **Discriminator form.** Modeled as `oneOf` over eleven flat variant schemas - (`SyncChangeEntryItem`, `SyncChangeEntryWarrantyBlock`, ...), each restating the shared - envelope fields (`entity_type`/`id`/`group_change_seq`/`data`) directly rather than - composing them via `allOf` against one shared base schema. This is a deliberate concession - to OpenAPI Generator's Kotlin target (assumption A141, D3.3): a `oneOf` of `allOf`-composed - subtypes is a known trouble spot for that generator (ambiguous/duplicated properties or an - incorrectly flattened sealed hierarchy), while a flat `oneOf` with a `discriminator.mapping` - generates one clean Kotlin sealed-interface subtype per mapped `entity_type`. redocly's - `lint` accepts either form equally, so where the two would pull in different directions - this document follows the generator, per this task's own brief (T155a). oneOf: - $ref: "#/components/schemas/SyncChangeEntryItem" - $ref: "#/components/schemas/SyncChangeEntryWarrantyBlock" @@ -6138,8 +4301,6 @@ components: SyncChangeEntryItem: type: object - description: >- - A changed `item` row (A7/A140). `data` is the existing [Item] response schema verbatim. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6157,13 +4318,6 @@ components: SyncChangeEntryWarrantyBlock: type: object - description: >- - A changed `warranty_block` row (A7/A140) — an item's 1:0..1 warranty detail block. `data` - is the existing [WarrantyBlock] response schema verbatim. [WarrantyBlock] itself carries no - `id` (it is addressed entirely by its parent item over REST, warranty.go's own file doc); - `id` here is the block's own underlying row id, never otherwise exposed, needed so the - block is individually addressable for tombstoning and upsert in a client's mirror the same - way every other synced row is. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6171,11 +4325,6 @@ components: enum: [warranty_block] id: type: string - description: >- - The warranty block's OWN underlying row id (`item_warranty.id`, migration 0001) — never - the parent item's id (that is `data.item_id`) and never otherwise exposed over REST - (this schema's own description). Needed here so a client mirror can address, upsert - and tombstone this specific block the same way every other synced row is addressed. group_change_seq: type: integer format: int64 @@ -6185,12 +4334,6 @@ components: SyncChangeEntrySoldToBlock: type: object - description: >- - A changed `sold_to_block` row (A7/A140) — an item's 1:0..1 sold-to detail block. `data` is - the existing [SaleBlock] response schema verbatim (the REST path is `/items/{itemID}/sale`; - `sold_to_block` is this protocol's own entity-type name for the identical block, matching - `data-model.md`). `id` here is the block's own underlying row id, for the identical reason - `SyncChangeEntryWarrantyBlock.id` needs one — [SaleBlock] itself carries none. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6198,11 +4341,6 @@ components: enum: [sold_to_block] id: type: string - description: >- - The sold-to block's OWN underlying row id (`item_sale.id`, migration 0001) — never the - parent item's id (that is `data.item_id`) and never otherwise exposed over REST (this - schema's own description). Needed here so a client mirror can address, upsert and - tombstone this specific block the same way every other synced row is addressed. group_change_seq: type: integer format: int64 @@ -6212,12 +4350,6 @@ components: SyncChangeEntryPurchasedFromBlock: type: object - description: >- - A changed `purchased_from_block` row (A7/A140) — an item's 1:0..1 purchased-from detail - block. `data` is the existing [PurchaseBlock] response schema verbatim (the REST path is - `/items/{itemID}/purchase`). `id` here is the block's own underlying row id, for the - identical reason `SyncChangeEntryWarrantyBlock.id` needs one — [PurchaseBlock] itself - carries none. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6225,12 +4357,6 @@ components: enum: [purchased_from_block] id: type: string - description: >- - The purchased-from block's OWN underlying row id (`item_purchase.id`, migration 0001) - — never the parent item's id (that is `data.item_id`) and never otherwise exposed over - REST (this schema's own description). Needed here so a client mirror can address, - upsert and tombstone this specific block the same way every other synced row is - addressed. group_change_seq: type: integer format: int64 @@ -6240,9 +4366,6 @@ components: SyncChangeEntryItemIdentification: type: object - description: >- - A changed `item_identification` row (A7/A140). `data` is the existing [Identification] - response schema verbatim, which already carries its own `id`. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6260,10 +4383,6 @@ components: SyncChangeEntryItemCustomField: type: object - description: >- - A changed `item_custom_field` row (A7/A140) — an item's own custom-field VALUE, not the - group-level `CustomFieldDef`, which is not itself a synced entity. `data` is the existing - [ItemCustomField] response schema verbatim, which already carries its own `id`. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6281,10 +4400,6 @@ components: SyncChangeEntryStockAdjustment: type: object - description: >- - A changed `stock_adjustment` row (A7/A140) — one append-only history entry (FR-018). `data` - is the existing [StockAdjustment] response schema verbatim, which already carries its own - `id`. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6302,9 +4417,6 @@ components: SyncChangeEntryLocation: type: object - description: >- - A changed `location` row (A7/A140). `data` is the existing [Location] response schema - verbatim, which already carries its own `id`. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6322,9 +4434,6 @@ components: SyncChangeEntryLabel: type: object - description: >- - A changed `label` row (A7/A140). `data` is the existing [Label] response schema verbatim, - which already carries its own `id`. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6342,10 +4451,6 @@ components: SyncChangeEntryItemLabel: type: object - description: >- - A changed `item_label` row (A7/A140) — one item↔label ASSIGNMENT (FR-032). `data` is the - new [ItemLabelAssignment] schema, not an existing REST response schema — see that schema's - own description for why no existing one applies here. required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6353,12 +4458,6 @@ components: enum: [item_label] id: type: string - description: >- - The assignment row's own id (`item_labels.id`, migration 0001) — never exposed by - `/items/{itemID}/labels{,/…}` over REST (this document's `labels` tag description: "a - client never needs the assignment row's own id"), but needed here so a client mirror - can upsert and tombstone this specific assignment the same way every other synced row - is addressed. group_change_seq: type: integer format: int64 @@ -6368,11 +4467,6 @@ components: SyncChangeEntryAttachment: type: object - description: >- - A changed `attachment` row (A7/A140) — metadata only, never bytes (FR-096). `data` is the - existing [Attachment] response schema verbatim, which already carries its own `id` and was - never able to carry bytes in the first place (attachment bytes are fetched separately, by - `attachmentID`). required: [entity_type, id, group_change_seq, data] properties: entity_type: @@ -6390,19 +4484,6 @@ components: ItemLabelAssignment: type: object - description: >- - The `data` payload for a `SyncChangeEntryItemLabel` (D3.1). No existing REST response - schema covers this shape: `GET /items/{itemID}/labels` returns whole [Label] objects (this - document's `labels` tag description), and `PUT`/`DELETE /items/{itemID}/labels/{labelID}` - take no request body and return no response body — "the edge is addressed by its two ends - ... a client never needs the assignment row's own id" over REST. Sync is the first consumer - that DOES need the assignment row's own id (Android's `ItemLabelEntity` mirror, D3.4, needs - a stable primary key to upsert/tombstone against, like every other synced row) and its two - content columns, `item_id`/`label_id`, with nothing else — the underlying `item_labels` - table (migration 0001) has no columns beyond these plus its own id/version/timestamps, - matching how the REST design already treats the edge as fact-only, content-free. This - schema is therefore intentionally new and minimal rather than a reuse of an existing REST - schema, unlike every other `SyncChangeEntry*` variant's `data`. required: [item_id, label_id] properties: item_id: @@ -6412,11 +4493,6 @@ components: SyncTombstoneEntry: type: object - description: >- - A deletion: `{ entity_type, id, deleted_at }` per the sync-protocol contract summary - (`specs/001-hho-ecosystem/contracts/sync-protocol.md`) and no other fields. `entity_type`'s - own enumerated value set is FR-139/D2.7's to pin, so it is left as a plain string rather - than an enum here. properties: entity_type: type: string @@ -6429,16 +4505,6 @@ components: SyncCursorTooOld: type: object - description: >- - `POST /sync/pull`'s answer when `since` predates the tombstone garbage-collection horizon - (FR-095): the tombstones the client would need to reconcile its own mirror are already - gone, so no partial answer is possible. The client MUST discard its local mirror entirely - and re-pull from `since: 0`. `since: 0` is a documented, permanent exception to this check: - it is always a valid full resync and is NEVER itself answered with `cursor_too_old`, no - matter how much tombstone history has been garbage-collected — a full resync is answered - from the live rows that remain and needs no tombstone history at all, so the very first - pull after a garbage-collection sweep runs (a pull this response itself just told the - client to make) can never loop back into this same response. required: [cursor_too_old] properties: cursor_too_old: @@ -6447,36 +4513,22 @@ components: SyncMutation: type: object - description: One queued client-side edit, as `POST /sync/push`'s `mutations[]` carries it (FR-091). required: [mutation_id, entity_type, entity_id, base_version, fields] properties: mutation_id: type: string - description: >- - A client-generated identifier, unique per `(group_id, mutation_id)` (FR-091). A retried - push carrying a `mutation_id` this group has already seen is answered in `skipped`, - never reapplied. entity_type: type: string - description: >- - Which member of the closed sync entity set (assumption A7) this mutation targets. - FR-139/D2.7 pins the enumerated value set; left a plain string here for the same reason - `SyncTombstoneEntry.entity_type` is. entity_id: type: string base_version: type: integer format: int64 - description: >- - The `version` this mutation was computed against, for FR-092/FR-093's field-level - conflict check — never trusted for ordering (that authority is exclusively server-side, - `change_seq` allocation at write time). fields: type: object - description: >- - The field-level edit itself. Deliberately free-form here, for the identical FR-139/D2.7 - reason `SyncChangeEntry` is: the precise per-entity-type field shape is a later, - separate documentation deliverable, not this Phase 1 stub's job to anticipate (T093). + op: + type: string + enum: [upsert, delete] SyncPushRequest: type: object @@ -6493,14 +4545,6 @@ components: SyncAppliedEntry: type: object - description: >- - One `SyncPushResponse.applied[]` entry (D4.1, T164a): this mutation wrote at least one - field. `version` is the entity's new WHOLE-ROW version after the write — the same - optimistic-concurrency value every REST `PUT` on that entity type already returns, so it is - exactly the `base_version` a client's NEXT mutation against this entity should carry. A - mutation that only partially applied (some fields conflicted) still gets ONE `applied` - entry for the fields that survived, alongside its own `SyncConflictEntry` row(s) for the - fields that didn't (FR-092's "divergent writes to different fields both survive"). required: [mutation_id, entity_type, entity_id, version] properties: mutation_id: @@ -6508,9 +4552,6 @@ components: description: Echoes the pushed `SyncMutation.mutation_id` this entry answers. entity_type: type: string - description: >- - Which member of the closed sync entity set (assumption A7) this mutation targeted — - the identical field and meaning `SyncMutation.entity_type` documents. entity_id: type: string version: @@ -6520,12 +4561,6 @@ components: SyncSkippedEntry: type: object - description: >- - One `SyncPushResponse.skipped[]` entry (D4.1, T164a): an already-seen `mutation_id` per - `(group_id, mutation_id)` (FR-091's replay case) — the idempotent no-op path. No entity - read, field comparison, or `change_seq` allocation happens for a skipped mutation at all, - mirroring `POST /sync/pull`'s own "no entity query executed" principle for - `cursor_too_old`. required: [mutation_id] properties: mutation_id: @@ -6534,13 +4569,6 @@ components: SyncConflictEntry: type: object - description: >- - One `SyncPushResponse.conflicts[]` entry (D4.1/D4.2, T164a) — ONE ENTRY PER CONFLICTING - FIELD, never per mutation: a mutation that partially conflicts appears in BOTH `applied[]` - (for the fields that survived, as a `SyncAppliedEntry`) and `conflicts[]` (for the ones - that didn't), which is the literal meaning of FR-092's "divergent writes to different - fields both survive." The server's stored value always wins for the named field (FR-092, - FR-093) — nothing in the mutation's `fields` for this `field_name` was written. required: [mutation_id, entity_type, entity_id, field_name] properties: mutation_id: @@ -6548,32 +4576,13 @@ components: description: Echoes the pushed `SyncMutation.mutation_id` this entry answers. entity_type: type: string - description: >- - Which member of the closed sync entity set (assumption A7) this mutation targeted — - the identical field and meaning `SyncMutation.entity_type` documents. entity_id: type: string field_name: type: string - description: >- - The conflicting field's name, taken from the mutation's own `fields` object — EXCEPT - the synthetic sentinel value `"_entity"`, which marks a WHOLE-ENTITY conflict: the - mutation's `entity_id` does not exist in this group (never created, or already - tombstoned) while `base_version > 0` (assumption A151). A whole-entity conflict is - never a silent no-op and never resurrects a deleted row — the entity stays absent, and - this is the mutation's only `conflicts[]` entry (no `SyncAppliedEntry` is emitted for - it, since nothing survived to apply). SyncPushResponse: type: object - description: >- - `POST /sync/push`'s 200 body. The per-entry shapes below are pinned at D4.1/D4.2 (T164a), - fixed for both passes so the wire shape never churns between them — only the server-side - decision logic behind `conflicts[]` gets more precise at D4.2 (field-level merge, D4.2). - `applied` newly wrote the entity's field(s) (`SyncAppliedEntry`), `skipped` was an - already-seen `mutation_id` (idempotent no-op, `SyncSkippedEntry`), `conflicts` hit a - same-field `base_version` divergence and the server's stored value won (`SyncConflictEntry`, - one entry PER CONFLICTING FIELD, not per mutation). required: [applied, skipped, conflicts, new_watermark] properties: applied: @@ -6591,9 +4600,6 @@ components: new_watermark: type: integer format: int64 - description: >- - The group's `change_seq` watermark after this push's writes — usable as a following - pull's own `since`, the same field `SyncPullResult.next_watermark` documents. ImportUploadResponse: type: object @@ -6602,9 +4608,6 @@ components: properties: import_id: type: string - description: >- - The newly staged import's id — the path parameter `GET /import/{importID}/preview` and - `POST /import/{importID}/commit` both take next. ImportPreviewRowError: type: object @@ -6623,11 +4626,6 @@ components: ImportPreviewRow: type: object - description: >- - One data row of a staged import, classified. Shared verbatim between `GET - /import/{importID}/preview`'s `200` and `POST /import/{importID}/commit`'s `422` — the two - routes classify every row through the identical internal path, so this shape can never mean - something different depending on which endpoint returned it. required: [line, action] properties: line: @@ -6636,20 +4634,11 @@ components: action: type: string enum: [create, update, unchanged, error] - description: >- - `create`: no matching `id`. `update`: a matching `id` whose proposed values differ from - what is currently stored. `unchanged`: a matching `id` whose proposed values are - byte-identical to what is currently stored (A122.3) — committing skips this row - entirely, bumping neither `version` nor `change_seq`. `error`: a per-row validation - failure; see `errors`. item_id: type: string description: The matched item's id, for an `update`/`unchanged` row only. Absent for `create`/`error`. name: type: string - description: >- - The row's own proposed name, read even for an `error` row (best effort) so a preview UI - can show which item a failing row was even trying to describe. changes: type: array items: @@ -6665,12 +4654,6 @@ components: - purchase - sale - custom_fields - description: >- - Every top-level column/block that differs from the item's current stored values, - present for an `update` row only (empty for `unchanged` by definition, meaningless for - `create`/`error`). Never includes `short_code`, `location_path` or `attachments` — none - of which import compares or writes (A128: `attachments` carries references only, never - bytes, so a spreadsheet edit to that column can never add or remove an uploaded file). errors: type: array items: @@ -6744,11 +4727,6 @@ components: ImportCommitRejectedResponse: type: object - description: >- - `POST /import/{importID}/commit`'s `422` body, when the staged file contains any row - classified `error` (FR-053 rule 1) — carries the identical per-row shape `GET - /import/{importID}/preview`'s `200` returns, since a flat `detail` string cannot carry a - per-row error list. required: [import_id, rows, summary] properties: import_id: diff --git a/server/internal/httpapi/authme.go b/server/internal/httpapi/authme.go new file mode 100644 index 0000000..0da1b10 --- /dev/null +++ b/server/internal/httpapi/authme.go @@ -0,0 +1,62 @@ +package httpapi + +import ( + "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/middleware" + "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/problem" + "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/requestid" + "github.com/ankek/Household-Objects-Dev/server/internal/storage" + "log/slog" + "net/http" +) + +type authMeResponseBody struct { + GroupID string `json:"group_id"` + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` +} + +func authMeHandler(cfg Config) middleware.ScopedHandler { + return func(w http.ResponseWriter, r *http.Request, scope storage.Scope) { + identity, ok := middleware.IdentityFromContext(r.Context()) + if !ok { + problem.Write(w, r, problem.Internal()) + return + } + + members, err := scope.Members().List(r.Context()) + if err != nil { + cfg.Logger.LogAttrs(r.Context(), slog.LevelError, "auth/me: list group members failed", + slog.String("request_id", requestid.FromContext(r.Context())), + slog.String("error", err.Error()), + ) + problem.Write(w, r, problem.Internal()) + return + } + + username, found := usernameForCaller(members, identity.UserID) + if !found { + cfg.Logger.LogAttrs(r.Context(), slog.LevelError, "auth/me: authenticated user not found in own group's roster", + slog.String("request_id", requestid.FromContext(r.Context())), + ) + problem.Write(w, r, problem.Internal()) + return + } + + writeJSON(w, r, http.StatusOK, authMeResponseBody{ + GroupID: identity.Group, + UserID: identity.UserID, + Username: username, + Role: identity.Role, + }) + } +} + +func usernameForCaller(members []storage.Member, userID string) (username string, found bool) { + for _, m := range members { + if m.ID == userID { + return m.Username, true + } + } + return "", false +} diff --git a/server/internal/httpapi/authme_test.go b/server/internal/httpapi/authme_test.go new file mode 100644 index 0000000..e662f72 --- /dev/null +++ b/server/internal/httpapi/authme_test.go @@ -0,0 +1,175 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "github.com/ankek/Household-Objects-Dev/server/internal/storage" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const authMePath = "/api/v1/auth/me" + +func authMeTestConfig(repo storage.MemberRepository) Config { + cfg := testConfig() + cfg.Authenticator = identityByHeaderAuth(sessionsTestIdentities) + cfg.Scopes = membersFakeScopes{repo: repo} + return cfg +} + +func authMeRoster() storage.MemberRepository { + return fakeMemberRepository{ + listFn: func(context.Context) ([]storage.Member, error) { + return []storage.Member{ + {ID: ownerUserID, Username: "alice", Role: "owner", JoinedAtUnixMilli: 1}, + {ID: memberUserID, Username: "bob", Role: "member", JoinedAtUnixMilli: 2}, + }, nil + }, + } +} + +func TestAuthMeHandlerReturnsCallerIdentity(t *testing.T) { + tests := []struct { + name string + user string + wantBody authMeResponseBody + }{ + { + name: "owner", + user: "owner", + wantBody: authMeResponseBody{ + GroupID: sessionsTestGroup, UserID: ownerUserID, Username: "alice", Role: "owner", + }, + }, + { + name: "member", + user: "member", + wantBody: authMeResponseBody{ + GroupID: sessionsTestGroup, UserID: memberUserID, Username: "bob", Role: "member", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, err := NewRouter(authMeTestConfig(authMeRoster())) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + req := asTestUser(httptest.NewRequest(http.MethodGet, authMePath, nil), tt.user) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET %s as %s: status = %d, want %d; body = %s", authMePath, tt.user, rec.Code, http.StatusOK, rec.Body.String()) + } + var got authMeResponseBody + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v; body = %s", err, rec.Body.String()) + } + if got != tt.wantBody { + t.Errorf("GET %s as %s: body = %+v, want %+v", authMePath, tt.user, got, tt.wantBody) + } + }) + } +} + +func TestAuthMeRouteAnswersADeviceToken(t *testing.T) { + h, err := NewRouter(authMeTestConfig(authMeRoster())) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + req := asTestUser(httptest.NewRequest(http.MethodGet, authMePath, nil), "owner") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestAuthMeRouteRejectsNoCredentialAtAll(t *testing.T) { + h, err := NewRouter(testConfig()) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, authMePath, nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestAuthMeHandlerBodyShapeCarriesNoSecret(t *testing.T) { + h, err := NewRouter(authMeTestConfig(authMeRoster())) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + req := asTestUser(httptest.NewRequest(http.MethodGet, authMePath, nil), "owner") + h.ServeHTTP(rec, req) + + var raw map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode response: %v; body = %s", err, rec.Body.String()) + } + + wantKeys := map[string]bool{"group_id": true, "user_id": true, "username": true, "role": true} + for k := range raw { + if !wantKeys[k] { + t.Errorf("unexpected key %q in auth/me response: %v", k, raw) + } + } + for k := range wantKeys { + if _, ok := raw[k]; !ok { + t.Errorf("missing key %q in auth/me response: %v", k, raw) + } + } + + lower := strings.ToLower(rec.Body.String()) + for _, forbidden := range []string{"password", "hash", "session", "token"} { + if strings.Contains(lower, forbidden) { + t.Errorf("response body mentions %q, which must never appear in an auth/me response: %s", forbidden, rec.Body.String()) + } + } +} + +func TestAuthMeHandlerReportsStorageErrors(t *testing.T) { + repo := fakeMemberRepository{ + listFn: func(context.Context) ([]storage.Member, error) { return nil, errors.New("boom") }, + } + h, err := NewRouter(authMeTestConfig(repo)) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + req := asTestUser(httptest.NewRequest(http.MethodGet, authMePath, nil), "owner") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } +} + +func TestAuthMeHandlerReturnsInternalWhenCallerMissingFromOwnRoster(t *testing.T) { + repo := fakeMemberRepository{ + listFn: func(context.Context) ([]storage.Member, error) { return nil, nil }, + } + h, err := NewRouter(authMeTestConfig(repo)) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + req := asTestUser(httptest.NewRequest(http.MethodGet, authMePath, nil), "owner") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusInternalServerError, rec.Body.String()) + } +} diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 467a59c..0530d4d 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -170,6 +170,8 @@ func mountV1(r chi.Router, cfg Config) { r.Delete("/auth/device-tokens/{deviceTokenID}", deviceTokenRevokeHandler(cfg)) } + r.Get("/auth/me", middleware.Scoped(authMeHandler(cfg))) + if cfg.InviteService != nil && cfg.Invites != nil { r.Group(func(r chi.Router) { r.Use(middleware.RequireOwner(cfg.Logger)) @@ -274,7 +276,7 @@ func mountV1(r chi.Router, cfg Config) { r.Route("/sync", func(r chi.Router) { r.Post("/pull", middleware.Scoped(syncPullHandler(cfg))) - r.Post("/push", syncPushHandler(cfg)) + r.Post("/push", middleware.Scoped(syncPushHandler(cfg))) }) }) diff --git a/server/internal/httpapi/sync.go b/server/internal/httpapi/sync.go index 891023e..e2f5267 100644 --- a/server/internal/httpapi/sync.go +++ b/server/internal/httpapi/sync.go @@ -1,16 +1,20 @@ package httpapi import ( + "context" "encoding/json" "errors" "fmt" "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/middleware" "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/problem" "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/requestid" + "github.com/ankek/Household-Objects-Dev/server/internal/items" + "github.com/ankek/Household-Objects-Dev/server/internal/labels" "github.com/ankek/Household-Objects-Dev/server/internal/storage" syncpkg "github.com/ankek/Household-Objects-Dev/server/internal/sync" "log/slog" "net/http" + "time" ) const maxSyncPullLimit = 500 @@ -240,8 +244,252 @@ func syncPullHandler(cfg Config) middleware.ScopedHandler { } } -func syncPushHandler(cfg Config) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - problem.Write(w, r, problem.NotImplemented()) +type syncPushMutationBody struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + BaseVersion int64 `json:"base_version"` + Fields map[string]json.RawMessage `json:"fields"` + Op string `json:"op,omitempty"` +} + +type syncPushRequestBody struct { + DeviceID string `json:"device_id"` + Mutations []syncPushMutationBody `json:"mutations"` +} + +type syncPushAppliedEntryBody struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + Version int64 `json:"version"` +} + +type syncPushSkippedEntryBody struct { + MutationID string `json:"mutation_id"` +} + +type syncPushConflictEntryBody struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + FieldName string `json:"field_name"` +} + +type syncPushResponseBody struct { + Applied []syncPushAppliedEntryBody `json:"applied"` + Skipped []syncPushSkippedEntryBody `json:"skipped"` + Conflicts []syncPushConflictEntryBody `json:"conflicts"` + NewWatermark int64 `json:"new_watermark"` +} + +func toSyncPushResponseBody(result syncpkg.PushResult) syncPushResponseBody { + applied := make([]syncPushAppliedEntryBody, 0, len(result.Applied)) + for _, a := range result.Applied { + applied = append(applied, syncPushAppliedEntryBody{ + MutationID: a.MutationID, + EntityType: a.EntityType, + EntityID: a.EntityID, + Version: a.Version, + }) + } + + skipped := make([]syncPushSkippedEntryBody, 0, len(result.Skipped)) + for _, s := range result.Skipped { + skipped = append(skipped, syncPushSkippedEntryBody{MutationID: s.MutationID}) + } + + conflicts := make([]syncPushConflictEntryBody, 0, len(result.Conflicts)) + for _, c := range result.Conflicts { + conflicts = append(conflicts, syncPushConflictEntryBody{ + MutationID: c.MutationID, + EntityType: c.EntityType, + EntityID: c.EntityID, + FieldName: c.FieldName, + }) + } + + return syncPushResponseBody{ + Applied: applied, + Skipped: skipped, + Conflicts: conflicts, + NewWatermark: result.NewWatermark, + } +} + +var syncPushCommitRepositoryFor = func(cfg Config, scope storage.Scope) (storage.PushCommitRepository, error) { + if cfg.Store == nil { + return nil, errors.New("httpapi: sync: no storage configured") + } + return cfg.Store.ForGroupPushCommit(scope.GroupID()) +} + +func pushMutationDetail(index int, mutationID, reason string) string { + return fmt.Sprintf("mutations[%d] (mutation_id=%q): %s", index, mutationID, reason) +} + +func syncPushFieldString(fields map[string]json.RawMessage, name string) (string, bool, error) { + raw, ok := fields[name] + if !ok { + return "", false, nil + } + var v string + if err := json.Unmarshal(raw, &v); err != nil { + return "", true, fmt.Errorf("field %q does not decode to a string", name) + } + return v, true, nil +} + +func validatePushMutationFields(ctx context.Context, scope storage.Scope, m syncPushMutationBody) error { + switch m.EntityType { + case "item_identification": + kind, present, err := syncPushFieldString(m.Fields, "kind") + if err != nil { + return err + } + if present { + if err := items.ValidateIdentificationKind(kind); err != nil { + return err + } + } + + case "warranty_block": + startsOn, _, err := syncPushFieldString(m.Fields, "starts_on") + if err != nil { + return err + } + expiresOn, _, err := syncPushFieldString(m.Fields, "expires_on") + if err != nil { + return err + } + if err := items.ValidateWarrantyDates(startsOn, expiresOn); err != nil { + return err + } + + case "sold_to_block": + soldOn, _, err := syncPushFieldString(m.Fields, "sold_on") + if err != nil { + return err + } + if err := items.ValidateSaleDate(soldOn); err != nil { + return err + } + + case "purchased_from_block": + purchasedOn, _, err := syncPushFieldString(m.Fields, "purchased_on") + if err != nil { + return err + } + if err := items.ValidatePurchaseDate(purchasedOn); err != nil { + return err + } + + case "item_custom_field": + fieldType, hasType, err := syncPushFieldString(m.Fields, "field_type") + if err != nil { + return err + } + if hasType { + if err := items.ValidateCustomFieldType(fieldType); err != nil { + return err + } + } + fieldDefID, hasDef, err := syncPushFieldString(m.Fields, "field_def_id") + if err != nil { + return err + } + if hasDef && fieldDefID != "" && hasType { + if err := items.ResolveCustomFieldDef(ctx, scope.CustomFieldDefs(), fieldDefID, fieldType); err != nil { + return err + } + } + + case "label": + color, present, err := syncPushFieldString(m.Fields, "color") + if err != nil { + return err + } + if present { + if err := labels.ValidateColor(color); err != nil { + return err + } + } + } + return nil +} + +func syncPushHandler(cfg Config) middleware.ScopedHandler { + return func(w http.ResponseWriter, r *http.Request, scope storage.Scope) { + var body syncPushRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + problem.Write(w, r, problem.BadRequest("the request body is not valid JSON")) + return + } + if body.DeviceID == "" { + problem.Write(w, r, problem.BadRequest("device_id is required")) + return + } + + batch := syncpkg.PushBatch{ + Mutations: make([]syncpkg.PushMutation, 0, len(body.Mutations)), + Now: time.Now().UnixMilli(), + } + for i, m := range body.Mutations { + switch { + case m.MutationID == "": + problem.Write(w, r, problem.BadRequest(pushMutationDetail(i, m.MutationID, "mutation_id is required"))) + return + case m.EntityType == "": + problem.Write(w, r, problem.BadRequest(pushMutationDetail(i, m.MutationID, "entity_type is required"))) + return + case m.EntityID == "": + problem.Write(w, r, problem.BadRequest(pushMutationDetail(i, m.MutationID, "entity_id is required"))) + return + case m.BaseVersion < 0: + problem.Write(w, r, problem.BadRequest(pushMutationDetail(i, m.MutationID, "base_version must not be negative"))) + return + } + + if err := validatePushMutationFields(r.Context(), scope, m); err != nil { + problem.Write(w, r, problem.BadRequest(pushMutationDetail(i, m.MutationID, err.Error()))) + return + } + + batch.Mutations = append(batch.Mutations, syncpkg.PushMutation{ + MutationID: m.MutationID, + EntityType: m.EntityType, + EntityID: m.EntityID, + BaseVersion: m.BaseVersion, + Fields: m.Fields, + Op: m.Op, + }) + } + + commit, err := syncPushCommitRepositoryFor(cfg, scope) + if err != nil { + cfg.Logger.LogAttrs(r.Context(), slog.LevelError, "sync: push: bind push commit repository failed", + slog.String("request_id", requestid.FromContext(r.Context())), + slog.String("error", err.Error()), + ) + problem.Write(w, r, problem.Internal()) + return + } + + result, err := syncpkg.Push(r.Context(), commit, batch) + if err != nil { + var mutationErr *syncpkg.PushMutationError + if errors.As(err, &mutationErr) { + problem.Write(w, r, problem.BadRequest(pushMutationDetail(mutationErr.Index, mutationErr.MutationID, mutationErr.Err.Error()))) + return + } + cfg.Logger.LogAttrs(r.Context(), slog.LevelError, "sync: push failed", + slog.String("request_id", requestid.FromContext(r.Context())), + slog.String("error", err.Error()), + ) + problem.Write(w, r, problem.Internal()) + return + } + + writeJSON(w, r, http.StatusOK, toSyncPushResponseBody(result)) } } diff --git a/server/internal/httpapi/sync_test.go b/server/internal/httpapi/sync_test.go index 87cd474..aa7ced7 100644 --- a/server/internal/httpapi/sync_test.go +++ b/server/internal/httpapi/sync_test.go @@ -1,9 +1,10 @@ package httpapi import ( + "context" "encoding/json" + "errors" "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/middleware" - "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/problem" "github.com/ankek/Household-Objects-Dev/server/internal/storage" "net/http" "net/http/httptest" @@ -12,7 +13,7 @@ import ( "testing" ) -func TestSyncPushRouteAnswersNotImplemented(t *testing.T) { +func TestSyncPushRouteIsRealNotAStub(t *testing.T) { cfg := testConfig() cfg.Authenticator = acceptingAuth h, err := NewRouter(cfg) @@ -20,15 +21,16 @@ func TestSyncPushRouteAnswersNotImplemented(t *testing.T) { t.Fatalf("NewRouter: %v", err) } - rec := do(t, h, http.MethodPost, "/api/v1/sync/push") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", strings.NewReader("")) + h.ServeHTTP(rec, req) - if rec.Code != http.StatusNotImplemented { - t.Fatalf("status = %d, want 501: %q", rec.Code, rec.Body.String()) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (empty body fails JSON decode): %q", rec.Code, rec.Body.String()) } got := decodeProblem(t, rec) - want := problem.NotImplemented() - if got.Type != want.Type || got.Title != want.Title || got.Status != want.Status { - t.Errorf("body = %+v, want type/title/status matching %+v", got, want) + if got.Detail == "" { + t.Errorf("body = %+v, want a non-empty Detail (BadRequest always carries one)", got) } } @@ -90,8 +92,8 @@ func TestSyncPushRevealsNothingThatVariesByGroup(t *testing.T) { recA := do(t, hA, http.MethodPost, "/api/v1/sync/push") recB := do(t, hB, http.MethodPost, "/api/v1/sync/push") - if recA.Code != http.StatusNotImplemented || recB.Code != http.StatusNotImplemented { - t.Fatalf("status = (A: %d, B: %d), want both 501: A=%q B=%q", + if recA.Code != http.StatusBadRequest || recB.Code != http.StatusBadRequest { + t.Fatalf("status = (A: %d, B: %d), want both 400: A=%q B=%q", recA.Code, recB.Code, recA.Body.String(), recB.Body.String()) } @@ -99,7 +101,7 @@ func TestSyncPushRevealsNothingThatVariesByGroup(t *testing.T) { gotA.RequestID, gotB.RequestID = "", "" if gotA != gotB { t.Errorf("household A's body %+v != household B's body %+v (ignoring request_id) -- "+ - "a stub route revealed something that differs by group", gotA, gotB) + "a request that never reached per-group data revealed something that differs by group", gotA, gotB) } } @@ -208,3 +210,289 @@ func TestSyncPullDoesNotLeakAcrossGroups(t *testing.T) { t.Errorf("group B's pull leaked group A's item %q: %+v", itemA.ID, resultB) } } + +func syncPushTestRouter(t *testing.T, groupID string) http.Handler { + t.Helper() + s := syncIsolationTestStorage(t) + if err := s.RegisterNewGroup(t.Context(), storage.FirstUserParams{ + GroupID: groupID, + GroupName: groupID, + UserID: groupID + "-owner", + Username: groupID + "-owner", + PasswordHash: "not-a-real-hash", + Now: 1, + }); err != nil { + t.Fatalf("RegisterNewGroup(%s): %v", groupID, err) + } + + cfg := testConfig() + cfg.Store, cfg.Scopes = s, s + cfg.Authenticator = authAsGroup(groupID) + h, err := NewRouter(cfg) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return h +} + +func doSyncPush(t *testing.T, h http.Handler, req syncPushRequestBody) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", strings.NewReader(string(raw))) + h.ServeHTTP(rec, httpReq) + return rec +} + +func pushField(t *testing.T, v any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal field: %v", err) + } + return raw +} + +func TestSyncPushRequiresDeviceID(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-devid") + + rec := doSyncPush(t, h, syncPushRequestBody{DeviceID: "", Mutations: nil}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %q", rec.Code, rec.Body.String()) + } + got := decodeProblem(t, rec) + if got.Detail != "device_id is required" { + t.Errorf("detail = %q, want %q", got.Detail, "device_id is required") + } +} + +func TestSyncPushRequiresMutationFields(t *testing.T) { + tests := []struct { + name string + mutation syncPushMutationBody + wantDetail string + }{ + { + name: "missing mutation_id", + mutation: syncPushMutationBody{EntityType: "item", EntityID: "e1", BaseVersion: 0, Fields: map[string]json.RawMessage{"name": pushField(t, "Foo")}}, + wantDetail: `mutations[0] (mutation_id=""): mutation_id is required`, + }, + { + name: "missing entity_type", + mutation: syncPushMutationBody{MutationID: "mut-1", EntityID: "e1", BaseVersion: 0, Fields: map[string]json.RawMessage{}}, + wantDetail: `mutations[0] (mutation_id="mut-1"): entity_type is required`, + }, + { + name: "missing entity_id", + mutation: syncPushMutationBody{MutationID: "mut-1", EntityType: "item", BaseVersion: 0, Fields: map[string]json.RawMessage{}}, + wantDetail: `mutations[0] (mutation_id="mut-1"): entity_id is required`, + }, + { + name: "negative base_version", + mutation: syncPushMutationBody{MutationID: "mut-1", EntityType: "item", EntityID: "e1", BaseVersion: -1, Fields: map[string]json.RawMessage{}}, + wantDetail: `mutations[0] (mutation_id="mut-1"): base_version must not be negative`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-reqfields-"+strings.ReplaceAll(tt.name, " ", "-")) + + rec := doSyncPush(t, h, syncPushRequestBody{DeviceID: "dev-1", Mutations: []syncPushMutationBody{tt.mutation}}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %q", rec.Code, rec.Body.String()) + } + got := decodeProblem(t, rec) + if got.Detail != tt.wantDetail { + t.Errorf("detail = %q, want %q", got.Detail, tt.wantDetail) + } + }) + } +} + +func TestSyncPushRejectsInvalidUpperLayerFieldValue(t *testing.T) { + tests := []struct { + name string + mutation syncPushMutationBody + wantSubstr string + }{ + { + name: "unknown identification kind", + mutation: syncPushMutationBody{ + MutationID: "mut-kind", EntityType: "item_identification", EntityID: "id-1", BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushField(t, "itm-1"), + "kind": pushField(t, "not-a-real-kind"), + "value": pushField(t, "12345"), + }, + }, + wantSubstr: "kind must be one of serial, model, asset_tag, barcode, other", + }, + { + name: "malformed label colour", + mutation: syncPushMutationBody{ + MutationID: "mut-color", EntityType: "label", EntityID: "lbl-1", BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "name": pushField(t, "Kitchen"), + "color": pushField(t, "not-a-color"), + }, + }, + wantSubstr: "color must be a 6-digit hex triplet", + }, + { + name: "malformed warranty date", + mutation: syncPushMutationBody{ + MutationID: "mut-date", EntityType: "warranty_block", EntityID: "w-1", BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushField(t, "itm-1"), + "starts_on": pushField(t, "2026-8-5"), + "is_lifetime": pushField(t, false), + }, + }, + wantSubstr: "warranty dates must be ISO-8601 calendar days", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-fieldval-"+strings.ReplaceAll(tt.name, " ", "-")) + + rec := doSyncPush(t, h, syncPushRequestBody{DeviceID: "dev-1", Mutations: []syncPushMutationBody{tt.mutation}}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %q", rec.Code, rec.Body.String()) + } + got := decodeProblem(t, rec) + if !strings.Contains(got.Detail, tt.wantSubstr) { + t.Errorf("detail = %q, want it to contain %q", got.Detail, tt.wantSubstr) + } + if !strings.Contains(got.Detail, tt.mutation.MutationID) { + t.Errorf("detail = %q, want it to name mutation_id %q", got.Detail, tt.mutation.MutationID) + } + if !strings.Contains(got.Detail, "mutations[0]") { + t.Errorf("detail = %q, want it to name the mutation's batch index", got.Detail) + } + }) + } +} + +func TestSyncPushStructuralErrorAnswers400(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-structural") + + rec := doSyncPush(t, h, syncPushRequestBody{ + DeviceID: "dev-1", + Mutations: []syncPushMutationBody{ + {MutationID: "mut-attach", EntityType: "attachment", EntityID: "att-1", BaseVersion: 0, Fields: map[string]json.RawMessage{}}, + }, + }) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %q", rec.Code, rec.Body.String()) + } + got := decodeProblem(t, rec) + if !strings.Contains(got.Detail, "mut-attach") || !strings.Contains(got.Detail, "mutations[0]") { + t.Errorf("detail = %q, want it to name mutations[0] and mutation_id mut-attach", got.Detail) + } +} + +type fakeFailingPushCommit struct { + err error +} + +func (f fakeFailingPushCommit) ApplyMutation(context.Context, storage.PushMutation) (storage.PushOutcome, error) { + return storage.PushOutcome{}, f.err +} + +func (f fakeFailingPushCommit) Watermark(context.Context) (int64, error) { return 0, nil } + +func TestSyncPushNonStructuralApplyMutationErrorAnswers500(t *testing.T) { + const injectedText = "disk I/O error reading the mutation ledger" + + orig := syncPushCommitRepositoryFor + syncPushCommitRepositoryFor = func(cfg Config, scope storage.Scope) (storage.PushCommitRepository, error) { + return fakeFailingPushCommit{err: errors.New("storage: push: check mutation ledger for \"mut-1\": " + injectedText)}, nil + } + t.Cleanup(func() { syncPushCommitRepositoryFor = orig }) + + h := syncPushTestRouter(t, "grp-push-nonstructural-500") + + rec := doSyncPush(t, h, syncPushRequestBody{ + DeviceID: "dev-1", + Mutations: []syncPushMutationBody{ + {MutationID: "mut-1", EntityType: "item", EntityID: "item-1", BaseVersion: 0, Fields: map[string]json.RawMessage{ + "name": pushField(t, "Drill"), + "description": pushField(t, ""), + "location_id": pushField(t, ""), + "quantity": pushField(t, 1), + }}, + }, + }) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500: %q", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if strings.Contains(body, "storage:") { + t.Errorf("body = %q, must not leak the storage-internal \"storage:\" prefix", body) + } + if strings.Contains(body, injectedText) { + t.Errorf("body = %q, must not leak the injected error text %q", body, injectedText) + } + if strings.Contains(body, "mut-1") { + t.Errorf("body = %q, must not name the mutation (that is the 400 path's own contract, not 500's)", body) + } +} + +func TestSyncPushCreatesItemAndReturnsApplied(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-create") + + const entityID = "itm-push-create-1" + rec := doSyncPush(t, h, syncPushRequestBody{ + DeviceID: "dev-1", + Mutations: []syncPushMutationBody{ + { + MutationID: "mut-create-1", EntityType: "item", EntityID: entityID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "name": pushField(t, "Pushed Item"), + "description": pushField(t, ""), + "location_id": pushField(t, ""), + "quantity": pushField(t, 1), + }, + }, + }, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %q", rec.Code, rec.Body.String()) + } + + var got syncPushResponseBody + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode response: %v (body: %s)", err, rec.Body.String()) + } + + if len(got.Applied) != 1 { + t.Fatalf("applied = %+v, want exactly one entry", got.Applied) + } + applied := got.Applied[0] + if applied.MutationID != "mut-create-1" || applied.EntityType != "item" || applied.EntityID != entityID { + t.Errorf("applied[0] = %+v, want mutation_id=mut-create-1 entity_type=item entity_id=%s", applied, entityID) + } + if applied.Version != 1 { + t.Errorf("applied[0].Version = %d, want 1 (a fresh create's first version)", applied.Version) + } + if len(got.Skipped) != 0 { + t.Errorf("skipped = %+v, want none", got.Skipped) + } + if len(got.Conflicts) != 0 { + t.Errorf("conflicts = %+v, want none", got.Conflicts) + } + if got.NewWatermark <= 0 { + t.Errorf("new_watermark = %d, want a positive change_seq watermark after this write", got.NewWatermark) + } +} diff --git a/server/internal/httpapi/tenant_isolation_coverage.txt b/server/internal/httpapi/tenant_isolation_coverage.txt index ee3ce58..e8c81c0 100644 --- a/server/internal/httpapi/tenant_isolation_coverage.txt +++ b/server/internal/httpapi/tenant_isolation_coverage.txt @@ -1,1084 +1,9 @@ -# tenant_isolation_coverage.txt -- NFR-010's coverage list (T046). -# -# WHAT THIS FILE IS -# -# One line per route the API actually serves, recording whether that route's cross-group isolation -# property is tested. It is the *claim*; `hho routes` is the *fact*. .orchestra/scripts/gate-tenant- -# isolation.sh diffs the two and fails when a registered route has no line here, which is the exact -# defect NFR-010 exists to prevent: an endpoint shipped with no isolation test and nobody noticing. -# -# FORMAT -# -# : -# -# The first two whitespace-separated fields are the route key and must byte-match a line of -# `go -C server run ./cmd/hho routes`. The third field is the status token: -# -# exempt: the route authenticates nobody, so there is no tenant to cross. Counted as -# covered. Every unauthenticated route is listed here EXPLICITLY rather than left -# out, because an omission and a forgotten route look identical to the gate. -# covered-by: a test asserts this route's cross-group behaviour. Name the test (and its -# subtest), as a string `go test -run` accepts verbatim, so a reader can go and -# check the claim rather than trusting it. -# -# Three assertion shapes count, because the API has three shapes of route. For a -# route that takes a resource id, it is the cross-group 404: authenticated as group -# B, naming a group-A id, the answer must be 404 and never 403 -- a 403 confirms the -# id exists, which is the existence oracle FR-008/NFR-010 forbid. For a collection -# route, which takes no id and correctly answers 200, it is the same property -# stated over the response: the other group's ids must be absent from the caller's -# listing. The second is not a weaker claim; it is the only form the first can take -# where there is no id in the request to be wrong about. -# -# The third shape applies to the CREATE routes that take NO resource id (POST -# /invites, POST /auth/device-tokens, T048; POST /items, T051). A create route that -# DOES name an existing resource -- POST /items/{itemID}/warranty (T052) names the -# parent item -- is the FIRST shape, not this one: there is an id in the request to -# be wrong about, so the cross-group 404 applies directly and is the stronger claim. -# -# The three id-less create routes take neither a resource id nor -# a collection to probe going in, so neither of the above applies. Their tenant -# property -- the row a create call produces lands in the CALLER's group -- is -# enforced structurally: inviteCreateHandler and deviceTokenIssueHandler both pass -# GroupID: identity.Group, the authenticated scope, never a request field, and -# itemCreateHandler passes middleware.Scoped's storage.Scope through to -# items.Create, whose storage.ItemRepository declares no method taking a group -# identifier at all -- so there is no group id for a caller to even name. P-3's -# group-bound repositories (T026) and TestUnscopedConstructionDoesNotCompile (T030, -# server/tests/tenant_isolation/compilefail_test.go) enforce and guard that at -# compile time. A covered-by: line for a create route cites that structural test -# as its primary claim, plus a belt-and-braces RUNTIME assertion in -# TestTenantIsolation (T048): create the resource as group B, and assert the new -# row is present in B's own listing and absent from A's. This is still one -# covered-by: line, not a new status token -- see A85 on why a status token that -# could be satisfied by typing a word would defeat T050's premise. -# -# cross-tenant-by-design: the route authenticates SOMEBODY (so exempt: does not apply), but its -# own spec deliberately gives one authenticated caller every tenant's data, so a -# cross-group-404 / collection-absence / structural-scope test would be asserting -# the ABSENCE of the one property the route exists to have. GET /api/v1/backup -# (T113, FR-132) is, as of T113a, this status's only member: an authenticated -# owner of ANY group receives a snapshot of EVERY group's data by design (see -# storage.Storage.VacuumInto's own doc, "a whole-database operation with no tenant -# of its own," and backup.WriteArchive's signature, which has no group parameter at -# all to filter by). -# -# Naming the test is required here exactly as covered-by: requires it, but the test -# named must assert the INVERSE property, POSITIVELY: that an owner authenticated in -# one group genuinely DOES receive another group's rows in the response, on purpose. -# This is what keeps this status from being T050/A85's exact hazard -- a token -# satisfied by typing a word rather than by evidence. "exempt:" is falsifiable by -# testing for a 401 (T113's own backup_test.go already does, and did before this -# status existed); "covered-by:" is falsifiable by testing for isolation; this status -# is falsifiable the same way, in the opposite direction -- the moment a future -# change narrows the route to the caller's own tenant (deliberately or by accident), -# the named test starts failing on exactly that change, converting "this route has -# no isolation property" from an untested assertion in this file's prose into a -# claim a reader can run `go test -run` against and watch fail the day it stops -# being true. A line here that named no test, or named a test that only checked -# status codes/headers/stream-validity (what T113's own backup_test.go already -# covers) without ever asserting a SECOND group's row is actually present, would be -# exactly the un-evidenced word T050/A85 warn against, so the gate crosscheck below -# treats this status identically to covered-by: (accepted) and nothing lesser is -# accepted in its place. -# -# One more requirement, added after T113a shipped without it: the test named must -# be one gate-tenant-isolation.sh's own isolation-suite step ACTUALLY EXECUTES, not -# merely one that exists somewhere in the tree. That script runs -# `go -C server test ... -run 'TestTenantIsolation'` -- an unanchored match against -# each top-level test's own name -- so a cited test's top-level function must itself -# match that pattern (carry the TestTenantIsolation prefix, the shape -# TestTenantIsolationBackupIncludesEveryGroupsData below takes) unless a future -# change widens the script's own -run pattern AND its neighbouring vacuity guard -# (the `grep -rqs -- 'func TestTenantIsolation'` existence check immediately above -# that invocation) to cover the new name too -- widening one without the other -# reopens the exact hole this paragraph closes, a pattern that matches nothing still -# exits 0. Citing a test the gate never runs is indistinguishable, in effect, from -# citing no test at all: the claim can go stale (deleted, renamed, broken) with the -# gate none the wiser, which is precisely the un-evidenced-word hazard the paragraph -# above already rules out for every other case. -# -# Any other status (pending:, for instance) is reported by the gate as listed-but-not-covered. That -# is deliberate: writing a line here must not, by itself, satisfy the requirement. -# -# HOW TO REGENERATE THE SKELETON -# -# go -C server run ./cmd/hho routes --classify -# -# prints " \t\t" for every route, where isolation is "exempt" for a -# public route and "required" for a tenant-scoped one. Copy in the new lines, keep the old ones. -# -# STATUS AS OF T048 -# -# T047 built the harness -- server/tests/integration/tenant_isolation_test.go, TestTenantIsolation -# -- covering the six tenant-scoped routes whose cross-group behaviour it asserts directly (the -# cross-group-404 move on an id-taking route, or the collection-absence move on a listing route), -# under BOTH credential kinds (session cookie and bearer device token), against two real, -# separately-registered households on a real on-disk SQLite database. It left the two CREATE -# routes (POST /invites, POST /auth/device-tokens) `pending:`, correctly: neither takes a resource -# id, so the cross-group-404 move has nothing to name. -# -# T048 closes both. The route (A86, orchestrator-verified -- see docs/sdlc/hho-ecosystem/ -# assumptions.md) is that inviteCreateHandler and deviceTokenIssueHandler both pass GroupID: -# identity.Group -- the AUTHENTICATED SCOPE, never a request field -- so a caller cannot name the -# group it writes to in the first place. That property is enforced structurally by P-3's -# group-bound repositories (T026) and guarded at compile time by -# TestUnscopedConstructionDoesNotCompile (T030, server/tests/tenant_isolation/compilefail_test.go): -# an expression that constructs an unscoped repository, or reaches a handler outside the -# TenantScope/Scoped adapter, does not compile. Both create routes' covered-by: lines below cite -# that test as their primary claim -- this is the third assertion shape the FORMAT section above -# now documents. -# -# T048 also adds a belt-and-braces RUNTIME check to TestTenantIsolation: create the resource as -# group B, then assert the new row is present in B's own listing and absent from A's. -# TestTenantIsolation/(cookie|bearer)/invites_create_readback does this for POST /invites under -# both credential kinds; TestTenantIsolation/cookie/device_tokens_create_readback does it for -# POST /auth/device-tokens under the cookie credential only, because that route is cookie-only by -# design (see TestTenantIsolation/bearer/device_tokens_issue_is_cookie_only, unchanged and still -# pinned at 401). Both covered-by: lines cite this runtime test as a secondary claim; the -# structural guard above is what actually does the enforcing. -# -# gate-tenant-isolation's coverage-crosscheck is green as of T048: every registered route now -# carries a covered-by: or exempt: line. -# -# STATUS AS OF T051 -# -# T051 mounted D1.5's four item routes (GET/POST /items, GET/PUT /items/{itemID}) and, in the same -# task, covers them here -- the precedent T048 set when it closed the create routes inside the task -# that mounted them. A `pending:` line is NOT a way to defer that: the FORMAT section above says -# so, and gate-tenant-isolation's coverage-crosscheck reports any other status as -# listed-but-not-covered, so deferring would have parked a milestone gate red. -# -# All three assertion shapes appear at once. items_get and items_update are the cross-group-404 -# move (items_update sends a VALID body -- non-empty name, version 1 -- because itemUpdateHandler -# rejects an empty name or a zero version as a 400 before any repository is reached, and a subtest -# that reads a 400 as its answer would be green while proving nothing); items_list is the -# collection-absence move; items_create_readback is the third, structural shape. items_create_readback -# runs under BOTH credentials, unlike device_tokens_create_readback: /items is mounted on the -# general authenticated group, so a device token can create one. -# -# Worth naming explicitly: every route above /items reaches storage through a Config-held service -# that reads middleware.IdentityFromContext(...).Group, whereas every /items handler reaches it -# through the storage.Scope middleware.Scoped hands it (see httpapi/items.go's file doc). That is -# a genuinely different seam, so no amount of green above these four lines says anything about -# them. -# -# Each of the four claims below was mutation-tested rather than merely observed to pass: breaking -# the group_id predicate in the relevant statement in storage/queries/items.sql (and, for the PUT, -# both GetItem's and UpdateItem's) makes the named subtest fail, and swapping problem.NotFound() -# for problem.Forbidden() in itemGetHandler/itemUpdateHandler trips -# assertCrossGroupNotFound's dedicated 403 existence-oracle branch. -# -# STATUS AS OF T052 -# -# T052 mounted D1.5's four warranty detail-block routes (GET/POST/PUT/DELETE -# /items/{itemID}/warranty) and covers them here, in the same task, following the precedent T048 set -# and T051 restated. T061 extends this list to the REST of D1.5 later; it does not cover these four. -# -# All four take a resource id -- the PARENT item's -- so all four are the cross-group-404 move, and -# there is no fourth assertion shape here. That includes the CREATE: unlike POST /invites, POST -# /auth/device-tokens and POST /items, a warranty create names an existing item going in, so the -# 404 is available directly and is a stronger claim than the structural argument those three had to -# fall back on. warranty_create's coverage line cites it as the primary claim and adds -# warranty_create_readback as the belt-and-braces half, because a 404 says nothing about where a -# create that SUCCEEDS puts its row. -# -# Three things make these four claims non-vacuous, and each is asserted rather than assumed: -# - provisionTenant gives BOTH households a real warranty block. Against an item with no block, -# every probe would answer 404 whether the group_id predicate existed or not. -# - the PUT probe sends a VALID body (non-empty holder, version 1 -- the version a freshly -# created block carries, pinned in provisionTenant) and the POST probe sends a valid body too, -# because both handlers reject an undecodable body, and the PUT a zero version, as a 400 before -# any repository is reached. -# - warranty_create additionally rules out 409, not just 403: a 409 would confirm household A's -# item exists AND already has a block, which is strictly more leakage than a 403. -# -# The seam is the same one /items uses (middleware.Scoped's storage.Scope, never -# middleware.IdentityFromContext(...).Group) but the STATEMENTS are new, so nothing green above -# these four lines says anything about them. Two of them differ from anything T051 shipped: -# DeleteItemWarranty has no scoped pre-read in front of it, so its own group_id predicate is the -# single, directly observable enforcement on that path; and CreateItemWarranty is guarded by an -# explicit scoped GetItem of the parent item, so that a cross-tenant create answers 404 rather than -# the 500 the composite FOREIGN KEY would otherwise produce. -# -# Mutation-tested rather than merely observed to pass; the results, including one mutation that -# does NOT fire and why, are recorded in .orchestra/tasks/T052.md. -# -# STATUS AS OF T053 -# -# T053 mounts D1.5's four sold-to detail-block routes (GET/POST/PUT/DELETE /items/{itemID}/sale) -# and covers them here, in the same task, on the T048/T051/T052 precedent. T061 does not cover -# these four. Coverage now stands at 24 routes: 4 exempt, 20 covered, 0 pending. -# -# Structurally this block is a clone of T052's warranty block against item_sale, so the reasoning -# in the T052 section above applies unchanged: all four routes take the PARENT item's id, so all -# four are the cross-group-404 shape including the CREATE, and the same three non-vacuity -# conditions are asserted rather than assumed -- -# - provisionTenant gives BOTH households a real sale block (it asserts version 1 and the -# buyer_name back at the fixture, so a future change that stops minting one fails loudly there -# rather than turning every probe below into a vacuous 404); -# - the PUT and POST probes send VALID bodies (non-empty buyer_name, version 1), because -# saleUpdateHandler rejects a zero or absent version as items.ErrVersionRequired -> 400 before -# any repository is reached, and a subtest reading a 400 as its answer would be green while -# proving nothing; -# - sale_create rules out 409 and 500 as well as 403 -- assertCrossGroupNotFoundWithBody accepts -# ONLY 404, naming 403 explicitly and failing everything else through its default branch. -# -# What the mutation testing established (full table in .orchestra/tasks/T053.md; every mutation was -# applied to the query source, regenerated with the pinned sqlc, and restored by explicit edit): -# - Breaking DeleteItemSale's group_id predicate ALONE fires: the cross-tenant DELETE answers 204 -# and group_a_resources_untouched then finds household A's block GONE. Delete has no scoped -# pre-read, so that predicate is the single directly observable enforcement on the path. -# - Breaking UpdateItemSale's group_id predicate alone does NOT fire, and that is a real finding -# rather than a weak test: saleRepository.Update opens with a scoped GetItemSale, which answers -# ErrNotFound -> 404 for a foreign item before the mutated UPDATE is ever issued. Breaking the -# pre-read AND the UPDATE together lands the write -- group_a_resources_untouched reports A's -# block as {buyer_name:"pwned by carol" version:2} -- which is what proves the UPDATE predicate -# is load-bearing and not dead code. -# - Breaking GetItemSale's group_id predicate leaks A's buyer_name to B on a 200. -# - Swapping problem.NotFound() for problem.Forbidden() fires on all four routes. -# - Removing Create's scoped GetItem of the parent item answers 500 (the composite FOREIGN KEY -# refusing the row) instead of 404 -- an existence oracle with extra steps, which is exactly -# what that guard exists to prevent. -# -# STATUS AS OF T054 -# -# T054 mounts D1.5's fourth and final detail-block route group (GET/POST/PUT/DELETE -# /items/{itemID}/purchase, FR-014) and covers it here, in the task that mounted it, on the -# T048/T051/T052/T053 precedent. T061 does not cover these four. Coverage now stands at 28 routes: -# 4 exempt, 24 covered, 0 pending. -# -# Structurally this block is a clone of T053's sold-to block against item_purchase (five content -# fields instead of four -- vendor, purchase date, purchase price, order reference, notes -- but -# the identical 1:0..1 shape, the identical composite FOREIGN KEY, the identical partial UNIQUE -# index), so the reasoning in the T053 section above applies unchanged: all four routes take the -# PARENT item's id, so all four are the cross-group-404 shape including the CREATE, and the same -# three non-vacuity conditions are asserted rather than assumed -- -# - provisionTenant gives BOTH households a real purchase block (it asserts version 1 and the -# vendor back at the fixture, so a future change that stops minting one fails loudly there -# rather than turning every probe below into a vacuous 404); -# - the PUT and POST probes send VALID bodies (non-empty vendor, version 1), because -# purchaseUpdateHandler rejects a zero or absent version as items.ErrVersionRequired -> 400 -# before any repository is reached, and a subtest reading a 400 as its answer would be green -# while proving nothing; -# - purchase_create rules out 409 and 500 as well as 403 -- assertCrossGroupNotFoundWithBody -# accepts ONLY 404, naming 403 explicitly and failing everything else through its default -# branch. -# -# What the mutation testing established (full table in .orchestra/tasks/T054.md; every mutation -# was applied to the query source, regenerated with the pinned sqlc, and restored by explicit -# edit, each restore verified with md5sum against a pre-mutation snapshot and a `git log --oneline -# -1` check): -# - Breaking DeleteItemPurchase's group_id predicate ALONE fires: the cross-tenant DELETE -# answers 204 and group_a_resources_untouched then finds household A's block GONE. Delete has -# no scoped pre-read, so that predicate is the single directly observable enforcement on the -# path. Re-applying this mutation alone against storage/purchase_test.go also makes -# TestPurchaseDeleteRejectsAnotherGroupsBlock fail on its own, confirming that test is -# non-vacuous. -# - Breaking UpdateItemPurchase's group_id predicate alone does NOT fire, and that is a real -# finding rather than a weak test: purchaseRepository.Update opens with a scoped -# GetItemPurchase, which answers ErrNotFound -> 404 for a foreign item before the mutated -# UPDATE is ever issued (observed instead as a 500 on the update probe: the pre-read leaked -# through GetItemPurchase's OWN broken predicate in the isolated MUT-3 run, so the intact -# UPDATE matched 0 rows and hit the "should be unreachable" guard). Breaking the pre-read AND -# the UPDATE together lands the write -- group_a_resources_untouched reports A's block as -# {vendor:"pwned by carol" version:2} -- which is what proves the UPDATE predicate is -# load-bearing and not dead code. -# - Breaking GetItemPurchase's group_id predicate leaks A's vendor to B on a 200, and turns -# purchase_create_readback's cross-tenant read into a 200 as well. -# - Swapping problem.NotFound() for problem.Forbidden() fires on all four routes. -# - Removing Create's scoped GetItem of the parent item answers 500 (the composite FOREIGN KEY -# refusing the row) instead of 404 -- an existence oracle with extra steps, which is exactly -# what that guard exists to prevent. -# -# STATUS AS OF T055 -# -# T055 mounts FR-011's per-group progressive-disclosure state (A98: three columns on `groups`, -# migration 0002 -- see that file's own comment) at GET/PUT /api/v1/groups/detail-visibility and -# covers both here, in the task that mounted them. Coverage now stands at 30 routes: 4 exempt, 26 -# covered, 0 pending. -# -# Both routes are the FORMAT section's second assertion shape, not the cross-group-404 shape every -# route above uses: there is no {id} in this path -- the resource IS the caller's own group, -# already identified by the credential -- so "authenticated as B, name A's id" has nothing to name. -# PUT is also the second owner-only route group in this codebase (middleware.RequireOwner, FR-007: -# "admin settings" is the owner's job, and toggling what the whole household sees is a group -# setting, not item CRUD -- see group_visibility.go's file doc for the full argument, including why -# the nearest same-shaped precedent, groups.registration_enabled, could not settle the question: it -# has no HTTP route at all). -# -# What the mutation testing established (every mutation applied to the SQL/Go source, regenerated -# with the pinned sqlc where applicable, and restored by explicit edit, each restore verified with -# md5sum against a pre-mutation snapshot): -# - Breaking GetGroupVisibility's `id = sqlc.arg(group_id)` predicate ALONE (mutated to -# `(id = sqlc.arg(group_id) OR 1=1)`) fires immediately and dramatically: with no predicate left -# to disambiguate, SQLite's QueryRowContext returns household A's row (seeded first, lower -# rowid) to EVERY caller regardless of which group's id was bound. Household B's GET starts -# answering with A's settings, and the PUT handler's own internal pre-read (the same query) -# reads A's version too, so the read-back after a nominally successful write echoes A's STALE -# state back to B instead of B's real new one; on the second credential-loop iteration this -# compounds into a spurious 500 (the version the broken pre-read hands the guarded UPDATE no -# longer matches B's real, already-bumped row). storage/group_visibility_test.go's -# TestGroupVisibilityGetIsScopedToItsOwnGroup fails on its own under this mutation with exactly -# this leak, independent of the HTTP-level failure. -# - Breaking UpdateGroupVisibility's `id = sqlc.arg(group_id)` predicate ALONE is NOT masked by -# the pre-read the way T054's UpdateItemPurchase was (that item-scoped predicate's masking -# doesn't reproduce here, because there is no id argument for the pre-read to disagree with the -# write about) -- it fires immediately as a 500 on both credential-loop iterations, because -# BOTH households start at version 1 (groups.sql's own default) and a bare `version = ?` with no -# tenant predicate left matches BOTH rows at once; [groupVisibilityRepository.Update]'s own -# `rows != 1` invariant guard (expecting exactly one matched row) catches the 2-row result and -# returns an error INSIDE the write transaction, which [binding.writeTx] then rolls back -# wholesale -- so group_a_resources_untouched (and storage/group_visibility_test.go's -# TestGroupVisibilityUpdateDoesNotAffectAnotherGroup) still find household A's row untouched -# after this SINGLE mutation, not because the predicate is dead but because a second, -# independent defense (the invariant check plus this package's single-transaction write path) -# contains the blast radius before it commits. Pairing this mutation with weakening that guard -# (`rows != 1` relaxed to `rows == 0`, so a 2-row match is accepted as success) is what lands the -# corruption for real: group_a_resources_untouched then reports household A's row as -# `{warranty_visible:true version:3}` (bumped twice, once per credential kind) -- -# "carol's own-group toggle bled into alice's group" -- and the SAME storage-layer test fails -# with the identical shape. This is the double-mutation-proves-load-bearing move T054's -# UpdateItemPurchase section established, applied to a different mechanism: there, a pre-read -# masked the predicate; here, an invariant guard plus transactional atomicity does, and both -# halves have to be broken together to show the predicate is load-bearing rather than redundant -# with that guard. -# - Stripping middleware.RequireOwner from PUT (mounting it directly on the general authenticated -# group like GET) fires immediately: TestOwnerOnlyRoutesRejectMembers's fake-scope case changes -# from 403 to 400 (a member now reaches body validation, which RequireOwner would never have let -# it do), and a scratch test against the real, DB-backed integration harness confirmed the -# stronger claim directly -- a genuine MEMBER (an invite-redeemed second user, not the -# registering owner) completes a real 200 write, `{"warranty_visible":true,...,"version":2}`, -# once the wrap is removed. The scratch test was deleted after confirming the result; it is not -# part of this task's shipped test suite. -# - problem.NotFound() -> problem.Forbidden() (T054's third required mutation) does not apply to -# either route: neither is id-addressed, so [problem.NotFound] renders only on the -# [storage.ErrNotFound] path each repository's own doc calls unreachable in practice (the bound -# group itself would have to be gone) -- there is no reachable 404-vs-403 boundary here to break, -# unlike every id-addressed route above. -# -# STATUS AS OF T056 -# -# T056 mounts FR-015's item identifications (GET/POST /items/{itemID}/identifications, PUT/DELETE -# /items/{itemID}/identifications/{identificationID}) and covers all four here, in the task that -# mounted them, on the T048/T051-T055 precedent. Coverage now stands at 34 routes: 4 exempt, 30 -# covered, 0 pending. -# -# This is the FIRST 0..N child collection under an item -- every prior detail block (warranty, -# sale, purchase, T052-T054) is 1:0..1, addressed by the parent item's id alone. GET/POST here are -# addressed the identical way and are the FORMAT section's first assertion shape (cross-group-404), -# exactly like POST /items/{itemID}/warranty. PUT/DELETE are ALSO the first shape, but each names a -# SECOND id -- the row itself -- which no route above this one has. That second id is the genuinely -# new tenant hazard this task adds: a child id belonging to a DIFFERENT item in the CALLER's OWN -# group, not a different tenant. This suite cannot exercise that hazard at all -- every subtest -# here authenticates as a second, independent GROUP, never as a second item within one group -- so -# it is proven instead at the storage layer, in -# server/internal/storage/item_identifications_test.go's -# TestIdentificationGetRejectsAnotherItemsRowInTheSameGroup, -# TestIdentificationListExcludesOtherItemsRowsInTheSameGroup, -# TestIdentificationUpdateRejectsAnotherItemsRowInTheSameGroup and -# TestIdentificationDeleteRejectsAnotherItemsRowInTheSameGroup. See T056.md's mutation table for the -# full results; the summary: -# - Breaking GetItemIdentification's group_id predicate ALONE fires directly: Get leaks group A's -# row to group B on a nil error, and Update's pre-read (which calls GetItemIdentification) leaks -# the same way, but the actual UPDATE statement (its own group_id predicate intact) then matches -# 0 rows against the pre-read's stale, wrongly-scoped version -- caught by the "matched 0 rows, -# want 1" invariant guard, surfacing as a 500 rather than a silent cross-tenant write. Both -# TestIdentificationGetRejectsAnotherGroupsRow and TestIdentificationUpdateRejectsAnotherGroupsRow -# fail under this single mutation. -# - Breaking GetItemIdentification's item_id predicate ALONE -- the headline new predicate -- is -# INVISIBLE to every cross-group test in this file (the whole isolation suite stays green) and -# fires ONLY on the two same-tenant, cross-item storage tests named above -# (TestIdentificationGetRejectsAnotherItemsRowInTheSameGroup / -# ...UpdateRejectsAnotherItemsRowInTheSameGroup), exactly confirming the gap this section states. -# - Breaking ListItemIdentifications' group_id predicate ALONE does not fire: List's own pre-read -# (a scoped GetItem, unrelated statement) already answers ErrNotFound for a foreign-group item -# before the mutated statement is reached -- masked, not dead; disabling that pre-read as well -# lands a real leak (List returns rows with a nil error to a foreign caller), proving the -# predicate load-bearing once nothing upstream confirms ownership. -# - Breaking ListItemIdentifications' item_id predicate ALONE was NOT caught by any test that -# existed before this task ran mutation testing -- List's pre-read only confirms the ITEM belongs -# to the caller's group, not which item's rows come back, so a same-tenant, cross-item leak (item -# A's list showing item A2's rows too) slipped through silently. TestIdentificationListExcludesOtherItemsRowsInTheSameGroup -# was added specifically to close this gap and fails cleanly under the mutation (item A2's row -# appears in item A's list) and passes clean once restored. -# - Breaking UpdateItemIdentification's group_id predicate ALONE is masked by its own pre-read, -# the identical T054 UpdateItemPurchase shape: the pre-read (unmutated) already refuses a -# foreign group before the mutated UPDATE runs. Breaking the pre-read's group_id too lands the -# write for real -- TestIdentificationUpdateRejectsAnotherGroupsRow then observes a nil error -# (the cross-group write succeeded). -# - Breaking UpdateItemIdentification's item_id predicate ALONE is masked the same way: the -# pre-read's own item_id predicate (intact) already refuses a wrong-item id before the mutated -# UPDATE runs. Breaking the pre-read's item_id too lands the write for real -- -# TestIdentificationUpdateRejectsAnotherItemsRowInTheSameGroup then observes a nil error (item -# A2 named in the URL for item A's real row succeeded). -# - Breaking DeleteItemIdentification's group_id predicate ALONE fires directly (no pre-read in -# front of Delete): the cross-tenant DELETE probe answers 204, and group_a_resources_untouched -# then finds household A's identification row GONE. -# - Breaking DeleteItemIdentification's item_id predicate ALONE -- the mutation this task's -# delegation prompt calls out as mattering most -- ALSO fires directly, with no masking: calling -# Delete(itemA2, itemA's real row id) inside group A succeeds (nil error) instead of answering -# ErrNotFound. Every cross-GROUP test in this suite, including identifications_delete and -# group_a_resources_untouched, stays green under this mutation -- the tenant-isolation HTTP -# suite is provably blind to it. TestIdentificationDeleteRejectsAnotherItemsRowInTheSameGroup is -# the ONLY test, anywhere in this task's suite, that fails. -# - Swapping problem.NotFound() for problem.Forbidden() on identificationUpdateHandler's -# storage.ErrNotFound branch fires immediately: TestTenantIsolation/(cookie|bearer)/identifications_update -# fails with the harness's dedicated existence-oracle message ("A 403 CONFIRMS the resource -# exists..."), and internal/httpapi's own TestIdentificationHandlersTranslateStorageErrors/ -# update:_not_found_is_404 catches it too, independent of the integration suite. -# - Stripping items.CreateIdentification's kind validation and posting an unrecognised kind -# through the real router against a real on-disk SQLite database answers 500 (`internal server -# error`), confirmed with a scratch integration test deleted after observing the result -- the -# exact CHECK-constraint failure mode ValidateIdentificationKind exists to prevent, in place of a -# 400. internal/items's own TestCreateIdentificationRejectsAnInvalidKind pins the same property -# against a fake repository, independent of a real database. -# -# STATUS AS OF T057 -# -# T057 mounts FR-017's group-level custom-field definitions (GET/POST /custom-field-defs, PUT/DELETE -# /custom-field-defs/{fieldDefID}) and covers all four here, in the task that mounted them, on the -# T048/T051-T056 precedent. Coverage now stands at 38 routes: 4 exempt, 34 covered, 0 pending. -# Counted with `go -C server run ./cmd/hho routes | wc -l` (38) against -# `grep -E '^(GET|POST|PUT|DELETE|PATCH) '` over this file (38 lines, matching -- a naive `grep -c` -# over the whole file would double-count, since the header prose above names every status token by -# example). -# -# This is the FIRST group-scoped 0..N collection with routes -- neither an item sub-resource -# (every route above, including T056's identifications) nor a group singleton with no id at all -# (PUT /groups/detail-visibility). GET is therefore the FORMAT section's SECOND assertion shape -# (collection-absence, like GET /items -- "another group's rows must be absent from the caller's -# own list"), not the cross-group-404 shape every /items/{itemID}/... route above uses. POST is the -# THIRD shape (id-less create, like POST /invites and POST /items): it names no existing resource -# in its path at all, unlike T056's POST /items/{itemID}/identifications, which named a parent item -# and was therefore the FIRST shape -- this is the genuine difference the delegation prompt asked -# this task to notice rather than copy T056's choice by reflex. PUT/DELETE ARE the first, -# cross-group-404 shape, addressed by {fieldDefID} ALONE -- one wildcard, not identifications' two, -# because this resource has no parent narrower than the group itself -# (storage.CustomFieldDefRepository's own doc, "Why this is a 0..N collection with a TWO-part -# address, unlike IdentificationRepository's three"). -# -# The genuinely NEW property this task adds: PUT/DELETE are OWNER-GATED (middleware.RequireOwner, -# A101.1, docs/sdlc/hho-ecosystem/assumptions.md), the polar opposite of identifications' member- -# writable posture despite both being 0..N collections -- see custom_field_defs.go's file doc, -# "Who may write". GET is NOT owner-gated, for the identical reason GET /groups/detail-visibility -# is not: every member needs the current definitions to render an item's data-entry form. Every -# PUT/DELETE probe in the credential loop is therefore ALSO evidence the tenant boundary answers -# independently of the role gate: household B's registering user IS an owner of its own group (the -# same fact invites_list/groups_visibility_update already rely on), so a 403 on either probe would -# be the WRONG failure and [crossTenantFixture.assertCrossGroupNotFoundWithBody] fails loudly on -# exactly that status. -# -# Unlike identifications, this table has NO same-tenant, cross-PARENT hazard for the storage layer -# to prove separately -- storage.CustomFieldDefRepository's own doc states why: there is no second -# parent identifier anywhere in that interface, so the only boundary a caller can attempt to cross -# is the ordinary cross-GROUP one this suite already exercises directly, and every subtest below is -# therefore a complete claim on its own (no companion storage-layer same-group test the way -# TestIdentificationDeleteRejectsAnotherItemsRowInTheSameGroup was for T056). -# -# What the mutation testing established (every mutation applied to the SQL/Go source, regenerated -# with the pinned sqlc where applicable, and restored by explicit edit, each restore verified with -# md5sum against a pre-mutation snapshot): -# - Breaking DeleteCustomFieldDef's group_id predicate ALONE fires directly (no pre-read in front -# of Delete): the cross-tenant DELETE probe answers 204 and group_a_resources_untouched then -# finds household A's definition GONE. storage/custom_field_defs_test.go's -# TestCustomFieldDefDeleteRejectsAnotherGroupsRow fails on its own under this mutation too. -# - Breaking DeleteCustomFieldDef's id predicate ALONE -- the T056 hole, restated on a table with -# only TWO predicates instead of three -- is NOT masked (there is no pre-read), and fires with a -# genuinely different shape than T056's did: this repository's guard checks `rows != 1`, not -# T056's `rows == 0` (storage/custom_field_defs.go's Delete doc explains the choice), so a -# cross-tenant attempt naming A's id, scoped correctly to B's OWN group_id, matches every LIVE -# row B's group has -- with B holding exactly one, that is `rows == 1`, so the guard is -# satisfied and the call reports success. Observed exactly that: household B's DELETE against -# A's real id answered 204 (`carol (cookie) DELETE /api/v1/custom-field-defs/{id} -> 204; want -# 404`), and B's OWN definition was gone on the very next subtest (`carol's custom-field-def -# list = [], want exactly carol's own definition {id}`) -- while household A's row was -# completely UNTOUCHED (group_a_resources_untouched passed clean under this single mutation). -# This is NOT a cross-tenant leak -- the group_id predicate held -- it is a same-tenant -# self-inflicted wipe: a caller naming ANY id, including a foreign one that names nothing in -# their own group, tombstones every row their OWN group has. storage/custom_field_defs_test.go's -# TestCustomFieldDefDeleteRemovesExactlyOneRow (three rows, one group, one named for deletion) -# is the one test anywhere in this suite that isolates this from the coincidence of B holding -# exactly one row: under the mutation it fails with `matched 3 rows, want 1` (the `rows != 1` -# guard surfacing the corruption as a loud storage error rather than accepting it silently the -# way T056's `rows == 0` guard would have) -- see that test's own doc for the full argument. -# - Breaking GetCustomFieldDef's group_id predicate ALONE fires directly on Get -# (TestCustomFieldDefGetRejectsAnotherGroupsRow) and, because UpdateCustomFieldDef's -# read-then-guarded-write opens with the SAME statement, also leaks through the pre-read on -# Update -- but the actual UPDATE (its own, unmutated group_id predicate intact) then matches 0 -# rows against the pre-read's wrongly-scoped version, caught by the "matched 0 rows, want 1" -# invariant and surfacing as a 500 (`carol (cookie) PUT /custom-field-defs/{id} -> 500`) rather -# than a silent cross-tenant write -- the identical T054 UpdateItemPurchase masking shape, -# restated here. TestCustomFieldDefUpdateRejectsAnotherGroupsRow fails under this single -# mutation too (a different failure message: "matched 0 rows, want 1 ... want ErrNotFound"). -# - Breaking UpdateCustomFieldDef's OWN group_id predicate ALONE does NOT fire: the pre-read -# (GetCustomFieldDef, unmutated) already answers ErrNotFound for a foreign group before the -# mutated UPDATE ever runs -- masked, not dead. Breaking the pre-read's group_id too (the -# double-mutation proof) lands the write for real: TestCustomFieldDefUpdateRejectsAnotherGroupsRow -# then observes a nil error, confirming the UPDATE's own predicate is load-bearing. -# - Breaking ListCustomFieldDefs' group_id predicate ALONE fires directly -- unlike -# ListItemIdentifications there is NO pre-read in front of List at all (there is no parent -# resource narrower than the group to pre-check), so this is the single, directly observable -# enforcement on the collection route: group A's definition appears in group B's own GET -# /custom-field-defs response (`carol's own custom-field-def list contains alice's def id -# ... -- cross-tenant leak`), and storage/custom_field_defs_test.go's -# TestCustomFieldDefListExcludesAnotherGroupsRows fails on its own under the identical mutation. -# - Removing CreateCustomFieldDef's group_id VALUES column entirely does not merely fail a test -- -# it does not COMPILE: `unknown field GroupID in struct literal of type -# gen.CreateCustomFieldDefParams`, confirming the structural claim this route's own coverage -# line below rests on (the identical property TestUnscopedConstructionDoesNotCompile, T030, -# proves for POST /invites and POST /items). -# - Swapping problem.NotFound() for problem.Forbidden() on customFieldDefUpdateHandler's -# storage.ErrNotFound branch fires immediately: TestTenantIsolation/(cookie|bearer)/custom_field_defs_update -# fails with the harness's dedicated existence-oracle message ("A 403 CONFIRMS the resource -# exists..."), and internal/httpapi's own TestCustomFieldDefHandlersTranslateStorageErrors/ -# update:_not_found_is_404 catches it too (`status = 403, want 404`), independent of the -# integration suite. -# - Removing middleware.RequireOwner from the /custom-field-defs write sub-group fires -# immediately: TestOwnerOnlyRoutesRejectMembers's three new cases (POST, PUT, DELETE) all fail -# (400/400/500 instead of 403 -- the fake scope's nil CustomFieldDefs() repository is what turns -# the reached DELETE into a 500 rather than a cleaner failure, itself confirming the handler now -# runs). A scratch integration test against a REAL invite-redeemed member (not the registering -# owner) confirmed the stronger claim directly: a genuine member's POST /custom-field-defs -# completes a real 201 (`{"id":"...","name":"member's field",...}`) with the wrap removed. The -# scratch test was deleted after confirming the result; it is not part of this task's shipped -# suite. -# - Stripping customfields.CreateCustomFieldDef's field_type validation and posting an -# unrecognised field_type through the real router against a real on-disk SQLite database -# answers 500 (`internal server error`), confirmed with a scratch integration test deleted -# after observing the result -- the exact CHECK-constraint failure mode ValidateFieldType -# exists to prevent, in place of a 400. internal/customfields's own -# TestCreateCustomFieldDefRejectsAnInvalidFieldType pins the same property against a fake -# repository, independent of a real database. -# -# STATUS AS OF T058 -# -# T058 mounts FR-016's item custom-field VALUES (GET/POST /items/{itemID}/custom-fields, PUT/DELETE -# /items/{itemID}/custom-fields/{customFieldID}) and covers all four here, in the task that mounted -# them. Coverage now stands at 42 routes: 4 exempt, 38 covered, 0 pending. Counted with -# `go -C server run ./cmd/hho routes | wc -l` (42) against `grep -E '^(GET|POST|PUT|DELETE|PATCH) '` -# over this file (42 lines, matching -- a naive `grep -c` over the whole file would double-count, -# since the header prose above names every status token by example, exactly as the T057 note warns). -# -# This is identifications' (T056) own TWIN, not custom_field_defs' (T057): a SECOND 0..N child -# collection under an item, GET/POST addressed by the parent item's id alone (the cross-group-404 -# shape), PUT/DELETE additionally naming the ROW's own id -- the identical second wildcard -# identifications' PUT/DELETE have, and the identical same-tenant, cross-item hazard this HTTP -# suite structurally cannot exercise (every probe here authenticates as a second, independent -# GROUP, never as a second item within one group). That hazard is proven instead by -# storage/item_custom_fields_test.go's TestItemCustomFieldUpdateRejectsAnotherItemsRowInTheSameGroup -# and TestItemCustomFieldDeleteRejectsAnotherItemsRowInTheSameGroup. -# -# The genuinely NEW property this task adds, and the reason it needed its own dispatch rather than -# copying T056's file wholesale: unlike EVERY typed field this schema has validated so far (kind, -# field_type), "exactly one of the four value columns is populated, matching field_type" carries NO -# CHECK-constraint backstop at all -- migration 0001's own schema comment states this is -# "deliberately NOT a CHECK constraint: data-model.md scopes that rule to the service layer." -# items.CreateItemCustomField/UpdateItemCustomField (internal/items/custom_fields.go) are the WHOLE -# of that enforcement, and this HTTP suite cannot exercise the STRIPPED-invariant case at all: every -# probe here sends a validly-shaped body (a single text_value, matching field_type "text") on -# purpose, because a malformed one would answer 400 before the tenant boundary was ever reached and -# prove nothing about scoping. That property, and what a caller gets when the invariant is stripped -# (a 201 and a SILENTLY ACCEPTED CORRUPT ROW -- confirmed live against a real on-disk SQLite database -# via a scratch test, deleted after observing the result, and NOT a 500 the way every other stripped -# CHECK-backed validation in this schema answers), is proven in internal/items/custom_fields_test.go -# and storage/item_custom_fields_test.go's -# TestItemCustomFieldCreateAcceptsAnyColumnCombinationAtTheStorageLayer, never here. -# -# The second genuinely NEW property: A103.2 (docs/sdlc/hho-ecosystem/assumptions.md) requires a row -# naming a field_def_id to read that definition (through the SAME bound storage.Scope's -# CustomFieldDefRepository, never a fresh unscoped query) and reject a contradicting field_type -# BEFORE the write. Stripping that check and posting a row typed "number" against a real "date" -# definition answers 201 with the mismatched row landed -- confirmed live against a real database -# (a scratch test created a "date" definition, then posted `{"field_def_id":"...","field_type": -# "number","number_value":42}` against it; the real router answered 201 with `"field_type":"number"` -# stored verbatim). This suite never exercises field_def_id at all (every provisioned row is ad -# hoc); the property is proven in internal/items/custom_fields_test.go's -# TestCreateItemCustomFieldRejectsAFieldTypeContradictingTheDefinition/ -# TestUpdateItemCustomFieldRejectsAFieldTypeContradictingTheDefinition and at the wire in -# internal/httpapi's TestItemCustomFieldCreateWithFieldDefIDReadsTheDefinitionAndRejectsAMismatch. -# A104 (docs/sdlc/hho-ecosystem/assumptions.md) records the companion decision this task made -# without a stated answer: field_def_id resolving to a SOFT-DELETED definition -# (GetCustomFieldDef's own `deleted_at IS NULL` filter) is treated identically to an unknown one -- -# items.ErrCustomFieldDefNotFound, a 400, never a 404 (field_def_id is a body field, not the path's -# resource id). -# -# Every route here is MEMBER-writable (router.go mounts it on the general authenticated group, no -# middleware.RequireOwner) -- the OPPOSITE of /custom-field-defs' owner-gated PUT/DELETE, despite -# both being 0..N collections (items/custom_fields.go's file doc, "Who may write": a VALUE is item -# data, a DEFINITION is a group setting, FR-007/A101.1). Household B's registering user is an owner -# of its own group throughout this suite, so a 403 on any of these four routes would be -# INDISTINGUISHABLE from a correctly-answered 404 to every probe here -- this suite cannot observe -# the member-writable claim at all, the identical gap T057's own fan-in found on custom_field_defs' -# READ side (the orchestrator's mutation there: moving GET /custom-field-defs into the owner-gated -# group left the full unit suite, the full tenant suite, `hho routes` and this file's own crosscheck -# all green while a real member got 403). Learning applied here BEFORE dispatch rather than found -# after: internal/httpapi's own TestItemCustomFieldCreateIsWritableByAMember authenticates as a -# genuine, non-owner member via identityByHeaderAuth and asserts POST answers 201, not 403 -- the -# assertion that closes the gap T057 found, proactively, for this task's own member-writable claim. -# -# What the mutation testing established (every mutation applied to the SQL/Go source, regenerated -# with the pinned sqlc where applicable, and restored by explicit edit, each restore verified with -# md5sum against a pre-mutation snapshot): -# - Breaking DeleteItemCustomField's group_id predicate ALONE fires directly (no pre-read in -# front of Delete): the cross-tenant DELETE probe answers 204 and group_a_resources_untouched -# then finds household A's row GONE (`carol (cookie) DELETE .../custom-fields/{id} -> 204; want -# 404`). storage/item_custom_fields_test.go's TestItemCustomFieldDeleteRejectsAnotherGroupsRow -# fails on its own under this mutation too. -# - Breaking DeleteItemCustomField's item_id predicate ALONE (the same-tenant, cross-item hazard) -# is NOT observable by this HTTP suite at all -- every cross-group probe stays green, exactly as -# T056's own identical mutation on DeleteItemIdentification was. It IS observable at the storage -# layer: TestItemCustomFieldDeleteRejectsAnotherItemsRowInTheSameGroup fails -# (`groupA Delete naming itemA2 for itemA's row = , want ErrNotFound`). -# - Breaking DeleteItemCustomField's id predicate ALONE -- the T056/T057 hole, restated on a -# table with THREE predicates ahead of the match (group_id, item_id, id), matching -# identifications' own count rather than custom_field_defs' two -- is NOT observable by this -# HTTP suite either: household B's DELETE, correctly scoped to its own group_id AND item_id, -# matches every LIVE row on B's OWN item (B holds exactly one, provisionTenant mints no second), -# so the `rows == 0` guard (this repository's choice, matching identificationRepository.Delete's -# rather than customFieldDefRepository.Delete's `rows != 1` -- see storage/item_custom_fields.go's -# Delete doc for why three predicates bounds the blast radius to one parent item rather than -# every row the group has) is satisfied and reports success. storage/item_custom_fields_test.go's -# TestItemCustomFieldDeleteRemovesExactlyOneRow (three rows on ONE item, the minimum that -# distinguishes "deleted the row I named" from "deleted every row on this item") is the one test -# anywhere in this suite that isolates this from B's single-row coincidence, and fails with -# `deleting cf-1 left 0 of 3 custom fields, want 2 -- the delete matched more than the row it -# named` under the identical mutation. -# - Breaking ListItemCustomFields' group_id predicate ALONE does NOT fire: List's own pre-read -# (GetItem, on the UNRELATED `items` table, scoped by group_id+item_id) already answers -# ErrNotFound for a foreign group before the mutated ListItemCustomFields statement is ever -# reached -- masked, not dead, the identical shape ListItemIdentifications' own group_id -# predicate has (STATUS AS OF T056 above). -# - Breaking ListItemCustomFields' item_id predicate ALONE fires directly (nothing else in the -# same statement re-checks it): itemA2's row leaks into itemA's list at the storage layer -# (TestItemCustomFieldListExcludesOtherItemsRowsInTheSameGroup fails, `want exactly one row ... -# itemA2's row leaked into itemA's list`) -- but this HTTP suite cannot observe it at all -# (every household here owns exactly one item), so TestTenantIsolation stays green under the -# identical mutation. -# - Breaking UpdateItemCustomField's OWN group_id predicate ALONE does NOT fire: the pre-read -# (GetItemCustomField, unmutated) already answers ErrNotFound for a foreign group before the -# mutated UPDATE ever runs -- masked, not dead. Breaking the pre-read's group_id too (the -# double-mutation proof) lands the write for real: TestItemCustomFieldUpdateRejectsAnotherGroupsRow -# then observes a nil error (`groupB Update of groupA's row = , want ErrNotFound`), and the -# live HTTP probe under this double mutation shows the same thing from the other side: the cookie -# credential's PUT answers 200 with the pwned text_value landed -# (`{"...","text_value":"pwned by carol",...,"version":2}`), and the bearer credential's -# identical PUT -- run second, against the version the cookie run had already bumped -- answers -# 409 instead of the 200 the cookie run got, which is ALSO evidence the write reached the UPDATE -# rather than evidence the boundary held (a 409 confirms the row exists and was just written to). -# - Breaking UpdateItemCustomField's OWN item_id predicate ALONE, identically, does NOT fire: -# TestItemCustomFieldUpdateRejectsAnotherItemsRowInTheSameGroup's own pre-read (GetItemCustomField, -# scoped by the SAME item_id the test names wrong) already answers ErrNotFound before the -# mutated UPDATE runs. Breaking GetItemCustomField's item_id too (double mutation) lands the -# write for real: the test then fails with `groupA Update naming itemA2 for itemA's row = , -# want ErrNotFound (never ErrVersionMismatch)`. -# - Stripping items.validateCustomFieldValueColumns (short-circuited to `return nil`) and posting -# `{"name":"Weight","field_type":"number"}` -- ZERO value columns populated -- through the real -# router against a real on-disk SQLite database answers 201, not 400, and the row that lands -# carries `field_type:"number"` with text_value/number_value/bool_value/date_value all NULL, -# confirmed by a GET readback showing the field with none of the four value keys present in the -# JSON at all. This is the ONE mutation in this whole task -- and, per this task's own delegation -# prompt, in this entire feature area D1.5 -- whose expected observation is NOT a 500: every -# other stripped validation in this schema (kind, field_type, name) is backed by a CHECK -# constraint that turns an unvalidated write into an opaque database fault; this column -# combination has no such backstop, so the mutated write simply succeeds. Scratch tests (one -# against a fake repository in internal/httpapi, one against a real database in -# tests/integration) were deleted after observing the result; they are not part of this task's -# shipped suite. internal/items/custom_fields_test.go's -# TestCreateItemCustomFieldRejectsZeroValueColumns/RejectsMultipleValueColumns/ -# RejectsAMismatchedValueColumn and storage/item_custom_fields_test.go's -# TestItemCustomFieldCreateAcceptsAnyColumnCombinationAtTheStorageLayer are what actually pin -# this property, independent of the integration suite. -# - Stripping resolveFieldDef's A103.2 field_type-match check (the `def.FieldType != fieldType` -# branch) and posting a row typed "number" against a real, freshly-created "date" definition -# through the real router against a real on-disk SQLite database answers 201 with the -# contradicting row landed (`{"field_def_id":"...","field_type":"number","number_value":42,...}`), -# confirmed live. A scratch integration test was deleted after observing the result. -# internal/items/custom_fields_test.go's -# TestCreateItemCustomFieldRejectsAFieldTypeContradictingTheDefinition/ -# TestUpdateItemCustomFieldRejectsAFieldTypeContradictingTheDefinition pin the same property -# against fake repositories, independent of a real database. -# - Removing item_custom_fields.go's SECOND schema-level FOREIGN KEY guard is not a mutation this -# task applied (there is no Go-level check to strip; the guard is migration 0001's own composite -# FOREIGN KEY (group_id, field_def_id) REFERENCES custom_field_defs (group_id, id)) -- instead, -# storage/item_custom_fields_test.go's -# TestItemCustomFieldCreateRejectsFieldDefFromAnotherGroupAtTheForeignKey confirms the guard -# itself fires (a raw constraint failure, not ErrNotFound) when group B names group A's -# definition id directly against the repository, bypassing package items' own A103.2 pre-read -# entirely -- the belt-and-braces half of the claim CreateItemCustomField's own SQL doc makes. -# - Swapping problem.NotFound() for problem.Forbidden() on itemCustomFieldUpdateHandler's -# storage.ErrNotFound branch fires immediately: TestTenantIsolation/(cookie|bearer)/item_custom_fields_update -# fails with the harness's dedicated existence-oracle message ("A 403 CONFIRMS the resource -# exists..."), and internal/httpapi's own TestItemCustomFieldHandlersTranslateStorageErrors/ -# update:_not_found_is_404 catches it too (`status = 403, want 404`), independent of the -# integration suite. -# - Removing middleware.RequireOwner is not a mutation this task applied -- these four routes are -# NOT owner-gated in the first place (that is the point). The inverse check -- -# TestItemCustomFieldCreateIsWritableByAMember, a genuine member's POST answering 201 -- is -# covered above under "Who may write." -# -# STATUS AS OF T059 -# -# T059 mounts FR-018's stock quantity plus append-only stock-adjustment HISTORY (GET/POST -# /items/{itemID}/stock-adjustments) and covers both here, in the task that mounted them. Coverage -# now stands at 44 routes: 4 exempt, 40 covered, 0 pending. Counted with -# `go -C server run ./cmd/hho routes | wc -l` (44) against `grep -cE '^(GET|POST|PUT|DELETE|PATCH) '` -# over this file (44 lines, matching -- the same naive-`grep -c` trap the T057/T058 notes warn -# about, since the header prose above names every status token by example). -# -# This is identifications'/item-custom-fields' own shape, narrowed: GET/POST are addressed by the -# parent item's id alone (the cross-group-404 shape, identical to both of those tables' own -# GET/POST), but there is NO PUT/DELETE at all, and therefore no SECOND wildcard and no -# same-tenant, cross-item hazard on a row's own id for either of those two routes to guard -- -# FR-018's history is APPEND-ONLY by design (data-model.md: "no update or delete path in the -# service layer once written -- a correction is a new adjustment row, not an edit"), so this table -# has only List/Create at all; see [storage.StockAdjustmentRepository]'s own doc, "Shape: narrower -# than every other 0..N child collection in this package." Create ALSO differs from every other -# create in this file addressed by a parent id: it takes no row id of its own to address again -# later (there is no PUT/DELETE address for one), and its own pre-read is scoped by BOTH group_id -# AND item_id together, so a caller can only ever adjust the EXACT item it names -- there is no -# same-tenant, cross-item hazard on Create the way List's own item_id predicate has, because Create -# has no second identifier for a caller to name wrong. -# -# List's item_id predicate still needs proving against that hazard's READ half (a broken item_id -# predicate leaking a DIFFERENT item's history into this item's list), and this HTTP suite cannot -# observe it at all for the identical structural reason identifications'/item-custom-fields' own -# STATUS sections give: every subtest here authenticates as a second, independent GROUP, never as a -# second item within the SAME group. storage/stock_adjustments_test.go's -# TestStockAdjustmentListExcludesOtherItemsRowsInTheSameGroup (a 3-row/2-row, two-item fixture -- -# the minimum that distinguishes "returned itemA's own rows" from "returned every row in the -# group," the identical carried-forward hazard T056's own verification found) was present in this -# task's own shipped suite from the start, not added afterward to close a gap mutation testing -# found. -# -# What the mutation testing established (every mutation applied to the SQL/Go source, regenerated -# with the pinned sqlc where applicable, and restored by explicit edit, each restore verified with -# `go build ./...`, `go vet ./...` and a clean `git status`/`git diff` against the pre-mutation tree -# after every restore): -# - Breaking ListStockAdjustments' OWN group_id predicate ALONE (neutralized to -# `sqlc.arg(group_id) = sqlc.arg(group_id)`, an always-true tautology that keeps the generated -# Params struct's GroupID field intact so the caller still compiles) does NOT fire: List's own -# pre-read (GetItem, on the UNRELATED `items` table, the SAME shared query -# identifications'/item-custom-fields' own List pre-reads use) already answers ErrNotFound for -# a foreign group before the mutated ListStockAdjustments statement is ever reached -- masked, -# not dead, the identical shape both of those tables' own List predicates have (STATUS AS OF -# T056/T058 above). Confirmed live: TestTenantIsolation (both stock_adjustments_list and -# stock_adjustments_create, every credential) and every test in storage/stock_adjustments_test.go, -# including TestStockAdjustmentListRejectsAnUnknownOrForeignItem, all stay green under this -# single mutation. -# - Breaking ListStockAdjustments' item_id predicate ALONE (the same neutralization, applied to -# `item_id = sqlc.arg(item_id)` instead) fires directly at the storage layer -- nothing else in -# the same statement re-checks it -- but is INVISIBLE to this HTTP suite, confirmed live both -# ways: storage/stock_adjustments_test.go's TestStockAdjustmentListExcludesOtherItemsRowsInTheSameGroup -# fails outright (`List(itemA) returned 5 rows, want exactly 3 -- itemA2's rows leaked in`, -# itemA2's two rows appearing in itemA's list), while TestTenantIsolation stays fully green -# under the IDENTICAL mutation (every household here owns exactly one item, so there is no -# second item in the SAME group for a cross-item leak to show up against) -- the exact gap -# GET .../custom-fields' own T058 line documents for ListItemCustomFields' item_id predicate, -# confirmed to hold here rather than assumed from that precedent. -# - Breaking AdjustItemQuantity's OWN group_id predicate ALONE (the identical always-true -# neutralization) does NOT fire: Create's own pre-read (GetItem, scoped group_id+item_id, -# opening the SAME transaction as the write) already answers ErrNotFound for a foreign group -# before AdjustItemQuantity is ever reached -- masked, not dead, the identical shape -# UpdateItemPurchase's/UpdateItemSale's own group_id predicates have (STATUS AS OF T053/T054). -# Confirmed live: TestTenantIsolation (stock_adjustments_create, every credential, PLUS -# group_a_resources_untouched) and every storage stock-adjustment test stay green under this -# single mutation. -# - Breaking the pre-read's OWN tenant check (Create's opening `if err != nil { return -# ErrNotFound }` branch on GetItem's sql.ErrNoRows, temporarily changed to swallow that error -# and proceed with a zero-valued item) AND AdjustItemQuantity's group_id predicate TOGETHER -- -# the double-mutation proof every masked-predicate line in this file uses to show the masked -# predicate is load-bearing rather than dead -- does NOT land a cross-tenant write either: it -# answers 500, not a silent success. Confirmed live: `carol (cookie) POST -# /api/v1/items/{itemA}/stock-adjustments -> 500`, and group_a_resources_untouched STILL passed -# clean immediately afterward (A's quantity and history both unchanged). The mechanism is -# CreateStockAdjustment's own composite FOREIGN KEY (group_id, item_id) REFERENCES items -# (group_id, id) (stock_adjustments.sql's own doc, "the identical composite FOREIGN KEY ... that -# makes a row attached to another tenant's item unrepresentable"): with the pre-read disabled, -# the transaction reaches CreateStockAdjustment's INSERT with group_id = B (the caller's own -# scope, always correct -- never attacker-controlled) and item_id = A's real item id, and no -# items row exists for THAT (group_id, item_id) pair, so the constraint itself rejects the -# INSERT and [binding.writeTx] rolls the whole transaction back before AdjustItemQuantity's own -# mutated statement ever runs -- the identical "existence oracle with extra steps" shape POST -# .../purchase's/.../sale's own coverage lines document for their explicit scoped GetItem guard, -# restated here with the schema's FOREIGN KEY as the final backstop underneath a disabled Go-level -# check rather than as a second explicit guard alongside an intact one. This is also this task's -# own confirmation of the "rule out the composite FOREIGN KEY's 500" claim POST -# .../stock-adjustments' own coverage line below makes for the UNMUTATED code: the live, -# unmutated stock_adjustments_create subtest answers 404, never 500, precisely because the intact -# pre-read stops the request before the INSERT the FOREIGN KEY would otherwise reject. -# -# STATUS AS OF T060 -# -# T060 mounts FR-019's item soft-delete (DELETE /items/{itemID}) and covers it here, in the task -# that mounted it. Coverage now stands at 45 routes: 4 exempt, 41 covered, 0 pending. Counted with -# `go -C server run ./cmd/hho routes | wc -l` (45) against `grep -cE '^(GET|POST|PUT|DELETE|PATCH) '` -# over this file (45 lines, matching -- the same naive-`grep -c` trap the T057/T058/T059 notes warn -# about, since the header prose above names every status token by example). -# -# This route is the cross-group-404 shape, addressed by the item's own id alone -- the identical -# shape items_get/items_update already use, and (like DeleteItemWarranty/DeleteItemSale/ -# DeleteItemPurchase/DeleteItemIdentification/DeleteItemCustomField above) has NO scoped pre-read in -# front of its own tombstone statement, so DeleteItem's own group_id predicate is directly, singly -# load-bearing. -# -# The genuinely new property this task adds is the CASCADE (A107, docs/sdlc/hho-ecosystem/ -# assumptions.md): a successful item delete tombstones the item's warranty/sale/purchase/ -# identifications/custom-fields too, inside the SAME transaction. The design question this task's -# own delegation prompt required resolving explicitly -- cascade or not -- was decided FOR cascading -# after reading every satellite repository's own Get/Update/Delete statement and finding NONE of -# them (unlike List/Create) ever check the parent item's own deleted_at: a "no cascade" item delete -# would have left every satellite block/row fully readable AND writable through its own route -# forever, a real leak this task's own storage tests (storage/items_delete_test.go) demonstrate by -# calling those same Get methods directly after an item Delete and requiring ErrNotFound. -# -# What the mutation testing established for THIS route specifically (every mutation applied to the -# SQL/Go source, regenerated with the pinned sqlc where applicable, and restored by explicit edit, -# each restore verified with `go build ./...`, `go vet ./...` and a clean `git status`/`git diff` -# against the pre-mutation tree after every restore): -# - Breaking DeleteItem's OWN group_id predicate ALONE (neutralized to -# `sqlc.arg(group_id) = sqlc.arg(group_id)`, an always-true tautology that keeps the generated -# Params struct's GroupID field intact so the caller still compiles), regenerated and re-run -# live, fires directly: TestTenantIsolation/cookie/items_delete answers 204 instead of 404 -# (`carol (cookie) DELETE /api/v1/items/{itemA} -> 204`), TestTenantIsolation/group_a_resources_untouched -# independently catches the same mutation re-reading A's own item (404 instead of 200), and -# storage/items_delete_test.go's TestItemDeleteRejectsAnotherGroupsItem fails on its own -# (`groupA deleting groupB's item = , want ErrNotFound`). Confirmed live, not assumed. -# - Breaking DeleteItem's OWN `id` predicate ALONE (the same neutralization, applied to -# `id = sqlc.arg(item_id)` instead) is a SAME-TENANT hazard this cross-group HTTP suite has no -# occasion to exercise -- household B here owns exactly one item, so a mutated `id` predicate -# that matches "every live item in B's own group" is indistinguishable from the correctly -# scoped statement to every probe in this file, and TestTenantIsolation stays fully green under -# this mutation. Proven instead at the storage layer: storage/items_delete_test.go's fixture -# seeds TWO items in the SAME group specifically so this is directly observable, and confirmed -# live -- every write-path test in that file fails loudly under the mutation -# (`matched 2 rows, want 1`, the itemRepository.Delete `rows != 1` guard firing), and a scratch -# assertion (not part of the shipped suite, used only to observe the mechanism) confirmed the -# SECOND item is left completely untouched afterward: db.Store.Tx rolls the whole transaction -# back on the guard's own returned error, so the multi-row UPDATE never actually commits, a -# stronger property than DeleteCustomFieldDef's own doc claims for its structurally identical -# `rows != 1` guard (see gen.Queries.DeleteItem's own SQL doc for the corrected claim, stated -# there rather than left to repeat DeleteCustomFieldDef's possibly-inaccurate one uncritically). -# - The cascade's own per-table statements (DeleteItemWarranty/DeleteItemSale/DeleteItemPurchase, -# reused unmodified from T052/T053/T054; CascadeDeleteItemIdentifications/ -# CascadeDeleteItemCustomFields, new to this task) are NOT independently mutation-tested against -# THIS route -- not measured live. They run only after DeleteItem's own group_id-scoped -# tombstone has already matched exactly one row in the caller's own group, using r.group() (the -# caller's own scope, never attacker-controlled) for every one of their own group_id arguments, -# so a foreign-group DELETE never reaches them at all: masked by DeleteItem's own gate, the -# identical shape UpdateItemPurchase's/UpdateItemSale's own group_id predicates have against -# their pre-read (STATUS AS OF T053/T054 above). The cascade's item_id scoping (a SAME-TENANT -# hazard, keeping a SIBLING item's own rows untouched) is proven instead at the storage layer -- -# storage/items_delete_test.go's TestItemDeleteCascadesToDetailBlocks and -# TestItemDeleteCascadesToIdentificationsAndCustomFields both use a two-item, multi-row fixture -# (itemA1 deleted; itemA2, with its OWN warranty/sale/purchase and 2 identifications/1 custom -# field, left untouched; itemA1 itself carries 3 identifications and 2 custom fields, never one) -# for the identical reason T056's own verification argues a single-row fixture cannot -# distinguish "the rows this delete cascaded to" from "every row the group has." One of the two -# cascade statements' own item_id predicates WAS independently broken and re-run live for this -# task (not merely assumed by analogy): neutralizing CascadeDeleteItemIdentifications' own -# item_id predicate ALONE (the same always-true-tautology mutation, regenerated) fires directly -# at the storage layer -- TestItemDeleteCascadesToIdentificationsAndCustomFields fails -# (`itemA2's identification id-a2-1 after itemA1's Delete = ... not found, want it still -# readable`, both of itemA2's own rows wrongly wiped out) -- while TestTenantIsolation stays -# fully green under the IDENTICAL mutation (household B here owns exactly one item, so there is -# no second item in the SAME group for this same-tenant leak to show up against), confirmed live -# both ways. CascadeDeleteItemCustomFields' own item_id predicate was SUBSEQUENTLY broken -# independently by the orchestrator at close-out, rather than left resting on its sibling's -# measurement and an identical statement shape -- the T056 precedent is that a cascade's blast -# radius is exactly the kind of claim that turns out untrue when nobody actually runs it. It -# fires: TestItemDeleteCascadesToIdentificationsAndCustomFields fails (`itemA2's custom field -# cf-a2-1 after itemA1's Delete = ... not found, want it still readable`), while -# TestTenantIsolation stays fully green under the identical mutation -- the same masked shape -# its sibling shows, now measured on BOTH tables rather than inferred across them. Note the -# blast radius this bounds is WIDER than T056's own: an unscoped cascade UPDATE here tombstones -# every custom field in the GROUP, not merely every row on one parent item. -# -# STATUS AS OF T061 -# -# T061 is D1.5's group-level closure (.orchestra/tasks/T061.md), not a new route: DELETE -# /api/v1/items/{itemID} was already `covered-by:` as of T060, and the route count is unchanged -- -# still 45 routes: 4 exempt, 41 covered, 0 pending. What T061 closes is the audit its own delegation -# prompt asked for: (1) re-derive D1.5's endpoint set from router.go and confirm every covered-by: -# line's named subtest still resolves under `go test -run` -- every one does (checked against -# TestTenantIsolation's actual t.Run tree; no stale reference found), and (2) T060's own genuine gap, -# restated below. -# -# The gap: T060's own STATUS section above proves the cross-group boundary against a LIVE household-A -# item throughout -- items_delete included, which only ever probes f.a.itemID while it is still -# alive. No task before T060 could construct the OTHER state a real client can now put an item into: -# a household tombstoning its own item for real, with B then asking for it (and its cascaded -# satellites) anyway. TestTenantIsolation never once asked "what does a foreign id answer once it is -# DEAD" until this task, because the DELETE route this needs did not exist before T060. -# -# items_delete_tombstone_cascade_cross_group, added to TestTenantIsolation by this task and nested -# inside the SAME cred loop as items_get/items_update/items_delete, closes it: doomedItem (built -# once per test run by provisionAndDeleteDoomedItem, a SECOND item for household A, never -# f.a.itemID) carries a real warranty/sale/purchase/identification/item-custom-field, is genuinely -# deleted by household A itself over the real router (so the cascade runs through httpapi, not only -# at the storage layer storage/items_delete_test.go already proves it at), and every one of those -# seven routes is then probed as household B and required to answer [wantCrossGroupStatus], never -# 403. Household A's stock-adjustment history is minted for doomedItem too and probed the same way, -# though storage.itemRepository.Delete's own doc explains it is the ONE satellite the cascade -# deliberately does NOT tombstone (List/Create's own pre-read already makes a dead item's history -# unreachable through its own route, cascade or not) -- included for the identical "this exact STATE -# was never constructible before this task" reason the cascaded five are, not because the cascade -# touches it. -# -# What this section is honest about NOT proving: the satellite Get/Update/Delete statements this -# covers (item_warranty.sql/item_sale.sql/item_purchase.sql/item_identifications.sql/ -# item_custom_fields.sql's own) each filter ONLY their own deleted_at, never the parent item's -- -# storage.itemRepository.Delete's own doc, "Why this cascades (A107)," names this asymmetry as -# EXACTLY what the cascade exists to close, and the delegation prompt asked this task to cover it. -# But that asymmetry was never a CROSS-group hole: group_id alone already keeps household B out of -# household A's rows whether the item is live or dead, so a broken cascade would change NOTHING this -# suite can observe -- a claim confirmed rather than assumed, by actually removing the -# CascadeDeleteItemIdentifications/CascadeDeleteItemCustomFields calls from storage.itemRepository.Delete -# (a scratch edit, reverted after observing the result, not part of this task's shipped diff): every -# subtest TestTenantIsolation runs, including the new items_delete_tombstone_cascade_cross_group and -# its own no_existence_oracle, stayed fully green -- household B could never see the cascade's -# absence because it was never able to see household A's satellites in the first place. The cascade's -# blast radius (does household A itself keep seeing its OTHER, untouched item's satellites after -# THIS item's delete) is a SAME-TENANT property this cross-group suite has no occasion to exercise at -# all, exactly as T060's own STATUS section states; storage/items_delete_test.go's -# TestItemDeleteCascadesToDetailBlocks and TestItemDeleteCascadesToIdentificationsAndCustomFields are -# what actually prove it, unchanged by this task. -# -# no_existence_oracle, nested inside items_delete_tombstone_cascade_cross_group, is the STRONGER half -# of this task's own delegation prompt ("group B must not be able to distinguish 'group A's item was -# tombstoned' from 'never existed'"): not merely that doomedItem's tombstoned id and -# neverExistedItemID (a well-formed id string this suite's own process never minted) both answer 404 -# -- every other probe in this file already settles for that weaker property, named explicitly in -# this suite's own doc above [wantCrossGroupStatus] -- but that the two [problem.Problem] bodies are -# BYTE-FOR-BYTE IDENTICAL once request_id (which legitimately differs per request) is cleared from -# both. This is expected to hold STRUCTURALLY, not by coincidence: [problem.NotFound] takes no -# arguments at all (its own doc: "a constructor with no parameters is the only version of it that a -# future handler cannot helpfully undermine by passing in the id it was looking for"), so there is no -# call site anywhere on this path that COULD write a distinguishing detail into the body even by -# accident, so mutation-testing a specific predicate the way every OTHER line in this file does has -# nothing to target: there is no group_id/item_id/deleted_at guard standing in front of this claim, -# because there is no per-route branch here at all -- every 404 in this API is the SAME fixed value. -# What was confirmed live instead (a scratch edit to [problem.NotFound] itself, reverted by explicit -# restore, verified byte-identical against the pre-mutation file with md5sum, and NOT part of this -# task's shipped diff) is that no_existence_oracle is not vacuously green: temporarily changing -# NotFound() to `New(...).WithDetail(fmt.Sprintf("MUTATION-TEST %d", time.Now().UnixNano()))` -- -# a single shared call site, so EVERY 404 this API answers, not merely this route's -- made -# no_existence_oracle fail immediately and loudly under BOTH credential kinds (`GET a TOMBSTONED -# item = {...Detail:MUTATION-TEST 1788380679849208346...}, GET an id that was NEVER minted = -# {...Detail:MUTATION-TEST 1788380679849287354...} ... these must be byte-for-byte identical`), -# while restoring the file put the whole suite back to green. This is also a genuinely different -# CLASS of regression than every predicate mutation elsewhere in this file targets: not a boundary -# that leaked a real value, but a per-RESPONSE nondeterminism (a timestamp) that would make every -# 404 in this API distinguishable from every other one it should be identical to -- a subtler -# existence-oracle shape (timing/uniqueness rather than content) this task's own delegation prompt's -# "does the cascade open an existence oracle" question covers, and one no OTHER assertion in this -# file is positioned to catch, because every other 404 comparison in this suite checks a STATUS code -# only, never the BODY against a second, independently-obtained 404. -# -# STATUS AS OF T065 -# -# T065 mounts D1.6's location hierarchy (/locations, FR-030): a flat, self-referencing 0..N group -# collection, shaped like /custom-field-defs (no {itemID} anywhere in this path) but member-writable -# like /items and its own item sub-resources (router.go's own file doc, "Who may write" -- a -# location is a place a household stores its possessions, item data, not a group-wide admin -# setting), and NEW to this task: one row may reference ANOTHER row of the SAME collection as its -# parent, so a move must not create a cycle. Five routes, five lines below, all closed in the SAME -# task that mounted them (T048's own precedent). Route count: 50 routes total, 4 exempt, 46 covered, -# 0 pending. -# -# All three assertion shapes appear at once, the identical spread /custom-field-defs' own STATUS -# section documents: GET (list) is the collection-absence shape (locations_list); GET/PUT/DELETE by -# {locationID} are the cross-group-404 shape (locations_get/locations_update/locations_delete); POST -# is the id-less structural shape (locations_create_readback), because locationCreateHandler reaches -# storage only through middleware.Scoped's storage.Scope, and storage.LocationRepository declares no -# method taking a group identifier at all (storage/locations.go) -- P-3's group-bound repositories -# (T026) enforce that at compile time, the identical TestUnscopedConstructionDoesNotCompile argument -# every other id-less create route's own line cites. -# -# Unlike every task before it (T047-T061), T065 ALSO adds this package's own httpapi-layer unit -# suite (internal/httpapi/locations_test.go) in the SAME task that mounted the routes, rather than -# leaving it to a later task the way T059's own STATUS section notes stock-adjustments did not need -# to: TestLocationRoutesAreMounted pins all five registrations and the {locationID} wildcard -# forwarding; TestLocationCreateIsWritableByAMember is the member-writable proof (a genuine, -# non-owner member's POST answers 201, not 403 -- household B's registering user is an owner -# throughout TestTenantIsolation, so that suite cannot observe this property at all, the identical -# gap TestItemCustomFieldCreateIsWritableByAMember/TestStockAdjustmentCreateIsWritableByAMember -# close for their own routes); and TestLocationHandlersTranslateStorageErrors is the full -# status-mapping table for this file's TWO tree-specific errors -- storage.ErrLocationParentNotFound -# and storage.ErrLocationCycle BOTH answer 400, proven directly rather than only by cross-group -# 404 probes that never exercise the tree logic at all (a cross-group PUT/DELETE probe's body is -# deliberately parent_id-free, precisely so it cannot ALSO be rejected by ErrLocationParentNotFound -# and mask the 404 under test -- see locations_update's own coverage line below). -# -# MUTATION TESTING (T065's own delegation prompt requirement) -- three predicates broken live, one -# at a time, each reverted immediately after observing the result (diffed byte-identical against the -# pre-mutation file afterward; none of this is part of the shipped diff): -# -# 1. DeleteLocation's group_id predicate, neutralized to `WHERE group_id = group_id` (an -# always-true tautology, id/deleted_at left intact): fires DIRECTLY and TWICE -- -# storage/locations_test.go's TestLocationDeleteRejectsAnotherGroupsLocation -# (`err = , want ErrNotFound`) and, independently, TestTenantIsolation/cookie/ -# locations_delete (`DELETE /api/v1/locations/{id} -> 204; want 404`) followed by -# group_a_resources_untouched (`alice's location ... is gone ... the tombstone landed -# anyway`) -- the identical two-catch shape custom_field_defs_delete's own coverage line -# documents, for the identical reason: DeleteLocation has no pre-read in front of it, so this -# predicate is the ONLY thing enforcing the boundary. -# 2. locationMoveCreatesCycle, neutralized to unconditionally `return false, nil` (no cycle ever -# detected): fires DIRECTLY on all three of storage/locations_test.go's own cycle tests -- -# TestLocationMoveRejectsSelfParent, TestLocationMoveRejectsDeepCycle (A -> B -> C, moving A -# under its own grandchild C) and TestLocationMoveRejectsCycleThroughATombstonedNode (a cycle -# closing through a dead node in the chain) -- this is the depth->=3 proof T065's own -# delegation prompt asked for, confirmed live rather than merely written down: a bare -# self-parent check alone would have caught only the first of these three. -# 3. locationRepository.Create's own pre-check (the scoped GetLocation on a non-empty ParentID, -# BEFORE the INSERT), removed entirely, leaving the schema's composite FOREIGN KEY (group_id, -# parent_id) REFERENCES locations (group_id, id) as the ONLY remaining guard: this is the "is -# your own check or the FK what actually stops it" question T065's own delegation prompt asks -# explicitly, and the answer is NOT the same for every case the pre-check covers. Two of three -# storage/locations_test.go cases stay GREEN under this mutation -- -# TestLocationCreateRejectsUnknownParent and TestLocationCreateRejectsAnotherGroupsParent both -# still pass, because the FK independently rejects a dangling or cross-group id and this -# repository's own INSERT branch translates that raw constraint violation into the SAME -# storage.ErrLocationParentNotFound the pre-check would have returned (the isForeignKeyConstraintViolation -# branch, confirmed live rather than assumed from ErrLocationParentNotFound's own doc) -- so -# for THOSE two properties the pre-check is genuinely redundant belt-and-braces, not the load- -# bearing mechanism. But TestLocationCreateRejectsTombstonedParent FAILS under this same -# mutation (`Create under a tombstoned parent: err = , want ErrLocationParentNotFound`): -# the composite FK has NO `deleted_at` clause at all (it cannot -- a FOREIGN KEY constrains -# column values, not soft-delete state), so a parent_id naming a tombstoned-but-still-physically- -# -present row satisfies the FK just fine. The pre-check is therefore the ONLY guard against a -# tombstoned parent specifically -- a distinction the "which guard actually enforces this" -# framing in ErrLocationParentNotFound's own doc understates by grouping "unknown, tombstoned, -# or cross-group" into one collapsed answer: the FK backstop covers two of those three cases, -# not all three, and this mutation is what makes that visible rather than assumed. -# -# DISCLOSURE: no HTTP-level probe in this file (or in TestTenantIsolation) exercises the tree logic -# itself -- cycle rejection, cross-group parent rejection, or the tombstoned-parent case above -- at -# the wire. locations_update's own cross-group-404 probe below sends a parent_id-free body -# specifically so it stays on the tenant-boundary question the FORMAT section defines; the tree -# properties are proven exclusively at the storage layer (storage/locations_test.go, cited by name -# in that line below) and, for the two tree-specific status codes, at the httpapi unit layer -# (TestLocationHandlersTranslateStorageErrors, this task). This mirrors D1.5's own precedent for -# non-tenant properties throughout this file (e.g. stock_adjustments' body-validation rules are -# proven by internal/httpapi's own unit suite, never re-proven here) and is the SAME judgment call -# this task's own delegation prompt explicitly permitted ("whether the extensive storage-layer -# cross-group tests already suffice ... use your judgement, and if you leave a gap, disclose it -# plainly"). -# -# A108 (T065's own delegation prompt names this decision by number): what a tombstoned parent means -# for its own live children. DECIDED as plain soft-delete only, NO cascade -- a tombstoned -# location's live children keep their parent_id pointing at it and remain fully live/readable. -# Reasoning and cost stated in full in storage/locations.go's locationRepository.Delete doc ("What a -# tombstoned parent means for its live children (A108 candidate)") and proven by -# storage/locations_test.go's TestLocationDeleteDoesNotCascadeToLiveChildren; T069's descendant/ -# ancestor roll-up counts (named explicitly by this task's own delegation prompt) inherit this -# decision and must walk THROUGH a tombstoned ancestor, never stop at one, per that same doc. -# GET /api/v1/auth/device-tokens covered-by: TestTenantIsolation/(cookie|bearer)/device_tokens_list -- group A's device token id is absent from group B's listing POST /api/v1/auth/logout covered-by: internal/httpapi/logout_test.go's TestLogoutRevokesOnlyTheSessionBehindTheCallersOwnCookie plus internal/storage's TestRevokeCallingSessionRejectsAnotherGroupsDigest -- this route takes NO id in its path, no body and no query parameter: the session cookie is simultaneously the credential AND the address of the thing it ends, so there is nothing in the request a caller could name wrongly and neither the cross-group-404 shape nor the collection-absence shape applies to it. The FORMAT section's THIRD (structural) shape is the closest fit and is stronger here than for the id-less CREATE routes it was written for: logoutHandler passes identity.Group and identity.UserID -- both read from the request's OWN authenticated context, never from anything a caller supplies -- into SessionAdmin.RevokeCallingSession, whose statement binds group_id, user_id AND token_hash. A request cannot express "end a session belonging to someone else" at all. The storage-layer test named above is the runtime half (household B presenting household A's digest leaves A's session live), and TestRevokeCallingSessionRejectsASameGroupDifferentUsersDigest closes the tenant->user level P-3's group scoping cannot see, exactly as DELETE /auth/sessions/{sessionID} already does. Read queries/sessions.sql's RevokeSessionByTokenHash doc before trusting the attribution: three of its four predicates fire under mutation, and group_id is honestly recorded there as MASKED by user_id (a user belongs to exactly one group, so no fixture this schema can hold makes the two independently observable) -- the route's tenant behaviour is proven, the credit to one predicate is not claimed. The cookie-only gate is proven separately by TestLogoutRejectsABearerOnlyRequest, against a config whose general authenticator accepts every bearer request so a 401 can only come from the narrower gate POST /api/v1/auth/device-tokens covered-by: TestUnscopedConstructionDoesNotCompile (T030, server/tests/tenant_isolation/compilefail_test.go) -- deviceTokenIssueHandler passes GroupID: identity.Group, the authenticated scope, never a request field, so P-3's group-bound repositories (T026) enforce this route's tenant property structurally and the compile-fail suite proves an unscoped construction does not build; belt-and-braces runtime check: TestTenantIsolation/cookie/device_tokens_create_readback -- create as group B, the new device token is present in B's own listing and absent from A's (cookie-only, per bearer/device_tokens_issue_is_cookie_only, so no bearer variant) DELETE /api/v1/auth/device-tokens/{deviceTokenID} covered-by: TestTenantIsolation/(cookie|bearer)/device_tokens_revoke -- 404 (never 403) on group A's id, and the id is not echoed in the body POST /api/v1/auth/login exempt: unauthenticated (public) -- no tenant to cross +GET /api/v1/auth/me covered-by: internal/httpapi/authme_test.go's TestAuthMeHandlerReturnsCallerIdentity plus tests/integration/auth_me_test.go's TestAuthMeAtHTTPLayer -- this route takes NO id in its path, no body and no query parameter, and returns ONLY the fields already carried by the request's own authenticated [middleware.Identity] (group, user id, role) plus a username looked up in that SAME identity's own group roster (scope.Members().List, already bound to the caller's own group by middleware.Scoped/P-3): there is nothing in the request a caller could name wrongly, so neither the cross-group-404 shape nor the collection-absence shape applies to it. The FORMAT section's THIRD (structural) shape is the closest fit, the identical posture POST /api/v1/auth/logout's own line above takes for the identical reason (a route addressed entirely by the caller's own authenticated context, not by anything the request supplies): authMeHandler passes identity.Group/identity.UserID -- both read from the request's OWN authenticated context, never from anything a caller supplies -- into the response, and the username lookup runs over scope.Members(), which P-3's group-bound repositories (T026) already restrict to that same group; a caller cannot express "tell me someone else's identity" at all, in either the URL or the body. TestAuthMeHandlerReturnsCallerIdentity is the unit-layer proof that a MEMBER identity gets the member's own username/role back, not the group owner's (the same distinguishing move TestSessionsListHandlerReturnsOnlyTheCallersOwnSessions makes for its own sibling route); TestAuthMeAtHTTPLayer is the real-router, real-database belt-and-braces half, authenticating as two genuinely different households' users (alice the owner, bob a redeemed member) over both credential kinds and requiring each caller's own GET /auth/me response to match that SAME caller's own POST /auth/login response exactly POST /api/v1/auth/register exempt: unauthenticated (public) -- no tenant to cross GET /api/v1/auth/sessions covered-by: TestTenantIsolation/(cookie|bearer)/sessions_list -- group A's session id is absent from group B's listing DELETE /api/v1/auth/sessions/{sessionID} covered-by: TestTenantIsolation/(cookie|bearer)/sessions_revoke -- 404 (never 403) on group A's id, and the id is not echoed in the body @@ -1141,503 +66,25 @@ GET /api/v1/items/{itemID}/attachments/{attachmentID} covered-by: internal/httpa GET /api/v1/items/{itemID}/attachments/{attachmentID}/thumbnail covered-by: internal/httpapi's own TestAttachmentThumbnailDownloadAnswersNotFoundWhenNoneExists proves the SAME scope.Attachments().Get pre-flight this route shares with its sibling GET (immediately above) refuses an unresolvable attachmentID identically -- both routes are gated by the identical getAttachmentOrNotFound helper (attachments.go), so a group_id/item_id/id predicate broken for one breaks identically for the other; this test additionally exercises the DISTINCT, non-tenant reason this specific route can answer 404 for a REAL, correctly-scoped row (ThumbnailPath NULL -- attachments.ErrNoThumbnail), proving the two 404 causes are deliberately indistinguishable on the wire. The tenant-boundary half proper is therefore already covered by the line directly above; the dedicated D1.8 cross-group suite (T089) is what will exercise this exact route against a real two-household database, not re-done here. Belt-and-braces at the real-database, real-router layer too: the dedicated D1.8 cross-group suite this line promised is TestTenantIsolation/{cookie,bearer}/attachments_thumbnail_download, now present -- household A's provisioned attachment is a real, decodable JPEG (has_thumbnail true), so this probe's 404 is unambiguously the tenant boundary above, never [attachments.ErrNoThumbnail]'s own distinct cause; group_a_resources_untouched re-fetches household A's own thumbnail afterwards and requires 200, disproving a delete-adjacent leak that removed only the thumbnail file. DELETE /api/v1/items/{itemID}/attachments/{attachmentID} covered-by: internal/httpapi's own TestAttachmentDeleteRejectsAForeignOrUnknownAttachment -- an attachmentID this route's scope.Attachments().Get (the SAME getAttachmentOrNotFound pre-flight its two sibling GET routes above share, P-3) reports storage.ErrNotFound for answers 404 with scope.Attachments().Delete NEVER REACHED (the fake's own deleteFn is left nil, which panics if called, so a bug that let a tombstone or a file removal happen before the group check completed would fail this test loudly, not merely return the wrong status by coincidence) -- no fixture file is ever unlinked from disk either. This is the basic proof T087 (the task that mounted this route) owns, matching T084/T086's own identical carve-out for their sibling routes above; the dedicated D1.8 cross-group suite exercising a REAL database is T089's job, not re-done here. Belt-and-braces at the storage layer: storage/attachments_test.go's TestAttachmentDeleteRejectsAnAttachmentFromAnotherGroup and TestAttachmentDeleteRejectsAnAttachmentFromAnotherItem prove the identical (group_id, item_id, id) predicate set one layer down, against a real on-disk database, for the storage.AttachmentRepository.Delete half of this same check -- unlike Get/SetThumbnailPath, this method carries NO pre-read of its own (storage/attachments.go's own Delete doc), so those three predicates on DeleteAttachment's own WHERE clause are the ONLY thing enforcing the boundary at that layer. Defence-in-depth belt-and-braces below the SQL layer too: internal/attachments's own TestDeleteRejectsAStoragePathNamingAnotherGroupsDirectory proves that even a storage_path SOMEHOW naming a different group's directory is refused by the filesystem-level boundary check ([removeUnderGroup]/[resolveUnderGroup], download.go) rather than unlinked, independent of the SQL predicate above it. Belt-and-braces at the real-database, real-router layer too: the dedicated D1.8 cross-group suite this line promised is TestTenantIsolation/{cookie,bearer}/attachments_delete, now present -- it authenticates as household B and sends a REAL DELETE against household A's real, uploaded attachment, requiring the same 404 this line's fake-repository proof already gives; group_a_resources_untouched then re-downloads household A's own attachment (as A, not B) and requires the SAME bytes back byte-for-byte, which is the only check that would catch attachments.Delete's own tombstone-plus-unlink landing on disk despite the 404 answered above. -# STATUS AS OF T086 - -T086 (D1.8, FR-042, NFR-017) mounts this subsystem's second and third routes: streamed, -authenticated download of an attachment's original bytes and of its generated thumbnail. Both are -the cross-group-404 shape (FORMAT section's first shape), like their sibling POST above and like -every other /items/{itemID}/... route that names an existing child resource by id. This task also -adds `has_thumbnail` to the Attachment wire shape (T085's own carry-forward), which needed no new -coverage line of its own -- it is a field on an existing response, not a new route. - -Both routes share ONE pre-flight helper (getAttachmentOrNotFound, attachments.go) that runs -scope.Attachments().Get -- itself newly added to storage.AttachmentRepository BY this task -- before -either handler ever calls attachments.OpenOriginal/OpenThumbnail and touches the filesystem, which -is what makes "the group check happens before the filesystem is touched" (this task's own FR-042 -requirement) true structurally rather than by convention: there is no code path in EITHER handler -that opens a file before that call has already succeeded. Coverage now stands at 62 routes: 4 -exempt, 58 covered, 0 pending. - -This is the cross-group-404 shape (FORMAT section's first shape): {itemID} names an existing parent -going in, exactly like POST /items/{itemID}/warranty and POST /items/{itemID}/identifications -before it. Unlike either of those, this task does not add a full TestTenantIsolation subtest under -a real database -- see the covered-by: line above for the httpapi-unit-level proof this task ships -instead, and why the fuller proof is left to T089 (the SOW's own dedicated tenant-isolation task for -D1.8, mirroring how T061 rather than T052-T060 individually was the SOW's placeholder for -consolidating D1.5's remaining coverage). T087 (delete) needed its own line here too, once it -landed -- see the STATUS AS OF T087 section below. - -This route is also the first to need a genuinely LARGER middleware.MaxBody cap than every other -route in this file, and mounting it correctly turned out to require a small structural change to -how [Config.MaxBodyBytes] is applied at all: through T083 it was installed exactly once, at the -root of the whole chain, wrapping literally every route's request body in one shared -http.MaxBytesReader. That shape cannot give ONE route a LARGER effective limit than the rest -- -http.MaxBytesReader wraps whatever reader it is given, so a second, more generous call on top of -the root-installed one still delegates every Read() to the smaller, root-installed reader -underneath it, which trips its own limit first regardless of what the outer wrapper would have -allowed. Nesting can only ever narrow an effective cap, never widen one -- confirmed by reading -net/http's own maxBytesReader.Read implementation rather than assumed. T084 therefore moved -[Config.MaxBodyBytes] off the router root and into each of mountV1's own route groups (the public -group, the general authenticated group, and the two narrower cookie-only groups), each installing -middleware.MaxBody(cfg.MaxBodyBytes) as its own first middleware so every EXISTING route keeps the -identical default cap it always effectively had -- and gave the new attachments group its own, -larger value instead, with no smaller wrap anywhere above it in that one group's chain. `go test -./...` was run in full after this change specifically to confirm no other route's behaviour moved; -see router.go's own comments at [newRouter] and [Config.MaxBodyBytes] for the full argument. - -# STATUS AS OF T087 - -T087 (D1.8, FR-045's delete half: "Attachments MUST be deletable") mounts this subsystem's FOURTH -and final route, DELETE /items/{itemID}/attachments/{attachmentID}, completing D1.8's own route -surface -- upload (T084), the download pair (T086) and now delete are all present. This is the -identical cross-group-404 shape (FORMAT section's first shape) its three siblings above already -are, gated by the SAME getAttachmentOrNotFound pre-flight helper (attachments.go, renamed from -getAttachmentForDownload by this task now that a THIRD handler, not just the download pair, shares -it) -- see the covered-by: line above for the full argument. Coverage now stands at 63 routes: 4 -exempt, 59 covered, 0 pending. - -storage.AttachmentRepository gained a fourth method, Delete, over a new sqlc query (DeleteAttachment) -following DeleteItemCustomField's own three-predicate shape exactly (group_id, item_id, id, -deleted_at IS NULL) -- no pre-read of its own, `rows == 0` reported as storage.ErrNotFound, the -identical A93-shaped contract every other 0..N-child Delete in this schema already has. Package -attachments gained its own Delete function (delete.go), the file-unlink half FR-045 also requires: -best-effort, reporting a slice of non-fatal errors rather than one the caller could be tempted to -treat as request-failing, and treating an already-missing file as success rather than as a fault -(the "idempotent-ish unlink" this task's own delegation prompt asked for). It reuses -[resolveUnderGroup] -- download.go's own boundary check, factored out of [openUnderGroup] by this -task specifically so the removal path gets the IDENTICAL path-traversal/cross-group defence the -read path already had, rather than a second, independently-drifting copy of it. - -The handler's own ordering is the property most worth pinning explicitly: the ROW is tombstoned -FIRST (scope.Attachments().Delete), and ONLY THEN is the file best-effort unlinked -(attachments.Delete) -- the reverse of upload's own write-then-insert ordering, and for the -identical reason stated in reverse: a crash between the two steps must leave an ORPHANED file -(bytes with no live row, T088's own future reclamation target) rather than a live row whose bytes -vanished. Any error attachments.Delete reports is logged at slog.LevelWarn (not Error -- a stuck -file is not this request's OWN failure) and never changes the response: by the time it runs, the -row is already gone from every other route's point of view, so there is nothing left for a -filesystem fault to make THIS request fail for. - -What T087 does NOT do, deliberately, matching this task's own scope: the periodic orphan-reclamation -pass (FR-045's OTHER half -- finding and removing files whose row was never created at all, or -whose unlink failed synchronously and was never retried) is T088's job, not a route and not touched -here. The dedicated D1.8 cross-group tenant-isolation suite exercising a REAL two-household database -(T089) has not been extended for this route either -- the covered-by: line above is the -httpapi-unit-level proof this task ships, the identical carve-out T084/T086 already took for their -own sibling routes, for the identical reason. - -# STATUS AS OF T093 - -T093 (D1.9) mounts /sync/pull and /sync/push (FR-090..FR-096) as declared-but-unimplemented -stubs, per spec.md's Phase 1 boundary: the route is real and authenticated exactly like every -other route on the general authenticated group, but sync.go's two handlers answer 501 Not -Implemented unconditionally, regardless of caller, group or credential, touching neither storage -nor package sync (internal/sync/doc.go) at all. - -There is no PER-ENTITY tenant behaviour yet -- neither handler reads a row, so there is nothing -shaped like /items{itemID}'s own cross-group-404 or /items' own collection-absence claim to make. -But there IS a genuine, falsifiable cross-group property available today, and it is the one -NFR-010 actually cares about for a route with no per-group data behind it: the response must not -vary by WHICH group asked. internal/httpapi's own TestSyncRoutesRevealNothingThatVariesByGroup -(sync_test.go) proves exactly that -- two independently-authenticated households, same path, same -method, byte-identical status and body (aside from the per-REQUEST, never per-tenant, request_id -field) -- so both lines below are covered-by:, not pending:. - -That test is deliberately the TRIPWIRE, not decoration: the moment D3.1 (T155, pull) or D4.1 -(T164, push, idempotent by mutation_id) makes either response depend on real per-group data -- the -whole point of implementing sync at all -- TestSyncRoutesRevealNothingThatVariesByGroup starts -failing, loudly, on the exact change that makes it stop being true. That failure is what forces -whoever lands T155/T164 to open this file and replace this classification with the richer -per-entity suite those tasks actually own (the cross-group-404 / collection-absence / structural -shapes the FORMAT section above catalogues), rather than inheriting a tenant-isolation gate that -quietly stopped meaning anything. See specs/001-hho-ecosystem/contracts/README.md's own "still -subject to the tenant-isolation suite" note for /sync/* -- T155/T164 are what close that note, not -this task; this task's own claim is narrower and expires the moment theirs lands. - POST /api/v1/sync/pull covered-by: server/tests/integration's own TestTenantIsolationSyncPullDoesNotLeakAcrossGroups (T155e) -- see "STATUS AS OF T155d" and "CLOSED BY T155e" below for the full argument. Supersedes T155d's own httpapi-unit-level TestSyncPullDoesNotLeakAcrossGroups as the claim this gate cites (that test still exists and still runs, kept as a cheap, always-on tripwire -- see "CLOSED BY T155e" for why it was not deleted). -POST /api/v1/sync/push covered-by: internal/httpapi's own TestSyncPushRevealsNothingThatVariesByGroup -- see "STATUS AS OF T155d" below. Unchanged from T093's own original claim (renamed, still push-only): push is still D4.1/T164's job. - -# STATUS AS OF T155d - -T155d (D3.1) replaces syncPullHandler's body wholesale with the real implementation: the -cursor_too_old decision (T155b) and the paged, change_seq-ordered envelope assembly (T155c), wired -through a real [storage.SyncRepository]. This is exactly the change the "STATUS AS OF T093" section -above predicted would fire its own tripwire -- TestSyncRoutesRevealNothingThatVariesByGroup's own -premise ("the response must not vary by WHICH group asked") is no longer true for POST /sync/pull, -because the whole point of this task is that it now does, correctly. - -That test is therefore SPLIT, not merely extended, matching this section's own earlier prediction -("whoever lands T155 ... replace this classification with the richer ... suite"): - - - TestSyncPushRevealsNothingThatVariesByGroup (sync_test.go) restates the ORIGINAL negative claim, - unchanged, for push alone -- push is still a stub (D4.1/T164's own job), so the property that - made it true in the first place still holds. - - TestSyncPullDoesNotLeakAcrossGroups (sync_test.go) is pull's OWN, genuinely positive - replacement: two households, each seeded with one real item of its own (through the ordinary - public storage API, never raw SQL), each pulling `since: 0` -- household A's response must - carry its OWN item id and must NOT carry household B's, and vice versa. This is the exact - non-vacuous cross-tenant shape plan.md's own D3.1 addendum names ("asserting group A's response - contains none of group B's entity ids and vice versa"). - -This is deliberately narrower than the FULL per-entity-type suite plan.md's own D3.1 addendum -describes (one item per group, not one row of every closed-sync-entity-set member, and not yet -promoted to the tests/integration-level TestTenantIsolation harness every other D2.x route in this -file uses). T155e is the task that owns that richer extension -- see this file's own T150b/T150e -precedent (STATUS AS OF T150b, above) for the identical shape of hand-off: an initial task lands a -real, sufficient, positive assertion at httpapi-unit level so the gate is never merely aspirational, -and a later task promotes it into the full cross-tenant harness. TestSyncPullGoldenFixture -(sync_pull_golden_test.go) is a separate, non-tenant-isolation test -- a byte-for-byte golden -response fixture for Android's own future decode test (T158b) -- and is not itself part of this -route's tenant-isolation coverage. - -# CLOSED BY T155e - -T155e lands the FULL per-entity-type suite the section above deferred: server/tests/integration/ -tenant_isolation_sync_pull_test.go's TestTenantIsolationSyncPullDoesNotLeakAcrossGroups. It lives -beside every other D2.x route's own suite-level claim in this file (an EXTERNAL `package -integration` driving the real chi router -against a real on-disk SQLite database, per A73) rather than extending TestTenantIsolation's own -shared credential loop (tenant_isolation_test.go) -- that loop's fixture never mints a SECOND item -or anything tombstoned, and mutating it mid-suite to add either would invalidate every later -subtest that assumes tenant.itemID/warrantyHolder/etc are still live. A dedicated top-level test -function, carrying the TestTenantIsolation prefix on purpose (see -TestTenantIsolationBackupIncludesEveryGroupsData's own doc, backup_archive_test.go, for why the -prefix alone is what makes gate-tenant-isolation.sh's unanchored `-run 'TestTenantIsolation'` pick -a new top-level function up with no change to that script), avoids the coupling entirely. - -Four requirements, all asserted against two real households seeded through the ordinary HTTP API -(never raw SQL), paged to exhaustion (limit=3, forcing several pages across ~18 real rows per -household) rather than answered in one page, under BOTH credential kinds (session cookie and -device-token bearer, T148g's own `{cookie,bearer}` precedent) for every read-only assertion: - - - Every one of the eleven closed-sync-entity-set members (item, warranty_block, sold_to_block, - purchased_from_block, item_identification, item_custom_field, stock_adjustment, location, - label, item_label, attachment), each with a distinct id per household: own present, other's - absent, checked over EVERY change in the page, not merely per entity type. - - Tombstones are a leak vector too: household B deletes some of its own rows, including a real - item-delete CASCADE (a second item, cascade-tombstoning its own warranty/sale/purchase blocks, - identification, item custom field and item<->label assignment, all sharing one change_seq per - internal/sync.Reader's own doc) plus three standalone deletes (a second label, location and - attachment) -- ten of the eleven entity types tombstoned (stock_adjustment has no DELETE route - at all, FR-018, and is excluded from this half for that reason). Household A's pull carries - NONE of the resulting tombstones; household B's own pull carries every one of them. - - change_seq is a PER-GROUP counter, so household A's and household B's own values occupy the - identical numeric range (both fixtures mint the identical eighteen resources in the identical - order). Household A pulling with `since` set to one of household B's own real change_seq - values still answers only household A's own rows. - - The tombstone GC-horizon low-watermark (FR-094/FR-095) is a per-group value too - (storage.SyncRepository.SetLowWatermark, reached directly via the test's own *storage.Storage - handle -- that method's own doc: "no production caller yet," so there is no HTTP route to drive - this through). Advancing ONLY household B's own low-watermark does not affect household A's - `cursor_too_old` decision at the identical `since`. - -Mutation-tested, not merely observed to pass: temporarily broadening ListItemChangesSince's own -`group_id = sqlc.arg(group_id)` predicate to `(group_id = sqlc.arg(group_id) OR 1 = 1)`, -regenerated with the pinned sqlc, left the FIRST-entity-type-set baseline subtest -(all_eleven_entity_types_own_present_other_absent, run before either household deletes anything) -GREEN -- a real, and informative, masking effect: internal/sync.Reader.Pull's own phase 2 always -re-fetches the FIRST change_seq group (S0) of any given pull window via the sibling AtSeq query -(ListItemChangesAtSeq, left correctly scoped by this mutation), discarding phase 1's own -(corrupted) view of that first group entirely -- see that method's own doc, "Phase 2 -- the AtSeq -COMPLETION fetch." The mutation only becomes VISIBLE once a pull's page reaches a LATER group that -phase 2 never re-verifies, which is exactly what the tombstones_do_not_leak_across_groups subtest -reaches (by then each household has produced enough change_seq groups that the item-delete's own -tombstone group is no longer S0): both credential subtests failed there with -`A's pull LEAKED B's item tombstone ... across groups`, naming the exact leaked id. Restoring -queries/items.sql and regenerating reproduced the pre-mutation gen/items.sql.go byte-for-byte (git -diff empty) and the suite returned to green. This is a stronger finding than T155d's own minimal -test could have produced: that test's single-item, since:0, one-page pull is permanently and -unconditionally the S0 case, so an identical mutation against it would never fire at all -- exactly -the gap this task's own paging and tombstone requirements exist to close. - -TestSyncPullDoesNotLeakAcrossGroups (internal/httpapi/sync_test.go, T155d) is NOT deleted: it is -this task's own judgment call to keep it as a fast, narrow, always-on httpapi-unit-level tripwire -(one item per group, no tombstones, no paging) that costs a small fraction of this suite's own -runtime and would still catch the single most common regression (the group_id predicate on -ListItemChangesSince itself, or the handler forgetting to bind middleware.Scoped's own scope). It -is no longer the line this file's own covered-by: cites as authoritative for POST /sync/pull -- -TestTenantIsolationSyncPullDoesNotLeakAcrossGroups is -- but it is not pure duplication of it -either, per the mutation-testing paragraph immediately above. - -POST /api/v1/sync/push is UNCHANGED by this task -- still D4.1/T164's own stub, still covered by -TestSyncPushRevealsNothingThatVariesByGroup, still the negative claim T155d's own section states. - -# STATUS AS OF T113 - -T113 (FR-132's admin-endpoint half) mounts GET /api/v1/backup: a real, authenticated route (owner -role, gated by middleware.RequireOwner exactly like /invites, PUT /groups/detail-visibility and -the /custom-field-defs write trio), so it is registered by `hho routes` and therefore appears here, -same as any other route -- but its own semantics do not fit either of this file's two established -classifications as written. It is not exempt: in the sense this file defines that word ("the route -authenticates nobody") -- an unauthenticated request gets 401. It is also not covered-by: in the -sense every other line above claims -- there is no cross-group isolation property to prove, because -FR-132's whole point is that an authenticated owner of ANY group receives a snapshot of EVERY -group's data (see storage.Storage.VacuumInto's own doc, "a whole-database operation with no tenant -of its own", and router.go's own mount-site comment for this route). A cross-group-404 or -collection-absence test would be testing for the ABSENCE of the one thing this endpoint is -supposed to do. - -Left as pending: rather than force-fit into exempt: or covered-by: by this task unilaterally. -Whether NFR-010's coverage taxonomy should gain a third first-class status for "authenticated, -deliberately whole-database, no per-tenant isolation to prove" is a call for whoever owns this -file's own contract and gate-tenant-isolation.sh's crosscheck script, not a reclassification T113 -is positioned to make by itself. What T113 DOES cover, in internal/httpapi/backup_test.go: the -owner/member/unauthenticated status codes (200/403/401), the streamed body's headers, and that the -body is a valid gzip+tar stream -- the HTTP-layer wiring on top of package backup's own -already-exhaustively-tested WriteArchive (T112), which is the property this task actually owns. - -T113 left GET /api/v1/backup pending: rather than force-fit it; see the line below (superseded by -T113a) for that original classification's exact wording. gate-tenant-isolation's -coverage-crosscheck read pending: as listed-but-not-covered, correctly, and stayed red on this one -route until T113a below. - -# STATUS AS OF T113a - -T113a is the reclassification T113 deliberately declined to make itself: the FORMAT section above -now defines cross-tenant-by-design: (see that section's own entry for the full argument -- what it -means, why it is not exempt: or covered-by:, and why it does not defeat A85), and this task -resolves GET /api/v1/backup's line to it, closing the gate T113 correctly left red. -TestTenantIsolationBackupIncludesEveryGroupsData (server/tests/integration/ -backup_archive_test.go, T113a) is the named test the new status requires: it provisions two -independent households entirely over HTTP (newCrossTenantFixture, the same machinery -TestTenantIsolation itself uses), requests GET /api/v1/backup authenticated ONLY as household B's -owner, decodes the returned archive's db/hho.db entry back into a real *storage.Storage, and -requires BOTH households' items to be readable out of it by their real ids -- the POSITIVE, -by-design property cross-tenant-by-design: demands, not merely that the archive is non-empty or -that household B's own row survived. - -A first pass shipped this test as TestBackupArchiveDeliberatelyIncludesEveryGroupsData, outside -the TestTenantIsolation prefix entirely -- the gate's own isolation-suite step -(gate-tenant-isolation.sh) runs `go test ... -run 'TestTenantIsolation'`, an unanchored match -against each top-level test's own name, so that name was never executed by the gate that accepts -this status. The gate stayed green regardless, because the coverage-crosscheck step only checks -that a status TOKEN is one of the accepted three -- it does not itself run the test a line names -- -so a cited-but-unexecuted test is indistinguishable from the gate's own point of view from a -line naming nothing at all, exactly the T050/A85 hazard the FORMAT section's own entry for this -status warns against. Renamed to TestTenantIsolationBackupIncludesEveryGroupsData so the gate's -existing -run pattern picks it up with no change to the script itself (confirmed: `go test -list -'TestTenantIsolation' ./tests/integration/...` lists it alongside TestTenantIsolation and -TestTenantIsolationItemListFilters), inheriting that script's own vacuity guard for free rather -than needing a second one. The test's own doc addresses the resulting tension head-on: unlike -every other TestTenantIsolation-prefixed test, this one asserts PRESENCE, not absence -- see its -own "Why this carries the TestTenantIsolation prefix despite asserting the OPPOSITE of isolation" -section above its func line. +POST /api/v1/sync/push covered-by: server/tests/integration's own TestTenantIsolationSyncPushDoesNotLeakAcrossGroups (T164e) -- see "STATUS AS OF T164d" and "CLOSED BY T164e" below for the full argument. Supersedes T164d's own httpapi-unit-level TestSyncPushCreatesItemAndReturnsApplied/TestSyncPushRevealsNothingThatVariesByGroup as the claim this gate cites (those tests still exist and still run, kept as cheap, always-on tripwires -- see "CLOSED BY T164e" for why they were not deleted). GET /api/v1/backup cross-tenant-by-design: authenticated (owner-only) but deliberately spans every tenant by design (FR-132) -- see this file's FORMAT section for the full argument, and TestTenantIsolationBackupIncludesEveryGroupsData (server/tests/integration/backup_archive_test.go), a test gate-tenant-isolation.sh's own isolation-suite step actually executes (see this section's own note on why that matters), for the test that makes it falsifiable rather than merely asserted. internal/httpapi/backup_test.go remains this route's HTTP-layer wiring coverage (status codes, headers, stream validity) -- unchanged and still correct, just not what this status token itself certifies. -# STATUS AS OF T147c - -T147c extends this suite to GET /api/v1/export/items.csv (T147b, FR-050, D2.1), the route this -gate flagged red the moment T147b mounted it. This route is member-readable, not owner-gated -- -the same posture GET /items already has (see export.go's own file doc, "Mounting: member-readable, -not owner-gated") -- and it takes no resource id at all, so it is the FORMAT section's SECOND -assertion shape, collection-absence, the identical shape items_list's own coverage line above -documents for its JSON counterpart, just decoded as CSV rather than JSON. - -TestTenantIsolation/(cookie|bearer)/export_items_csv (tests/integration/tenant_isolation_test.go) -authenticates as household B, requests this route, decodes the response with encoding/csv, and -requires household A's item id absent from every data row while household B's OWN item id IS -present, with its name matching the fixture's own record -- the identical non-vacuity argument -items_list's own comment makes ("an empty listing would satisfy an exclusion check vacuously"), -restated here for a CSV body: an export that answered an empty file to everyone would satisfy the -exclusion loop trivially, so the subtest additionally requires B's own row to appear and requires -the data-row count to be exactly 1 (the same "household B holds exactly one item" invariant -items_list already pins earlier in the same credential loop, which this read-only probe does not -disturb). The header row's `id`/`name` column positions are looked up via -importexport.ColumnID/ColumnName rather than a hardcoded index, so a future column reorder cannot -silently turn this subtest into one reading the wrong cell. - -This is the SAME seam GET /items uses (exportItemsCSVHandler reaches storage through -middleware.Scoped's storage.Scope, per export.go's own file doc), and the read itself -(storage.ItemRepository.List, paged by listAllItemsForExport) is the SAME generated, group-scoped -statement GET /items already reads for its unfiltered case -- so this line documents no NEW SQL -predicate of its own; what T147b genuinely adds beyond that shared read is the N+1 fan-out across -every satellite repository (labels/identifications/attachments/custom-fields/warranty/purchase/ -sale) per item, each of which is ALREADY its own covered-by: line elsewhere in this file (e.g. GET -/items/{itemID}/warranty above) proving its own group_id predicate directly. This subtest does not -re-run that per-satellite mutation table; it proves the ONE property specific to this route's own -assembly and encoding -- that a foreign group's item and its rendered CSV row never reach this -response at all -- exactly as the collection-absence shape requires. - -# STATUS AS OF T151c - -T151c mounts GET /i/{token} (FR-073/FR-074, D2.5, resolve.go), the QR/deep-link resolve route -qrlabels.ItemURL's payload points at. This is the first route in this file classified "exempt:" -that is NOT one of mountV1's five plain-public routes (POST /auth/login, POST /auth/register, POST -/invites/redeem, GET /status, GET /openapi.yaml) -- unlike every one of those, itemResolveHandler -DOES authenticate somebody on two of its three branches, so the plain boilerplate "unauthenticated -(public) -- no tenant to cross" those five lines carry would be a materially inaccurate claim if -copied here unchanged. The longer line below states the actual reasoning instead, matching T151e's -own pre-decided exclusion (specs/001-hho-ecosystem/tasks.md: "/i/{token} is deliberately excluded -per A123, since its UUID path defers all tenant-sensitive reads to the already-covered -/items/{id} and its short-code path never crosses groups by construction"). - -Why "exempt:" rather than "covered-by:" or "pending:": this route's own code performs, at most, ONE -tenant-scoped SQL read (the short-code branch's GetItemByShortCode) and that read cannot exhibit -the cross-group leak shape NFR-010's covered-by: entries prove absent, because short_code carries -no cross-tenant ADDRESS at all -- unlike an id, "group B's row for this short_code" and "group A's -row for this short_code" are simply two different, independently-keyed rows (ux_items_group_short_code -is a per-group unique index, not a global one), so there is no single value an attacker could hold -that names one group's row while resolving against another's binding; the WHERE clause itself -(group_id = r.group() AND short_code = ?) can only ever match a row in the caller's OWN group, -structurally, the identical guarantee P-3's group-bound repositories give every other read in this -package (TestUnscopedConstructionDoesNotCompile, T030). The UUID branch reads NOTHING from storage -at all -- see resolve.go's own file doc -- so it poses no read for a coverage entry to certify in -the first place; its own tenant safety is GET /items/{itemID}'s, already proven by that route's own -covered-by: line above. "pending:" would misstate this as an open gap with no plan to close it, -which is not what either branch is. - GET /i/{token} exempt: this handler never reaches storage on an unauthenticated request (see resolve.go's own file doc, "'No database read' ... is structural, not an optimisation") and, once authenticated, its UUID branch redirects to GET /items/{id} WITHOUT reading the items table at all -- the actual tenant-scoped lookup is that already-covered route's, this handler only ever chooses a URL. Its short-code branch is the one branch that does read storage (storage.ItemRepository.GetByShortCode), but it can never cross groups by construction: the statement's group_id predicate comes from r.group() (the SAME structurally-enforced [middleware.Scoped]-equivalent binding every other repository method in this package uses, never a value this request controls), and short_code carries no cross-tenant ADDRESS to begin with -- two groups' rows sharing a code are simply two independently-keyed rows (ux_items_group_short_code is per-group, not global), never "the same row, wrong owner," so there is no cross-group-404 move this route could even pose. See A123 (docs/sdlc/hho-ecosystem/assumptions.md) for the full design decision and T151e's own note (specs/001-hho-ecosystem/tasks.md) for why the tenant-isolation suite deliberately does not extend to this route. GET /api/v1/export/items.csv covered-by: TestTenantIsolation/(cookie|bearer)/export_items_csv -- group A's item id is absent from every data row of group B's exported CSV, which holds exactly one data row (B's own item, matched by id AND by name so a row is not merely counted but confirmed to be B's own) -- an empty export would satisfy the exclusion check vacuously, the identical non-vacuity argument GET /api/v1/items' own line above makes for its JSON counterpart. This route takes no resource id, so it is the FORMAT section's collection-absence shape, not the cross-group-404 shape; it shares GET /items' own seam (middleware.Scoped's storage.Scope) and its own underlying group-scoped read (storage.ItemRepository.List), already proven elsewhere in this file, and additionally reads seven already-covered per-item satellite repositories (labels, identifications, attachments, custom fields, warranty, purchase, sale) whose own group_id predicates are each proven directly by their own covered-by: line above -- this line's own contribution is that the assembled, encoded CSV response never surfaces a foreign group's row. -# STATUS AS OF T151e -# -# T151e extends this suite to POST /api/v1/labels/qr/batch (T151d, FR-070..FR-073, D2.5), the -# second of T151's two new routes -- GET /i/{token}, mounted alongside it in T151c, was already -# covered above as exempt: and this task's own text (specs/001-hho-ecosystem/tasks.md) explicitly -# leaves that line untouched, per A123. Coverage now stands at 72 routes: 6 exempt, 65 covered-by, 1 -# cross-tenant-by-design, 0 pending. -# -# This route takes no resource id in its PATH -- the ids it names travel in the request BODY, as a -# batch -- so on that count alone it would look like the FORMAT section's second (collection- -# absence) shape, the same one GET /items and GET /export/items.csv above take. But its actual -# cross-tenant contract is narrower and stranger than either: labels_qr_batch.go's own file doc, -# "Foreign and nonexistent ids are indistinguishable, by omission (P-3)," means a foreign id and a -# nonexistent one both simply vanish from the response's `items` array, with no per-id error, no -# 404 and no count mismatch anywhere on the wire -- there is no cross-group-404 move this route -# could even pose, the identical argument GET /i/{token}'s own exempt: line above makes for its -# short-code branch. That is why this line is covered-by:, not exempt:, despite that: the route DOES -# authenticate somebody and DOES perform a real group-scoped read (storage.ItemRepository.GetByIDs), -# so its tenant property -- that a foreign id never surfaces in the response -- is exactly the kind -# of claim covered-by: exists to certify, restated over a response keyed by REQUEST-NAMED ids rather -# than an unscoped listing. -# -# TestTenantIsolation/(cookie|bearer)/labels_qr_batch (tests/integration/tenant_isolation_test.go) -# posts household A's real item id and household B's own item id in the SAME request body, and -# requires household A's id absent from the response's `items` array while household B's own id IS -# present, matched by id AND by name (the identical non-vacuity discipline export_items_csv's own -# line above documents) -- and additionally requires the response hold EXACTLY ONE item, since A's -# was correctly omitted and only B's real id remains. Non-vacuity here is not merely stylistic, it -# is the whole point of this route's OMISSION contract: a handler that always answered an empty -# `items` array, to everyone, regardless of ownership, would satisfy a naive "A's id is absent" check -# trivially, and this route's own by-design silence on WHY an id is missing (nonexistent vs. foreign) -# makes that vacuous-pass shape easier to reach here than on any route above it -- requiring B's own -# id present, in the very same request that also names A's, is what rules it out. -# -# This subtest runs read-only, creating nothing, so it disturbs neither items_list's nor -# export_items_csv's own exact-count invariants and can run after both without consequence. -# labels_qr_batch reaches storage through the identical seam GET /items and GET /export/items.csv -# both use (middleware.Scoped's storage.Scope), and its own underlying statement -# (storage.ItemRepository.GetByIDs, T151d) is a NEW generated statement this task's own worker did -# not write -- see that method's own doc for its group_id predicate's argument -- so this line -# documents a genuinely new SQL surface, not a restatement of GetItem/List's already-proven one. - POST /api/v1/labels/qr/batch covered-by: TestTenantIsolation/(cookie|bearer)/labels_qr_batch -- group A's item id, named in the same request body as group B's own, is absent from the response's `items` array, while group B's own id IS present (matched by id AND by name) and is the response's ONLY entry -- the non-vacuity argument this route's own by-design foreign/nonexistent-id omission (labels_qr_batch.go, "Foreign and nonexistent ids are indistinguishable, by omission (P-3)") makes essential rather than merely stylistic. This route takes no resource id in its path (ids travel in the request body instead), and its cross-tenant contract poses no cross-group-404 move at all -- a foreign id and a nonexistent one are indistinguishable by design, the identical argument GET /i/{token}'s own exempt: line above makes for its short-code branch -- so this is the FORMAT section's collection-absence shape, restated over a response keyed by request-named ids. It shares GET /items' and GET /export/items.csv's own seam (middleware.Scoped's storage.Scope) but reads a NEW statement, storage.ItemRepository.GetByIDs (T151d), not previously proven by any covered-by: line above. -# STATUS AS OF T150b -# -# T150b mounts D2.4's four fixed reports (FR-060..FR-062): GET /reports/valuation, -# GET /reports/warranty-expiring, GET /reports/purchases, GET /reports/item-count-by-location. All -# four are tenant-scoped (middleware.Scoped, the same seam GET /export/items.csv and -# POST /labels/qr/batch already use) and member-readable, not owner-gated -- reports.go's own file -# doc, "Mounting: member-readable, not owner-gated." Each is a group-wide AGGREGATE with no resource -# id in its path (the cross-group-404 move has nothing to name), so its isolation property is the -# same collection-absence shape GET /items' and GET /export/items.csv's own covered-by: lines take: -# a second household's rows must be absent from the aggregate, under both credential kinds, against -# a real on-disk SQLite database -- not yet asserted here. -# -# CLOSED BY T150e -# -# T150e extended TestTenantIsolation to all four /reports/* routes plus GET /export/bom, using -# provisionReportsFixture (tenant_isolation_test.go) to mint each household a third, real item -- -# placed, labelled, priced and dated -- so every report/export below has a non-vacuous per-tenant -# row to assert against, not merely an empty response to omit household A from. Each of the five -# subtests requires household A's own id (label, item or location, per route) explicitly absent -# from household B's response while household B's own id IS present, matched against the fixture's -# known values -- the same non-vacuity discipline POST /labels/qr/batch's own covered-by: line -# above documents. GET /export/bom's subtest additionally names household A's real id, of all three -# selection kinds (item_id/location_id/label_id) in turn, in the SAME request as household B's own -# -- the sharpest form of the omission hazard bom.go's own file doc describes -- and asserts exactly -# one data row survives each time. - GET /api/v1/reports/valuation covered-by: TestTenantIsolation/(cookie|bearer)/reports_valuation -- group_by=label; group A's label id is absent from group B's rows while group B's own label row is present with the correct item_count and total_value_minor computed from provisionReportsFixture's known price. GET /api/v1/reports/warranty-expiring covered-by: TestTenantIsolation/(cookie|bearer)/reports_warranty_expiring -- group A's report-fixture item id is absent from group B's rows while group B's own item is present with the fixture's expires_on. GET /api/v1/reports/purchases covered-by: TestTenantIsolation/(cookie|bearer)/reports_purchases -- over a [from,to] window bracketing both households' identical purchased_on date, group A's item id and vendor are absent from group B's rows while group B's own purchase row is present intact. GET /api/v1/reports/item-count-by-location covered-by: TestTenantIsolation/(cookie|bearer)/reports_item_count_by_location -- group A's location id is absent from group B's rows while group B's own location shows item_count exactly 1 (only the report-fixture item is placed there). GET /api/v1/export/bom covered-by: TestTenantIsolation/(cookie|bearer)/export_bom -- item_id, location_id and label_id selection modes each name group A's real id explicitly alongside group B's own in the same request; group A's item is omitted (exactly one data row survives) while group B's own item and its value_minor are present. -# STATUS AS OF T148c -# -# T148c mounts D2.2's first import-round-trip step, POST /import/native/upload (FR-051): stage the -# uploaded CSV under ${DATA_DIR}/tmp/imports//.csv and create its -# import_sessions row. It is tenant-scoped (middleware.Scoped, the same seam every route above -# uses) and member-writable, not owner-gated -- import_native_upload.go's own file doc, "Who may -# write: member-writable, like attachment upload." Its own storage.ImportSessionRepository is -# reached through Config.Store (storage.Storage.ForGroupImportSessions), the identical -# second-constructor seam GET /reports/* above already uses through ForGroupReports, and its own -# INSERT statement is scoped by group_id exactly as CreateAttachment's own covered-by: line proves -# for its sibling Create statement (queries/scoping_test.go's own unscopedWriteAllowlist entry for -# CreateImportSession makes the identical "explicit, required, positional VALUES column" argument). -# A dedicated cross-group PROOF against a real on-disk SQLite database -- creating a session as -# group A and confirming group B can never observe it (there is no read route yet for a live HTTP -# request to attempt this through; storage's own import_sessions_test.go proves the SQL predicate -# directly in the meantime) -- is left pending: rather than force-fit into this file's own -# TestTenantIsolation suite, matching GET /api/v1/backup's own T113-era precedent above: T148g -# (named explicitly by T148c's own task brief as the owner of extending this suite) is expected to -# close this the moment GET /import/{import_id}/preview (T148d) gives it a read path to assert -# through, the same way T150e closed T150b's own pending status for /reports/*. - POST /api/v1/import/native/upload covered-by: TestUnscopedConstructionDoesNotCompile (T030, server/tests/tenant_isolation/compilefail_test.go) -- importNativeUploadHandler reaches storage.ImportSessionRepository.Create only through importSessionRepositoryFor's Config.Store.ForGroupImportSessions(scope.GroupID()) construction, and CreateImportSessionParams (storage/import_sessions.go) declares no GroupID field at all, so a caller cannot name the group its session lands in; P-3's group-bound repositories (T026) enforce this route's tenant property structurally and the compile-fail suite proves an unscoped construction does not build. Belt-and-braces runtime check (T148g): TestTenantIsolation/(cookie|bearer)/import_native_upload_symmetry -- upload as group B (via stageOwnExportAsImport), then confirm group A cannot preview or commit that same import_id, mirroring the import_preview/import_commit probes immediately below this route's own that already confirm group B cannot reach group A's upload either; storage/import_sessions_test.go's TestImportSessionCreateIsInvisibleToAnotherGroup proves the identical group_id-scoped INSERT predicate directly, one layer down. -# STATUS AS OF T148d -# -# T148d mounts D2.2's second import-round-trip step, GET /import/{importID}/preview (FR-052): -# re-parse the staged file and classify every row create/update/unchanged/error without writing. -# It is tenant-scoped (middleware.Scoped, the same seam every route above uses) and -# member-readable, not owner-gated, mounted flat beside GET /export/items.csv -- see -# import_preview.go's own file doc. storage.ImportSessionRepository.Get is scoped by group_id -# exactly as GetImportSession's own doc already argues (single-tenant-table predicate, no -# unscopedWriteAllowlist entry needed), and this route's own 404-never-403 collapse folds an -# unknown id, an expired session, and a FOREIGN group's session into the identical response -- -# storage/import_sessions_test.go's TestImportSessionCreateIsInvisibleToAnotherGroup already proves -# the underlying SQL predicate directly. A dedicated HTTP-level cross-group PROOF (stage as group -# A, confirm group B's own preview request against that same import_id answers 404) is left -# pending: rather than force-fit into this file's own TestTenantIsolation suite, matching -# POST /import/native/upload's own T148c-era precedent immediately above: T148g (named explicitly -# by T148c's own task brief as the owner of extending this suite, now that this route gives it a -# read path to assert through) is expected to close both pending: lines in the same pass. - GET /api/v1/import/{importID}/preview covered-by: TestTenantIsolation/(cookie|bearer)/import_preview (T148g) -- group B, naming group A's real import_id (freshly staged from A's own current export via stageOwnExportAsImport), answers 404 (never 403, and never echoes the id back); TestTenantIsolation/import_group_a_untouched re-reads group A's own session immediately afterward, over the SAME import_id, and requires it still previewable with every row "unchanged" -- a 404 answered after the session was deleted or otherwise mutated would be exactly what that re-read catches. This route's own 404-never-403 collapse (resolveStagedImportSession) already folds an unknown id, an expired session and a foreign group's session into the identical response; storage/import_sessions_test.go's TestImportSessionCreateIsInvisibleToAnotherGroup proves the underlying group_id-scoped SELECT predicate directly, one layer down. -# STATUS AS OF T148e -# -# T148e mounts D2.2's third and final import-round-trip step, POST /import/{importID}/commit -# (FR-053): classify the staged file through the exact same path GET .../preview uses and, if no -# row classified "error", apply every create/update inside one transaction -# (storage.ImportCommitRepository.Commit, import_commit.go). It is tenant-scoped -# (middleware.Scoped, the same seam every route above uses) and member-writable, matching -# POST /import/native/upload's own default. Session resolution reuses T148d's -# resolveStagedImportSession verbatim, so a foreign or unknown import_id answers the identical 404 -# GET .../preview already gives, and an already-committed session answers 409 the identical way -- -# storage/import_sessions_test.go's TestImportSessionCreateIsInvisibleToAnotherGroup already proves -# the underlying group_id-scoped SELECT/UPDATE predicates this route's own session lookup and its -# MarkImportSessionCommitted transition both use. The COMMIT WRITE PATH itself -# (storage.ImportCommitRepository.Commit) is new SQL surface no prior covered-by: line has proved: -# every generated statement it issues (CreateItem, UpdateItem, CreateItemLabel, -# CascadeDeleteItemLabels, CreateItemIdentification, CascadeDeleteItemIdentifications, -# CreateItemCustomField, CascadeDeleteItemCustomFields, CreateItemWarranty/UpdateItemWarranty/ -# DeleteItemWarranty and their Purchase/Sale siblings) is an EXISTING, already group_id-scoped -# statement reused from the ordinary item/label/identification/custom-field/warranty/purchase/sale -# write paths (T051-T060, T067-T068) -- see import_commit.go's own doc for the full list -- so its -# tenant-isolation property rests on those statements' own already-covered predicates, not on a new -# unscoped surface. A dedicated HTTP-level cross-group PROOF (commit as group A, confirm group B's -# own commit request against that same import_id answers 404, and that a real multi-table commit -# genuinely lands only in the committing group) is left pending: rather than force-fit into this -# file's own TestTenantIsolation suite, matching POST /import/native/upload's and -# GET /import/{importID}/preview's own T148c/T148d-era precedent immediately above: T148g (named -# explicitly by both of those tasks' own briefs as the owner of extending this suite) is expected -# to close all three pending: lines in the same pass, now that a real write path exists to assert -# through. - POST /api/v1/import/{importID}/commit covered-by: TestTenantIsolation/(cookie|bearer)/import_commit (T148g) -- group B, naming group A's real import_id, answers 404 (never 403, never the 409 an already-committed session would give, and never echoes the id back); TestTenantIsolation/import_group_a_untouched then commits group A's own session directly afterward, over the SAME import_id, as a genuine no-op (Summary {Created:0 Updated:0 Unchanged:len(rows)}, no created_items) -- the strongest available proof that none of group B's rejected attempts left a partial write behind, since this exercises the exact same session-lookup/transition statements (resolveStagedImportSession, MarkImportSessionCommitted) a broken group_id predicate would have corrupted. storage/import_sessions_test.go's TestImportSessionCreateIsInvisibleToAnotherGroup proves the underlying group_id-scoped SELECT/UPDATE predicates directly, one layer down; the COMMIT WRITE PATH itself (storage.ImportCommitRepository.Commit) rests entirely on the already-scoped CreateItem/CreateItemLabel/CreateItemIdentification/CreateItemCustomField/CreateItemWarranty/Purchase/Sale and cascade-delete/update statements T051-T060/T067-T068's own covered-by: lines above already prove. diff --git a/server/internal/items/custom_fields.go b/server/internal/items/custom_fields.go index c819994..690f8d5 100644 --- a/server/internal/items/custom_fields.go +++ b/server/internal/items/custom_fields.go @@ -101,6 +101,10 @@ func validateCustomFieldContent(name, fieldType string, text *string, number *fl return validateCustomFieldValueColumns(fieldType, text, number, boolean, date) } +func ResolveCustomFieldDef(ctx context.Context, defRepo storage.CustomFieldDefRepository, fieldDefID, fieldType string) error { + return resolveFieldDef(ctx, defRepo, fieldDefID, fieldType) +} + func resolveFieldDef(ctx context.Context, defRepo storage.CustomFieldDefRepository, fieldDefID, fieldType string) error { if fieldDefID == "" { return nil diff --git a/server/internal/items/purchase.go b/server/internal/items/purchase.go index 536dbcf..2b69ca6 100644 --- a/server/internal/items/purchase.go +++ b/server/internal/items/purchase.go @@ -87,6 +87,10 @@ func UpdatePurchase(ctx context.Context, repo storage.PurchaseRepository, req Up }) } +func ValidatePurchaseDate(purchasedOn string) error { + return validatePurchaseDate(purchasedOn) +} + func validatePurchaseDate(purchasedOn string) error { if purchasedOn == "" { return nil diff --git a/server/internal/items/sale.go b/server/internal/items/sale.go index f9f5dd6..fa0a859 100644 --- a/server/internal/items/sale.go +++ b/server/internal/items/sale.go @@ -83,6 +83,10 @@ func UpdateSale(ctx context.Context, repo storage.SaleRepository, req UpdateSale }) } +func ValidateSaleDate(soldOn string) error { + return validateSaleDate(soldOn) +} + func validateSaleDate(soldOn string) error { if soldOn == "" { return nil diff --git a/server/internal/items/warranty.go b/server/internal/items/warranty.go index 3e3bbae..dae543c 100644 --- a/server/internal/items/warranty.go +++ b/server/internal/items/warranty.go @@ -83,6 +83,10 @@ func UpdateWarranty(ctx context.Context, repo storage.WarrantyRepository, req Up }) } +func ValidateWarrantyDates(startsOn, expiresOn string) error { + return validateWarrantyDates(startsOn, expiresOn) +} + func validateWarrantyDates(startsOn, expiresOn string) error { for _, d := range []struct{ field, value string }{ {"StartsOn", startsOn}, diff --git a/server/internal/storage/boundary_test.go b/server/internal/storage/boundary_test.go index 1b31fcc..417903d 100644 --- a/server/internal/storage/boundary_test.go +++ b/server/internal/storage/boundary_test.go @@ -96,6 +96,8 @@ var exportedAliasAllowlist = map[string]string{ "ImportSession": "gen.ImportSession", "ItemLabelAssignment": "gen.ItemLabel", "MutationLedgerEntry": "gen.Mutation", + "FieldVersion": "gen.FieldVersion", + "ConflictRecord": "gen.Conflict", } func TestExportedSurfaceExposesNoDatabaseHandle(t *testing.T) { diff --git a/server/internal/storage/field_versions_record.go b/server/internal/storage/field_versions_record.go new file mode 100644 index 0000000..b83b83c --- /dev/null +++ b/server/internal/storage/field_versions_record.go @@ -0,0 +1,157 @@ +package storage + +import ( + "context" + "github.com/ankek/Household-Objects-Dev/server/internal/storage/internal/gen" +) + +func recordFieldVersionsTx(ctx context.Context, q *gen.Queries, group, entityType, entityID string, version, now int64, changedFields []string) error { + for _, field := range changedFields { + if err := upsertFieldVersionTx(ctx, q, group, UpsertFieldVersionParams{ + EntityType: entityType, + EntityID: entityID, + FieldName: field, + Version: version, + Now: now, + }); err != nil { + return err + } + } + return nil +} + +func diffItemFieldVersions(before, after Item) []string { + var changed []string + if before.Name != after.Name { + changed = append(changed, "name") + } + if before.Description != after.Description { + changed = append(changed, "description") + } + if before.LocationID != after.LocationID { + changed = append(changed, "location_id") + } + if before.Quantity != after.Quantity { + changed = append(changed, "quantity") + } + return changed +} + +func diffWarrantyFieldVersions(before, after Warranty) []string { + var changed []string + if before.Holder != after.Holder { + changed = append(changed, "holder") + } + if before.Provider != after.Provider { + changed = append(changed, "provider") + } + if before.StartsOn != after.StartsOn { + changed = append(changed, "starts_on") + } + if before.ExpiresOn != after.ExpiresOn { + changed = append(changed, "expires_on") + } + if before.IsLifetime != after.IsLifetime { + changed = append(changed, "is_lifetime") + } + if before.Notes != after.Notes { + changed = append(changed, "notes") + } + return changed +} + +func diffSaleFieldVersions(before, after Sale) []string { + var changed []string + if before.BuyerName != after.BuyerName { + changed = append(changed, "buyer_name") + } + if before.SoldOn != after.SoldOn { + changed = append(changed, "sold_on") + } + if before.SalePriceMinor != after.SalePriceMinor { + changed = append(changed, "sale_price_minor") + } + if before.Notes != after.Notes { + changed = append(changed, "notes") + } + return changed +} + +func diffPurchaseFieldVersions(before, after Purchase) []string { + var changed []string + if before.Vendor != after.Vendor { + changed = append(changed, "vendor") + } + if before.PurchasedOn != after.PurchasedOn { + changed = append(changed, "purchased_on") + } + if before.PurchasePriceMinor != after.PurchasePriceMinor { + changed = append(changed, "purchase_price_minor") + } + if before.OrderReference != after.OrderReference { + changed = append(changed, "order_reference") + } + if before.Notes != after.Notes { + changed = append(changed, "notes") + } + return changed +} + +func diffIdentificationFieldVersions(before, after Identification) []string { + var changed []string + if before.Kind != after.Kind { + changed = append(changed, "kind") + } + if before.Value != after.Value { + changed = append(changed, "value") + } + return changed +} + +func diffItemCustomFieldFieldVersions(before, after ItemCustomField) []string { + var changed []string + if before.FieldDefID != after.FieldDefID { + changed = append(changed, "field_def_id") + } + if before.Name != after.Name { + changed = append(changed, "name") + } + if before.FieldType != after.FieldType { + changed = append(changed, "field_type") + } + if before.TextValue != after.TextValue { + changed = append(changed, "text_value") + } + if before.NumberValue != after.NumberValue { + changed = append(changed, "number_value") + } + if before.BoolValue != after.BoolValue { + changed = append(changed, "bool_value") + } + if before.DateValue != after.DateValue { + changed = append(changed, "date_value") + } + return changed +} + +func diffLocationFieldVersions(before, after Location) []string { + var changed []string + if before.Name != after.Name { + changed = append(changed, "name") + } + if before.ParentID != after.ParentID { + changed = append(changed, "parent_id") + } + return changed +} + +func diffLabelFieldVersions(before, after Label) []string { + var changed []string + if before.Name != after.Name { + changed = append(changed, "name") + } + if before.Color != after.Color { + changed = append(changed, "color") + } + return changed +} diff --git a/server/internal/storage/field_versions_record_test.go b/server/internal/storage/field_versions_record_test.go new file mode 100644 index 0000000..07bf9d4 --- /dev/null +++ b/server/internal/storage/field_versions_record_test.go @@ -0,0 +1,833 @@ +package storage + +import ( + "errors" + "testing" +) + +func fvPushRepo(t *testing.T, s *Storage, groupID string) PushRepository { + t.Helper() + repo, err := s.ForGroupPush(MustGroupID(groupID)) + if err != nil { + t.Fatalf("ForGroupPush(%s): %v", groupID, err) + } + return repo +} + +func fvFieldVersions(t *testing.T, push PushRepository, entityType, entityID string) map[string]int64 { + t.Helper() + rows, err := push.LookupFieldVersions(t.Context(), entityType, entityID) + if err != nil { + t.Fatalf("LookupFieldVersions(%s, %s): %v", entityType, entityID, err) + } + out := make(map[string]int64, len(rows)) + for _, r := range rows { + out[r.FieldName] = r.Version + } + return out +} + +func TestItemUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Items().Create(t.Context(), CreateItemParams{ + ID: "item-1", Name: "Lamp", Description: "desk lamp", Quantity: 2, ShortCode: "CODE0001", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Items().Update(t.Context(), UpdateItemParams{ + ItemID: created.ID, Name: created.Name, Description: created.Description, + Quantity: 9, ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushItemFields["quantity"] { + t.Fatal("sanity: \"quantity\" is not in push_commit.go's own pushItemFields -- the key this test asserts would not match what push reads") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for item %q = %+v, want exactly one entry (quantity)", created.ID, got) + } + if v, ok := got["quantity"]; !ok || v != updated.Version { + t.Errorf("field_versions[quantity] = %v (present=%v), want %d", v, ok, updated.Version) + } + for _, unchanged := range []string{"name", "description", "location_id"} { + if _, ok := got[unchanged]; ok { + t.Errorf("field_versions[%s] was recorded despite that field being resent unchanged", unchanged) + } + } +} + +func TestItemUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Items().Create(t.Context(), CreateItemParams{ + ID: "item-1", Name: "Lamp", Description: "desk lamp", Quantity: 2, ShortCode: "CODE0001", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Items().Update(t.Context(), UpdateItemParams{ + ItemID: created.ID, Name: created.Name, Description: created.Description, + Quantity: created.Quantity, ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op update = %+v, want none", got) + } +} + +func TestItemUpdateFieldVersionsRollBackWithTheFailedWrite(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Items().Create(t.Context(), CreateItemParams{ + ID: "item-1", Name: "Lamp", Quantity: 2, ShortCode: "CODE0001", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + _, err = scope.Items().Update(t.Context(), UpdateItemParams{ + ItemID: created.ID, Name: "New Name", Quantity: 99, + ExpectedVersion: created.Version + 5, Now: 1001, + }) + if !errors.Is(err, ErrVersionMismatch) { + t.Fatalf("Update with a stale ExpectedVersion: err = %v, want ErrVersionMismatch", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a FAILED update = %+v, want none", got) + } +} + +func TestItemUpdateFieldVersionsAreGroupScoped(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + seedGroupRow(t, s, "groupB") + scopeA, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup(groupA): %v", err) + } + + created, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-a", Name: "Lamp", Quantity: 2, ShortCode: "CODE0001", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: created.ID, Name: created.Name, Quantity: 7, ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update: %v", err) + } + + gotA := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item", created.ID) + if len(gotA) != 1 || gotA["quantity"] == 0 { + t.Fatalf("groupA field_versions = %+v, want exactly one tracked quantity row", gotA) + } + + gotB := fvFieldVersions(t, fvPushRepo(t, s, "groupB"), "item", created.ID) + if len(gotB) != 0 { + t.Fatalf("groupB field_versions for groupA's own item = %+v, want none (P-3)", gotB) + } +} + +func TestWarrantyUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Warranty().Create(t.Context(), CreateWarrantyParams{ + ID: "war-1", ItemID: "itemA", Holder: "Alice", Provider: "Acme", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Warranty().Update(t.Context(), UpdateWarrantyParams{ + ItemID: "itemA", Holder: created.Holder, Provider: "New Provider", + ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushWarrantyUpdateFields["provider"] { + t.Fatal("sanity: \"provider\" is not in push_commit.go's own pushWarrantyUpdateFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "warranty_block", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for warranty_block %q = %+v, want exactly one entry (provider)", created.ID, got) + } + if v, ok := got["provider"]; !ok || v != updated.Version { + t.Errorf("field_versions[provider] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["holder"]; ok { + t.Errorf("field_versions[holder] recorded despite being resent unchanged") + } +} + +func TestWarrantyUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Warranty().Create(t.Context(), CreateWarrantyParams{ + ID: "war-1", ItemID: "itemA", Holder: "Alice", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Warranty().Update(t.Context(), UpdateWarrantyParams{ + ItemID: "itemA", Holder: created.Holder, Provider: created.Provider, + StartsOn: "", ExpiresOn: "", IsLifetime: false, Notes: created.Notes, + ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "warranty_block", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op warranty update = %+v, want none", got) + } +} + +func TestSaleUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Sale().Create(t.Context(), CreateSaleParams{ + ID: "sale-1", ItemID: "itemA", BuyerName: "Bob", SalePriceMinor: 1000, Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Sale().Update(t.Context(), UpdateSaleParams{ + ItemID: "itemA", BuyerName: created.BuyerName, SalePriceMinor: 2500, Notes: created.Notes, + ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushSaleUpdateFields["sale_price_minor"] { + t.Fatal("sanity: \"sale_price_minor\" is not in push_commit.go's own pushSaleUpdateFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "sold_to_block", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for sold_to_block %q = %+v, want exactly one entry (sale_price_minor)", created.ID, got) + } + if v, ok := got["sale_price_minor"]; !ok || v != updated.Version { + t.Errorf("field_versions[sale_price_minor] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["buyer_name"]; ok { + t.Errorf("field_versions[buyer_name] recorded despite being resent unchanged") + } +} + +func TestSaleUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Sale().Create(t.Context(), CreateSaleParams{ + ID: "sale-1", ItemID: "itemA", BuyerName: "Bob", SalePriceMinor: 1000, Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Sale().Update(t.Context(), UpdateSaleParams{ + ItemID: "itemA", BuyerName: created.BuyerName, SoldOn: "", SalePriceMinor: created.SalePriceMinor, + Notes: created.Notes, ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "sold_to_block", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op sale update = %+v, want none", got) + } +} + +func TestPurchaseUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Purchase().Create(t.Context(), CreatePurchaseParams{ + ID: "pur-1", ItemID: "itemA", Vendor: "Acme", PurchasePriceMinor: 500, Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Purchase().Update(t.Context(), UpdatePurchaseParams{ + ItemID: "itemA", Vendor: "New Vendor", PurchasePriceMinor: created.PurchasePriceMinor, + OrderReference: created.OrderReference, Notes: created.Notes, + ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushPurchaseUpdateFields["vendor"] { + t.Fatal("sanity: \"vendor\" is not in push_commit.go's own pushPurchaseUpdateFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "purchased_from_block", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for purchased_from_block %q = %+v, want exactly one entry (vendor)", created.ID, got) + } + if v, ok := got["vendor"]; !ok || v != updated.Version { + t.Errorf("field_versions[vendor] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["purchase_price_minor"]; ok { + t.Errorf("field_versions[purchase_price_minor] recorded despite being resent unchanged") + } +} + +func TestPurchaseUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Purchase().Create(t.Context(), CreatePurchaseParams{ + ID: "pur-1", ItemID: "itemA", Vendor: "Acme", PurchasePriceMinor: 500, Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Purchase().Update(t.Context(), UpdatePurchaseParams{ + ItemID: "itemA", Vendor: created.Vendor, PurchasedOn: "", PurchasePriceMinor: created.PurchasePriceMinor, + OrderReference: created.OrderReference, Notes: created.Notes, + ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "purchased_from_block", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op purchase update = %+v, want none", got) + } +} + +func TestIdentificationUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Identifications().Create(t.Context(), CreateIdentificationParams{ + ID: "ident-1", ItemID: "itemA", Kind: "serial", Value: "SN-1", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Identifications().Update(t.Context(), UpdateIdentificationParams{ + ItemID: "itemA", ID: created.ID, Kind: created.Kind, Value: "SN-2", + ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushIdentificationUpdateFields["value"] { + t.Fatal("sanity: \"value\" is not in push_commit.go's own pushIdentificationUpdateFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item_identification", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for item_identification %q = %+v, want exactly one entry (value)", created.ID, got) + } + if v, ok := got["value"]; !ok || v != updated.Version { + t.Errorf("field_versions[value] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["kind"]; ok { + t.Errorf("field_versions[kind] recorded despite being resent unchanged") + } +} + +func TestIdentificationUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Identifications().Create(t.Context(), CreateIdentificationParams{ + ID: "ident-1", ItemID: "itemA", Kind: "serial", Value: "SN-1", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Identifications().Update(t.Context(), UpdateIdentificationParams{ + ItemID: "itemA", ID: created.ID, Kind: created.Kind, Value: created.Value, + ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item_identification", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op identification update = %+v, want none", got) + } +} + +func TestItemCustomFieldUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + textValue := "red" + created, err := scope.ItemCustomFields().Create(t.Context(), CreateItemCustomFieldParams{ + ID: "cf-1", ItemID: "itemA", Name: "Colour", FieldType: "text", TextValue: &textValue, Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + newValue := "blue" + updated, err := scope.ItemCustomFields().Update(t.Context(), UpdateItemCustomFieldParams{ + ItemID: "itemA", ID: created.ID, FieldDefID: created.FieldDefID.String, Name: created.Name, + FieldType: created.FieldType, TextValue: &newValue, + ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushCustomFieldUpdateFields["text_value"] { + t.Fatal("sanity: \"text_value\" is not in push_commit.go's own pushCustomFieldUpdateFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item_custom_field", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for item_custom_field %q = %+v, want exactly one entry (text_value)", created.ID, got) + } + if v, ok := got["text_value"]; !ok || v != updated.Version { + t.Errorf("field_versions[text_value] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["name"]; ok { + t.Errorf("field_versions[name] recorded despite being resent unchanged") + } +} + +func TestItemCustomFieldUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + textValue := "red" + created, err := scope.ItemCustomFields().Create(t.Context(), CreateItemCustomFieldParams{ + ID: "cf-1", ItemID: "itemA", Name: "Colour", FieldType: "text", TextValue: &textValue, Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.ItemCustomFields().Update(t.Context(), UpdateItemCustomFieldParams{ + ItemID: "itemA", ID: created.ID, FieldDefID: created.FieldDefID.String, Name: created.Name, + FieldType: created.FieldType, TextValue: &textValue, + ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item_custom_field", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op custom field update = %+v, want none", got) + } +} + +func TestLocationUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Locations().Create(t.Context(), CreateLocationParams{ + ID: "loc-1", Name: "Garage", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Locations().Update(t.Context(), UpdateLocationParams{ + LocationID: created.ID, Name: "Garage Shelf", ParentID: created.ParentID.String, + ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushLocationFields["name"] { + t.Fatal("sanity: \"name\" is not in push_commit.go's own pushLocationFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "location", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for location %q = %+v, want exactly one entry (name)", created.ID, got) + } + if v, ok := got["name"]; !ok || v != updated.Version { + t.Errorf("field_versions[name] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["parent_id"]; ok { + t.Errorf("field_versions[parent_id] recorded despite being resent unchanged") + } +} + +func TestLocationUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Locations().Create(t.Context(), CreateLocationParams{ + ID: "loc-1", Name: "Garage", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Locations().Update(t.Context(), UpdateLocationParams{ + LocationID: created.ID, Name: created.Name, ParentID: created.ParentID.String, + ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "location", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op location update = %+v, want none", got) + } +} + +func TestLabelUpdateRecordsFieldVersionsForChangedFieldOnly(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Labels().Create(t.Context(), CreateLabelParams{ + ID: "label-1", Name: "Fragile", Color: "#ff0000", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + updated, err := scope.Labels().Update(t.Context(), UpdateLabelParams{ + ID: created.ID, Name: created.Name, Color: "#00ff00", ExpectedVersion: created.Version, Now: 1001, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + + if !pushLabelFields["color"] { + t.Fatal("sanity: \"color\" is not in push_commit.go's own pushLabelFields") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "label", created.ID) + if len(got) != 1 { + t.Fatalf("field_versions for label %q = %+v, want exactly one entry (color)", created.ID, got) + } + if v, ok := got["color"]; !ok || v != updated.Version { + t.Errorf("field_versions[color] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["name"]; ok { + t.Errorf("field_versions[name] recorded despite being resent unchanged") + } +} + +func TestLabelUpdateNoOpRecordsNoFieldVersions(t *testing.T) { + s := newTestStorage(t) + seedGroupRow(t, s, "groupA") + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + created, err := scope.Labels().Create(t.Context(), CreateLabelParams{ + ID: "label-1", Name: "Fragile", Color: "#ff0000", Now: 1000, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if _, err := scope.Labels().Update(t.Context(), UpdateLabelParams{ + ID: created.ID, Name: created.Name, Color: created.Color, ExpectedVersion: created.Version, Now: 1001, + }); err != nil { + t.Fatalf("Update (no-op): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "label", created.ID) + if len(got) != 0 { + t.Fatalf("field_versions after a no-op label update = %+v, want none", got) + } +} + +func TestStockAdjustmentCreateRecordsItemQuantityFieldVersion(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + itemBefore, err := scope.Items().Get(t.Context(), "itemA") + if err != nil { + t.Fatalf("Get(itemA): %v", err) + } + + if _, err := scope.StockAdjustments().Create(t.Context(), CreateStockAdjustmentParams{ + ID: "adj-1", ItemID: "itemA", Delta: 3, Reason: "recount", Now: 1001, + }); err != nil { + t.Fatalf("StockAdjustments.Create: %v", err) + } + + itemAfter, err := scope.Items().Get(t.Context(), "itemA") + if err != nil { + t.Fatalf("Get(itemA) after adjustment: %v", err) + } + if itemAfter.Quantity != itemBefore.Quantity+3 { + t.Fatalf("item quantity after adjustment = %d, want %d", itemAfter.Quantity, itemBefore.Quantity+3) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item", "itemA") + if len(got) != 1 { + t.Fatalf("field_versions for item %q after a stock adjustment = %+v, want exactly one entry (quantity)", "itemA", got) + } + if v, ok := got["quantity"]; !ok || v != itemAfter.Version { + t.Errorf("field_versions[quantity] = %v (present=%v), want %d", v, ok, itemAfter.Version) + } +} + +func TestStockAdjustmentCreateWithZeroDeltaRecordsNoFieldVersion(t *testing.T) { + s := newTestStorage(t) + seedItem(t, s, "groupA", "itemA", 10) + scope, err := s.ForGroup(MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + if _, err := scope.StockAdjustments().Create(t.Context(), CreateStockAdjustmentParams{ + ID: "adj-1", ItemID: "itemA", Delta: 0, Reason: "recount, no change", Now: 1001, + }); err != nil { + t.Fatalf("StockAdjustments.Create: %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, "groupA"), "item", "itemA") + if len(got) != 0 { + t.Fatalf("field_versions after a zero-delta stock adjustment = %+v, want none (quantity did not actually change)", got) + } +} + +func TestImportCommitUpdateRecordsItemCoreFieldVersion(t *testing.T) { + const group = "grp-import-fv" + commit, scope, s := importCommitScope(t, group) + + itemToUpdate, err := scope.Items().Create(t.Context(), CreateItemParams{ + ID: "item-upd", Name: "Old Name", Quantity: 2, ShortCode: "UPD0001", Now: 1000, + }) + if err != nil { + t.Fatalf("create item-upd: %v", err) + } + stageSession(t, s, group, "import-1", 2000) + + result, err := commit.Commit(t.Context(), ImportCommitParams{ + ImportSessionID: "import-1", + Now: 3000, + Rows: []ImportCommitRow{ + { + Line: 2, Action: ImportCommitRowUpdate, ItemID: itemToUpdate.ID, + Changes: []string{"name"}, + Proposed: ImportCommitProposedRow{Name: "New Name", Quantity: itemToUpdate.Quantity}, + }, + }, + }) + if err != nil { + t.Fatalf("Commit: %v", err) + } + if result.Updated != 1 { + t.Fatalf("result = %+v, want Updated:1", result) + } + + updated, err := scope.Items().Get(t.Context(), itemToUpdate.ID) + if err != nil { + t.Fatalf("Get(updated): %v", err) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, group), "item", itemToUpdate.ID) + if len(got) != 1 { + t.Fatalf("field_versions for imported item %q = %+v, want exactly one entry (name)", itemToUpdate.ID, got) + } + if v, ok := got["name"]; !ok || v != updated.Version { + t.Errorf("field_versions[name] = %v (present=%v), want %d", v, ok, updated.Version) + } + if _, ok := got["quantity"]; ok { + t.Errorf("field_versions[quantity] recorded despite being resent unchanged") + } +} + +func TestImportCommitUpdateSkippingCoreFieldsRecordsNoItemFieldVersion(t *testing.T) { + const group = "grp-import-fv-skip" + commit, scope, s := importCommitScope(t, group) + + itemToUpdate, err := scope.Items().Create(t.Context(), CreateItemParams{ + ID: "item-upd", Name: "Same Name", Quantity: 2, ShortCode: "UPD0001", Now: 1000, + }) + if err != nil { + t.Fatalf("create item-upd: %v", err) + } + stageSession(t, s, group, "import-1", 2000) + + result, err := commit.Commit(t.Context(), ImportCommitParams{ + ImportSessionID: "import-1", + Now: 3000, + Rows: []ImportCommitRow{ + { + Line: 2, Action: ImportCommitRowUpdate, ItemID: itemToUpdate.ID, + Changes: []string{"labels"}, + Proposed: ImportCommitProposedRow{Name: itemToUpdate.Name, Quantity: itemToUpdate.Quantity, Labels: []string{"Fragile"}}, + }, + }, + }) + if err != nil { + t.Fatalf("Commit: %v", err) + } + if result.Updated != 1 { + t.Fatalf("result = %+v, want Updated:1", result) + } + + got := fvFieldVersions(t, fvPushRepo(t, s, group), "item", itemToUpdate.ID) + if len(got) != 0 { + t.Fatalf("field_versions for entity_type item after a labels-only import update = %+v, want none", got) + } +} + +func TestImportCommitUpdateRecordsWarrantyBlockFieldVersion(t *testing.T) { + const group = "grp-import-fv-warranty" + commit, scope, s := importCommitScope(t, group) + + item, err := scope.Items().Create(t.Context(), CreateItemParams{ + ID: "item-1", Name: "Lamp", ShortCode: "WAR0001", Now: 1000, + }) + if err != nil { + t.Fatalf("create item: %v", err) + } + warranty, err := scope.Warranty().Create(t.Context(), CreateWarrantyParams{ + ID: "war-1", ItemID: item.ID, Holder: "Alice", Now: 1000, + }) + if err != nil { + t.Fatalf("create warranty: %v", err) + } + stageSession(t, s, group, "import-1", 2000) + + result, err := commit.Commit(t.Context(), ImportCommitParams{ + ImportSessionID: "import-1", + Now: 3000, + Rows: []ImportCommitRow{ + { + Line: 2, Action: ImportCommitRowUpdate, ItemID: item.ID, + Changes: []string{"warranty"}, + Proposed: ImportCommitProposedRow{ + Name: item.Name, Quantity: item.Quantity, + Warranty: &ImportCommitWarranty{Holder: "New Holder"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Commit: %v", err) + } + if result.Updated != 1 { + t.Fatalf("result = %+v, want Updated:1", result) + } + + updatedWarranty, err := scope.Warranty().Get(t.Context(), item.ID) + if err != nil { + t.Fatalf("Warranty.Get after import update: %v", err) + } + if updatedWarranty.Holder != "New Holder" { + t.Fatalf("warranty.Holder = %q, want %q", updatedWarranty.Holder, "New Holder") + } + + got := fvFieldVersions(t, fvPushRepo(t, s, group), "warranty_block", warranty.ID) + if len(got) != 1 { + t.Fatalf("field_versions for warranty_block %q = %+v, want exactly one entry (holder)", warranty.ID, got) + } + if v, ok := got["holder"]; !ok || v != updatedWarranty.Version { + t.Errorf("field_versions[holder] = %v (present=%v), want %d", v, ok, updatedWarranty.Version) + } + + gotItem := fvFieldVersions(t, fvPushRepo(t, s, group), "item", item.ID) + if len(gotItem) != 0 { + t.Fatalf("field_versions for entity_type item after a warranty-only import update = %+v, want none", gotItem) + } +} diff --git a/server/internal/storage/import_commit.go b/server/internal/storage/import_commit.go index 18ee6df..c49a192 100644 --- a/server/internal/storage/import_commit.go +++ b/server/internal/storage/import_commit.go @@ -272,6 +272,14 @@ func (r importCommitRepository) commitUpdateRow(ctx context.Context, tx *sql.Tx, if rows != 1 { return fmt.Errorf("update item core fields: matched %d rows, want 1 (read version %d); concurrent modification during import commit", rows, current.Version) } + + after := Item{ + Name: row.Proposed.Name, Description: row.Proposed.Description, + LocationID: nullString(row.Proposed.LocationID), Quantity: row.Proposed.Quantity, + } + if err := recordFieldVersionsTx(ctx, q, r.group, "item", row.ItemID, current.Version+1, now, diffItemFieldVersions(current, after)); err != nil { + return fmt.Errorf("field versions: %w", err) + } } if changed["labels"] { @@ -509,7 +517,13 @@ func upsertWarranty(ctx context.Context, q *gen.Queries, group, itemID string, c if rows != 1 { return fmt.Errorf("update: matched %d rows, want 1 (read version %d); concurrent modification during import commit", rows, current.Version) } - return nil + + after := Warranty{ + Holder: proposed.Holder, Provider: proposed.Provider, + StartsOn: nullString(proposed.StartsOn), ExpiresOn: nullString(proposed.ExpiresOn), + IsLifetime: boolToInt(proposed.IsLifetime), Notes: proposed.Notes, + } + return recordFieldVersionsTx(ctx, q, group, "warranty_block", current.ID, current.Version+1, now, diffWarrantyFieldVersions(*current, after)) } } @@ -550,7 +564,12 @@ func upsertPurchase(ctx context.Context, q *gen.Queries, group, itemID string, c if rows != 1 { return fmt.Errorf("update: matched %d rows, want 1 (read version %d); concurrent modification during import commit", rows, current.Version) } - return nil + + after := Purchase{ + Vendor: proposed.Vendor, PurchasedOn: nullString(proposed.PurchasedOn), + PurchasePriceMinor: proposed.PurchasePriceMinor, OrderReference: proposed.OrderReference, Notes: proposed.Notes, + } + return recordFieldVersionsTx(ctx, q, group, "purchased_from_block", current.ID, current.Version+1, now, diffPurchaseFieldVersions(*current, after)) } } @@ -591,7 +610,12 @@ func upsertSale(ctx context.Context, q *gen.Queries, group, itemID string, curre if rows != 1 { return fmt.Errorf("update: matched %d rows, want 1 (read version %d); concurrent modification during import commit", rows, current.Version) } - return nil + + after := Sale{ + BuyerName: proposed.BuyerName, SoldOn: nullString(proposed.SoldOn), + SalePriceMinor: proposed.SalePriceMinor, Notes: proposed.Notes, + } + return recordFieldVersionsTx(ctx, q, group, "sold_to_block", current.ID, current.Version+1, now, diffSaleFieldVersions(*current, after)) } } diff --git a/server/internal/storage/internal/gen/conflicts.sql.go b/server/internal/storage/internal/gen/conflicts.sql.go new file mode 100644 index 0000000..5b870e2 --- /dev/null +++ b/server/internal/storage/internal/gen/conflicts.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. + +package gen + +import ( + "context" + "database/sql" +) + +const createConflict = `-- name: CreateConflict :exec + +INSERT INTO conflicts ( + id, group_id, entity_type, entity_id, field_name, + server_value_snapshot, losing_client_value_snapshot, + detected_at, mutation_id, created_at, updated_at +) +VALUES ( + ?1, ?2, ?3, ?4, ?5, + ?6, ?7, + ?8, ?9, ?10, ?10 +) +` + +type CreateConflictParams struct { + ID string + GroupID string + EntityType string + EntityID string + FieldName string + ServerValueSnapshot sql.NullString + LosingClientValueSnapshot sql.NullString + DetectedAt int64 + MutationID sql.NullString + Now int64 +} + +func (q *Queries) CreateConflict(ctx context.Context, arg CreateConflictParams) error { + _, err := q.db.ExecContext(ctx, createConflict, + arg.ID, + arg.GroupID, + arg.EntityType, + arg.EntityID, + arg.FieldName, + arg.ServerValueSnapshot, + arg.LosingClientValueSnapshot, + arg.DetectedAt, + arg.MutationID, + arg.Now, + ) + return err +} diff --git a/server/internal/storage/internal/gen/field_versions.sql.go b/server/internal/storage/internal/gen/field_versions.sql.go new file mode 100644 index 0000000..1c53364 --- /dev/null +++ b/server/internal/storage/internal/gen/field_versions.sql.go @@ -0,0 +1,82 @@ +// Code generated by sqlc. DO NOT EDIT. + +package gen + +import ( + "context" +) + +const listFieldVersionsByEntity = `-- name: ListFieldVersionsByEntity :many + +SELECT group_id, entity_type, entity_id, field_name, version, updated_at FROM field_versions +WHERE group_id = ?1 + AND entity_type = ?2 + AND entity_id = ?3 +` + +type ListFieldVersionsByEntityParams struct { + GroupID string + EntityType string + EntityID string +} + +func (q *Queries) ListFieldVersionsByEntity(ctx context.Context, arg ListFieldVersionsByEntityParams) ([]FieldVersion, error) { + rows, err := q.db.QueryContext(ctx, listFieldVersionsByEntity, arg.GroupID, arg.EntityType, arg.EntityID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FieldVersion{} + for rows.Next() { + var i FieldVersion + if err := rows.Scan( + &i.GroupID, + &i.EntityType, + &i.EntityID, + &i.FieldName, + &i.Version, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertFieldVersion = `-- name: UpsertFieldVersion :exec +INSERT INTO field_versions (group_id, entity_type, entity_id, field_name, version, updated_at) +VALUES ( + ?1, ?2, ?3, ?4, + ?5, ?6 +) +ON CONFLICT (group_id, entity_type, entity_id, field_name) +DO UPDATE SET version = excluded.version, updated_at = excluded.updated_at +` + +type UpsertFieldVersionParams struct { + GroupID string + EntityType string + EntityID string + FieldName string + Version int64 + Now int64 +} + +func (q *Queries) UpsertFieldVersion(ctx context.Context, arg UpsertFieldVersionParams) error { + _, err := q.db.ExecContext(ctx, upsertFieldVersion, + arg.GroupID, + arg.EntityType, + arg.EntityID, + arg.FieldName, + arg.Version, + arg.Now, + ) + return err +} diff --git a/server/internal/storage/internal/gen/models.go b/server/internal/storage/internal/gen/models.go index 4707de6..330ff7e 100644 --- a/server/internal/storage/internal/gen/models.go +++ b/server/internal/storage/internal/gen/models.go @@ -65,6 +65,15 @@ type DeviceToken struct { ChangeSeq int64 } +type FieldVersion struct { + GroupID string + EntityType string + EntityID string + FieldName string + Version int64 + UpdatedAt int64 +} + type Group struct { ID string Name string diff --git a/server/internal/storage/item_custom_fields.go b/server/internal/storage/item_custom_fields.go index c0e15d9..d0eb4d8 100644 --- a/server/internal/storage/item_custom_fields.go +++ b/server/internal/storage/item_custom_fields.go @@ -186,12 +186,18 @@ func (r itemCustomFieldRepository) Update(ctx context.Context, p UpdateItemCusto var updated ItemCustomField err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItemCustomField(ctx, gen.GetItemCustomFieldParams{GroupID: r.group(), ItemID: p.ItemID, ID: p.ID}) + row, err := updateItemCustomFieldTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: custom field %q on item %q: field-version diff pre-read: %w", p.ID, p.ItemID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "item_custom_field", row.ID, row.Version, p.Now, diffItemCustomFieldFieldVersions(before, row)) }) if err != nil { return ItemCustomField{}, err diff --git a/server/internal/storage/item_identifications.go b/server/internal/storage/item_identifications.go index ec6aebf..661dd3b 100644 --- a/server/internal/storage/item_identifications.go +++ b/server/internal/storage/item_identifications.go @@ -170,12 +170,18 @@ func (r identificationRepository) Update(ctx context.Context, p UpdateIdentifica var updated Identification err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItemIdentification(ctx, gen.GetItemIdentificationParams{GroupID: r.group(), ItemID: p.ItemID, ID: p.ID}) + row, err := updateIdentificationTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: identification %q on item %q: field-version diff pre-read: %w", p.ID, p.ItemID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "item_identification", row.ID, row.Version, p.Now, diffIdentificationFieldVersions(before, row)) }) if err != nil { return Identification{}, err diff --git a/server/internal/storage/item_labels.go b/server/internal/storage/item_labels.go index b67a355..9b24764 100644 --- a/server/internal/storage/item_labels.go +++ b/server/internal/storage/item_labels.go @@ -163,29 +163,33 @@ func (r itemLabelRepository) Detach(ctx context.Context, itemID, labelID string, } return r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { - seq, err := db.AllocChangeSeq(ctx, tx, r.group()) - if err != nil { - return fmt.Errorf("storage: allocate change_seq for label detach: %w", err) - } + return detachItemLabelTx(ctx, tx, q, r.group(), itemID, labelID, now) + }) +} - rows, err := q.DeleteItemLabel(ctx, gen.DeleteItemLabelParams{ - DeletedAt: sql.NullInt64{Int64: now, Valid: true}, - Now: now, - ChangeSeq: seq, - GroupID: r.group(), - ItemID: itemID, - LabelID: labelID, - }) - if err != nil { - return fmt.Errorf("storage: detach label %q from item %q: %w", labelID, itemID, err) - } - switch { - case rows == 0: - return fmt.Errorf("storage: label %q on item %q: %w", labelID, itemID, ErrNotFound) - case rows != 1: - return fmt.Errorf("storage: detach label %q from item %q: matched %d rows, want 1; ux_item_labels_group_item_label makes (group_id, item_id, label_id) unique among live rows, so this is reachable only if a predicate was lost from DeleteItemLabel -- see this method's own doc", - labelID, itemID, rows) - } - return nil +func detachItemLabelTx(ctx context.Context, tx *sql.Tx, q *gen.Queries, group, itemID, labelID string, now int64) error { + seq, err := db.AllocChangeSeq(ctx, tx, group) + if err != nil { + return fmt.Errorf("storage: allocate change_seq for label detach: %w", err) + } + + rows, err := q.DeleteItemLabel(ctx, gen.DeleteItemLabelParams{ + DeletedAt: sql.NullInt64{Int64: now, Valid: true}, + Now: now, + ChangeSeq: seq, + GroupID: group, + ItemID: itemID, + LabelID: labelID, }) + if err != nil { + return fmt.Errorf("storage: detach label %q from item %q: %w", labelID, itemID, err) + } + switch { + case rows == 0: + return fmt.Errorf("storage: label %q on item %q: %w", labelID, itemID, ErrNotFound) + case rows != 1: + return fmt.Errorf("storage: detach label %q from item %q: matched %d rows, want 1; ux_item_labels_group_item_label makes (group_id, item_id, label_id) unique among live rows, so this is reachable only if a predicate was lost from DeleteItemLabel -- see this method's own doc", + labelID, itemID, rows) + } + return nil } diff --git a/server/internal/storage/items.go b/server/internal/storage/items.go index 554aa7d..1130722 100644 --- a/server/internal/storage/items.go +++ b/server/internal/storage/items.go @@ -209,12 +209,18 @@ func (r itemRepository) Update(ctx context.Context, p UpdateItemParams) (Item, e var updated Item err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItem(ctx, gen.GetItemParams{GroupID: r.group(), ItemID: p.ItemID}) + row, err := updateItemTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: item %q: field-version diff pre-read: %w", p.ItemID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "item", row.ID, row.Version, p.Now, diffItemFieldVersions(before, row)) }) if err != nil { return Item{}, err diff --git a/server/internal/storage/labels.go b/server/internal/storage/labels.go index 2ef36f4..363495a 100644 --- a/server/internal/storage/labels.go +++ b/server/internal/storage/labels.go @@ -155,12 +155,18 @@ func (r labelRepository) Update(ctx context.Context, p UpdateLabelParams) (Label var updated Label err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetLabel(ctx, gen.GetLabelParams{GroupID: r.group(), ID: p.ID}) + row, err := updateLabelTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: label %q: field-version diff pre-read: %w", p.ID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "label", row.ID, row.Version, p.Now, diffLabelFieldVersions(before, row)) }) if err != nil { return Label{}, err diff --git a/server/internal/storage/locations.go b/server/internal/storage/locations.go index 2f4a2fb..ce23164 100644 --- a/server/internal/storage/locations.go +++ b/server/internal/storage/locations.go @@ -211,12 +211,18 @@ func (r locationRepository) Update(ctx context.Context, p UpdateLocationParams) var updated Location err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetLocation(ctx, gen.GetLocationParams{GroupID: r.group(), LocationID: p.LocationID}) + row, err := updateLocationTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: location %q: field-version diff pre-read: %w", p.LocationID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "location", row.ID, row.Version, p.Now, diffLocationFieldVersions(before, row)) }) if err != nil { return Location{}, err diff --git a/server/internal/storage/purchase.go b/server/internal/storage/purchase.go index 85bee78..1df5312 100644 --- a/server/internal/storage/purchase.go +++ b/server/internal/storage/purchase.go @@ -163,12 +163,18 @@ func (r purchaseRepository) Update(ctx context.Context, p UpdatePurchaseParams) var updated Purchase err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItemPurchase(ctx, gen.GetItemPurchaseParams{GroupID: r.group(), ItemID: p.ItemID}) + row, err := updatePurchaseTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: purchase for item %q: field-version diff pre-read: %w", p.ItemID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "purchased_from_block", row.ID, row.Version, p.Now, diffPurchaseFieldVersions(before, row)) }) if err != nil { return Purchase{}, err diff --git a/server/internal/storage/push.go b/server/internal/storage/push.go index 0e53387..6e1e758 100644 --- a/server/internal/storage/push.go +++ b/server/internal/storage/push.go @@ -10,6 +10,10 @@ import ( type MutationLedgerEntry = gen.Mutation +type FieldVersion = gen.FieldVersion + +type ConflictRecord = gen.Conflict + type MutationOutcome string const ( @@ -56,10 +60,70 @@ func (p InsertMutationLedgerEntryParams) validate() error { return nil } +type UpsertFieldVersionParams struct { + EntityType string + EntityID string + FieldName string + Version int64 + Now int64 +} + +func (p UpsertFieldVersionParams) validate() error { + switch { + case p.EntityType == "": + return errors.New("storage: UpsertFieldVersionParams: EntityType is empty") + case p.EntityID == "": + return errors.New("storage: UpsertFieldVersionParams: EntityID is empty") + case p.FieldName == "": + return errors.New("storage: UpsertFieldVersionParams: FieldName is empty") + case p.Version <= 0: + return errors.New("storage: UpsertFieldVersionParams: Version must be positive (migration 0005's bootstrap rule treats an absent row as version 1, never 0)") + case p.Now <= 0: + return errors.New("storage: UpsertFieldVersionParams: Now must be a positive Unix-millisecond timestamp") + } + return nil +} + +type InsertConflictParams struct { + ID string + EntityType string + EntityID string + FieldName string + ServerValueSnapshot string + LosingClientValueSnapshot string + DetectedAt int64 + MutationID string + Now int64 +} + +func (p InsertConflictParams) validate() error { + switch { + case p.ID == "": + return errors.New("storage: InsertConflictParams: ID is empty") + case p.EntityType == "": + return errors.New("storage: InsertConflictParams: EntityType is empty") + case p.EntityID == "": + return errors.New("storage: InsertConflictParams: EntityID is empty") + case p.FieldName == "": + return errors.New("storage: InsertConflictParams: FieldName is empty") + case p.DetectedAt <= 0: + return errors.New("storage: InsertConflictParams: DetectedAt must be a positive Unix-millisecond timestamp") + case p.Now <= 0: + return errors.New("storage: InsertConflictParams: Now must be a positive Unix-millisecond timestamp") + } + return nil +} + type PushRepository interface { Lookup(ctx context.Context, mutationID string) (MutationLedgerEntry, error) Insert(ctx context.Context, p InsertMutationLedgerEntryParams) (MutationLedgerEntry, error) + + LookupFieldVersions(ctx context.Context, entityType, entityID string) ([]FieldVersion, error) + + UpsertFieldVersion(ctx context.Context, p UpsertFieldVersionParams) error + + InsertConflict(ctx context.Context, p InsertConflictParams) (ConflictRecord, error) } type pushRepository struct { @@ -90,6 +154,39 @@ func (r pushRepository) Insert(ctx context.Context, p InsertMutationLedgerEntryP return created, nil } +func (r pushRepository) LookupFieldVersions(ctx context.Context, entityType, entityID string) ([]FieldVersion, error) { + return lookupFieldVersionsTx(ctx, r.queries(), r.group(), entityType, entityID) +} + +func (r pushRepository) UpsertFieldVersion(ctx context.Context, p UpsertFieldVersionParams) error { + if err := p.validate(); err != nil { + return err + } + return r.writeTx(ctx, func(_ *sql.Tx, q *gen.Queries) error { + return upsertFieldVersionTx(ctx, q, r.group(), p) + }) +} + +func (r pushRepository) InsertConflict(ctx context.Context, p InsertConflictParams) (ConflictRecord, error) { + if err := p.validate(); err != nil { + return ConflictRecord{}, err + } + + var created ConflictRecord + err := r.writeTx(ctx, func(_ *sql.Tx, q *gen.Queries) error { + row, err := insertConflictTx(ctx, q, r.group(), p) + if err != nil { + return err + } + created = row + return nil + }) + if err != nil { + return ConflictRecord{}, err + } + return created, nil +} + func lookupMutationLedgerEntryTx(ctx context.Context, q *gen.Queries, group, mutationID string) (MutationLedgerEntry, error) { row, err := q.GetMutationByMutationID(ctx, gen.GetMutationByMutationIDParams{ GroupID: group, MutationID: mutationID, @@ -128,6 +225,65 @@ func insertMutationLedgerEntryTx(ctx context.Context, q *gen.Queries, group stri return row, nil } +func lookupFieldVersionsTx(ctx context.Context, q *gen.Queries, group, entityType, entityID string) ([]FieldVersion, error) { + rows, err := q.ListFieldVersionsByEntity(ctx, gen.ListFieldVersionsByEntityParams{ + GroupID: group, EntityType: entityType, EntityID: entityID, + }) + if err != nil { + return nil, fmt.Errorf("storage: list field versions for %s %q: %w", entityType, entityID, err) + } + return rows, nil +} + +func upsertFieldVersionTx(ctx context.Context, q *gen.Queries, group string, p UpsertFieldVersionParams) error { + if err := q.UpsertFieldVersion(ctx, gen.UpsertFieldVersionParams{ + GroupID: group, + EntityType: p.EntityType, + EntityID: p.EntityID, + FieldName: p.FieldName, + Version: p.Version, + Now: p.Now, + }); err != nil { + return fmt.Errorf("storage: upsert field version %s/%s[%s]: %w", p.EntityType, p.EntityID, p.FieldName, err) + } + return nil +} + +func insertConflictTx(ctx context.Context, q *gen.Queries, group string, p InsertConflictParams) (ConflictRecord, error) { + serverSnapshot := nullString(p.ServerValueSnapshot) + losingSnapshot := nullString(p.LosingClientValueSnapshot) + mutationID := nullString(p.MutationID) + + if err := q.CreateConflict(ctx, gen.CreateConflictParams{ + ID: p.ID, + GroupID: group, + EntityType: p.EntityType, + EntityID: p.EntityID, + FieldName: p.FieldName, + ServerValueSnapshot: serverSnapshot, + LosingClientValueSnapshot: losingSnapshot, + DetectedAt: p.DetectedAt, + MutationID: mutationID, + Now: p.Now, + }); err != nil { + return ConflictRecord{}, fmt.Errorf("storage: insert conflict %q: %w", p.ID, err) + } + + return ConflictRecord{ + ID: p.ID, + GroupID: group, + EntityType: p.EntityType, + EntityID: p.EntityID, + FieldName: p.FieldName, + ServerValueSnapshot: serverSnapshot, + LosingClientValueSnapshot: losingSnapshot, + DetectedAt: p.DetectedAt, + MutationID: mutationID, + CreatedAt: p.Now, + UpdatedAt: p.Now, + }, nil +} + func (s *Storage) ForGroupPush(g GroupID) (PushRepository, error) { if s == nil || s.store == nil { return nil, fmt.Errorf("%w: storage is not open", ErrNoGroup) diff --git a/server/internal/storage/push_commit.go b/server/internal/storage/push_commit.go index 723a357..384e8b1 100644 --- a/server/internal/storage/push_commit.go +++ b/server/internal/storage/push_commit.go @@ -21,10 +21,44 @@ var ( ErrPushInvalidFieldValue = errors.New("storage: push: a field value does not decode to the type this field expects") ErrPushAttachmentRejected = errors.New("storage: push: attachment mutations are rejected; attachment bytes travel through the photo-queue, never through push fields") ErrPushCreationOnly = errors.New("storage: push: this entity_type is creation-only over push; base_version must be 0") + ErrPushUnknownOp = errors.New("storage: push: op is not one of upsert/delete") + ErrPushDeleteNotSupported = errors.New("storage: push: op: delete is not supported for this entity_type") ) +func IsPushStructuralError(err error) bool { + switch { + case errors.Is(err, ErrPushUnknownEntityType), + errors.Is(err, ErrPushUnknownField), + errors.Is(err, ErrPushMissingField), + errors.Is(err, ErrPushInvalidFieldValue), + errors.Is(err, ErrPushAttachmentRejected), + errors.Is(err, ErrPushCreationOnly), + errors.Is(err, ErrPushUnknownOp), + errors.Is(err, ErrPushDeleteNotSupported): + return true + default: + return false + } +} + var pushCommitFailAfterEntityWrite func() error +var pushNewItemShortCode = newImportShortCode + +type PushOp string + +const ( + PushOpUpsert PushOp = "upsert" + PushOpDelete PushOp = "delete" +) + +func (o PushOp) normalized() PushOp { + if o == "" { + return PushOpUpsert + } + return o +} + type PushMutation struct { MutationID string EntityType string @@ -32,18 +66,19 @@ type PushMutation struct { BaseVersion int64 Fields map[string]json.RawMessage Now int64 + Op PushOp } func (m PushMutation) validate() error { switch { case m.MutationID == "": - return errors.New("storage: PushMutation: MutationID is empty") + return fmt.Errorf("%w: %q", ErrPushMissingField, "mutation_id") case m.EntityType == "": - return errors.New("storage: PushMutation: EntityType is empty") + return fmt.Errorf("%w: %q", ErrPushMissingField, "entity_type") case m.EntityID == "": - return errors.New("storage: PushMutation: EntityID is empty") + return fmt.Errorf("%w: %q", ErrPushMissingField, "entity_id") case m.BaseVersion < 0: - return errors.New("storage: PushMutation: BaseVersion must not be negative") + return fmt.Errorf("%w: %q", ErrPushInvalidFieldValue, "base_version") case m.Now <= 0: return errors.New("storage: PushMutation: Now must be a positive Unix-millisecond timestamp") } @@ -57,6 +92,7 @@ type PushOutcome struct { EntityID string Version int64 ConflictFields []string + FieldSnapshots map[string]string } type PushCommitRepository interface { @@ -99,12 +135,23 @@ func (r pushCommitRepository) ApplyMutation(ctx context.Context, m PushMutation) return fmt.Errorf("storage: push: check mutation ledger for %q: %w", m.MutationID, err) } - handler, ok := pushEntityDispatch[m.EntityType] - if !ok { - return fmt.Errorf("%w: %q", ErrPushUnknownEntityType, m.EntityType) + var result PushOutcome + var err error + switch m.Op.normalized() { + case PushOpDelete: + if m.EntityType != "item_label" { + return fmt.Errorf("%w: entity_type %q", ErrPushDeleteNotSupported, m.EntityType) + } + result, err = pushItemLabelDelete(ctx, tx, q, r.group, m) + case PushOpUpsert: + handler, ok := pushEntityDispatch[m.EntityType] + if !ok { + return fmt.Errorf("%w: %q", ErrPushUnknownEntityType, m.EntityType) + } + result, err = handler(ctx, tx, q, r.group, m) + default: + return fmt.Errorf("%w: %q", ErrPushUnknownOp, m.Op) } - - result, err := handler(ctx, tx, q, r.group, m) if err != nil { return err } @@ -139,6 +186,32 @@ func (r pushCommitRepository) ApplyMutation(ctx context.Context, m PushMutation) }); err != nil { return fmt.Errorf("storage: push: record mutation ledger row for %q: %w", m.MutationID, err) } + + for _, name := range outcome.ConflictFields { + conflictID, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("storage: push: mint conflict row id: %w", err) + } + losing := "" + if name != entityConflictField { + if raw, ok := m.Fields[name]; ok { + losing = string(raw) + } + } + if _, err := insertConflictTx(ctx, q, r.group, InsertConflictParams{ + ID: conflictID.String(), + EntityType: m.EntityType, + EntityID: outcome.EntityID, + FieldName: name, + ServerValueSnapshot: outcome.FieldSnapshots[name], + LosingClientValueSnapshot: losing, + DetectedAt: m.Now, + MutationID: ledgerID.String(), + Now: m.Now, + }); err != nil { + return fmt.Errorf("storage: push: record conflict %q for mutation %q: %w", name, m.MutationID, err) + } + } return nil }) if err != nil { @@ -171,10 +244,57 @@ var pushEntityDispatch = map[string]pushEntityHandler{ "attachment": pushAttachment, } -var pushVersionMismatchPolicy = pushWholeEntityMismatch +var pushVersionMismatchPolicy = pushFieldLevelMergePolicy -func pushWholeEntityMismatch(_ context.Context, _ *gen.Queries, _, _, _ string, fieldNames []string) (conflict, apply []string, err error) { - return fieldNames, nil, nil +func pushFieldLevelMergePolicy(ctx context.Context, q *gen.Queries, group, entityType, entityID string, baseVersion int64, fieldNames []string) (conflict, apply []string, err error) { + tracked, err := lookupFieldVersionsTx(ctx, q, group, entityType, entityID) + if err != nil { + return nil, nil, err + } + versions := make(map[string]int64, len(tracked)) + for _, row := range tracked { + versions[row.FieldName] = row.Version + } + + for _, name := range fieldNames { + fv, tracked := versions[name] + if !tracked { + fv = 1 + } + if fv > baseVersion { + conflict = append(conflict, name) + } else { + apply = append(apply, name) + } + } + return conflict, apply, nil +} + +func pushRowExists(ctx context.Context, tx *sql.Tx, table, id string) (bool, error) { + var n int + err := tx.QueryRowContext(ctx, "SELECT 1 FROM "+table+" WHERE id = ? LIMIT 1", id).Scan(&n) + switch { + case errors.Is(err, sql.ErrNoRows): + return false, nil + case err != nil: + return false, fmt.Errorf("storage: push: check existing row %q in %s: %w", id, table, err) + } + return true, nil +} + +func pushClassifyEntityError(err error) (PushOutcome, bool) { + switch { + case errors.Is(err, ErrNotFound): + return PushOutcome{ConflictFields: []string{"item_id"}}, true + case errors.Is(err, ErrWarrantyExists), errors.Is(err, ErrSaleExists), errors.Is(err, ErrPurchaseExists): + return PushOutcome{ConflictFields: []string{entityConflictField}}, true + case errors.Is(err, ErrLabelNameConflict): + return PushOutcome{ConflictFields: []string{"name"}}, true + case errors.Is(err, ErrLocationParentNotFound), errors.Is(err, ErrLocationCycle): + return PushOutcome{ConflictFields: []string{"parent_id"}}, true + default: + return PushOutcome{}, false + } } func pushFieldNames(fields map[string]json.RawMessage) []string { @@ -293,6 +413,74 @@ func pushAllowSet(names []string) func(string) bool { return func(name string) bool { return set[name] } } +func pushSnapshotJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func pushSnapshotServerValues(conflict []string, values map[string]any) map[string]string { + if len(conflict) == 0 { + return nil + } + var out map[string]string + for _, name := range conflict { + v, ok := values[name] + if !ok { + continue + } + if out == nil { + out = make(map[string]string, len(conflict)) + } + out[name] = pushSnapshotJSON(v) + } + return out +} + +var pushCustomFieldValueUnion = []string{"field_type", "text_value", "number_value", "bool_value", "date_value"} + +func pushCustomFieldValueUnionRule(fields map[string]json.RawMessage, conflict, apply []string) ([]string, []string) { + present := func(name string) bool { + _, ok := fields[name] + return ok + } + inConflict := make(map[string]bool, len(conflict)) + for _, name := range conflict { + inConflict[name] = true + } + + anyPresentUnionMemberConflicts := false + for _, name := range pushCustomFieldValueUnion { + if present(name) && inConflict[name] { + anyPresentUnionMemberConflicts = true + break + } + } + if !anyPresentUnionMemberConflicts { + return conflict, apply + } + + moveToConflict := make(map[string]bool, len(pushCustomFieldValueUnion)) + for _, name := range pushCustomFieldValueUnion { + if present(name) { + moveToConflict[name] = true + } + } + newApply := apply[:0:0] + for _, name := range apply { + if moveToConflict[name] { + inConflict[name] = true + continue + } + newApply = append(newApply, name) + } + newConflict := make([]string, 0, len(inConflict)) + for name := range inConflict { + newConflict = append(newConflict, name) + } + sort.Strings(newConflict) + return newConflict, newApply +} + func pushMergeString(fields map[string]json.RawMessage, name string, allow func(string) bool, current string) (string, error) { if !allow(name) { return current, nil @@ -418,6 +606,14 @@ func pushItem(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m P } if m.BaseVersion == 0 { + exists, err := pushRowExists(ctx, tx, "items", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } + name, _, err := pushDecodeString(m.Fields, "name") if err != nil { return PushOutcome{}, err @@ -438,7 +634,7 @@ func pushItem(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m P var created Item var ok bool for attempt := 0; attempt < maxImportShortCodeAttempts; attempt++ { - code, err := newImportShortCode() + code, err := pushNewItemShortCode() if err != nil { return PushOutcome{}, err } @@ -456,7 +652,7 @@ func pushItem(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m P break } if !ok { - return PushOutcome{}, fmt.Errorf("storage: push: create item %q: exhausted %d short-code attempts", m.EntityID, maxImportShortCodeAttempts) + return PushOutcome{ConflictFields: []string{"short_code"}}, nil } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil } @@ -471,15 +667,20 @@ func pushItem(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m P allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "name": current.Name, "description": current.Description, + "location_id": stringFromNull(current.LocationID), "quantity": current.Quantity, + }) if len(apply) == 0 { - return PushOutcome{ConflictFields: conflict}, nil + return PushOutcome{ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -506,9 +707,16 @@ func pushItem(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m P Quantity: quantity, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } + return PushOutcome{}, err + } + if err := recordFieldVersionsTx(ctx, q, group, "item", updated.ID, updated.Version, m.Now, diffItemFieldVersions(current, updated)); err != nil { return PushOutcome{}, err } - return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict}, nil + return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushWarrantyCreateFields = map[string]bool{ @@ -532,6 +740,13 @@ func pushWarrantyBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group st if err != nil { return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "item_warranty", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } holder, _, err := pushDecodeString(m.Fields, "holder") if err != nil { return PushOutcome{}, err @@ -562,6 +777,9 @@ func pushWarrantyBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group st ExpiresOn: expiresOn, IsLifetime: isLifetime, Notes: notes, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -588,15 +806,21 @@ func pushWarrantyBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group st allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "holder": current.Holder, "provider": current.Provider, + "starts_on": stringFromNull(current.StartsOn), "expires_on": stringFromNull(current.ExpiresOn), + "is_lifetime": boolFromInt(current.IsLifetime), "notes": current.Notes, + }) if len(apply) == 0 { - return PushOutcome{EntityID: current.ID, ConflictFields: conflict}, nil + return PushOutcome{EntityID: current.ID, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -631,9 +855,16 @@ func pushWarrantyBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group st IsLifetime: isLifetime, Notes: notes, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } + return PushOutcome{}, err + } + if err := recordFieldVersionsTx(ctx, q, group, "warranty_block", updated.ID, updated.Version, m.Now, diffWarrantyFieldVersions(current, updated)); err != nil { return PushOutcome{}, err } - return PushOutcome{Applied: true, EntityID: updated.ID, Version: updated.Version, ConflictFields: conflict}, nil + return PushOutcome{Applied: true, EntityID: updated.ID, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushSaleCreateFields = map[string]bool{ @@ -653,6 +884,13 @@ func pushSoldToBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group stri if err != nil { return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "item_sale", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } buyerName, _, err := pushDecodeString(m.Fields, "buyer_name") if err != nil { return PushOutcome{}, err @@ -675,6 +913,9 @@ func pushSoldToBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group stri SalePriceMinor: salePriceMinor, Notes: notes, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -701,15 +942,20 @@ func pushSoldToBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group stri allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "buyer_name": current.BuyerName, "sold_on": stringFromNull(current.SoldOn), + "sale_price_minor": current.SalePriceMinor, "notes": current.Notes, + }) if len(apply) == 0 { - return PushOutcome{EntityID: current.ID, ConflictFields: conflict}, nil + return PushOutcome{EntityID: current.ID, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -736,9 +982,16 @@ func pushSoldToBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, group stri Notes: notes, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } return PushOutcome{}, err } - return PushOutcome{Applied: true, EntityID: updated.ID, Version: updated.Version, ConflictFields: conflict}, nil + if err := recordFieldVersionsTx(ctx, q, group, "sold_to_block", updated.ID, updated.Version, m.Now, diffSaleFieldVersions(current, updated)); err != nil { + return PushOutcome{}, err + } + return PushOutcome{Applied: true, EntityID: updated.ID, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushPurchaseCreateFields = map[string]bool{ @@ -759,6 +1012,13 @@ func pushPurchasedFromBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro if err != nil { return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "item_purchase", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } vendor, _, err := pushDecodeString(m.Fields, "vendor") if err != nil { return PushOutcome{}, err @@ -785,6 +1045,9 @@ func pushPurchasedFromBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro PurchasePriceMinor: purchasePriceMinor, OrderReference: orderReference, Notes: notes, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -811,15 +1074,21 @@ func pushPurchasedFromBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "vendor": current.Vendor, "purchased_on": stringFromNull(current.PurchasedOn), + "purchase_price_minor": current.PurchasePriceMinor, "order_reference": current.OrderReference, + "notes": current.Notes, + }) if len(apply) == 0 { - return PushOutcome{EntityID: current.ID, ConflictFields: conflict}, nil + return PushOutcome{EntityID: current.ID, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -850,9 +1119,16 @@ func pushPurchasedFromBlock(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro OrderReference: orderReference, Notes: notes, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } return PushOutcome{}, err } - return PushOutcome{Applied: true, EntityID: updated.ID, Version: updated.Version, ConflictFields: conflict}, nil + if err := recordFieldVersionsTx(ctx, q, group, "purchased_from_block", updated.ID, updated.Version, m.Now, diffPurchaseFieldVersions(current, updated)); err != nil { + return PushOutcome{}, err + } + return PushOutcome{Applied: true, EntityID: updated.ID, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushIdentificationCreateFields = map[string]bool{"item_id": true, "kind": true, "value": true} @@ -870,6 +1146,13 @@ func pushItemIdentification(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro if err != nil { return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "item_identifications", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } kind, _, err := pushDecodeString(m.Fields, "kind") if err != nil { return PushOutcome{}, err @@ -883,6 +1166,9 @@ func pushItemIdentification(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro ID: m.EntityID, ItemID: itemID, Kind: kind, Value: value, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -909,15 +1195,19 @@ func pushItemIdentification(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "kind": current.Kind, "value": current.Value, + }) if len(apply) == 0 { - return PushOutcome{ConflictFields: conflict}, nil + return PushOutcome{ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -935,9 +1225,16 @@ func pushItemIdentification(ctx context.Context, tx *sql.Tx, q *gen.Queries, gro ItemID: itemID, ID: m.EntityID, Kind: kind, Value: value, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } return PushOutcome{}, err } - return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict}, nil + if err := recordFieldVersionsTx(ctx, q, group, "item_identification", updated.ID, updated.Version, m.Now, diffIdentificationFieldVersions(current, updated)); err != nil { + return PushOutcome{}, err + } + return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushCustomFieldCreateFields = map[string]bool{ @@ -958,6 +1255,13 @@ func pushItemCustomField(ctx context.Context, tx *sql.Tx, q *gen.Queries, group if err != nil { return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "item_custom_fields", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } fieldDefID, _, err := pushDecodeString(m.Fields, "field_def_id") if err != nil { return PushOutcome{}, err @@ -992,6 +1296,9 @@ func pushItemCustomField(ctx context.Context, tx *sql.Tx, q *gen.Queries, group TextValue: textValue, NumberValue: numberValue, BoolValue: boolValue, DateValue: dateValue, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -1018,15 +1325,25 @@ func pushItemCustomField(ctx context.Context, tx *sql.Tx, q *gen.Queries, group allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + conflict, apply = pushCustomFieldValueUnionRule(m.Fields, conflict, apply) + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "field_def_id": stringFromNull(current.FieldDefID), "name": current.Name, + "field_type": current.FieldType, + "text_value": ptrFromNullString(current.TextValue), + "number_value": ptrFromNullFloat64(current.NumberValue), + "bool_value": ptrFromNullBool(current.BoolValue), + "date_value": ptrFromNullString(current.DateValue), + }) if len(apply) == 0 { - return PushOutcome{ConflictFields: conflict}, nil + return PushOutcome{ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -1066,9 +1383,16 @@ func pushItemCustomField(ctx context.Context, tx *sql.Tx, q *gen.Queries, group ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } + return PushOutcome{}, err + } + if err := recordFieldVersionsTx(ctx, q, group, "item_custom_field", updated.ID, updated.Version, m.Now, diffItemCustomFieldFieldVersions(current, updated)); err != nil { return PushOutcome{}, err } - return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict}, nil + return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushLocationFields = map[string]bool{"name": true, "parent_id": true} @@ -1079,6 +1403,13 @@ func pushLocation(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, } if m.BaseVersion == 0 { + exists, err := pushRowExists(ctx, tx, "locations", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } name, _, err := pushDecodeString(m.Fields, "name") if err != nil { return PushOutcome{}, err @@ -1091,6 +1422,9 @@ func pushLocation(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, ID: m.EntityID, Name: name, ParentID: parentID, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -1106,15 +1440,19 @@ func pushLocation(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "name": current.Name, "parent_id": stringFromNull(current.ParentID), + }) if len(apply) == 0 { - return PushOutcome{ConflictFields: conflict}, nil + return PushOutcome{ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -1132,9 +1470,16 @@ func pushLocation(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, LocationID: m.EntityID, Name: name, ParentID: parentID, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } + return PushOutcome{}, err + } + if err := recordFieldVersionsTx(ctx, q, group, "location", updated.ID, updated.Version, m.Now, diffLocationFieldVersions(current, updated)); err != nil { return PushOutcome{}, err } - return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict}, nil + return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushLabelFields = map[string]bool{"name": true, "color": true} @@ -1145,6 +1490,13 @@ func pushLabel(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m } if m.BaseVersion == 0 { + exists, err := pushRowExists(ctx, tx, "labels", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } name, _, err := pushDecodeString(m.Fields, "name") if err != nil { return PushOutcome{}, err @@ -1157,6 +1509,9 @@ func pushLabel(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m ID: m.EntityID, Name: name, Color: color, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil @@ -1172,15 +1527,19 @@ func pushLabel(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m allow := pushAllowAll conflict := []string(nil) + var snapshots map[string]string if current.Version != m.BaseVersion { names := pushFieldNames(m.Fields) var apply []string - conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, names) + conflict, apply, err = pushVersionMismatchPolicy(ctx, q, group, m.EntityType, m.EntityID, m.BaseVersion, names) if err != nil { return PushOutcome{}, err } + snapshots = pushSnapshotServerValues(conflict, map[string]any{ + "name": current.Name, "color": current.Color, + }) if len(apply) == 0 { - return PushOutcome{ConflictFields: conflict}, nil + return PushOutcome{ConflictFields: conflict, FieldSnapshots: snapshots}, nil } allow = pushAllowSet(apply) } @@ -1198,9 +1557,16 @@ func pushLabel(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m ID: m.EntityID, Name: name, Color: color, ExpectedVersion: current.Version, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + outcome.ConflictFields = append(outcome.ConflictFields, conflict...) + return outcome, nil + } + return PushOutcome{}, err + } + if err := recordFieldVersionsTx(ctx, q, group, "label", updated.ID, updated.Version, m.Now, diffLabelFieldVersions(current, updated)); err != nil { return PushOutcome{}, err } - return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict}, nil + return PushOutcome{Applied: true, Version: updated.Version, ConflictFields: conflict, FieldSnapshots: snapshots}, nil } var pushItemLabelFields = map[string]bool{"item_id": true, "label_id": true} @@ -1227,6 +1593,27 @@ func pushItemLabel(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "item_labels", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } + + if _, err := q.GetItem(ctx, gen.GetItemParams{GroupID: group, ItemID: itemID}); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PushOutcome{ConflictFields: []string{"item_id"}}, nil + } + return PushOutcome{}, fmt.Errorf("storage: push: get item %q for item_label attach: %w", itemID, err) + } + if _, err := q.GetLabel(ctx, gen.GetLabelParams{GroupID: group, ID: labelID}); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PushOutcome{ConflictFields: []string{"label_id"}}, nil + } + return PushOutcome{}, fmt.Errorf("storage: push: get label %q for item_label attach: %w", labelID, err) + } + row, err := attachItemLabelTx(ctx, tx, q, group, AttachLabelParams{ ID: m.EntityID, ItemID: itemID, LabelID: labelID, Now: m.Now, }) @@ -1236,6 +1623,41 @@ func pushItemLabel(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string return PushOutcome{Applied: true, EntityID: row.ID, Version: row.Version}, nil } +var pushItemLabelDeleteFields = map[string]bool{"item_id": true, "label_id": true} + +func pushItemLabelDelete(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m PushMutation) (PushOutcome, error) { + if err := pushRequireKnownFields(m.Fields, pushItemLabelDeleteFields); err != nil { + return PushOutcome{}, err + } + if err := pushRequireField(m.Fields, "item_id"); err != nil { + return PushOutcome{}, err + } + if err := pushRequireField(m.Fields, "label_id"); err != nil { + return PushOutcome{}, err + } + itemID, _, err := pushDecodeString(m.Fields, "item_id") + if err != nil { + return PushOutcome{}, err + } + labelID, _, err := pushDecodeString(m.Fields, "label_id") + if err != nil { + return PushOutcome{}, err + } + + current, err := q.GetItemLabel(ctx, gen.GetItemLabelParams{GroupID: group, ItemID: itemID, LabelID: labelID}) + if errors.Is(err, sql.ErrNoRows) { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } + if err != nil { + return PushOutcome{}, fmt.Errorf("storage: push: get item_label edge (%q,%q) for delete: %w", itemID, labelID, err) + } + + if err := detachItemLabelTx(ctx, tx, q, group, itemID, labelID, m.Now); err != nil { + return PushOutcome{}, err + } + return PushOutcome{Applied: true, EntityID: current.ID, Version: current.Version + 1}, nil +} + var pushStockAdjustmentFields = map[string]bool{"item_id": true, "delta": true, "reason": true, "note": true} func pushStockAdjustment(ctx context.Context, tx *sql.Tx, q *gen.Queries, group string, m PushMutation) (PushOutcome, error) { @@ -1252,6 +1674,13 @@ func pushStockAdjustment(ctx context.Context, tx *sql.Tx, q *gen.Queries, group if err != nil { return PushOutcome{}, err } + exists, err := pushRowExists(ctx, tx, "stock_adjustments", m.EntityID) + if err != nil { + return PushOutcome{}, err + } + if exists { + return PushOutcome{ConflictFields: []string{entityConflictField}}, nil + } delta, _, err := pushDecodeInt64(m.Fields, "delta") if err != nil { return PushOutcome{}, err @@ -1265,10 +1694,25 @@ func pushStockAdjustment(ctx context.Context, tx *sql.Tx, q *gen.Queries, group return PushOutcome{}, err } + before, beforeErr := q.GetItem(ctx, gen.GetItemParams{GroupID: group, ItemID: itemID}) + created, err := createStockAdjustmentTx(ctx, tx, q, group, CreateStockAdjustmentParams{ ID: m.EntityID, ItemID: itemID, Delta: delta, Reason: reason, Note: note, Now: m.Now, }) if err != nil { + if outcome, ok := pushClassifyEntityError(err); ok { + return outcome, nil + } + return PushOutcome{}, err + } + if beforeErr != nil { + return PushOutcome{}, fmt.Errorf("storage: push: item %q: field-version diff pre-read: %w", itemID, beforeErr) + } + after, err := q.GetItem(ctx, gen.GetItemParams{GroupID: group, ItemID: itemID}) + if err != nil { + return PushOutcome{}, fmt.Errorf("storage: push: item %q: field-version diff post-read: %w", itemID, err) + } + if err := recordFieldVersionsTx(ctx, q, group, "item", after.ID, after.Version, m.Now, diffItemFieldVersions(before, after)); err != nil { return PushOutcome{}, err } return PushOutcome{Applied: true, EntityID: created.ID, Version: created.Version}, nil diff --git a/server/internal/storage/push_commit_clock_test.go b/server/internal/storage/push_commit_clock_test.go new file mode 100644 index 0000000..ea758d6 --- /dev/null +++ b/server/internal/storage/push_commit_clock_test.go @@ -0,0 +1,346 @@ +package storage + +import "testing" + +const ( + clockIndepFarPastNow int64 = 1 + clockIndepNormalNow int64 = 1_700_000_000_000 + clockIndepFarFutureNow int64 = 4_102_444_800_000 +) + +type clockIndepPartialMergeResult struct { + applied bool + conflictFields []string + outcomeVersion int64 + itemName string + itemQuantity int64 + itemVersion int64 + itemChangeSeq int64 + nameFieldVersion int64 + quantityFVVersion int64 + quantityFVTracked bool + conflictFieldName string + conflictServer string + conflictLosing string +} + +func clockIndepRunPartialMerge(t *testing.T, now int64) clockIndepPartialMergeResult { + t.Helper() + commitA, _, scopeA, _, s := pushCommitScope(t) + + if _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-clock-partial", Name: "Hammer", Quantity: 1, ShortCode: "SC-CLKP1", Now: 1, + }); err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-clock-partial", Name: "Hammer", Quantity: 9, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance item to version 2: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-clock-partial", + EntityType: "item", + EntityID: "item-clock-partial", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Mallet", "quantity": 2}), + Now: now, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + + item, err := scopeA.Items().Get(t.Context(), "item-clock-partial") + if err != nil { + t.Fatalf("Get item: %v", err) + } + + nameVersion, _ := fieldVersionRow(t, s, "groupA", "item", "item-clock-partial", "name") + quantityVersion, quantityTracked := fieldVersionRow(t, s, "groupA", "item", "item-clock-partial", "quantity") + + conflicts := readConflictRows(t, s, "groupA", "item-clock-partial") + if len(conflicts) != 1 { + t.Fatalf("len(conflicts) = %d, want 1: %+v", len(conflicts), conflicts) + } + + return clockIndepPartialMergeResult{ + applied: outcome.Applied, + conflictFields: outcome.ConflictFields, + outcomeVersion: outcome.Version, + itemName: item.Name, + itemQuantity: item.Quantity, + itemVersion: item.Version, + itemChangeSeq: item.ChangeSeq, + nameFieldVersion: nameVersion, + quantityFVVersion: quantityVersion, + quantityFVTracked: quantityTracked, + conflictFieldName: conflicts[0].fieldName, + conflictServer: conflicts[0].serverSnapshot.String, + conflictLosing: conflicts[0].losingSnapshot.String, + } +} + +func TestApplyMutationPartialMergeOutcomeIsClockIndependent(t *testing.T) { + want := clockIndepPartialMergeResult{ + applied: true, + conflictFields: []string{"quantity"}, + outcomeVersion: 3, + itemName: "Mallet", + itemQuantity: 9, + itemVersion: 3, + itemChangeSeq: 3, + nameFieldVersion: 3, + quantityFVVersion: 2, + quantityFVTracked: true, + conflictFieldName: "quantity", + conflictServer: "9", + conflictLosing: "2", + } + + tests := []struct { + name string + now int64 + }{ + {"normal Now", clockIndepNormalNow}, + {"far-past Now (1970, near epoch)", clockIndepFarPastNow}, + {"far-future Now (2100)", clockIndepFarFutureNow}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clockIndepRunPartialMerge(t, tt.now) + if got.applied != want.applied || + !equalStringSlices(got.conflictFields, want.conflictFields) || + got.outcomeVersion != want.outcomeVersion || + got.itemName != want.itemName || + got.itemQuantity != want.itemQuantity || + got.itemVersion != want.itemVersion || + got.itemChangeSeq != want.itemChangeSeq || + got.nameFieldVersion != want.nameFieldVersion || + got.quantityFVVersion != want.quantityFVVersion || + got.quantityFVTracked != want.quantityFVTracked || + got.conflictFieldName != want.conflictFieldName || + got.conflictServer != want.conflictServer || + got.conflictLosing != want.conflictLosing { + t.Fatalf("Now=%d result = %+v, want %+v (result must be identical regardless of Now)", tt.now, got, want) + } + }) + } +} + +func TestApplyMutationFarFutureNowDoesNotBypassStaleBaseVersionConflict(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + if _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-clock-bypass", Name: "Saw", Quantity: 1, ShortCode: "SC-CLKB1", Now: 1, + }); err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-clock-bypass", Name: "Saw", Quantity: 9, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance item to version 2 (tracks quantity=2): %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-clock-bypass", + EntityType: "item", + EntityID: "item-clock-bypass", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"quantity": 999}), + Now: clockIndepFarFutureNow, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false -- a far-future Now must not bypass the stale base_version conflict") + } + if !equalStringSlices(outcome.ConflictFields, []string{"quantity"}) { + t.Fatalf("outcome.ConflictFields = %v, want [quantity]", outcome.ConflictFields) + } + + item, err := scopeA.Items().Get(t.Context(), "item-clock-bypass") + if err != nil { + t.Fatalf("Get item: %v", err) + } + if item.Quantity != 9 || item.Version != 2 { + t.Fatalf("item after rejected merge = %+v, want Quantity=9 Version=2 (server value untouched)", item) + } + + fv, tracked := fieldVersionRow(t, s, "groupA", "item", "item-clock-bypass", "quantity") + if !tracked || fv != 2 { + t.Fatalf("field_versions[quantity] = (tracked=%v, version=%d), want (true, 2) -- untouched by the rejected merge", tracked, fv) + } +} + +func TestApplyMutationChangeSeqAllocationOrderIsServerTxOrderNotNow(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + batch := []struct { + entityID string + now int64 + }{ + {"item-clock-seq-1", clockIndepFarFutureNow}, + {"item-clock-seq-2", clockIndepNormalNow}, + {"item-clock-seq-3", clockIndepFarPastNow}, + {"item-clock-seq-4", clockIndepFarFutureNow}, + } + + for i, m := range batch { + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-clock-seq-" + m.entityID, + EntityType: "item", + EntityID: m.entityID, + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Item", "quantity": 1}), + Now: m.now, + }) + if err != nil { + t.Fatalf("ApplyMutation[%d] (%s): %v", i, m.entityID, err) + } + if !outcome.Applied { + t.Fatalf("ApplyMutation[%d] (%s): outcome.Applied = false, want true", i, m.entityID) + } + } + + for i, m := range batch { + item, err := scopeA.Items().Get(t.Context(), m.entityID) + if err != nil { + t.Fatalf("Get %s: %v", m.entityID, err) + } + wantSeq := int64(i + 1) + if item.ChangeSeq != wantSeq { + t.Fatalf("%s: ChangeSeq = %d, want %d (call order %d, Now=%d) -- change_seq must follow call order, not Now", m.entityID, item.ChangeSeq, wantSeq, i, m.now) + } + } + + watermark, err := commitA.Watermark(t.Context()) + if err != nil { + t.Fatalf("Watermark: %v", err) + } + if watermark != int64(len(batch)) { + t.Fatalf("Watermark = %d, want %d", watermark, len(batch)) + } + _ = s +} + +func TestApplyMutationFieldVersionEqualsEntityVersionNeverDerivedFromNow(t *testing.T) { + tests := []struct { + name string + now int64 + }{ + {"normal Now", clockIndepNormalNow}, + {"far-past Now", clockIndepFarPastNow}, + {"far-future Now", clockIndepFarFutureNow}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + if _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-clock-fv", Name: "Drill", Quantity: 1, ShortCode: "SC-CLKFV", Now: 1, + }); err != nil { + t.Fatalf("seed item: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-clock-fv", + EntityType: "item", + EntityID: "item-clock-fv", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"quantity": 42}), + Now: tt.now, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied || outcome.Version != 2 { + t.Fatalf("outcome = %+v, want Applied=true Version=2", outcome) + } + + item, err := scopeA.Items().Get(t.Context(), "item-clock-fv") + if err != nil { + t.Fatalf("Get item: %v", err) + } + if item.Version != 2 { + t.Fatalf("item.Version = %d, want 2", item.Version) + } + + fv, tracked := fieldVersionRow(t, s, "groupA", "item", "item-clock-fv", "quantity") + if !tracked || fv != item.Version { + t.Fatalf("field_versions[quantity] = (tracked=%v, version=%d), want (true, %d) == item.Version, regardless of Now=%d", tracked, fv, item.Version, tt.now) + } + }) + } +} + +func TestApplyMutationFarPastNowDoesNotSortBehindWatermark(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + if _, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-clock-watermark-1", + EntityType: "item", + EntityID: "item-clock-watermark-1", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Wrench", "quantity": 1}), + Now: clockIndepNormalNow, + }); err != nil { + t.Fatalf("ApplyMutation[1]: %v", err) + } + + watermarkBefore, err := commitA.Watermark(t.Context()) + if err != nil { + t.Fatalf("Watermark (before): %v", err) + } + + outcome2, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-clock-watermark-2", + EntityType: "item", + EntityID: "item-clock-watermark-2", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Plane", "quantity": 1}), + Now: clockIndepFarPastNow, + }) + if err != nil { + t.Fatalf("ApplyMutation[2] (far-past Now): %v", err) + } + if !outcome2.Applied { + t.Fatalf("outcome2.Applied = false, want true") + } + + item2, err := scopeA.Items().Get(t.Context(), "item-clock-watermark-2") + if err != nil { + t.Fatalf("Get item-clock-watermark-2: %v", err) + } + if item2.ChangeSeq <= watermarkBefore { + t.Fatalf("item-clock-watermark-2.ChangeSeq = %d, want > watermarkBefore=%d -- a far-past Now must not sort the row behind an already-published watermark", item2.ChangeSeq, watermarkBefore) + } + + watermarkAfter, err := commitA.Watermark(t.Context()) + if err != nil { + t.Fatalf("Watermark (after): %v", err) + } + if watermarkAfter != item2.ChangeSeq { + t.Fatalf("watermarkAfter = %d, want %d (== the far-past-Now mutation's own change_seq)", watermarkAfter, item2.ChangeSeq) + } + _ = s +} + +func TestApplyMutationRejectsNonPositiveNow(t *testing.T) { + commitA, _, _, _, _ := pushCommitScope(t) + + for _, now := range []int64{0, -1, -4_102_444_800_000} { + m := PushMutation{ + MutationID: "mut-clock-nonpositive", + EntityType: "item", + EntityID: "item-clock-nonpositive", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "X", "quantity": 1}), + Now: now, + } + if _, err := commitA.ApplyMutation(t.Context(), m); err == nil { + t.Fatalf("ApplyMutation with Now=%d: want error, got nil", now) + } + } +} diff --git a/server/internal/storage/push_commit_merge_test.go b/server/internal/storage/push_commit_merge_test.go new file mode 100644 index 0000000..ada6276 --- /dev/null +++ b/server/internal/storage/push_commit_merge_test.go @@ -0,0 +1,720 @@ +package storage + +import ( + "database/sql" + "github.com/ankek/Household-Objects-Dev/server/internal/storage/internal/gen" + "sort" + "testing" +) + +type conflictRow struct { + entityType, entityID, fieldName string + serverSnapshot, losingSnapshot sql.NullString + mutationID string +} + +func readConflictRows(t *testing.T, s *Storage, groupID, entityID string) []conflictRow { + t.Helper() + rows, err := s.store.Reader().QueryContext(t.Context(), + `SELECT entity_type, entity_id, field_name, server_value_snapshot, losing_client_value_snapshot, mutation_id + FROM conflicts WHERE group_id = ? AND entity_id = ? ORDER BY field_name`, + groupID, entityID) + if err != nil { + t.Fatalf("query conflicts: %v", err) + } + defer rows.Close() + + var out []conflictRow + for rows.Next() { + var c conflictRow + if err := rows.Scan(&c.entityType, &c.entityID, &c.fieldName, &c.serverSnapshot, &c.losingSnapshot, &c.mutationID); err != nil { + t.Fatalf("scan conflict row: %v", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate conflicts: %v", err) + } + return out +} + +func fieldVersionRow(t *testing.T, s *Storage, groupID, entityType, entityID, fieldName string) (version int64, exists bool) { + t.Helper() + err := s.store.Reader().QueryRowContext(t.Context(), + `SELECT version FROM field_versions WHERE group_id = ? AND entity_type = ? AND entity_id = ? AND field_name = ?`, + groupID, entityType, entityID, fieldName).Scan(&version) + switch { + case err == sql.ErrNoRows: + return 0, false + case err != nil: + t.Fatalf("read field_versions[%s/%s/%s]: %v", entityType, entityID, fieldName, err) + } + return version, true +} + +func mustLedgerID(t *testing.T, s *Storage, groupID, mutationID string) string { + t.Helper() + row, err := gen.New(s.store.Reader()).GetMutationByMutationID(t.Context(), gen.GetMutationByMutationIDParams{ + GroupID: groupID, MutationID: mutationID, + }) + if err != nil { + t.Fatalf("read back ledger row for %q: %v", mutationID, err) + } + return row.ID +} + +func TestApplyMutationFieldLevelMergePartialConflictAppliesUntrackedFieldConflictsTrackedField(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-partial-1", Name: "Hammer", Quantity: 1, ShortCode: "SC-PART1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-partial-1", Name: "Hammer", Quantity: 9, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance item to version 2: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-partial-1", + EntityType: "item", + EntityID: "item-partial-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Mallet", "quantity": 2}), + Now: 3000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied { + t.Fatalf("outcome.Applied = false, want true -- name is untracked and must merge") + } + if !equalStringSlices(outcome.ConflictFields, []string{"quantity"}) { + t.Fatalf("outcome.ConflictFields = %v, want [quantity]", outcome.ConflictFields) + } + if outcome.Version != 3 { + t.Fatalf("outcome.Version = %d, want 3", outcome.Version) + } + + item, err := scopeA.Items().Get(t.Context(), "item-partial-1") + if err != nil { + t.Fatalf("Get item: %v", err) + } + if item.Name != "Mallet" || item.Quantity != 9 || item.Version != 3 { + t.Fatalf("item after partial merge = %+v, want Name=Mallet Quantity=9 Version=3", item) + } + + if v, ok := fieldVersionRow(t, s, "groupA", "item", "item-partial-1", "name"); !ok || v != 3 { + t.Fatalf("field_versions[item/item-partial-1/name] = (%d,%v), want (3,true)", v, ok) + } + if v, ok := fieldVersionRow(t, s, "groupA", "item", "item-partial-1", "quantity"); !ok || v != 2 { + t.Fatalf("field_versions[item/item-partial-1/quantity] = (%d,%v), want (2,true) -- unchanged by the conflicting merge", v, ok) + } + + rows := readConflictRows(t, s, "groupA", "item-partial-1") + if len(rows) != 1 { + t.Fatalf("conflicts rows = %+v, want exactly 1", rows) + } + c := rows[0] + if c.fieldName != "quantity" || c.entityType != "item" { + t.Fatalf("conflict row = %+v, want field_name=quantity entity_type=item", c) + } + if !c.serverSnapshot.Valid || c.serverSnapshot.String != "9" { + t.Fatalf("conflict server_value_snapshot = %+v, want valid \"9\"", c.serverSnapshot) + } + if !c.losingSnapshot.Valid || c.losingSnapshot.String != "2" { + t.Fatalf("conflict losing_client_value_snapshot = %+v, want valid \"2\"", c.losingSnapshot) + } + if c.mutationID != mustLedgerID(t, s, "groupA", "mut-partial-1") { + t.Fatalf("conflict mutation_id = %q, want the ledger row's own id", c.mutationID) + } +} + +func TestApplyMutationFieldLevelMergeDifferentFieldsBothSurvive(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-both-1", Name: "Drill", Description: "old desc", Quantity: 1, ShortCode: "SC-BOTH1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-both-1", Name: "Drill", Description: "old desc", Quantity: 5, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance item to version 2: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-both-1", + EntityType: "item", + EntityID: "item-both-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "New Name", "description": "new desc"}), + Now: 3000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied || len(outcome.ConflictFields) != 0 { + t.Fatalf("outcome = %+v, want Applied=true, no conflicts (both fields untracked since base_version)", outcome) + } + + item, err := scopeA.Items().Get(t.Context(), "item-both-1") + if err != nil { + t.Fatalf("Get item: %v", err) + } + if item.Name != "New Name" || item.Description != "new desc" || item.Quantity != 5 { + t.Fatalf("item after merge = %+v, want Name=New Name Description=new desc Quantity=5", item) + } +} + +func TestApplyMutationFieldLevelMergeBootstrapDefaultAppliesUntrackedField(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-bootstrap-1", Name: "Ladder", Quantity: 1, ShortCode: "SC-BOOT1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if v, ok := fieldVersionRow(t, s, "groupA", "item", "item-bootstrap-1", "name"); ok { + t.Fatalf("field_versions[item/item-bootstrap-1/name] = %d, want no row before any push", v) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-bootstrap-1", Name: "Ladder", Quantity: 2, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance to v2: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-bootstrap-1", Name: "Ladder", Quantity: 3, ExpectedVersion: 2, Now: 3, + }); err != nil { + t.Fatalf("advance to v3: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-bootstrap-1", + EntityType: "item", + EntityID: "item-bootstrap-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Step Ladder"}), + Now: 4000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied || len(outcome.ConflictFields) != 0 { + t.Fatalf("outcome = %+v, want Applied=true, no conflicts (bootstrap default merges)", outcome) + } + if outcome.Version != 4 { + t.Fatalf("outcome.Version = %d, want 4", outcome.Version) + } + if v, ok := fieldVersionRow(t, s, "groupA", "item", "item-bootstrap-1", "name"); !ok || v != 4 { + t.Fatalf("field_versions[item/item-bootstrap-1/name] = (%d,%v), want (4,true) after this merge", v, ok) + } +} + +func TestApplyMutationFieldLevelMergeSameFieldConflictWritesConflictRowWithSnapshots(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-samefield-1", Name: "Hammer", Quantity: 1, ShortCode: "SC-SAME1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-samefield-1", Name: "Mallet", Quantity: 1, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("REST-edit name to Mallet: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-samefield-1", + EntityType: "item", + EntityID: "item-samefield-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Rubber Mallet"}), + Now: 3000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false -- name is the ONLY named field and it conflicts") + } + if !equalStringSlices(outcome.ConflictFields, []string{"name"}) { + t.Fatalf("outcome.ConflictFields = %v, want [name]", outcome.ConflictFields) + } + + item, err := scopeA.Items().Get(t.Context(), "item-samefield-1") + if err != nil { + t.Fatalf("Get item: %v", err) + } + if item.Name != "Mallet" || item.Version != 2 { + t.Fatalf("item = %+v, want unchanged (Name=Mallet Version=2) -- server value wins", item) + } + + rows := readConflictRows(t, s, "groupA", "item-samefield-1") + if len(rows) != 1 { + t.Fatalf("conflicts rows = %+v, want exactly 1", rows) + } + c := rows[0] + if c.fieldName != "name" { + t.Fatalf("conflict field_name = %q, want name", c.fieldName) + } + if !c.serverSnapshot.Valid || c.serverSnapshot.String != `"Mallet"` { + t.Fatalf("conflict server_value_snapshot = %+v, want valid %q", c.serverSnapshot, `"Mallet"`) + } + if !c.losingSnapshot.Valid || c.losingSnapshot.String != `"Rubber Mallet"` { + t.Fatalf("conflict losing_client_value_snapshot = %+v, want valid %q", c.losingSnapshot, `"Rubber Mallet"`) + } + if c.mutationID != mustLedgerID(t, s, "groupA", "mut-samefield-1") { + t.Fatalf("conflict mutation_id = %q, want the ledger row's own id", c.mutationID) + } +} + +func TestApplyMutationEntityConflictWritesConflictRowWithNullSnapshots(t *testing.T) { + commitA, _, _, _, s := pushCommitScope(t) + + _, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-entity-null-1", + EntityType: "item", + EntityID: "item-never-existed-null", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Ghost"}), + Now: 4000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + + rows := readConflictRows(t, s, "groupA", "item-never-existed-null") + if len(rows) != 1 { + t.Fatalf("conflicts rows = %+v, want exactly 1", rows) + } + c := rows[0] + if c.fieldName != entityConflictField { + t.Fatalf("conflict field_name = %q, want %q", c.fieldName, entityConflictField) + } + if c.serverSnapshot.Valid || c.losingSnapshot.Valid { + t.Fatalf("conflict snapshots = %+v, want both NULL for a whole-entity conflict", c) + } +} + +func TestApplyMutationA155ConflictStillWritesConflictRowAndLedgerRowAfterEntityWriteRollback(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + if _, err := scopeA.Labels().Create(t.Context(), CreateLabelParams{ + ID: "label-a155-existing", Name: "Garage", Color: "#123456", Now: 1, + }); err != nil { + t.Fatalf("seed existing label: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a155-1", + EntityType: "label", + EntityID: "label-a155-loser", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Garage", "color": "#abcdef"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + + q := gen.New(s.store.Reader()) + ledger, err := q.GetMutationByMutationID(t.Context(), gen.GetMutationByMutationIDParams{ + GroupID: "groupA", MutationID: "mut-a155-1", + }) + if err != nil { + t.Fatalf("read back ledger row: %v", err) + } + if ledger.Outcome != string(MutationOutcomeConflict) { + t.Fatalf("ledger.Outcome = %q, want conflict", ledger.Outcome) + } + + rows := readConflictRows(t, s, "groupA", "label-a155-loser") + if len(rows) != 1 { + t.Fatalf("conflicts rows = %+v, want exactly 1", rows) + } + if rows[0].fieldName != "name" || rows[0].mutationID != ledger.ID { + t.Fatalf("conflict row = %+v, want field_name=name mutation_id=%s", rows[0], ledger.ID) + } + if n := countRowsForGroup(t, s, "labels", "groupA"); n != 1 { + t.Fatalf("labels row count = %d, want 1 (the losing create must never durably write)", n) + } +} + +func TestApplyMutationStockAdjustmentThenSameBatchItemUpdate(t *testing.T) { + t.Run("name only: both mutations apply", func(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-a158-1", Name: "Box", Quantity: 1, ShortCode: "SC-A1581", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + baseVersion := item.Version + + stockOutcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a158-stock-1", + EntityType: "stock_adjustment", + EntityID: "adj-a158-1", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"item_id": item.ID, "delta": 5, "reason": "restock", "note": ""}), + Now: 2000, + }) + if err != nil || !stockOutcome.Applied { + t.Fatalf("stock adjustment ApplyMutation = %+v, %v, want Applied=true", stockOutcome, err) + } + + updateOutcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a158-item-1", + EntityType: "item", + EntityID: item.ID, + BaseVersion: baseVersion, + Fields: pushFields(t, map[string]any{"name": "Renamed Box"}), + Now: 2001, + }) + if err != nil { + t.Fatalf("item update ApplyMutation: %v", err) + } + if !updateOutcome.Applied || len(updateOutcome.ConflictFields) != 0 { + t.Fatalf("item update outcome = %+v, want Applied=true, no conflicts", updateOutcome) + } + + got, err := scopeA.Items().Get(t.Context(), item.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "Renamed Box" || got.Quantity != 6 { + t.Fatalf("item = %+v, want Name=Renamed Box Quantity=6", got) + } + }) + + t.Run("name and quantity: quantity conflicts, name still applies", func(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-a158-2", Name: "Crate", Quantity: 1, ShortCode: "SC-A1582", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + baseVersion := item.Version + + if _, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a158-stock-2", + EntityType: "stock_adjustment", + EntityID: "adj-a158-2", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"item_id": item.ID, "delta": 3, "reason": "restock", "note": ""}), + Now: 2000, + }); err != nil { + t.Fatalf("stock adjustment ApplyMutation: %v", err) + } + + updateOutcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a158-item-2", + EntityType: "item", + EntityID: item.ID, + BaseVersion: baseVersion, + Fields: pushFields(t, map[string]any{"name": "Renamed Crate", "quantity": 99}), + Now: 2001, + }) + if err != nil { + t.Fatalf("item update ApplyMutation: %v", err) + } + if !updateOutcome.Applied { + t.Fatalf("outcome.Applied = false, want true (name still applies)") + } + if !equalStringSlices(updateOutcome.ConflictFields, []string{"quantity"}) { + t.Fatalf("outcome.ConflictFields = %v, want [quantity]", updateOutcome.ConflictFields) + } + + got, err := scopeA.Items().Get(t.Context(), item.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "Renamed Crate" || got.Quantity != 4 { + t.Fatalf("item = %+v, want Name=Renamed Crate Quantity=4", got) + } + }) +} + +func TestApplyMutationRESTEditThenStalePushConflictsTouchedFieldMergesUntouched(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-a153-1", Name: "Old Name", Description: "Old Description", Quantity: 1, ShortCode: "SC-A1531", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-a153-1", Name: "REST Name", Description: "Old Description", Quantity: 1, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("REST-edit name: %v", err) + } + + t.Run("stale push touching name conflicts, REST value wins", func(t *testing.T) { + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a153-name", + EntityType: "item", + EntityID: "item-a153-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Offline Name"}), + Now: 3000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{"name"}) { + t.Fatalf("outcome.ConflictFields = %v, want [name]", outcome.ConflictFields) + } + item, err := scopeA.Items().Get(t.Context(), "item-a153-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if item.Name != "REST Name" { + t.Fatalf("item.Name = %q, want REST Name (server/REST value wins)", item.Name) + } + }) + + t.Run("same stale push touching description only merges", func(t *testing.T) { + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-a153-description", + EntityType: "item", + EntityID: "item-a153-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"description": "Offline Description"}), + Now: 3001, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied || len(outcome.ConflictFields) != 0 { + t.Fatalf("outcome = %+v, want Applied=true, no conflicts", outcome) + } + item, err := scopeA.Items().Get(t.Context(), "item-a153-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if item.Description != "Offline Description" { + t.Fatalf("item.Description = %q, want Offline Description", item.Description) + } + }) +} + +func TestApplyMutationCustomFieldValueUnionRuleConflictsWholeUnionTogether(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-union-1", Name: "Box", Quantity: 1, ShortCode: "SC-UNION1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + cf, err := scopeA.ItemCustomFields().Create(t.Context(), CreateItemCustomFieldParams{ + ID: "cf-union-1", ItemID: item.ID, Name: "Weight", FieldType: "text", TextValue: strPtr("light"), Now: 1, + }) + if err != nil { + t.Fatalf("seed custom field: %v", err) + } + + setup, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-union-setup", + EntityType: "item_custom_field", + EntityID: cf.ID, + BaseVersion: cf.Version, + Fields: pushFields(t, map[string]any{ + "item_id": item.ID, "field_type": "number", "text_value": nil, "number_value": 42.0, + "bool_value": nil, "date_value": nil, + }), + Now: 2000, + }) + if err != nil || !setup.Applied { + t.Fatalf("setup ApplyMutation = %+v, %v, want Applied=true", setup, err) + } + + row, err := scopeA.ItemCustomFields().Get(t.Context(), item.ID, cf.ID) + if err != nil { + t.Fatalf("Get after setup: %v", err) + } + baseVersion := int64(1) + if row.Version != 2 { + t.Fatalf("row.Version after setup = %d, want 2", row.Version) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-union-conflict", + EntityType: "item_custom_field", + EntityID: cf.ID, + BaseVersion: baseVersion, + Fields: pushFields(t, map[string]any{ + "item_id": item.ID, "field_type": "boolean", "bool_value": true, "name": "Renamed Weight", + }), + Now: 3000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied { + t.Fatalf("outcome.Applied = false, want true -- name still merges") + } + wantConflicts := []string{"bool_value", "field_type"} + got := append([]string(nil), outcome.ConflictFields...) + sort.Strings(got) + if !equalStringSlices(got, wantConflicts) { + t.Fatalf("outcome.ConflictFields = %v, want %v (the whole present union, together)", outcome.ConflictFields, wantConflicts) + } + + final, err := scopeA.ItemCustomFields().Get(t.Context(), item.ID, cf.ID) + if err != nil { + t.Fatalf("Get after conflicting merge: %v", err) + } + if final.Name != "Renamed Weight" { + t.Fatalf("final.Name = %q, want Renamed Weight (outside the union, merges independently)", final.Name) + } + if final.FieldType != "number" { + t.Fatalf("final.FieldType = %q, want number (union kept untouched)", final.FieldType) + } + if !final.NumberValue.Valid || final.NumberValue.Float64 != 42 { + t.Fatalf("final.NumberValue = %+v, want valid 42", final.NumberValue) + } + if final.BoolValue.Valid || final.TextValue.Valid || final.DateValue.Valid { + t.Fatalf("final value columns = %+v, want ONLY NumberValue set (A103.2)", final) + } + + rows := readConflictRows(t, s, "groupA", cf.ID) + if len(rows) != 2 { + t.Fatalf("conflicts rows = %+v, want exactly 2 (field_type, bool_value)", rows) + } +} + +func TestApplyMutationFieldLevelMergeIsScopedToItsOwnGroup(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-scope-1", Name: "Saw", Quantity: 1, ShortCode: "SC-SCOPE1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: item.ID, Name: "Saw", Quantity: 2, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance groupA item: %v", err) + } + + pushB, err := s.ForGroupPush(MustGroupID("groupB")) + if err != nil { + t.Fatalf("ForGroupPush(groupB): %v", err) + } + if err := pushB.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: item.ID, FieldName: "name", Version: 999, Now: 1, + }); err != nil { + t.Fatalf("poison groupB field_versions: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-scope-1", + EntityType: "item", + EntityID: item.ID, + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Circular Saw"}), + Now: 3000, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied || len(outcome.ConflictFields) != 0 { + t.Fatalf("outcome = %+v, want Applied=true, no conflicts -- groupB's poisoned row must not leak into groupA's merge", outcome) + } + + rows := readConflictRows(t, s, "groupB", item.ID) + if len(rows) != 0 { + t.Fatalf("groupB conflicts rows = %+v, want none (groupA's push must never write groupB's ledger)", rows) + } + if v, ok := fieldVersionRow(t, s, "groupA", "item", item.ID, "name"); !ok || v != 3 { + t.Fatalf("field_versions[groupA/item/%s/name] = (%d,%v), want (3,true)", item.ID, v, ok) + } +} + +func TestApplyMutationReplayOfPartiallyAppliedMutationAnswersSkippedWithNoSecondWrite(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-replay-partial-1", Name: "Hammer", Quantity: 1, ShortCode: "SC-REPPART1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ + ItemID: "item-replay-partial-1", Name: "Hammer", Quantity: 9, ExpectedVersion: 1, Now: 2, + }); err != nil { + t.Fatalf("advance to v2: %v", err) + } + + m := PushMutation{ + MutationID: "mut-replay-partial-1", + EntityType: "item", + EntityID: "item-replay-partial-1", + BaseVersion: 1, + Fields: pushFields(t, map[string]any{"name": "Mallet", "quantity": 2}), + Now: 3000, + } + + first, err := commitA.ApplyMutation(t.Context(), m) + if err != nil { + t.Fatalf("first ApplyMutation: %v", err) + } + if !first.Applied || !equalStringSlices(first.ConflictFields, []string{"quantity"}) { + t.Fatalf("first outcome = %+v, want Applied=true ConflictFields=[quantity]", first) + } + + itemAfterFirst, err := scopeA.Items().Get(t.Context(), "item-replay-partial-1") + if err != nil { + t.Fatalf("Get after first: %v", err) + } + conflictsAfterFirst := readConflictRows(t, s, "groupA", "item-replay-partial-1") + fieldVersionAfterFirst, _ := fieldVersionRow(t, s, "groupA", "item", "item-replay-partial-1", "name") + + second, err := commitA.ApplyMutation(t.Context(), m) + if err != nil { + t.Fatalf("second (replay) ApplyMutation: %v", err) + } + if !second.Skipped || second.Applied { + t.Fatalf("replay outcome = %+v, want Skipped=true Applied=false", second) + } + + itemAfterReplay, err := scopeA.Items().Get(t.Context(), "item-replay-partial-1") + if err != nil { + t.Fatalf("Get after replay: %v", err) + } + if itemAfterReplay.Version != itemAfterFirst.Version || itemAfterReplay.Name != itemAfterFirst.Name { + t.Fatalf("item changed by replay: before=%+v after=%+v", itemAfterFirst, itemAfterReplay) + } + conflictsAfterReplay := readConflictRows(t, s, "groupA", "item-replay-partial-1") + if len(conflictsAfterReplay) != len(conflictsAfterFirst) { + t.Fatalf("conflicts row count changed by replay: before=%d after=%d", len(conflictsAfterFirst), len(conflictsAfterReplay)) + } + fieldVersionAfterReplay, _ := fieldVersionRow(t, s, "groupA", "item", "item-replay-partial-1", "name") + if fieldVersionAfterReplay != fieldVersionAfterFirst { + t.Fatalf("field_versions[name] changed by replay: before=%d after=%d", fieldVersionAfterFirst, fieldVersionAfterReplay) + } + if n := countRowsForGroup(t, s, "mutations", "groupA"); n != 1 { + t.Fatalf("mutations row count = %d, want 1 (replay must not record a second ledger row)", n) + } +} diff --git a/server/internal/storage/push_commit_test.go b/server/internal/storage/push_commit_test.go index 7a48541..76edc71 100644 --- a/server/internal/storage/push_commit_test.go +++ b/server/internal/storage/push_commit_test.go @@ -185,49 +185,6 @@ func TestApplyMutationUpdateMatchingBaseVersionAppliesAndBumpsVersion(t *testing } } -func TestApplyMutationStaleBaseVersionConflictsEveryNamedFieldAndWritesNothing(t *testing.T) { - commitA, _, scopeA, _, _ := pushCommitScope(t) - - _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ - ID: "item-stale-1", Name: "Hammer", Quantity: 1, ShortCode: "SC-STALE1", Now: 1, - }) - if err != nil { - t.Fatalf("seed item: %v", err) - } - if _, err := scopeA.Items().Update(t.Context(), UpdateItemParams{ - ItemID: "item-stale-1", Name: "Hammer", Quantity: 9, ExpectedVersion: 1, Now: 2, - }); err != nil { - t.Fatalf("advance item to version 2: %v", err) - } - - outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ - MutationID: "mut-stale-1", - EntityType: "item", - EntityID: "item-stale-1", - BaseVersion: 1, - Fields: pushFields(t, map[string]any{"name": "Mallet", "quantity": 2}), - Now: 3000, - }) - if err != nil { - t.Fatalf("ApplyMutation: %v", err) - } - if outcome.Applied { - t.Fatalf("outcome.Applied = true, want false for a stale base_version") - } - wantConflicts := []string{"name", "quantity"} - if !equalStringSlices(outcome.ConflictFields, wantConflicts) { - t.Fatalf("outcome.ConflictFields = %v, want %v", outcome.ConflictFields, wantConflicts) - } - - item, err := scopeA.Items().Get(t.Context(), "item-stale-1") - if err != nil { - t.Fatalf("Get item: %v", err) - } - if item.Name != "Hammer" || item.Quantity != 9 || item.Version != 2 { - t.Fatalf("item after conflicting push = %+v, want unchanged (Name=Hammer Quantity=9 Version=2)", item) - } -} - func TestApplyMutationUpdateAbsentEntityConflictsWithoutResurrection(t *testing.T) { commitA, _, scopeA, _, _ := pushCommitScope(t) @@ -381,6 +338,423 @@ func TestApplyMutationCreationOnlyEntityRejectsNonZeroBaseVersion(t *testing.T) } } +func TestApplyMutationWarrantyCreateOnExistingBlockIsConflict(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-warr-1", Name: "Fridge", ShortCode: "SC-WARR1", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + if _, err := scopeA.Warranty().Create(t.Context(), CreateWarrantyParams{ + ID: "warr-existing", ItemID: "item-warr-1", Holder: "Acme", Now: 1, + }); err != nil { + t.Fatalf("seed existing warranty: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-warr-conflict", + EntityType: "warranty_block", + EntityID: "warr-second-device", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"item_id": "item-warr-1", "holder": "Other Co"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{entityConflictField}) { + t.Fatalf("outcome.ConflictFields = %v, want [%s]", outcome.ConflictFields, entityConflictField) + } + + q := gen.New(s.store.Reader()) + ledger, err := q.GetMutationByMutationID(t.Context(), gen.GetMutationByMutationIDParams{ + GroupID: "groupA", MutationID: "mut-warr-conflict", + }) + if err != nil { + t.Fatalf("read back ledger row: %v", err) + } + if ledger.Outcome != string(MutationOutcomeConflict) { + t.Fatalf("ledger.Outcome = %q, want conflict", ledger.Outcome) + } + if n := countRowsForGroup(t, s, "item_warranty", "groupA"); n != 1 { + t.Fatalf("item_warranty row count = %d, want 1 (the losing create must write nothing)", n) + } +} + +func TestApplyMutationLabelCreateNameConflictIsRecorded(t *testing.T) { + commitA, _, scopeA, _, s := pushCommitScope(t) + + if _, err := scopeA.Labels().Create(t.Context(), CreateLabelParams{ + ID: "label-existing", Name: "Garage", Color: "#123456", Now: 1, + }); err != nil { + t.Fatalf("seed existing label: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-label-conflict", + EntityType: "label", + EntityID: "label-second-device", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Garage", "color": "#abcdef"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{"name"}) { + t.Fatalf("outcome.ConflictFields = %v, want [name]", outcome.ConflictFields) + } + if n := countRowsForGroup(t, s, "labels", "groupA"); n != 1 { + t.Fatalf("labels row count = %d, want 1", n) + } +} + +func TestApplyMutationItemCreateShortCodeExhaustionIsConflict(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + if _, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-taken-code", Name: "Ladder", ShortCode: "FIXEDCODE", Now: 1, + }); err != nil { + t.Fatalf("seed item with the fixed short code: %v", err) + } + + prev := pushNewItemShortCode + pushNewItemShortCode = func() (string, error) { return "FIXEDCODE", nil } + t.Cleanup(func() { pushNewItemShortCode = prev }) + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-shortcode-exhausted", + EntityType: "item", + EntityID: "item-new-id", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Second Ladder"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{"short_code"}) { + t.Fatalf("outcome.ConflictFields = %v, want [short_code]", outcome.ConflictFields) + } +} + +func TestApplyMutationLocationCreateMissingParentIsConflict(t *testing.T) { + commitA, _, _, _, _ := pushCommitScope(t) + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-loc-parent-missing", + EntityType: "location", + EntityID: "loc-1", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Shed", "parent_id": "does-not-exist"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{"parent_id"}) { + t.Fatalf("outcome.ConflictFields = %v, want [parent_id]", outcome.ConflictFields) + } +} + +func TestApplyMutationLocationUpdateCycleIsConflict(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + root, err := scopeA.Locations().Create(t.Context(), CreateLocationParams{ID: "loc-root", Name: "House", Now: 1}) + if err != nil { + t.Fatalf("seed root location: %v", err) + } + child, err := scopeA.Locations().Create(t.Context(), CreateLocationParams{ID: "loc-child", Name: "Garage", ParentID: root.ID, Now: 1}) + if err != nil { + t.Fatalf("seed child location: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-loc-cycle", + EntityType: "location", + EntityID: root.ID, + BaseVersion: root.Version, + Fields: pushFields(t, map[string]any{"name": "House", "parent_id": child.ID}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{"parent_id"}) { + t.Fatalf("outcome.ConflictFields = %v, want [parent_id]", outcome.ConflictFields) + } +} + +func TestApplyMutationCreateClientEntityIDAlreadyExistsIsConflict(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + existing, err := scopeA.Items().Create(t.Context(), CreateItemParams{ + ID: "item-collide", Name: "Original", ShortCode: "SC-ORIG", Now: 1, + }) + if err != nil { + t.Fatalf("seed item: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-id-collide", + EntityType: "item", + EntityID: existing.ID, + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Impostor"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{entityConflictField}) { + t.Fatalf("outcome.ConflictFields = %v, want [%s]", outcome.ConflictFields, entityConflictField) + } + + item, err := scopeA.Items().Get(t.Context(), existing.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if item.Name != "Original" { + t.Fatalf("item.Name = %q, want unchanged Original", item.Name) + } +} + +func TestApplyMutationWarrantyCreateMissingItemIsConflict(t *testing.T) { + commitA, _, _, _, _ := pushCommitScope(t) + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-warr-no-item", + EntityType: "warranty_block", + EntityID: "warr-1", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"item_id": "item-does-not-exist", "holder": "Acme"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{"item_id"}) { + t.Fatalf("outcome.ConflictFields = %v, want [item_id]", outcome.ConflictFields) + } +} + +func TestApplyMutationItemLabelCreateMissingItemOrLabelIsConflict(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ID: "item-for-edge", Name: "Box", ShortCode: "SC-EDGE1", Now: 1}) + if err != nil { + t.Fatalf("seed item: %v", err) + } + label, err := scopeA.Labels().Create(t.Context(), CreateLabelParams{ID: "label-for-edge", Name: "Fragile", Color: "#ff0000", Now: 1}) + if err != nil { + t.Fatalf("seed label: %v", err) + } + + t.Run("missing item_id", func(t *testing.T) { + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-edge-no-item", + EntityType: "item_label", + EntityID: "edge-1", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"item_id": "does-not-exist", "label_id": label.ID}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if !equalStringSlices(outcome.ConflictFields, []string{"item_id"}) { + t.Fatalf("outcome.ConflictFields = %v, want [item_id]", outcome.ConflictFields) + } + }) + + t.Run("missing label_id", func(t *testing.T) { + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-edge-no-label", + EntityType: "item_label", + EntityID: "edge-2", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"item_id": item.ID, "label_id": "does-not-exist"}), + Now: 2000, + }) + if err != nil { + t.Fatalf("ApplyMutation returned an error, want a recorded conflict: %v", err) + } + if !equalStringSlices(outcome.ConflictFields, []string{"label_id"}) { + t.Fatalf("outcome.ConflictFields = %v, want [label_id]", outcome.ConflictFields) + } + }) +} + +func TestApplyMutationConflictedMutationReplaysAsSkipped(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + if _, err := scopeA.Labels().Create(t.Context(), CreateLabelParams{ + ID: "label-existing-2", Name: "Garage", Color: "#123456", Now: 1, + }); err != nil { + t.Fatalf("seed existing label: %v", err) + } + + m := PushMutation{ + MutationID: "mut-label-conflict-replay", + EntityType: "label", + EntityID: "label-second-device-2", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Garage", "color": "#abcdef"}), + Now: 2000, + } + + first, err := commitA.ApplyMutation(t.Context(), m) + if err != nil { + t.Fatalf("first ApplyMutation: %v", err) + } + if first.Applied || first.Skipped { + t.Fatalf("first outcome = %+v, want a conflict (Applied=false Skipped=false)", first) + } + + second, err := commitA.ApplyMutation(t.Context(), m) + if err != nil { + t.Fatalf("second (replay) ApplyMutation: %v", err) + } + if !second.Skipped { + t.Fatalf("replay outcome = %+v, want Skipped=true", second) + } +} + +func TestApplyMutationItemLabelDeleteTombstonesLiveEdge(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ID: "item-del-edge", Name: "Box", ShortCode: "SC-DEL1", Now: 1}) + if err != nil { + t.Fatalf("seed item: %v", err) + } + label, err := scopeA.Labels().Create(t.Context(), CreateLabelParams{ID: "label-del-edge", Name: "Fragile", Color: "#ff0000", Now: 1}) + if err != nil { + t.Fatalf("seed label: %v", err) + } + if err := scopeA.ItemLabels().Attach(t.Context(), AttachLabelParams{ID: "edge-del-1", ItemID: item.ID, LabelID: label.ID, Now: 1}); err != nil { + t.Fatalf("seed attach: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-edge-delete", + EntityType: "item_label", + EntityID: "edge-del-1", + Fields: pushFields(t, map[string]any{"item_id": item.ID, "label_id": label.ID}), + Now: 2000, + Op: PushOpDelete, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if !outcome.Applied { + t.Fatalf("outcome.Applied = false, want true") + } + if outcome.Version != 2 { + t.Fatalf("outcome.Version = %d, want 2 (attach=1, detach=2)", outcome.Version) + } + + labels, err := scopeA.ItemLabels().ListForItem(t.Context(), item.ID) + if err != nil { + t.Fatalf("ListForItem: %v", err) + } + if len(labels) != 0 { + t.Fatalf("ListForItem = %+v, want empty after delete", labels) + } +} + +func TestApplyMutationItemLabelDeleteOnAbsentEdgeIsEntityConflict(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ID: "item-del-absent", Name: "Box", ShortCode: "SC-DEL2", Now: 1}) + if err != nil { + t.Fatalf("seed item: %v", err) + } + label, err := scopeA.Labels().Create(t.Context(), CreateLabelParams{ID: "label-del-absent", Name: "Fragile", Color: "#ff0000", Now: 1}) + if err != nil { + t.Fatalf("seed label: %v", err) + } + + outcome, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-edge-delete-absent", + EntityType: "item_label", + EntityID: "edge-never-attached", + Fields: pushFields(t, map[string]any{"item_id": item.ID, "label_id": label.ID}), + Now: 2000, + Op: PushOpDelete, + }) + if err != nil { + t.Fatalf("ApplyMutation: %v", err) + } + if outcome.Applied { + t.Fatalf("outcome.Applied = true, want false") + } + if !equalStringSlices(outcome.ConflictFields, []string{entityConflictField}) { + t.Fatalf("outcome.ConflictFields = %v, want [%s]", outcome.ConflictFields, entityConflictField) + } +} + +func TestApplyMutationDeleteOnUnsupportedEntityTypeIsStructuralError(t *testing.T) { + commitA, _, scopeA, _, _ := pushCommitScope(t) + + item, err := scopeA.Items().Create(t.Context(), CreateItemParams{ID: "item-del-unsupported", Name: "Box", ShortCode: "SC-DEL3", Now: 1}) + if err != nil { + t.Fatalf("seed item: %v", err) + } + + _, err = commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-item-delete-unsupported", + EntityType: "item", + EntityID: item.ID, + BaseVersion: item.Version, + Fields: pushFields(t, map[string]any{}), + Now: 2000, + Op: PushOpDelete, + }) + if !errors.Is(err, ErrPushDeleteNotSupported) { + t.Fatalf("ApplyMutation error = %v, want ErrPushDeleteNotSupported", err) + } +} + +func TestApplyMutationUnknownOpIsStructuralError(t *testing.T) { + commitA, _, _, _, _ := pushCommitScope(t) + + _, err := commitA.ApplyMutation(t.Context(), PushMutation{ + MutationID: "mut-bad-op", + EntityType: "item", + EntityID: "item-1", + BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "X"}), + Now: 2000, + Op: "bogus", + }) + if !errors.Is(err, ErrPushUnknownOp) { + t.Fatalf("ApplyMutation error = %v, want ErrPushUnknownOp", err) + } +} + func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false diff --git a/server/internal/storage/push_test.go b/server/internal/storage/push_test.go index 3ad8f52..a0cd56f 100644 --- a/server/internal/storage/push_test.go +++ b/server/internal/storage/push_test.go @@ -210,3 +210,394 @@ func TestPushLookupIsScopedToItsOwnGroupAtTheGeneratedQueryLevel(t *testing.T) { "recorded for groupA must be invisible to groupB (P-3)", created.MutationID, err) } } + +func TestPushLookupFieldVersionsReturnsEmptyForAnUntrackedEntity(t *testing.T) { + repoA, _, _ := pushScope(t) + + got, err := repoA.LookupFieldVersions(t.Context(), "item", "item-1") + if err != nil { + t.Fatalf("LookupFieldVersions: %v", err) + } + if got == nil { + t.Error("LookupFieldVersions(untracked entity) = nil, want a non-nil empty slice (P-4: emit_empty_slices)") + } + if len(got) != 0 { + t.Errorf("LookupFieldVersions(untracked entity) = %+v, want empty", got) + } +} + +func TestPushUpsertFieldVersionThenLookupReturnsTheRow(t *testing.T) { + repoA, _, _ := pushScope(t) + + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "item-1", FieldName: "name", Version: 3, Now: 1_000, + }); err != nil { + t.Fatalf("UpsertFieldVersion: %v", err) + } + + got, err := repoA.LookupFieldVersions(t.Context(), "item", "item-1") + if err != nil { + t.Fatalf("LookupFieldVersions: %v", err) + } + if len(got) != 1 { + t.Fatalf("LookupFieldVersions = %+v, want exactly one row", got) + } + row := got[0] + if row.GroupID != "groupA" { + t.Errorf("row.GroupID = %q, want %q", row.GroupID, "groupA") + } + if row.EntityType != "item" || row.EntityID != "item-1" || row.FieldName != "name" { + t.Errorf("row.EntityType/EntityID/FieldName = %q/%q/%q, want %q/%q/%q", + row.EntityType, row.EntityID, row.FieldName, "item", "item-1", "name") + } + if row.Version != 3 { + t.Errorf("row.Version = %d, want %d", row.Version, 3) + } + if row.UpdatedAt != 1_000 { + t.Errorf("row.UpdatedAt = %d, want %d", row.UpdatedAt, 1_000) + } +} + +func TestPushUpsertFieldVersionOverwritesRatherThanIncrements(t *testing.T) { + repoA, _, _ := pushScope(t) + + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "item-1", FieldName: "name", Version: 5, Now: 1_000, + }); err != nil { + t.Fatalf("first UpsertFieldVersion: %v", err) + } + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "item-1", FieldName: "name", Version: 2, Now: 2_000, + }); err != nil { + t.Fatalf("second UpsertFieldVersion: %v", err) + } + + got, err := repoA.LookupFieldVersions(t.Context(), "item", "item-1") + if err != nil { + t.Fatalf("LookupFieldVersions: %v", err) + } + if len(got) != 1 { + t.Fatalf("LookupFieldVersions = %+v, want exactly one row (the upsert must replace, not add a second row)", got) + } + if got[0].Version != 2 { + t.Errorf("row.Version after second upsert = %d, want %d (overwritten, not max()'d or incremented)", got[0].Version, 2) + } + if got[0].UpdatedAt != 2_000 { + t.Errorf("row.UpdatedAt after second upsert = %d, want %d", got[0].UpdatedAt, 2_000) + } +} + +func TestPushLookupFieldVersionsBatchesMultipleFields(t *testing.T) { + repoA, _, _ := pushScope(t) + + fields := []string{"name", "notes", "location_id"} + for i, field := range fields { + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "item-1", FieldName: field, Version: int64(i + 1), Now: 1_000, + }); err != nil { + t.Fatalf("UpsertFieldVersion(%q): %v", field, err) + } + } + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "item-2", FieldName: "name", Version: 9, Now: 1_000, + }); err != nil { + t.Fatalf("UpsertFieldVersion(item-2): %v", err) + } + + got, err := repoA.LookupFieldVersions(t.Context(), "item", "item-1") + if err != nil { + t.Fatalf("LookupFieldVersions: %v", err) + } + if len(got) != len(fields) { + t.Fatalf("LookupFieldVersions returned %d rows, want %d: %+v", len(got), len(fields), got) + } + seen := make(map[string]int64, len(got)) + for _, row := range got { + if row.EntityID != "item-1" { + t.Errorf("row.EntityID = %q, want %q (item-2's own field must not appear in item-1's batch)", row.EntityID, "item-1") + } + seen[row.FieldName] = row.Version + } + for i, field := range fields { + if seen[field] != int64(i+1) { + t.Errorf("field %q version = %d, want %d", field, seen[field], i+1) + } + } +} + +func TestPushUpsertFieldVersionValidatesRequiredFields(t *testing.T) { + base := UpsertFieldVersionParams{EntityType: "item", EntityID: "item-1", FieldName: "name", Version: 1, Now: 1} + + tests := []struct { + name string + mutate func(p UpsertFieldVersionParams) UpsertFieldVersionParams + wantErr bool + }{ + {"valid", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { return p }, false}, + {"empty EntityType", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.EntityType = ""; return p }, true}, + {"empty EntityID", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.EntityID = ""; return p }, true}, + {"empty FieldName", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.FieldName = ""; return p }, true}, + {"zero Version", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.Version = 0; return p }, true}, + {"negative Version", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.Version = -1; return p }, true}, + {"zero Now", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.Now = 0; return p }, true}, + {"negative Now", func(p UpsertFieldVersionParams) UpsertFieldVersionParams { p.Now = -1; return p }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoA, _, _ := pushScope(t) + err := repoA.UpsertFieldVersion(t.Context(), tt.mutate(base)) + if (err != nil) != tt.wantErr { + t.Fatalf("UpsertFieldVersion error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPushFieldVersionsAreScopedToTheirOwnGroup(t *testing.T) { + repoA, repoB, _ := pushScope(t) + + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "shared-item", FieldName: "name", Version: 1, Now: 1, + }); err != nil { + t.Fatalf("UpsertFieldVersion (groupA): %v", err) + } + + gotB, err := repoB.LookupFieldVersions(t.Context(), "item", "shared-item") + if err != nil { + t.Fatalf("groupB LookupFieldVersions before its own Upsert: %v", err) + } + if len(gotB) != 0 { + t.Fatalf("groupB LookupFieldVersions before its own Upsert = %+v, want empty; a field tracked "+ + "for groupA must be invisible to groupB (P-3)", gotB) + } + + if err := repoB.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "shared-item", FieldName: "name", Version: 7, Now: 2, + }); err != nil { + t.Fatalf("UpsertFieldVersion (groupB), same tuple as groupA: %v", err) + } + + gotA, err := repoA.LookupFieldVersions(t.Context(), "item", "shared-item") + if err != nil { + t.Fatalf("groupA LookupFieldVersions after groupB's own Upsert: %v", err) + } + if len(gotA) != 1 || gotA[0].Version != 1 { + t.Fatalf("groupA LookupFieldVersions after groupB's own Upsert = %+v, want exactly one row at version 1 (groupB's own Upsert must not have overwritten it)", gotA) + } + + gotB, err = repoB.LookupFieldVersions(t.Context(), "item", "shared-item") + if err != nil { + t.Fatalf("groupB LookupFieldVersions: %v", err) + } + if len(gotB) != 1 || gotB[0].Version != 7 { + t.Fatalf("groupB LookupFieldVersions = %+v, want exactly one row at version 7", gotB) + } +} + +func TestPushFieldVersionsAreScopedToTheirOwnGroupAtTheGeneratedQueryLevel(t *testing.T) { + repoA, _, s := pushScope(t) + + if err := repoA.UpsertFieldVersion(t.Context(), UpsertFieldVersionParams{ + EntityType: "item", EntityID: "item-cross-tenant", FieldName: "name", Version: 1, Now: 1, + }); err != nil { + t.Fatalf("UpsertFieldVersion (groupA): %v", err) + } + + q := gen.New(s.store.Reader()) + + rowsA, err := q.ListFieldVersionsByEntity(t.Context(), gen.ListFieldVersionsByEntityParams{ + GroupID: "groupA", EntityType: "item", EntityID: "item-cross-tenant", + }) + if err != nil { + t.Fatalf("ListFieldVersionsByEntity(groupA): %v", err) + } + if len(rowsA) != 1 { + t.Fatalf("ListFieldVersionsByEntity(groupA) = %+v, want exactly one row", rowsA) + } + + rowsB, err := q.ListFieldVersionsByEntity(t.Context(), gen.ListFieldVersionsByEntityParams{ + GroupID: "groupB", EntityType: "item", EntityID: "item-cross-tenant", + }) + if err != nil { + t.Fatalf("ListFieldVersionsByEntity(groupB): %v", err) + } + if len(rowsB) != 0 { + t.Fatalf("ListFieldVersionsByEntity(groupB) = %+v, want empty; a field tracked for groupA "+ + "must be invisible to groupB (P-3)", rowsB) + } +} + +func TestPushInsertConflictThenReadBackFromTheDatabase(t *testing.T) { + repoA, _, s := pushScope(t) + + ledger, err := repoA.Insert(t.Context(), InsertMutationLedgerEntryParams{ + ID: "ledger-1", MutationID: "mutation-1", EntityType: "item", EntityID: "item-1", + Outcome: MutationOutcomeConflict, Now: 500, + }) + if err != nil { + t.Fatalf("Insert (ledger row for the FK): %v", err) + } + + created, err := repoA.InsertConflict(t.Context(), InsertConflictParams{ + ID: "conflict-1", EntityType: "item", EntityID: "item-1", FieldName: "name", + ServerValueSnapshot: `"server value"`, LosingClientValueSnapshot: `"client value"`, + DetectedAt: 1_000, MutationID: ledger.ID, Now: 1_000, + }) + if err != nil { + t.Fatalf("InsertConflict: %v", err) + } + + if created.ID != "conflict-1" { + t.Errorf("created.ID = %q, want %q", created.ID, "conflict-1") + } + if created.GroupID != "groupA" { + t.Errorf("created.GroupID = %q, want %q", created.GroupID, "groupA") + } + if created.EntityType != "item" || created.EntityID != "item-1" || created.FieldName != "name" { + t.Errorf("created.EntityType/EntityID/FieldName = %q/%q/%q, want %q/%q/%q", + created.EntityType, created.EntityID, created.FieldName, "item", "item-1", "name") + } + if !created.ServerValueSnapshot.Valid || created.ServerValueSnapshot.String != `"server value"` { + t.Errorf("created.ServerValueSnapshot = %+v, want valid %q", created.ServerValueSnapshot, `"server value"`) + } + if !created.LosingClientValueSnapshot.Valid || created.LosingClientValueSnapshot.String != `"client value"` { + t.Errorf("created.LosingClientValueSnapshot = %+v, want valid %q", created.LosingClientValueSnapshot, `"client value"`) + } + if !created.MutationID.Valid || created.MutationID.String != ledger.ID { + t.Errorf("created.MutationID = %+v, want valid %q", created.MutationID, ledger.ID) + } + if created.DetectedAt != 1_000 || created.CreatedAt != 1_000 || created.UpdatedAt != 1_000 { + t.Errorf("created.DetectedAt/CreatedAt/UpdatedAt = %d/%d/%d, want all %d", + created.DetectedAt, created.CreatedAt, created.UpdatedAt, 1_000) + } + + var ( + groupID, entityType, entityID, fieldName string + serverSnap, losingSnap, mutationID sql.NullString + detectedAt, createdAt, updatedAt int64 + ) + err = s.store.Reader().QueryRowContext(t.Context(), ` + SELECT group_id, entity_type, entity_id, field_name, + server_value_snapshot, losing_client_value_snapshot, + detected_at, mutation_id, created_at, updated_at + FROM conflicts WHERE id = ?`, created.ID). + Scan(&groupID, &entityType, &entityID, &fieldName, &serverSnap, &losingSnap, &detectedAt, &mutationID, &createdAt, &updatedAt) + if err != nil { + t.Fatalf("read back conflict row %q: %v", created.ID, err) + } + if groupID != "groupA" || entityType != "item" || entityID != "item-1" || fieldName != "name" { + t.Errorf("persisted group_id/entity_type/entity_id/field_name = %q/%q/%q/%q, want %q/%q/%q/%q", + groupID, entityType, entityID, fieldName, "groupA", "item", "item-1", "name") + } + if !serverSnap.Valid || serverSnap.String != `"server value"` { + t.Errorf("persisted server_value_snapshot = %+v, want valid %q", serverSnap, `"server value"`) + } + if !losingSnap.Valid || losingSnap.String != `"client value"` { + t.Errorf("persisted losing_client_value_snapshot = %+v, want valid %q", losingSnap, `"client value"`) + } + if !mutationID.Valid || mutationID.String != ledger.ID { + t.Errorf("persisted mutation_id = %+v, want valid %q", mutationID, ledger.ID) + } + if detectedAt != 1_000 || createdAt != 1_000 || updatedAt != 1_000 { + t.Errorf("persisted detected_at/created_at/updated_at = %d/%d/%d, want all %d", detectedAt, createdAt, updatedAt, 1_000) + } +} + +func TestPushInsertConflictLeavesOptionalSnapshotsAndMutationIDNull(t *testing.T) { + repoA, _, _ := pushScope(t) + + created, err := repoA.InsertConflict(t.Context(), InsertConflictParams{ + ID: "conflict-no-mutation", EntityType: "item", EntityID: "item-1", FieldName: "_entity", + DetectedAt: 1_000, Now: 1_000, + }) + if err != nil { + t.Fatalf("InsertConflict: %v", err) + } + if created.ServerValueSnapshot.Valid { + t.Errorf("created.ServerValueSnapshot = %+v, want NULL (Valid=false)", created.ServerValueSnapshot) + } + if created.LosingClientValueSnapshot.Valid { + t.Errorf("created.LosingClientValueSnapshot = %+v, want NULL (Valid=false)", created.LosingClientValueSnapshot) + } + if created.MutationID.Valid { + t.Errorf("created.MutationID = %+v, want NULL (Valid=false) -- migration 0001's own comment: a conflict can be detected outside a push", created.MutationID) + } +} + +func TestPushInsertConflictValidatesRequiredFields(t *testing.T) { + base := InsertConflictParams{ + ID: "conflict-1", EntityType: "item", EntityID: "item-1", FieldName: "name", + DetectedAt: 1, Now: 1, + } + + tests := []struct { + name string + mutate func(p InsertConflictParams) InsertConflictParams + wantErr bool + }{ + {"valid", func(p InsertConflictParams) InsertConflictParams { return p }, false}, + {"empty ID", func(p InsertConflictParams) InsertConflictParams { p.ID = ""; return p }, true}, + {"empty EntityType", func(p InsertConflictParams) InsertConflictParams { p.EntityType = ""; return p }, true}, + {"empty EntityID", func(p InsertConflictParams) InsertConflictParams { p.EntityID = ""; return p }, true}, + {"empty FieldName", func(p InsertConflictParams) InsertConflictParams { p.FieldName = ""; return p }, true}, + {"zero DetectedAt", func(p InsertConflictParams) InsertConflictParams { p.DetectedAt = 0; return p }, true}, + {"negative DetectedAt", func(p InsertConflictParams) InsertConflictParams { p.DetectedAt = -1; return p }, true}, + {"zero Now", func(p InsertConflictParams) InsertConflictParams { p.Now = 0; return p }, true}, + {"negative Now", func(p InsertConflictParams) InsertConflictParams { p.Now = -1; return p }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoA, _, _ := pushScope(t) + _, err := repoA.InsertConflict(t.Context(), tt.mutate(base)) + if (err != nil) != tt.wantErr { + t.Fatalf("InsertConflict error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPushInsertConflictIsScopedToItsOwnGroup(t *testing.T) { + repoA, repoB, s := pushScope(t) + + createdA, err := repoA.InsertConflict(t.Context(), InsertConflictParams{ + ID: "conflict-a", EntityType: "item", EntityID: "shared-item", FieldName: "name", + DetectedAt: 1, Now: 1, + }) + if err != nil { + t.Fatalf("InsertConflict (groupA): %v", err) + } + createdB, err := repoB.InsertConflict(t.Context(), InsertConflictParams{ + ID: "conflict-b", EntityType: "item", EntityID: "shared-item", FieldName: "name", + DetectedAt: 2, Now: 2, + }) + if err != nil { + t.Fatalf("InsertConflict (groupB): %v", err) + } + + rows, err := s.store.Reader().QueryContext(t.Context(), `SELECT id, group_id FROM conflicts WHERE id IN (?, ?) ORDER BY id`, createdA.ID, createdB.ID) + if err != nil { + t.Fatalf("query persisted conflicts: %v", err) + } + defer rows.Close() + + got := map[string]string{} + for rows.Next() { + var id, groupID string + if err := rows.Scan(&id, &groupID); err != nil { + t.Fatalf("scan persisted conflict: %v", err) + } + got[id] = groupID + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate persisted conflicts: %v", err) + } + + if got["conflict-a"] != "groupA" { + t.Errorf("persisted group_id for conflict-a = %q, want %q", got["conflict-a"], "groupA") + } + if got["conflict-b"] != "groupB" { + t.Errorf("persisted group_id for conflict-b = %q, want %q", got["conflict-b"], "groupB") + } +} diff --git a/server/internal/storage/queries/conflicts.sql b/server/internal/storage/queries/conflicts.sql new file mode 100644 index 0000000..6d50639 --- /dev/null +++ b/server/internal/storage/queries/conflicts.sql @@ -0,0 +1,11 @@ +-- name: CreateConflict :exec +INSERT INTO conflicts ( + id, group_id, entity_type, entity_id, field_name, + server_value_snapshot, losing_client_value_snapshot, + detected_at, mutation_id, created_at, updated_at +) +VALUES ( + sqlc.arg(id), sqlc.arg(group_id), sqlc.arg(entity_type), sqlc.arg(entity_id), sqlc.arg(field_name), + sqlc.arg(server_value_snapshot), sqlc.arg(losing_client_value_snapshot), + sqlc.arg(detected_at), sqlc.arg(mutation_id), sqlc.arg(now), sqlc.arg(now) +); diff --git a/server/internal/storage/queries/field_versions.sql b/server/internal/storage/queries/field_versions.sql new file mode 100644 index 0000000..ffaa2f0 --- /dev/null +++ b/server/internal/storage/queries/field_versions.sql @@ -0,0 +1,14 @@ +-- name: ListFieldVersionsByEntity :many +SELECT * FROM field_versions +WHERE group_id = sqlc.arg(group_id) + AND entity_type = sqlc.arg(entity_type) + AND entity_id = sqlc.arg(entity_id); + +-- name: UpsertFieldVersion :exec +INSERT INTO field_versions (group_id, entity_type, entity_id, field_name, version, updated_at) +VALUES ( + sqlc.arg(group_id), sqlc.arg(entity_type), sqlc.arg(entity_id), sqlc.arg(field_name), + sqlc.arg(version), sqlc.arg(now) +) +ON CONFLICT (group_id, entity_type, entity_id, field_name) +DO UPDATE SET version = excluded.version, updated_at = excluded.updated_at; diff --git a/server/internal/storage/queries/scoping_test.go b/server/internal/storage/queries/scoping_test.go index da05358..5db7002 100644 --- a/server/internal/storage/queries/scoping_test.go +++ b/server/internal/storage/queries/scoping_test.go @@ -219,6 +219,32 @@ var unscopedWriteAllowlist = map[string]string{ "this very statement's own error, not by a pre-check SELECT ahead of it, so there is " + "still no existing row being read here to leak (a fresh INSERT, not a filtered " + "SELECT/UPDATE).", + "CreateConflict": "T165b: group_id is an explicit, required, positional VALUES column; " + + "conflicts.group_id is NOT NULL REFERENCES groups (id) with no default, so sqlc refuses " + + "to compile this statement without it -- the identical shape as CreateMutation, " + + "CreateImportSession, CreateAttachment, CreateLabel and every other Create* already " + + "allowlisted here for the same reason. Like CreateImportSession this row's only parent is " + + "the group itself (no composite FOREIGN KEY to a parent item -- entity_type/entity_id are " + + "polymorphic, unconstrained TEXT, the identical shape mutations.entity_type/entity_id " + + "already have), so group_id being an explicit, required column IS the entire tenant " + + "guarantee this statement carries, and it is the one this lint would otherwise (wrongly) " + + "flag as unscoped. Unlike CreateMutation this table carries NO UNIQUE index of its own to " + + "collide on beyond its own primary key, so there is not even a same-table collision to " + + "detect here -- a fresh INSERT with no existing row being read to leak, full stop.", + "UpsertFieldVersion": "T165b: group_id is an explicit, required, positional VALUES column; " + + "field_versions.group_id is NOT NULL REFERENCES groups (id) with no default (migration " + + "0005), so sqlc refuses to compile this statement without it -- the identical shape as " + + "CreateConflict, CreateMutation and every other Create* already allowlisted here for the " + + "same reason. Unlike an ordinary Create* this statement is an UPSERT (INSERT ... ON " + + "CONFLICT (group_id, entity_type, entity_id, field_name) DO UPDATE), so it CAN touch an " + + "existing row -- but the row it can touch is pinned to the exact composite primary key " + + "tuple its own VALUES clause supplies, INCLUDING group_id, so the DO UPDATE branch can " + + "only ever land on that one tuple, never a different tenant's row with the same " + + "entity_type/entity_id/field_name. group_id being an explicit, required VALUES column is " + + "still the entire tenant guarantee this statement carries, and it is the one this lint " + + "would otherwise (wrongly) flag as unscoped, since neither the VALUES list nor the ON " + + "CONFLICT column list nor the DO UPDATE SET clause contains the literal `group_id = ...` " + + "predicate shape this lint's regex looks for.", } var queryNamePattern = regexp.MustCompile(`^--\s*name:\s*(\w+)\s+:(\w+)`) diff --git a/server/internal/storage/sale.go b/server/internal/storage/sale.go index effc57d..11693d5 100644 --- a/server/internal/storage/sale.go +++ b/server/internal/storage/sale.go @@ -160,12 +160,18 @@ func (r saleRepository) Update(ctx context.Context, p UpdateSaleParams) (Sale, e var updated Sale err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItemSale(ctx, gen.GetItemSaleParams{GroupID: r.group(), ItemID: p.ItemID}) + row, err := updateSaleTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: sale for item %q: field-version diff pre-read: %w", p.ItemID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "sold_to_block", row.ID, row.Version, p.Now, diffSaleFieldVersions(before, row)) }) if err != nil { return Sale{}, err diff --git a/server/internal/storage/stock_adjustments.go b/server/internal/storage/stock_adjustments.go index afb756f..defd074 100644 --- a/server/internal/storage/stock_adjustments.go +++ b/server/internal/storage/stock_adjustments.go @@ -67,12 +67,22 @@ func (r stockAdjustmentRepository) Create(ctx context.Context, p CreateStockAdju var created StockAdjustment err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItem(ctx, gen.GetItemParams{GroupID: r.group(), ItemID: p.ItemID}) + row, err := createStockAdjustmentTx(ctx, tx, q, r.group(), p) if err != nil { return err } created = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: item %q: field-version diff pre-read: %w", p.ItemID, beforeErr) + } + after, err := q.GetItem(ctx, gen.GetItemParams{GroupID: r.group(), ItemID: p.ItemID}) + if err != nil { + return fmt.Errorf("storage: item %q: field-version diff post-read: %w", p.ItemID, err) + } + return recordFieldVersionsTx(ctx, q, r.group(), "item", after.ID, after.Version, p.Now, diffItemFieldVersions(before, after)) }) if err != nil { return StockAdjustment{}, err diff --git a/server/internal/storage/warranty.go b/server/internal/storage/warranty.go index b9e844d..9dcd933 100644 --- a/server/internal/storage/warranty.go +++ b/server/internal/storage/warranty.go @@ -162,12 +162,18 @@ func (r warrantyRepository) Update(ctx context.Context, p UpdateWarrantyParams) var updated Warranty err := r.writeTx(ctx, func(tx *sql.Tx, q *gen.Queries) error { + before, beforeErr := q.GetItemWarranty(ctx, gen.GetItemWarrantyParams{GroupID: r.group(), ItemID: p.ItemID}) + row, err := updateWarrantyTx(ctx, tx, q, r.group(), p) if err != nil { return err } updated = row - return nil + + if beforeErr != nil { + return fmt.Errorf("storage: warranty for item %q: field-version diff pre-read: %w", p.ItemID, beforeErr) + } + return recordFieldVersionsTx(ctx, q, r.group(), "warranty_block", row.ID, row.Version, p.Now, diffWarrantyFieldVersions(before, row)) }) if err != nil { return Warranty{}, err diff --git a/server/internal/sync/push.go b/server/internal/sync/push.go index d765b2b..a4241c9 100644 --- a/server/internal/sync/push.go +++ b/server/internal/sync/push.go @@ -13,6 +13,7 @@ type PushMutation struct { EntityID string BaseVersion int64 Fields map[string]json.RawMessage + Op string } type PushBatch struct { @@ -45,13 +46,25 @@ type PushResult struct { NewWatermark int64 } +type PushMutationError struct { + Index int + MutationID string + Err error +} + +func (e *PushMutationError) Error() string { + return fmt.Sprintf("sync: push: mutation[%d] %q: %v", e.Index, e.MutationID, e.Err) +} + +func (e *PushMutationError) Unwrap() error { return e.Err } + func Push(ctx context.Context, commit storage.PushCommitRepository, batch PushBatch) (PushResult, error) { if commit == nil { return PushResult{}, fmt.Errorf("%w", ErrNoRepository) } var result PushResult - for _, m := range batch.Mutations { + for i, m := range batch.Mutations { outcome, err := commit.ApplyMutation(ctx, storage.PushMutation{ MutationID: m.MutationID, EntityType: m.EntityType, @@ -59,9 +72,13 @@ func Push(ctx context.Context, commit storage.PushCommitRepository, batch PushBa BaseVersion: m.BaseVersion, Fields: m.Fields, Now: batch.Now, + Op: storage.PushOp(m.Op), }) if err != nil { - return PushResult{}, fmt.Errorf("sync: push: mutation %q: %w", m.MutationID, err) + if storage.IsPushStructuralError(err) { + return PushResult{}, &PushMutationError{Index: i, MutationID: m.MutationID, Err: err} + } + return PushResult{}, fmt.Errorf("sync: push: mutation[%d] %q: %w", i, m.MutationID, err) } if outcome.Skipped { diff --git a/server/internal/sync/push_test.go b/server/internal/sync/push_test.go index fa02464..2cd3f91 100644 --- a/server/internal/sync/push_test.go +++ b/server/internal/sync/push_test.go @@ -1,8 +1,10 @@ package sync import ( + "context" "encoding/json" "errors" + "fmt" "github.com/ankek/Household-Objects-Dev/server/internal/storage" "testing" ) @@ -155,3 +157,196 @@ func TestPushNilCommitRepositoryIsRejected(t *testing.T) { t.Fatalf("Push(nil) error = %v, want ErrNoRepository", err) } } + +func TestPushStructuralErrorIsAPushMutationErrorNamingIndexAndMutationID(t *testing.T) { + s := newSyncTestStorage(t) + seedGroup(t, s, "groupA") + commit := pushCommitFor(t, s, "groupA") + + _, err := Push(t.Context(), commit, PushBatch{ + Mutations: []PushMutation{ + {MutationID: "mut-ok", EntityType: "item", EntityID: "item-1", BaseVersion: 0, Fields: pushFields(t, map[string]any{"name": "Drill"})}, + {MutationID: "mut-bad", EntityType: "attachment", EntityID: "attach-1", BaseVersion: 0, Fields: pushFields(t, map[string]any{})}, + }, + Now: 1000, + }) + + var mutErr *PushMutationError + if !errors.As(err, &mutErr) { + t.Fatalf("Push error = %v (%T), want *PushMutationError", err, err) + } + if mutErr.Index != 1 || mutErr.MutationID != "mut-bad" { + t.Fatalf("PushMutationError = %+v, want Index=1 MutationID=mut-bad", mutErr) + } + if !errors.Is(err, storage.ErrPushAttachmentRejected) { + t.Fatalf("errors.Is(err, storage.ErrPushAttachmentRejected) = false, want true (Unwrap must reach the sentinel)") + } +} + +func TestPushStateDependentConflictDoesNotAbortTheBatch(t *testing.T) { + s := newSyncTestStorage(t) + seedGroup(t, s, "groupA") + commit := pushCommitFor(t, s, "groupA") + + scope, err := s.ForGroup(storage.MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + if _, err := scope.Labels().Create(t.Context(), storage.CreateLabelParams{ + ID: "label-existing", Name: "Garage", Color: "#123456", Now: 1, + }); err != nil { + t.Fatalf("seed existing label: %v", err) + } + + result, err := Push(t.Context(), commit, PushBatch{ + Mutations: []PushMutation{ + {MutationID: "mut-conflict", EntityType: "label", EntityID: "label-loser", BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Garage", "color": "#abcdef"})}, + {MutationID: "mut-after-conflict", EntityType: "item", EntityID: "item-after", BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Drill"})}, + }, + Now: 1000, + }) + if err != nil { + t.Fatalf("Push: %v", err) + } + if len(result.Conflicts) != 1 || result.Conflicts[0].MutationID != "mut-conflict" || result.Conflicts[0].FieldName != "name" { + t.Fatalf("Conflicts = %+v, want one entry for mut-conflict field_name=name", result.Conflicts) + } + if len(result.Applied) != 1 || result.Applied[0].MutationID != "mut-after-conflict" { + t.Fatalf("Applied = %+v, want one entry for mut-after-conflict -- the conflict must not abort the batch", result.Applied) + } +} + +func TestPushItemLabelDeleteOp(t *testing.T) { + s := newSyncTestStorage(t) + seedGroup(t, s, "groupA") + commit := pushCommitFor(t, s, "groupA") + scope, err := s.ForGroup(storage.MustGroupID("groupA")) + if err != nil { + t.Fatalf("ForGroup: %v", err) + } + + item, err := scope.Items().Create(t.Context(), storage.CreateItemParams{ID: "item-1", Name: "Box", ShortCode: "SC-1", Now: 1}) + if err != nil { + t.Fatalf("seed item: %v", err) + } + label, err := scope.Labels().Create(t.Context(), storage.CreateLabelParams{ID: "label-1", Name: "Fragile", Color: "#ff0000", Now: 1}) + if err != nil { + t.Fatalf("seed label: %v", err) + } + if err := scope.ItemLabels().Attach(t.Context(), storage.AttachLabelParams{ID: "edge-1", ItemID: item.ID, LabelID: label.ID, Now: 1}); err != nil { + t.Fatalf("seed attach: %v", err) + } + + result, err := Push(t.Context(), commit, PushBatch{ + Mutations: []PushMutation{ + {MutationID: "mut-delete-edge", EntityType: "item_label", EntityID: "edge-1", + Fields: pushFields(t, map[string]any{"item_id": item.ID, "label_id": label.ID}), Op: "delete"}, + }, + Now: 2000, + }) + if err != nil { + t.Fatalf("Push: %v", err) + } + if len(result.Applied) != 1 || result.Applied[0].MutationID != "mut-delete-edge" { + t.Fatalf("Applied = %+v, want one entry for mut-delete-edge", result.Applied) + } + + labels, err := scope.ItemLabels().ListForItem(t.Context(), item.ID) + if err != nil { + t.Fatalf("ListForItem: %v", err) + } + if len(labels) != 0 { + t.Fatalf("ListForItem = %+v, want empty after delete", labels) + } +} + +type fakePushCommit struct { + applyErr error +} + +func (f fakePushCommit) ApplyMutation(context.Context, storage.PushMutation) (storage.PushOutcome, error) { + return storage.PushOutcome{}, f.applyErr +} + +func (f fakePushCommit) Watermark(context.Context) (int64, error) { return 0, nil } + +func TestPushClassifiesApplyMutationErrors(t *testing.T) { + tests := []struct { + name string + applyErr error + wantMutationErr bool + wantErrIsSentinel error + }{ + { + name: "structural error becomes a *PushMutationError", + applyErr: storage.ErrPushAttachmentRejected, + wantMutationErr: true, + }, + { + name: "transient ledger failure is NOT a *PushMutationError", + applyErr: errors.New("storage: push: check mutation ledger for \"mut-1\": disk I/O error"), + wantMutationErr: false, + }, + { + name: "ErrVersionMismatch backstop is NOT a *PushMutationError", + applyErr: fmt.Errorf("storage: item: update: %w", storage.ErrVersionMismatch), + wantMutationErr: false, + wantErrIsSentinel: storage.ErrVersionMismatch, + }, + { + name: "validate()'s missing-field error IS a *PushMutationError", + applyErr: fmt.Errorf("%w: %q", storage.ErrPushMissingField, "entity_id"), + wantMutationErr: true, + wantErrIsSentinel: storage.ErrPushMissingField, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commit := fakePushCommit{applyErr: tt.applyErr} + + _, err := Push(t.Context(), commit, PushBatch{ + Mutations: []PushMutation{{MutationID: "mut-1", EntityType: "item", EntityID: "item-1", BaseVersion: 0, Fields: pushFields(t, map[string]any{"name": "Drill"})}}, + Now: 1000, + }) + if err == nil { + t.Fatalf("Push: got nil error, want non-nil") + } + + var mutErr *PushMutationError + gotMutationErr := errors.As(err, &mutErr) + if gotMutationErr != tt.wantMutationErr { + t.Fatalf("errors.As(err, *PushMutationError) = %v, want %v (err = %v, %T)", gotMutationErr, tt.wantMutationErr, err, err) + } + if tt.wantErrIsSentinel != nil && !errors.Is(err, tt.wantErrIsSentinel) { + t.Fatalf("errors.Is(err, %v) = false, want true (err = %v)", tt.wantErrIsSentinel, err) + } + }) + } +} + +func TestPushClientMalformedMutationIsAPushMutationError(t *testing.T) { + s := newSyncTestStorage(t) + seedGroup(t, s, "groupA") + commit := pushCommitFor(t, s, "groupA") + + _, err := Push(t.Context(), commit, PushBatch{ + Mutations: []PushMutation{ + {MutationID: "mut-no-entity-id", EntityType: "item", EntityID: "", BaseVersion: 0, Fields: pushFields(t, map[string]any{"name": "Drill"})}, + }, + Now: 1000, + }) + + var mutErr *PushMutationError + if !errors.As(err, &mutErr) { + t.Fatalf("Push error = %v (%T), want *PushMutationError", err, err) + } + if mutErr.Index != 0 || mutErr.MutationID != "mut-no-entity-id" { + t.Fatalf("PushMutationError = %+v, want Index=0 MutationID=mut-no-entity-id", mutErr) + } + if !errors.Is(err, storage.ErrPushMissingField) { + t.Fatalf("errors.Is(err, storage.ErrPushMissingField) = false, want true (err = %v)", err) + } +} diff --git a/server/tests/integration/auth_me_test.go b/server/tests/integration/auth_me_test.go new file mode 100644 index 0000000..9472009 --- /dev/null +++ b/server/tests/integration/auth_me_test.go @@ -0,0 +1,94 @@ +package integration + +import ( + "net/http" + "testing" +) + +func TestAuthMeAtHTTPLayer(t *testing.T) { + srv := newLiveServer(t, false) + const alicePassword = "alice-correct-horse-battery-auth-me" + const bobPassword = "bob-correct-horse-battery-auth-me" + + if rec := srv.do(t, http.MethodPost, "/api/v1/auth/register", "", regBody("alice", alicePassword)); rec.Code != http.StatusCreated { + t.Fatalf("register: status = %d: %s", rec.Code, rec.Body.String()) + } + rec := srv.do(t, http.MethodPost, "/api/v1/auth/login", "", regBody("alice", alicePassword)) + if rec.Code != http.StatusOK { + t.Fatalf("alice login: status = %d: %s", rec.Code, rec.Body.String()) + } + var aliceLogin loginResponse + mustDecode(t, rec, &aliceLogin) + aliceCookie := sessionCookie(t, rec) + + rec = srv.do(t, http.MethodGet, "/api/v1/auth/me", aliceCookie, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /auth/me as owner (cookie): status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var aliceMe loginResponse + mustDecode(t, rec, &aliceMe) + if aliceMe != aliceLogin { + t.Fatalf("GET /auth/me as owner (cookie) = %+v, want it to match POST /auth/login's own response %+v", aliceMe, aliceLogin) + } + if aliceMe.Role != "owner" { + t.Fatalf("GET /auth/me as owner: role = %q, want owner", aliceMe.Role) + } + + rec = srv.do(t, http.MethodPost, "/api/v1/auth/device-tokens", aliceCookie, []byte(`{"device_label":"alice's phone"}`)) + if rec.Code != http.StatusCreated { + t.Fatalf("issue device token: status = %d: %s", rec.Code, rec.Body.String()) + } + var deviceToken deviceTokenIssueResponse + mustDecode(t, rec, &deviceToken) + + rec = srv.doBearer(t, http.MethodGet, "/api/v1/auth/me", deviceToken.Token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /auth/me as owner (bearer device token): status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var aliceMeBearer loginResponse + mustDecode(t, rec, &aliceMeBearer) + if aliceMeBearer != aliceLogin { + t.Fatalf("GET /auth/me as owner (bearer device token) = %+v, want it to match the cookie-authenticated response %+v", aliceMeBearer, aliceLogin) + } + + rec = srv.do(t, http.MethodPost, "/api/v1/invites", aliceCookie, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("invite issue: status = %d: %s", rec.Code, rec.Body.String()) + } + var issued inviteCreateResponse + mustDecode(t, rec, &issued) + + rec = srv.do(t, http.MethodPost, "/api/v1/invites/redeem", "", redeemBody(issued.Token, "bob", bobPassword)) + if rec.Code != http.StatusCreated { + t.Fatalf("invite redeem: status = %d: %s", rec.Code, rec.Body.String()) + } + + rec = srv.do(t, http.MethodPost, "/api/v1/auth/login", "", regBody("bob", bobPassword)) + if rec.Code != http.StatusOK { + t.Fatalf("bob login: status = %d: %s", rec.Code, rec.Body.String()) + } + var bobLogin loginResponse + mustDecode(t, rec, &bobLogin) + bobCookie := sessionCookie(t, rec) + if bobLogin.Role != "member" { + t.Fatalf("bob's role = %q, want member (invite redemption never grants owner)", bobLogin.Role) + } + + rec = srv.do(t, http.MethodGet, "/api/v1/auth/me", bobCookie, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /auth/me as member (cookie): status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var bobMe loginResponse + mustDecode(t, rec, &bobMe) + if bobMe != bobLogin { + t.Fatalf("GET /auth/me as member (cookie) = %+v, want it to match POST /auth/login's own response %+v", bobMe, bobLogin) + } + if bobMe.GroupID != aliceLogin.GroupID { + t.Fatalf("bob's GroupID via GET /auth/me = %q, want alice's group %q (invite redemption seats the redeemer in the invite's own group)", bobMe.GroupID, aliceLogin.GroupID) + } + + rec = srv.do(t, http.MethodGet, "/api/v1/auth/me", "", nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("GET /auth/me with no credential: status = %d, want 401: %s", rec.Code, rec.Body.String()) + } +} diff --git a/server/tests/integration/sync_push_field_merge_test.go b/server/tests/integration/sync_push_field_merge_test.go new file mode 100644 index 0000000..e96388b --- /dev/null +++ b/server/tests/integration/sync_push_field_merge_test.go @@ -0,0 +1,455 @@ +package integration + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "testing" +) + +func fieldMergeItemUpdateBody(name, description, locationID string, quantity, version int64) []byte { + b, _ := json.Marshal(map[string]any{ + "name": name, "description": description, "location_id": locationID, + "quantity": quantity, "version": version, + }) + return b +} + +type fieldMergeConflictRow struct { + entityType string + entityID string + fieldName string + serverValue sql.NullString + losingValue sql.NullString + ledgerID string +} + +func fieldMergeSelectConflicts(t *testing.T, db *sql.DB, groupID, entityID string) []fieldMergeConflictRow { + t.Helper() + rows, err := db.Query( + `SELECT entity_type, entity_id, field_name, server_value_snapshot, losing_client_value_snapshot, mutation_id + FROM conflicts WHERE group_id = ? AND entity_id = ? ORDER BY detected_at, id`, + groupID, entityID) + if err != nil { + t.Fatalf("query conflicts for entity %s: %v", entityID, err) + } + defer func() { _ = rows.Close() }() + + var out []fieldMergeConflictRow + for rows.Next() { + var r fieldMergeConflictRow + if err := rows.Scan(&r.entityType, &r.entityID, &r.fieldName, &r.serverValue, &r.losingValue, &r.ledgerID); err != nil { + t.Fatalf("scan conflicts row: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate conflicts rows: %v", err) + } + return out +} + +func fieldMergeConflictCount(t *testing.T, db *sql.DB, groupID string) int64 { + t.Helper() + var n int64 + if err := db.QueryRow(`SELECT COUNT(*) FROM conflicts WHERE group_id = ?`, groupID).Scan(&n); err != nil { + t.Fatalf("count conflicts for group %s: %v", groupID, err) + } + return n +} + +func fieldMergeLedgerRowID(t *testing.T, db *sql.DB, groupID, mutationID string) string { + t.Helper() + var id string + err := db.QueryRow(`SELECT id FROM mutations WHERE group_id = ? AND mutation_id = ?`, groupID, mutationID).Scan(&id) + if err != nil { + t.Fatalf("lookup ledger row for mutation_id %s: %v", mutationID, err) + } + return id +} + +func fieldMergeFieldVersion(t *testing.T, db *sql.DB, groupID, entityType, entityID, fieldName string) (version int64, tracked bool) { + t.Helper() + err := db.QueryRow( + `SELECT version FROM field_versions WHERE group_id = ? AND entity_type = ? AND entity_id = ? AND field_name = ?`, + groupID, entityType, entityID, fieldName, + ).Scan(&version) + switch { + case errors.Is(err, sql.ErrNoRows): + return 0, false + case err != nil: + t.Fatalf("query field_versions %s/%s/%s: %v", entityType, entityID, fieldName, err) + } + return version, true +} + +type fieldMergeItemRow struct { + name, description string + quantity, version, changeSeq int64 +} + +func fieldMergeReadItem(t *testing.T, db *sql.DB, itemID string) fieldMergeItemRow { + t.Helper() + var r fieldMergeItemRow + err := db.QueryRow(`SELECT name, description, quantity, version, change_seq FROM items WHERE id = ?`, itemID). + Scan(&r.name, &r.description, &r.quantity, &r.version, &r.changeSeq) + if err != nil { + t.Fatalf("read item %s: %v", itemID, err) + } + return r +} + +func fieldMergeGroupWatermark(t *testing.T, db *sql.DB, groupID string) int64 { + t.Helper() + var n int64 + if err := db.QueryRow(`SELECT change_seq_counter FROM groups WHERE id = ?`, groupID).Scan(&n); err != nil { + t.Fatalf("read group %s watermark: %v", groupID, err) + } + return n +} + +type fieldMergePulledItemFields struct { + Name string + Description string + Quantity int64 +} + +func fieldMergeFindItemChange(t *testing.T, page syncPullOKBody, itemID string) fieldMergePulledItemFields { + t.Helper() + for _, c := range page.Changes { + if c.EntityType == "item" && c.ID == itemID { + var f fieldMergePulledItemFields + if err := json.Unmarshal(c.Data, &f); err != nil { + t.Fatalf("decode pulled item %s data: %v; raw=%s", itemID, err, c.Data) + } + return f + } + } + t.Fatalf("pull(since=0) has no item change for id=%s", itemID) + return fieldMergePulledItemFields{} +} + +func fieldMergeSinglePush(t *testing.T, srv *liveServer, cookie, deviceID string, m pushReplayMutationWire) pushReplayResponseWire { + t.Helper() + body := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: []pushReplayMutationWire{m}}) + rec, resp := pushReplayPush(t, srv, cookie, body) + if rec.Code != http.StatusOK { + t.Fatalf("push %s: status = %d, want 200: %s", m.MutationID, rec.Code, rec.Body.String()) + } + return resp +} + +func TestFieldMergePushDifferentFieldsBothSurvive(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "fm1-owner", "fm1-owner-pw-1") + + item := createSyncItem(t, srv, cookie, "fm1-item") + baseVersion := item.Version + + nameResp := fieldMergeSinglePush(t, srv, cookie, "fm1-dev-a", pushReplayMutationWire{ + MutationID: "fm1-name", EntityType: "item", EntityID: item.ID, BaseVersion: baseVersion, + Fields: map[string]json.RawMessage{"name": pushReplayField("fm1-item-renamed")}, + }) + if len(nameResp.Applied) != 1 || len(nameResp.Conflicts) != 0 { + t.Fatalf("name push: applied=%+v conflicts=%+v, want exactly one applied entry, no conflicts", nameResp.Applied, nameResp.Conflicts) + } + + qtyResp := fieldMergeSinglePush(t, srv, cookie, "fm1-dev-b", pushReplayMutationWire{ + MutationID: "fm1-qty", EntityType: "item", EntityID: item.ID, BaseVersion: baseVersion, + Fields: map[string]json.RawMessage{"quantity": pushReplayField(int64(42))}, + }) + if len(qtyResp.Applied) != 1 || len(qtyResp.Conflicts) != 0 { + t.Fatalf("quantity push: applied=%+v conflicts=%+v, want exactly one applied entry, no conflicts", qtyResp.Applied, qtyResp.Conflicts) + } + + db := pushReplayOpenDB(t, srv.dbPath) + if n := fieldMergeConflictCount(t, db, groupID); n != 0 { + t.Errorf("conflicts row count = %d, want 0 (different fields must never conflict)", n) + } + + if v, tracked := fieldMergeFieldVersion(t, db, groupID, "item", item.ID, "name"); !tracked || v != nameResp.Applied[0].Version { + t.Errorf("field_versions[name] = (%d, tracked=%v), want (%d, true)", v, tracked, nameResp.Applied[0].Version) + } + if v, tracked := fieldMergeFieldVersion(t, db, groupID, "item", item.ID, "quantity"); !tracked || v != qtyResp.Applied[0].Version { + t.Errorf("field_versions[quantity] = (%d, tracked=%v), want (%d, true)", v, tracked, qtyResp.Applied[0].Version) + } + + row := fieldMergeReadItem(t, db, item.ID) + if row.name != "fm1-item-renamed" || row.quantity != 42 { + t.Errorf("item row = %+v, want name=fm1-item-renamed quantity=42", row) + } + + if got := fieldMergeGroupWatermark(t, db, groupID); got != qtyResp.NewWatermark { + t.Errorf("group change_seq_counter = %d, want %d (the second push's own new_watermark)", got, qtyResp.NewWatermark) + } + + page := srv.decodeSyncPullOK(t, cookie, "fm1-puller", 0, 500) + pulled := fieldMergeFindItemChange(t, page, item.ID) + if pulled.Name != "fm1-item-renamed" || pulled.Quantity != 42 { + t.Errorf("pulled item = %+v, want name=fm1-item-renamed quantity=42", pulled) + } +} + +func TestFieldMergePushSameFieldDiverges(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "fm2-owner", "fm2-owner-pw-1") + + item := createSyncItem(t, srv, cookie, "fm2-item") + baseVersion := item.Version + + first := fieldMergeSinglePush(t, srv, cookie, "fm2-dev-a", pushReplayMutationWire{ + MutationID: "fm2-name-a", EntityType: "item", EntityID: item.ID, BaseVersion: baseVersion, + Fields: map[string]json.RawMessage{"name": pushReplayField("fm2-name-A")}, + }) + if len(first.Applied) != 1 || len(first.Conflicts) != 0 { + t.Fatalf("first push: applied=%+v conflicts=%+v, want exactly one applied entry, no conflicts", first.Applied, first.Conflicts) + } + + second := fieldMergeSinglePush(t, srv, cookie, "fm2-dev-b", pushReplayMutationWire{ + MutationID: "fm2-name-b", EntityType: "item", EntityID: item.ID, BaseVersion: baseVersion, + Fields: map[string]json.RawMessage{"name": pushReplayField("fm2-name-B")}, + }) + if len(second.Applied) != 0 { + t.Errorf("second push applied = %+v, want none (the whole mutation's only field conflicted)", second.Applied) + } + if len(second.Conflicts) != 1 || second.Conflicts[0].MutationID != "fm2-name-b" || second.Conflicts[0].FieldName != "name" { + t.Fatalf("second push conflicts = %+v, want exactly one fm2-name-b/name entry", second.Conflicts) + } + if second.NewWatermark != first.NewWatermark { + t.Errorf("second push new_watermark = %d, want %d (unchanged -- it wrote nothing)", second.NewWatermark, first.NewWatermark) + } + + db := pushReplayOpenDB(t, srv.dbPath) + conflicts := fieldMergeSelectConflicts(t, db, groupID, item.ID) + if len(conflicts) != 1 { + t.Fatalf("persisted conflicts rows = %+v, want exactly 1", conflicts) + } + c := conflicts[0] + if c.entityType != "item" || c.fieldName != "name" { + t.Errorf("conflict row = %+v, want entity_type=item field_name=name", c) + } + wantLedgerID := fieldMergeLedgerRowID(t, db, groupID, "fm2-name-b") + if c.ledgerID != wantLedgerID { + t.Errorf("conflict row mutation_id = %q, want the LEDGER row id %q (not the wire mutation_id fm2-name-b)", c.ledgerID, wantLedgerID) + } + if !c.serverValue.Valid || c.serverValue.String != `"fm2-name-A"` { + t.Errorf("conflict server_value_snapshot = %+v, want the JSON-encoded winning value \"fm2-name-A\"", c.serverValue) + } + if !c.losingValue.Valid || c.losingValue.String != `"fm2-name-B"` { + t.Errorf("conflict losing_client_value_snapshot = %+v, want the JSON-encoded losing value \"fm2-name-B\"", c.losingValue) + } + + row := fieldMergeReadItem(t, db, item.ID) + if row.name != "fm2-name-A" { + t.Errorf("item name = %q, want %q (the first, winning write) -- the losing value must never land on the row", row.name, "fm2-name-A") + } + + page := srv.decodeSyncPullOK(t, cookie, "fm2-puller", 0, 500) + pulled := fieldMergeFindItemChange(t, page, item.ID) + if pulled.Name != "fm2-name-A" { + t.Errorf("pulled item name = %q, want %q -- the losing client's value must never appear anywhere but the conflict row's own snapshot", pulled.Name, "fm2-name-A") + } +} + +func TestFieldMergePushStaleAfterRESTEdit(t *testing.T) { + t.Run("SameField", func(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "fm3a-owner", "fm3a-owner-pw-1") + + item := createSyncItem(t, srv, cookie, "fm3a-item") + staleBaseVersion := item.Version + + restRec := srv.do(t, http.MethodPut, "/api/v1/items/"+item.ID, cookie, + fieldMergeItemUpdateBody("fm3a-rest-name", "", "", 0, item.Version)) + if restRec.Code != http.StatusOK { + t.Fatalf("REST PUT item: status = %d: %s", restRec.Code, restRec.Body.String()) + } + + push := fieldMergeSinglePush(t, srv, cookie, "fm3a-dev", pushReplayMutationWire{ + MutationID: "fm3a-push-name", EntityType: "item", EntityID: item.ID, BaseVersion: staleBaseVersion, + Fields: map[string]json.RawMessage{"name": pushReplayField("fm3a-push-name")}, + }) + if len(push.Applied) != 0 { + t.Errorf("stale same-field push applied = %+v, want none", push.Applied) + } + if len(push.Conflicts) != 1 || push.Conflicts[0].FieldName != "name" { + t.Fatalf("stale same-field push conflicts = %+v, want exactly one name entry", push.Conflicts) + } + + db := pushReplayOpenDB(t, srv.dbPath) + conflicts := fieldMergeSelectConflicts(t, db, groupID, item.ID) + if len(conflicts) != 1 || !conflicts[0].serverValue.Valid || conflicts[0].serverValue.String != `"fm3a-rest-name"` { + t.Fatalf("conflicts = %+v, want one row snapshotting the REST value \"fm3a-rest-name\"", conflicts) + } + + row := fieldMergeReadItem(t, db, item.ID) + if row.name != "fm3a-rest-name" { + t.Errorf("item name = %q, want %q (the REST value wins over the stale push)", row.name, "fm3a-rest-name") + } + + page := srv.decodeSyncPullOK(t, cookie, "fm3a-puller", 0, 500) + pulled := fieldMergeFindItemChange(t, page, item.ID) + if pulled.Name != "fm3a-rest-name" { + t.Errorf("pulled item name = %q, want %q", pulled.Name, "fm3a-rest-name") + } + }) + + t.Run("DifferentField", func(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "fm3b-owner", "fm3b-owner-pw-1") + + item := createSyncItem(t, srv, cookie, "fm3b-item") + staleBaseVersion := item.Version + + restRec := srv.do(t, http.MethodPut, "/api/v1/items/"+item.ID, cookie, + fieldMergeItemUpdateBody("fm3b-rest-name", "", "", 0, item.Version)) + if restRec.Code != http.StatusOK { + t.Fatalf("REST PUT item: status = %d: %s", restRec.Code, restRec.Body.String()) + } + + push := fieldMergeSinglePush(t, srv, cookie, "fm3b-dev", pushReplayMutationWire{ + MutationID: "fm3b-push-desc", EntityType: "item", EntityID: item.ID, BaseVersion: staleBaseVersion, + Fields: map[string]json.RawMessage{"description": pushReplayField("fm3b-push-desc")}, + }) + if len(push.Conflicts) != 0 { + t.Fatalf("stale different-field push conflicts = %+v, want none (A153: a stale push touching a different field still merges)", push.Conflicts) + } + if len(push.Applied) != 1 { + t.Fatalf("stale different-field push applied = %+v, want exactly one entry", push.Applied) + } + + db := pushReplayOpenDB(t, srv.dbPath) + if n := fieldMergeConflictCount(t, db, groupID); n != 0 { + t.Errorf("conflicts row count = %d, want 0", n) + } + + row := fieldMergeReadItem(t, db, item.ID) + if row.name != "fm3b-rest-name" || row.description != "fm3b-push-desc" { + t.Errorf("item row = %+v, want name=fm3b-rest-name (REST) description=fm3b-push-desc (push) -- both must survive", row) + } + + page := srv.decodeSyncPullOK(t, cookie, "fm3b-puller", 0, 500) + pulled := fieldMergeFindItemChange(t, page, item.ID) + if pulled.Name != "fm3b-rest-name" || pulled.Description != "fm3b-push-desc" { + t.Errorf("pulled item = %+v, want name=fm3b-rest-name description=fm3b-push-desc", pulled) + } + }) +} + +func TestFieldMergePushSameBatchStockAdjustmentThenItemUpdate(t *testing.T) { + t.Run("NameOnlyBothApply", func(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "fm4a-owner", "fm4a-owner-pw-1") + + item := createSyncItem(t, srv, cookie, "fm4a-item") + staleBaseVersion := item.Version + + stockID := pushReplayNewID(t) + batch := []pushReplayMutationWire{ + { + MutationID: "fm4a-stock", EntityType: "stock_adjustment", EntityID: stockID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushReplayField(item.ID), "delta": pushReplayField(int64(7)), + "reason": pushReplayField("restock"), "note": pushReplayField("fm4a"), + }, + }, + { + MutationID: "fm4a-item-name", EntityType: "item", EntityID: item.ID, BaseVersion: staleBaseVersion, + Fields: map[string]json.RawMessage{"name": pushReplayField("fm4a-renamed")}, + }, + } + body := mustMarshal(t, pushReplayRequestWire{DeviceID: "fm4a-dev", Mutations: batch}) + rec, resp := pushReplayPush(t, srv, cookie, body) + if rec.Code != http.StatusOK { + t.Fatalf("batch push: status = %d: %s", rec.Code, rec.Body.String()) + } + if len(resp.Applied) != 2 { + t.Fatalf("batch applied = %+v, want both mutations applied", resp.Applied) + } + if len(resp.Conflicts) != 0 { + t.Fatalf("batch conflicts = %+v, want none", resp.Conflicts) + } + + db := pushReplayOpenDB(t, srv.dbPath) + if n := fieldMergeConflictCount(t, db, groupID); n != 0 { + t.Errorf("conflicts row count = %d, want 0", n) + } + row := fieldMergeReadItem(t, db, item.ID) + if row.name != "fm4a-renamed" || row.quantity != 7 { + t.Errorf("item row = %+v, want name=fm4a-renamed quantity=7 (stock delta applied, name applied)", row) + } + if got := fieldMergeGroupWatermark(t, db, groupID); got != resp.NewWatermark { + t.Errorf("group change_seq_counter = %d, want %d", got, resp.NewWatermark) + } + + page := srv.decodeSyncPullOK(t, cookie, "fm4a-puller", 0, 500) + pulled := fieldMergeFindItemChange(t, page, item.ID) + if pulled.Name != "fm4a-renamed" || pulled.Quantity != 7 { + t.Errorf("pulled item = %+v, want name=fm4a-renamed quantity=7", pulled) + } + }) + + t.Run("NameAndQuantityConflictsOnQuantity", func(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "fm4b-owner", "fm4b-owner-pw-1") + + item := createSyncItem(t, srv, cookie, "fm4b-item") + staleBaseVersion := item.Version + + stockID := pushReplayNewID(t) + batch := []pushReplayMutationWire{ + { + MutationID: "fm4b-stock", EntityType: "stock_adjustment", EntityID: stockID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushReplayField(item.ID), "delta": pushReplayField(int64(3)), + "reason": pushReplayField("restock"), "note": pushReplayField("fm4b"), + }, + }, + { + MutationID: "fm4b-item-update", EntityType: "item", EntityID: item.ID, BaseVersion: staleBaseVersion, + Fields: map[string]json.RawMessage{ + "name": pushReplayField("fm4b-renamed"), + "quantity": pushReplayField(int64(999)), + }, + }, + } + body := mustMarshal(t, pushReplayRequestWire{DeviceID: "fm4b-dev", Mutations: batch}) + rec, resp := pushReplayPush(t, srv, cookie, body) + if rec.Code != http.StatusOK { + t.Fatalf("batch push: status = %d: %s", rec.Code, rec.Body.String()) + } + if len(resp.Applied) != 2 { + t.Fatalf("batch applied = %+v, want both mutation_ids present (stock create + the partially-applied item update)", resp.Applied) + } + if len(resp.Conflicts) != 1 || resp.Conflicts[0].MutationID != "fm4b-item-update" || resp.Conflicts[0].FieldName != "quantity" { + t.Fatalf("batch conflicts = %+v, want exactly one fm4b-item-update/quantity entry", resp.Conflicts) + } + + db := pushReplayOpenDB(t, srv.dbPath) + conflicts := fieldMergeSelectConflicts(t, db, groupID, item.ID) + if len(conflicts) != 1 || conflicts[0].fieldName != "quantity" { + t.Fatalf("persisted conflicts = %+v, want exactly one quantity row", conflicts) + } + if !conflicts[0].serverValue.Valid || conflicts[0].serverValue.String != "3" { + t.Errorf("conflict server_value_snapshot = %+v, want the stock adjustment's own resulting quantity, JSON-encoded (\"3\")", conflicts[0].serverValue) + } + if !conflicts[0].losingValue.Valid || conflicts[0].losingValue.String != "999" { + t.Errorf("conflict losing_client_value_snapshot = %+v, want the push's own losing quantity, JSON-encoded (\"999\")", conflicts[0].losingValue) + } + + row := fieldMergeReadItem(t, db, item.ID) + if row.name != "fm4b-renamed" { + t.Errorf("item name = %q, want %q (name still applied)", row.name, "fm4b-renamed") + } + if row.quantity != 3 { + t.Errorf("item quantity = %d, want 3 (the stock adjustment's own value, never overwritten by the conflicting push)", row.quantity) + } + + page := srv.decodeSyncPullOK(t, cookie, "fm4b-puller", 0, 500) + pulled := fieldMergeFindItemChange(t, page, item.ID) + if pulled.Name != "fm4b-renamed" || pulled.Quantity != 3 { + t.Errorf("pulled item = %+v, want name=fm4b-renamed quantity=3", pulled) + } + }) +} diff --git a/server/tests/integration/sync_push_idempotent_replay_test.go b/server/tests/integration/sync_push_idempotent_replay_test.go new file mode 100644 index 0000000..72015a2 --- /dev/null +++ b/server/tests/integration/sync_push_idempotent_replay_test.go @@ -0,0 +1,496 @@ +package integration + +import ( + "database/sql" + "encoding/json" + "github.com/google/uuid" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" +) + +type pushReplayMutationWire struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + BaseVersion int64 `json:"base_version"` + Fields map[string]json.RawMessage `json:"fields"` + Op string `json:"op,omitempty"` +} + +type pushReplayRequestWire struct { + DeviceID string `json:"device_id"` + Mutations []pushReplayMutationWire `json:"mutations"` +} + +type pushReplayAppliedEntry struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + Version int64 `json:"version"` +} + +type pushReplaySkippedEntry struct { + MutationID string `json:"mutation_id"` +} + +type pushReplayConflictEntry struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + FieldName string `json:"field_name"` +} + +type pushReplayResponseWire struct { + Applied []pushReplayAppliedEntry `json:"applied"` + Skipped []pushReplaySkippedEntry `json:"skipped"` + Conflicts []pushReplayConflictEntry `json:"conflicts"` + NewWatermark int64 `json:"new_watermark"` +} + +func pushReplayField(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +} + +func pushReplayNewID(t *testing.T) string { + t.Helper() + id, err := uuid.NewV7() + if err != nil { + t.Fatalf("uuid.NewV7: %v", err) + } + return id.String() +} + +func pushReplayCreateItemMutation(mutationID, entityID, name string) pushReplayMutationWire { + return pushReplayMutationWire{ + MutationID: mutationID, EntityType: "item", EntityID: entityID, BaseVersion: 0, + Fields: map[string]json.RawMessage{"name": pushReplayField(name)}, + } +} + +func pushReplayMutationIDs(mutations []pushReplayMutationWire) []string { + ids := make([]string, len(mutations)) + for i, m := range mutations { + ids[i] = m.MutationID + } + return ids +} + +func pushReplaySkippedIDs(entries []pushReplaySkippedEntry) []string { + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.MutationID + } + return ids +} + +func pushReplayAppliedIDs(entries []pushReplayAppliedEntry) []string { + ids := make([]string, len(entries)) + for i, e := range entries { + ids[i] = e.MutationID + } + return ids +} + +func pushReplayAssertIDSet(t *testing.T, got, want []string, label string) { + t.Helper() + gotSorted := append([]string(nil), got...) + wantSorted := append([]string(nil), want...) + sort.Strings(gotSorted) + sort.Strings(wantSorted) + if len(gotSorted) != len(wantSorted) { + t.Fatalf("%s: got %v, want %v", label, gotSorted, wantSorted) + } + for i := range gotSorted { + if gotSorted[i] != wantSorted[i] { + t.Fatalf("%s: got %v, want %v", label, gotSorted, wantSorted) + } + } +} + +func pushReplayPush(t *testing.T, srv *liveServer, cookie string, body []byte) (*httptest.ResponseRecorder, pushReplayResponseWire) { + t.Helper() + rec := srv.do(t, http.MethodPost, "/api/v1/sync/push", cookie, body) + var resp pushReplayResponseWire + if rec.Code == http.StatusOK { + mustDecode(t, rec, &resp) + } + return rec, resp +} + +func pushReplayPullBody(t *testing.T, srv *liveServer, cookie, deviceID string, since, limit int64) string { + t.Helper() + rec := srv.do(t, http.MethodPost, "/api/v1/sync/pull", cookie, mustMarshal(t, syncPullRequestWire{DeviceID: deviceID, Since: since, Limit: limit})) + if rec.Code != http.StatusOK { + t.Fatalf("sync pull: status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + return rec.Body.String() +} + +func pushReplayOpenDB(t *testing.T, dbPath string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", "file:"+dbPath) + if err != nil { + t.Fatalf("open independent connection to %s: %v", dbPath, err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func pushReplayCount(t *testing.T, db *sql.DB, table string) int64 { + t.Helper() + var n int64 + if err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + return n +} + +func pushReplayCounts(t *testing.T, db *sql.DB, tables []string) map[string]int64 { + t.Helper() + counts := make(map[string]int64, len(tables)) + for _, table := range tables { + counts[table] = pushReplayCount(t, db, table) + } + return counts +} + +func pushReplayAssertCountsEqual(t *testing.T, got, want map[string]int64, context string) { + t.Helper() + for table, wantN := range want { + if gotN := got[table]; gotN != wantN { + t.Errorf("%s: table %s row count = %d, want %d (a replay must never duplicate a row)", context, table, gotN, wantN) + } + } +} + +type pushReplayRowState struct { + version, changeSeq, updatedAt int64 +} + +func pushReplaySnapshotRow(t *testing.T, db *sql.DB, table, id string) pushReplayRowState { + t.Helper() + var s pushReplayRowState + err := db.QueryRow("SELECT version, change_seq, updated_at FROM "+table+" WHERE id = ?", id).Scan(&s.version, &s.changeSeq, &s.updatedAt) + if err != nil { + t.Fatalf("snapshot %s row %s: %v", table, id, err) + } + return s +} + +func pushReplayMutationLedgerCount(t *testing.T, db *sql.DB, groupID string) int64 { + t.Helper() + var n int64 + if err := db.QueryRow("SELECT COUNT(*) FROM mutations WHERE group_id = ?", groupID).Scan(&n); err != nil { + t.Fatalf("count mutations for group %s: %v", groupID, err) + } + return n +} + +func TestSyncPushIdempotentReplayMixedBatch(t *testing.T) { + srv := newLiveServer(t, false) + cookie, _ := registerAndLogin(t, srv, "replay-mixed-owner", "replay-mixed-owner-pw-1") + const deviceID = "replay-mixed-device" + + baseItem := createSyncItem(t, srv, cookie, "replay-mixed-base-item") + + locRec := srv.do(t, http.MethodPost, "/api/v1/locations", cookie, locationCreateBody("replay-mixed-base-location", "")) + if locRec.Code != http.StatusCreated { + t.Fatalf("create base location: status = %d: %s", locRec.Code, locRec.Body.String()) + } + var baseLocation locationResponse + mustDecode(t, locRec, &baseLocation) + + labelRec := srv.do(t, http.MethodPost, "/api/v1/labels", cookie, labelCreateBody("replay-mixed-base-label", "#336699")) + if labelRec.Code != http.StatusCreated { + t.Fatalf("create base label: status = %d: %s", labelRec.Code, labelRec.Body.String()) + } + var baseLabel labelResponse + mustDecode(t, labelRec, &baseLabel) + + warrantyID := pushReplayNewID(t) + itemLabelID := pushReplayNewID(t) + stockID := pushReplayNewID(t) + + mutations := []pushReplayMutationWire{ + { + MutationID: "mix-warranty-create", EntityType: "warranty_block", EntityID: warrantyID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushReplayField(baseItem.ID), + "holder": pushReplayField("Acme Corp"), + "provider": pushReplayField("Acme Warranty Co"), + "starts_on": pushReplayField("2024-01-01"), + "expires_on": pushReplayField("2026-01-01"), + "is_lifetime": pushReplayField(false), + "notes": pushReplayField(""), + }, + }, + { + MutationID: "mix-itemlabel-create", EntityType: "item_label", EntityID: itemLabelID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushReplayField(baseItem.ID), + "label_id": pushReplayField(baseLabel.ID), + }, + }, + { + MutationID: "mix-item-update", EntityType: "item", EntityID: baseItem.ID, BaseVersion: baseItem.Version, + Fields: map[string]json.RawMessage{"name": pushReplayField("replay-mixed-base-item-renamed")}, + }, + { + MutationID: "mix-stock-create", EntityType: "stock_adjustment", EntityID: stockID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "item_id": pushReplayField(baseItem.ID), + "delta": pushReplayField(int64(5)), + "reason": pushReplayField("restock"), + "note": pushReplayField("replay-mixed initial stock"), + }, + }, + { + MutationID: "mix-location-update", EntityType: "location", EntityID: baseLocation.ID, BaseVersion: baseLocation.Version, + Fields: map[string]json.RawMessage{"name": pushReplayField("replay-mixed-base-location-renamed")}, + }, + } + + body := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: mutations}) + + rec1, first := pushReplayPush(t, srv, cookie, body) + if rec1.Code != http.StatusOK { + t.Fatalf("first push: status = %d, want 200: %s", rec1.Code, rec1.Body.String()) + } + if len(first.Applied) != len(mutations) { + t.Fatalf("first push applied = %+v, want %d entries", first.Applied, len(mutations)) + } + if len(first.Skipped) != 0 { + t.Fatalf("first push skipped = %+v, want none", first.Skipped) + } + if len(first.Conflicts) != 0 { + t.Fatalf("first push conflicts = %+v, want none", first.Conflicts) + } + + db := pushReplayOpenDB(t, srv.dbPath) + tables := []string{"items", "item_warranty", "item_labels", "stock_adjustments", "locations", "mutations"} + beforeCounts := pushReplayCounts(t, db, tables) + beforeItem := pushReplaySnapshotRow(t, db, "items", baseItem.ID) + beforeLocation := pushReplaySnapshotRow(t, db, "locations", baseLocation.ID) + beforeWarranty := pushReplaySnapshotRow(t, db, "item_warranty", warrantyID) + beforeItemLabel := pushReplaySnapshotRow(t, db, "item_labels", itemLabelID) + beforeStock := pushReplaySnapshotRow(t, db, "stock_adjustments", stockID) + + if beforeItem.version != baseItem.Version+2 { + t.Fatalf("item version after the first push = %d, want %d (one applied update, plus the stock-adjustment's own implicit bump)", beforeItem.version, baseItem.Version+2) + } + + beforePull := pushReplayPullBody(t, srv, cookie, deviceID, 0, 500) + + rec2, second := pushReplayPush(t, srv, cookie, body) + if rec2.Code != http.StatusOK { + t.Fatalf("replay push: status = %d, want 200: %s", rec2.Code, rec2.Body.String()) + } + if len(second.Applied) != 0 { + t.Errorf("replay applied = %+v, want none", second.Applied) + } + if len(second.Conflicts) != 0 { + t.Errorf("replay conflicts = %+v, want none", second.Conflicts) + } + pushReplayAssertIDSet(t, pushReplaySkippedIDs(second.Skipped), pushReplayMutationIDs(mutations), "replay skipped set") + if second.NewWatermark != first.NewWatermark { + t.Errorf("replay new_watermark = %d, want %d (unchanged -- no change_seq allocation on a replay)", second.NewWatermark, first.NewWatermark) + } + + afterCounts := pushReplayCounts(t, db, tables) + pushReplayAssertCountsEqual(t, afterCounts, beforeCounts, "mixed batch replay") + + afterItem := pushReplaySnapshotRow(t, db, "items", baseItem.ID) + afterLocation := pushReplaySnapshotRow(t, db, "locations", baseLocation.ID) + afterWarranty := pushReplaySnapshotRow(t, db, "item_warranty", warrantyID) + afterItemLabel := pushReplaySnapshotRow(t, db, "item_labels", itemLabelID) + afterStock := pushReplaySnapshotRow(t, db, "stock_adjustments", stockID) + + for _, row := range []struct { + name string + before, after pushReplayRowState + }{ + {"items", beforeItem, afterItem}, + {"locations", beforeLocation, afterLocation}, + {"item_warranty", beforeWarranty, afterWarranty}, + {"item_labels", beforeItemLabel, afterItemLabel}, + {"stock_adjustments", beforeStock, afterStock}, + } { + if row.before != row.after { + t.Errorf("%s row state changed by replay: before=%+v after=%+v", row.name, row.before, row.after) + } + } + + if afterItem.version != beforeItem.version { + t.Errorf("item version double-bumped by replay: before=%d after=%d", beforeItem.version, afterItem.version) + } + + afterPull := pushReplayPullBody(t, srv, cookie, deviceID, 0, 500) + if afterPull != beforePull { + t.Errorf("sync pull payload changed after an idempotent replay:\nbefore=%s\nafter=%s", beforePull, afterPull) + } +} + +func TestSyncPushIdempotentReplayRetryAfterPartialDelivery(t *testing.T) { + srv := newLiveServer(t, false) + cookie, _ := registerAndLogin(t, srv, "replay-retry-owner", "replay-retry-owner-pw-1") + const deviceID = "replay-retry-device" + + id1, id2, id3, id4 := pushReplayNewID(t), pushReplayNewID(t), pushReplayNewID(t), pushReplayNewID(t) + m1 := pushReplayCreateItemMutation("retry-m1", id1, "replay-retry-item-1") + m2 := pushReplayCreateItemMutation("retry-m2", id2, "replay-retry-item-2") + m3 := pushReplayCreateItemMutation("retry-m3", id3, "replay-retry-item-3") + m4 := pushReplayCreateItemMutation("retry-m4", id4, "replay-retry-item-4") + + firstBody := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: []pushReplayMutationWire{m1, m2}}) + rec1, first := pushReplayPush(t, srv, cookie, firstBody) + if rec1.Code != http.StatusOK { + t.Fatalf("first (partial-delivery) push: status = %d: %s", rec1.Code, rec1.Body.String()) + } + pushReplayAssertIDSet(t, pushReplayAppliedIDs(first.Applied), []string{"retry-m1", "retry-m2"}, "first push applied set") + + retryBody := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: []pushReplayMutationWire{m1, m2, m3, m4}}) + rec2, second := pushReplayPush(t, srv, cookie, retryBody) + if rec2.Code != http.StatusOK { + t.Fatalf("retry push: status = %d: %s", rec2.Code, rec2.Body.String()) + } + pushReplayAssertIDSet(t, pushReplaySkippedIDs(second.Skipped), []string{"retry-m1", "retry-m2"}, "retry skipped set") + pushReplayAssertIDSet(t, pushReplayAppliedIDs(second.Applied), []string{"retry-m3", "retry-m4"}, "retry applied set") + if len(second.Conflicts) != 0 { + t.Errorf("retry conflicts = %+v, want none", second.Conflicts) + } + + db := pushReplayOpenDB(t, srv.dbPath) + if n := pushReplayCount(t, db, "items"); n != 4 { + t.Errorf("items row count = %d, want exactly 4 (no duplicate of the replayed m1/m2)", n) + } + for i, id := range []string{id1, id2, id3, id4} { + if n := pushReplaySnapshotItemCount(t, db, id); n != 1 { + t.Errorf("items row count for m%d's id = %d, want exactly 1", i+1, n) + } + } +} + +func pushReplaySnapshotItemCount(t *testing.T, db *sql.DB, id string) int64 { + t.Helper() + var n int64 + if err := db.QueryRow("SELECT COUNT(*) FROM items WHERE id = ?", id).Scan(&n); err != nil { + t.Fatalf("count items id=%s: %v", id, err) + } + return n +} + +func TestSyncPushIdempotentReplayPoisonPillConflictSkipsWithoutReconflict(t *testing.T) { + srv := newLiveServer(t, false) + cookie, groupID := registerAndLogin(t, srv, "replay-poison-owner", "replay-poison-owner-pw-1") + const deviceID = "replay-poison-device" + + existingLabelRec := srv.do(t, http.MethodPost, "/api/v1/labels", cookie, labelCreateBody("replay-poison-taken-name", "#112233")) + if existingLabelRec.Code != http.StatusCreated { + t.Fatalf("create existing label: status = %d: %s", existingLabelRec.Code, existingLabelRec.Body.String()) + } + + itemID := pushReplayNewID(t) + conflictLabelID := pushReplayNewID(t) + + m1 := pushReplayCreateItemMutation("poison-m1", itemID, "replay-poison-item") + m2 := pushReplayMutationWire{ + MutationID: "poison-m2", EntityType: "label", EntityID: conflictLabelID, BaseVersion: 0, + Fields: map[string]json.RawMessage{ + "name": pushReplayField("replay-poison-taken-name"), + "color": pushReplayField("#445566"), + }, + } + + body := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: []pushReplayMutationWire{m1, m2}}) + + rec1, first := pushReplayPush(t, srv, cookie, body) + if rec1.Code != http.StatusOK { + t.Fatalf("first push: status = %d: %s", rec1.Code, rec1.Body.String()) + } + pushReplayAssertIDSet(t, pushReplayAppliedIDs(first.Applied), []string{"poison-m1"}, "first push applied set") + if len(first.Conflicts) != 1 || first.Conflicts[0].MutationID != "poison-m2" || first.Conflicts[0].FieldName != "name" { + t.Fatalf("first push conflicts = %+v, want exactly one poison-m2/name entry", first.Conflicts) + } + if len(first.Skipped) != 0 { + t.Fatalf("first push skipped = %+v, want none", first.Skipped) + } + + db := pushReplayOpenDB(t, srv.dbPath) + ledgerBefore := pushReplayMutationLedgerCount(t, db, groupID) + if ledgerBefore != 2 { + t.Fatalf("mutation ledger rows after the first push = %d, want 2 (one applied, one conflict -- both ledgered)", ledgerBefore) + } + labelsBefore := pushReplayCount(t, db, "labels") + + rec2, second := pushReplayPush(t, srv, cookie, body) + if rec2.Code != http.StatusOK { + t.Fatalf("replay push: status = %d, want 200 (never an error): %s", rec2.Code, rec2.Body.String()) + } + if len(second.Applied) != 0 { + t.Errorf("replay applied = %+v, want none", second.Applied) + } + if len(second.Conflicts) != 0 { + t.Errorf("replay conflicts = %+v, want none -- a recorded conflict must replay as skipped, never re-conflicted", second.Conflicts) + } + pushReplayAssertIDSet(t, pushReplaySkippedIDs(second.Skipped), []string{"poison-m1", "poison-m2"}, "replay skipped set") + + ledgerAfter := pushReplayMutationLedgerCount(t, db, groupID) + if ledgerAfter != ledgerBefore { + t.Errorf("mutation ledger row count changed by replay: before=%d after=%d", ledgerBefore, ledgerAfter) + } + labelsAfter := pushReplayCount(t, db, "labels") + if labelsAfter != labelsBefore { + t.Errorf("labels row count changed by replay: before=%d after=%d (poison-m2 must never create a row, on either push)", labelsBefore, labelsAfter) + } +} + +func TestSyncPushIdempotentReplayStructuralAbortThenRetry(t *testing.T) { + srv := newLiveServer(t, false) + cookie, _ := registerAndLogin(t, srv, "replay-abort-owner", "replay-abort-owner-pw-1") + const deviceID = "replay-abort-device" + + id1, id3 := pushReplayNewID(t), pushReplayNewID(t) + m1 := pushReplayCreateItemMutation("abort-m1", id1, "replay-abort-item-1") + m2 := pushReplayMutationWire{ + MutationID: "abort-m2", EntityType: "attachment", EntityID: pushReplayNewID(t), BaseVersion: 0, + Fields: map[string]json.RawMessage{}, + } + m3 := pushReplayCreateItemMutation("abort-m3", id3, "replay-abort-item-3") + + firstBody := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: []pushReplayMutationWire{m1, m2, m3}}) + rec1 := srv.do(t, http.MethodPost, "/api/v1/sync/push", cookie, firstBody) + if rec1.Code != http.StatusBadRequest { + t.Fatalf("structurally invalid batch: status = %d, want 400: %s", rec1.Code, rec1.Body.String()) + } + prob := decodeProblem(t, rec1) + if !strings.Contains(prob.Detail, "mutations[1]") || !strings.Contains(prob.Detail, "abort-m2") { + t.Errorf("400 detail = %q, want it to name mutations[1] and mutation_id=abort-m2", prob.Detail) + } + + db := pushReplayOpenDB(t, srv.dbPath) + if n := pushReplayCount(t, db, "items"); n != 1 { + t.Fatalf("items row count after the aborted batch = %d, want exactly 1 (m1 already committed)", n) + } + + retryBody := mustMarshal(t, pushReplayRequestWire{DeviceID: deviceID, Mutations: []pushReplayMutationWire{m1, m3}}) + rec2, second := pushReplayPush(t, srv, cookie, retryBody) + if rec2.Code != http.StatusOK { + t.Fatalf("retry push: status = %d, want 200: %s", rec2.Code, rec2.Body.String()) + } + pushReplayAssertIDSet(t, pushReplaySkippedIDs(second.Skipped), []string{"abort-m1"}, "retry skipped set") + pushReplayAssertIDSet(t, pushReplayAppliedIDs(second.Applied), []string{"abort-m3"}, "retry applied set") + if len(second.Conflicts) != 0 { + t.Errorf("retry conflicts = %+v, want none", second.Conflicts) + } + + if n := pushReplayCount(t, db, "items"); n != 2 { + t.Errorf("items row count after retry = %d, want exactly 2 (no duplicate of m1)", n) + } + if n := pushReplaySnapshotItemCount(t, db, id1); n != 1 { + t.Errorf("items row count for m1's id = %d, want exactly 1 (no duplicate)", n) + } +} diff --git a/server/tests/integration/tenant_isolation_sync_push_test.go b/server/tests/integration/tenant_isolation_sync_push_test.go new file mode 100644 index 0000000..4d1d4e6 --- /dev/null +++ b/server/tests/integration/tenant_isolation_sync_push_test.go @@ -0,0 +1,498 @@ +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTenantIsolationSyncPushDoesNotLeakAcrossGroups(t *testing.T) { + srv := newLiveServer(t, true) + a := provisionPushIsoTenant(t, srv, "push-iso-alice") + b := provisionPushIsoTenant(t, srv, "push-iso-brenda") + requirePushIsoDistinctTenantIDs(t, a, b) + + credentials := []struct { + kind string + bearer bool + }{ + {"cookie", false}, + {"bearer", true}, + } + + for _, cred := range credentials { + t.Run("own_pushes_applied_and_visible_only_to_own_group/"+cred.kind, func(t *testing.T) { + aEntityID := "push-iso-a-own-item-" + cred.kind + bEntityID := "push-iso-b-own-item-" + cred.kind + + bWatermarkBefore := pushIsoWatermark(t, srv, cred.bearer, pushIsoCredValue(b, cred.bearer), "dev-b-wm-before-"+cred.kind) + + respA, _ := pushIsoPushOnce(t, srv, cred.bearer, pushIsoCredValue(a, cred.bearer), "dev-a-own-"+cred.kind, []pushIsoMutationWire{ + pushIsoItemCreateMutation("mut-a-own-"+cred.kind, aEntityID, "A's own pushed item "+cred.kind), + }) + if len(respA.Applied) != 1 || respA.Applied[0].EntityID != aEntityID { + t.Fatalf("A's own create response = %+v, want exactly one applied entry for entity_id %q", respA, aEntityID) + } + if len(respA.Conflicts) != 0 { + t.Fatalf("A's own create conflicted: %+v, want none", respA.Conflicts) + } + + bWatermarkAfterAPushed := pushIsoWatermark(t, srv, cred.bearer, pushIsoCredValue(b, cred.bearer), "dev-b-wm-after-"+cred.kind) + if bWatermarkAfterAPushed != bWatermarkBefore { + t.Fatalf("B's own watermark moved from %d to %d after ONLY A pushed -- push's watermark read is not scoped per group", + bWatermarkBefore, bWatermarkAfterAPushed) + } + + respB, _ := pushIsoPushOnce(t, srv, cred.bearer, pushIsoCredValue(b, cred.bearer), "dev-b-own-"+cred.kind, []pushIsoMutationWire{ + pushIsoItemCreateMutation("mut-b-own-"+cred.kind, bEntityID, "B's own pushed item "+cred.kind), + }) + if len(respB.Applied) != 1 || respB.Applied[0].EntityID != bEntityID { + t.Fatalf("B's own create response = %+v, want exactly one applied entry for entity_id %q", respB, bEntityID) + } + + changesA, _ := syncIsoPullAllPages(t, srv, cred.bearer, pushIsoCredValue(a, cred.bearer), "dev-a-pull-"+cred.kind, 0, 500) + changesB, _ := syncIsoPullAllPages(t, srv, cred.bearer, pushIsoCredValue(b, cred.bearer), "dev-b-pull-"+cred.kind, 0, 500) + + if !pushIsoChangesContainItemID(changesA, aEntityID) { + t.Errorf("A's own pull is missing the item A itself just pushed (id %q)", aEntityID) + } + if pushIsoChangesContainItemID(changesA, bEntityID) { + t.Errorf("A's pull LEAKED B's pushed item (id %q) across groups", bEntityID) + } + if !pushIsoChangesContainItemID(changesB, bEntityID) { + t.Errorf("B's own pull is missing the item B itself just pushed (id %q)", bEntityID) + } + if pushIsoChangesContainItemID(changesB, aEntityID) { + t.Errorf("B's pull LEAKED A's pushed item (id %q) across groups", aEntityID) + } + }) + } + + if rec := srv.do(t, http.MethodPut, "/api/v1/items/"+a.itemID+"/labels/"+a.labelID, a.cookie, nil); rec.Code != http.StatusNoContent { + t.Fatalf("seed A's own item<->label edge (item %q, label %q): status = %d, want 204: %s", a.itemID, a.labelID, rec.Code, rec.Body.String()) + } + + aItemBeforeAttack := pushIsoGetItem(t, srv, false, a.cookie, a.itemID) + + attackResp, attackRec := pushIsoPushOnce(t, srv, false, b.cookie, "dev-b-attack", []pushIsoMutationWire{ + pushIsoItemUpdateMutation("mut-b-attack-item-update", a.itemID, aItemBeforeAttack.Version, "HIJACKED BY B"), + pushIsoWarrantyCreateMutation("mut-b-attack-warranty-create", "push-iso-b-attack-warranty-1", a.itemID, "HIJACKED HOLDER"), + pushIsoItemLabelCreateMutation("mut-b-attack-item-label-create", "push-iso-b-attack-item-label-1", a.itemID, b.labelID), + pushIsoItemLabelDeleteMutation("mut-b-attack-item-label-delete", a.itemID, a.labelID), + }) + + if len(attackResp.Applied) != 0 { + t.Fatalf("B's cross-group attack batch applied %d mutation(s), want 0: %+v", len(attackResp.Applied), attackResp.Applied) + } + if len(attackResp.Skipped) != 0 { + t.Fatalf("B's cross-group attack batch skipped %d mutation(s), want 0 (every mutation_id here is fresh): %+v", len(attackResp.Skipped), attackResp.Skipped) + } + wantConflictField := map[string]string{ + "mut-b-attack-item-update": entityConflictFieldWire, + "mut-b-attack-warranty-create": "item_id", + "mut-b-attack-item-label-create": "item_id", + "mut-b-attack-item-label-delete": entityConflictFieldWire, + } + gotConflictField := pushIsoConflictFieldByMutationID(attackResp.Conflicts) + for mutationID, wantField := range wantConflictField { + if got, ok := gotConflictField[mutationID]; !ok { + t.Errorf("B's attack batch has no conflicts[] entry for mutation_id %q; got conflicts: %+v", mutationID, attackResp.Conflicts) + } else if got != wantField { + t.Errorf("B's attack mutation %q conflicted on field_name %q, want %q", mutationID, got, wantField) + } + } + if len(attackResp.Conflicts) != len(wantConflictField) { + t.Errorf("B's attack batch produced %d conflicts, want exactly %d (one per attack mutation): %+v", + len(attackResp.Conflicts), len(wantConflictField), attackResp.Conflicts) + } + + pushIsoResponseBodyMustNotContain(t, "B's cross-group attack response", attackRec, aItemBeforeAttack.Name) + + for _, cred := range credentials { + t.Run("cross_group_attack_wrote_nothing_to_A/"+cred.kind, func(t *testing.T) { + aItemAfterAttack := pushIsoGetItem(t, srv, cred.bearer, pushIsoCredValue(a, cred.bearer), a.itemID) + if aItemAfterAttack != aItemBeforeAttack { + t.Fatalf("A's item changed after B's cross-group attack batch: before=%+v after=%+v", aItemBeforeAttack, aItemAfterAttack) + } + + if code := pushIsoWarrantyStatus(t, srv, cred.bearer, pushIsoCredValue(a, cred.bearer), a.itemID); code != http.StatusNotFound { + t.Fatalf("A's item now answers GET .../warranty with status %d, want 404 (no warranty block was ever created) -- B's cross-group warranty_block create landed", code) + } + + labelIDs := pushIsoItemLabelIDs(t, srv, cred.bearer, pushIsoCredValue(a, cred.bearer), a.itemID) + if len(labelIDs) != 1 || labelIDs[0] != a.labelID { + t.Fatalf("A's item now carries label edge(s) %v, want exactly [%q] (A's own pre-existing edge, seeded before the attack) -- B's cross-group item_label create/delete landed", + labelIDs, a.labelID) + } + }) + } + + respAItemUpdateControl, _ := pushIsoPushOnce(t, srv, false, a.cookie, "dev-a-control-item-update", []pushIsoMutationWire{ + pushIsoItemUpdateMutation("mut-a-control-item-update", a.itemID, aItemBeforeAttack.Version, "A's own legitimate update -- same entity_id/base_version B's attack just tried"), + }) + if len(respAItemUpdateControl.Applied) != 1 || respAItemUpdateControl.Applied[0].Version != aItemBeforeAttack.Version+1 { + t.Fatalf("A's own control update (identical entity_id=%q base_version=%d to B's attack) = %+v, want exactly one applied entry at version %d -- "+ + "if this fails, a.itemID/base_version was never a live, A-owned target and requirement 2's attack proved nothing about isolation", + a.itemID, aItemBeforeAttack.Version, respAItemUpdateControl, aItemBeforeAttack.Version+1) + } + + respAWarrantyControl, _ := pushIsoPushOnce(t, srv, false, a.cookie, "dev-a-control-warranty-create", []pushIsoMutationWire{ + pushIsoWarrantyCreateMutation("mut-a-control-warranty-create", "push-iso-a-control-warranty-1", a.itemID, "A's own legitimate warranty -- same fields.item_id B's attack just tried"), + }) + if len(respAWarrantyControl.Applied) != 1 { + t.Fatalf("A's own control warranty_block create (identical fields.item_id=%q to B's attack) = %+v, want exactly one applied entry -- "+ + "if this fails, a.itemID was never a live, A-owned item and requirement 2's attack proved nothing about isolation", + a.itemID, respAWarrantyControl) + } + + aControlLabelID := syncIsoCreateLabel(t, srv, a.cookie, "push-iso-alice-control-label-1") + respAItemLabelCreateControl, _ := pushIsoPushOnce(t, srv, false, a.cookie, "dev-a-control-item-label-create", []pushIsoMutationWire{ + pushIsoItemLabelCreateMutation("mut-a-control-item-label-create", "push-iso-a-control-item-label-1", a.itemID, aControlLabelID), + }) + if len(respAItemLabelCreateControl.Applied) != 1 { + t.Fatalf("A's own control item_label create (identical fields.item_id=%q to B's attack) = %+v, want exactly one applied entry -- "+ + "if this fails, a.itemID was never a live, A-owned item and requirement 2's attack proved nothing about isolation", + a.itemID, respAItemLabelCreateControl) + } + + respAItemLabelDeleteControl, _ := pushIsoPushOnce(t, srv, false, a.cookie, "dev-a-control-item-label-delete", []pushIsoMutationWire{ + pushIsoItemLabelDeleteMutation("mut-a-control-item-label-delete", a.itemID, a.labelID), + }) + if len(respAItemLabelDeleteControl.Applied) != 1 { + t.Fatalf("A's own control item_label delete (identical item_id=%q label_id=%q to B's attack) = %+v, want exactly one applied entry -- "+ + "if this fails, that edge was never live and A-owned, and requirement 2's delete attack proved nothing about isolation", + a.itemID, a.labelID, respAItemLabelDeleteControl) + } + + const sharedMutationID = "push-iso-shared-mutation-id-across-groups" + aIdemEntityID := "push-iso-a-idem-item" + bIdemEntityID := "push-iso-b-idem-item" + + respAIdem, _ := pushIsoPushOnce(t, srv, false, a.cookie, "dev-a-idem", []pushIsoMutationWire{ + pushIsoItemCreateMutation(sharedMutationID, aIdemEntityID, "A's idempotency-ledger item"), + }) + if len(respAIdem.Applied) != 1 || respAIdem.Applied[0].EntityID != aIdemEntityID { + t.Fatalf("A's own create under mutation_id %q = %+v, want exactly one applied entry for entity_id %q", sharedMutationID, respAIdem, aIdemEntityID) + } + + respBIdem, _ := pushIsoPushOnce(t, srv, false, b.cookie, "dev-b-idem", []pushIsoMutationWire{ + pushIsoItemCreateMutation(sharedMutationID, bIdemEntityID, "B's idempotency-ledger item"), + }) + if len(respBIdem.Skipped) != 0 { + t.Fatalf("B's push using the SAME mutation_id %q A already consumed was skipped: %+v -- the idempotency ledger is not scoped per group (B could be permanently blocked by guessing/replaying A's own mutation_id)", + sharedMutationID, respBIdem.Skipped) + } + if len(respBIdem.Applied) != 1 || respBIdem.Applied[0].EntityID != bIdemEntityID { + t.Fatalf("B's own create under mutation_id %q = %+v, want exactly one applied entry for entity_id %q", sharedMutationID, respBIdem, bIdemEntityID) + } + + respAReplay, _ := pushIsoPushOnce(t, srv, false, a.cookie, "dev-a-idem-replay", []pushIsoMutationWire{ + pushIsoItemCreateMutation(sharedMutationID, aIdemEntityID, "A's REPLAYED idempotency-ledger item (should never apply)"), + }) + if len(respAReplay.Skipped) != 1 || respAReplay.Skipped[0].MutationID != sharedMutationID { + t.Fatalf("A's own replay of mutation_id %q = %+v, want exactly one skipped entry -- the idempotency ledger does not appear to work at all", sharedMutationID, respAReplay) + } + if len(respAReplay.Applied) != 0 { + t.Fatalf("A's own replay of mutation_id %q applied %+v, want none", sharedMutationID, respAReplay.Applied) + } + + aItemBeforeCollide := pushIsoGetItem(t, srv, false, a.cookie, a.itemID) + + collideResp, _ := pushIsoPushOnce(t, srv, false, b.cookie, "dev-b-collide", []pushIsoMutationWire{ + pushIsoItemCreateMutation("mut-b-collide-with-a-item-id", a.itemID, "B trying to steal A's item id"), + }) + if len(collideResp.Applied) != 0 { + t.Fatalf("B's create naming A's own real item id as entity_id applied: %+v, want none (a same-id create must never overwrite another tenant's row)", collideResp.Applied) + } + if len(collideResp.Conflicts) != 1 || collideResp.Conflicts[0].MutationID != "mut-b-collide-with-a-item-id" || collideResp.Conflicts[0].FieldName != entityConflictFieldWire { + t.Fatalf("B's create naming A's own real item id as entity_id = conflicts %+v, want exactly one whole-entity (%q) conflict", collideResp.Conflicts, entityConflictFieldWire) + } + + for _, cred := range credentials { + t.Run("entity_id_collision_wrote_nothing_to_A/"+cred.kind, func(t *testing.T) { + aItemAfterCollide := pushIsoGetItem(t, srv, cred.bearer, pushIsoCredValue(a, cred.bearer), a.itemID) + if aItemAfterCollide != aItemBeforeCollide { + t.Fatalf("A's item changed after B's client-supplied entity_id collision attempt: before=%+v after=%+v", aItemBeforeCollide, aItemAfterCollide) + } + }) + } + + changesBAfterCollide, _ := syncIsoPullAllPages(t, srv, false, b.cookie, "dev-b-collide-pull", 0, 500) + if pushIsoChangesContainItemID(changesBAfterCollide, a.itemID) { + t.Fatalf("B's own pull shows A's item id %q as one of B's rows after a REJECTED entity_id-collision create -- nothing should have been written", a.itemID) + } +} + +const entityConflictFieldWire = "_entity" + +type pushIsoMutationWire struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + BaseVersion int64 `json:"base_version"` + Fields map[string]any `json:"fields"` + Op string `json:"op,omitempty"` +} + +type pushIsoRequestWire struct { + DeviceID string `json:"device_id"` + Mutations []pushIsoMutationWire `json:"mutations"` +} + +type pushIsoAppliedWire struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + Version int64 `json:"version"` +} + +type pushIsoSkippedWire struct { + MutationID string `json:"mutation_id"` +} + +type pushIsoConflictWire struct { + MutationID string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + FieldName string `json:"field_name"` +} + +type pushIsoResponseWire struct { + Applied []pushIsoAppliedWire `json:"applied"` + Skipped []pushIsoSkippedWire `json:"skipped"` + Conflicts []pushIsoConflictWire `json:"conflicts"` + NewWatermark int64 `json:"new_watermark"` +} + +type pushIsoTenant struct { + name string + groupID string + cookie string + bearer string + + itemID string + labelID string +} + +func provisionPushIsoTenant(t *testing.T, srv *liveServer, username string) pushIsoTenant { + t.Helper() + tn := pushIsoTenant{name: username} + password := username + "-password-1" + + if rec := srv.do(t, http.MethodPost, "/api/v1/auth/register", "", regBody(username, password)); rec.Code != http.StatusCreated { + t.Fatalf("register %s: status = %d: %s", username, rec.Code, rec.Body.String()) + } + rec := srv.do(t, http.MethodPost, "/api/v1/auth/login", "", regBody(username, password)) + if rec.Code != http.StatusOK { + t.Fatalf("login %s: status = %d: %s", username, rec.Code, rec.Body.String()) + } + var login loginResponse + mustDecode(t, rec, &login) + tn.groupID = login.GroupID + tn.cookie = sessionCookie(t, rec) + + rec = srv.do(t, http.MethodPost, "/api/v1/auth/device-tokens", tn.cookie, + []byte(fmt.Sprintf(`{"device_label":%q}`, username+"'s phone"))) + if rec.Code != http.StatusCreated { + t.Fatalf("%s issue device token: status = %d: %s", username, rec.Code, rec.Body.String()) + } + var device deviceTokenIssueResponse + mustDecode(t, rec, &device) + tn.bearer = device.Token + if tn.bearer == "" { + t.Fatalf("%s's device-token issue returned no token; the bearer half of this suite would authenticate as nobody", username) + } + + itemRec := srv.do(t, http.MethodPost, "/api/v1/items", tn.cookie, itemCreateBody(username+"-push-iso-item-1")) + if itemRec.Code != http.StatusCreated { + t.Fatalf("%s create item: status = %d: %s", username, itemRec.Code, itemRec.Body.String()) + } + var it itemResponse + mustDecode(t, itemRec, &it) + if it.ID == "" { + t.Fatalf("%s create item: response carried no id", username) + } + tn.itemID = it.ID + + labelRec := srv.do(t, http.MethodPost, "/api/v1/labels", tn.cookie, labelCreateBody(username+"-push-iso-label-1", "#654321")) + if labelRec.Code != http.StatusCreated { + t.Fatalf("%s create label: status = %d: %s", username, labelRec.Code, labelRec.Body.String()) + } + var lbl labelResponse + mustDecode(t, labelRec, &lbl) + if lbl.ID == "" { + t.Fatalf("%s create label: response carried no id", username) + } + tn.labelID = lbl.ID + + return tn +} + +func requirePushIsoDistinctTenantIDs(t *testing.T, a, b pushIsoTenant) { + t.Helper() + if a.groupID == b.groupID { + t.Fatalf("%s and %s landed in the SAME group (%q); this suite proves nothing without two independent households", a.name, b.name, a.groupID) + } + pairs := []struct{ what, x, y string }{ + {"item id", a.itemID, b.itemID}, + {"label id", a.labelID, b.labelID}, + } + for _, p := range pairs { + if p.x == "" || p.y == "" { + t.Fatalf("test bug: empty %s (a=%q b=%q)", p.what, p.x, p.y) + } + if p.x == p.y { + t.Fatalf("both households got the same %s (%q); the exclusion assertions cannot distinguish them", p.what, p.x) + } + } +} + +func pushIsoCredValue(tn pushIsoTenant, bearer bool) string { + if bearer { + return tn.bearer + } + return tn.cookie +} + +func pushIsoPushOnce(t *testing.T, srv *liveServer, bearer bool, credValue, deviceID string, mutations []pushIsoMutationWire) (pushIsoResponseWire, *httptest.ResponseRecorder) { + t.Helper() + body, err := json.Marshal(pushIsoRequestWire{DeviceID: deviceID, Mutations: mutations}) + if err != nil { + t.Fatalf("marshal sync push request body: %v", err) + } + var rec *httptest.ResponseRecorder + if bearer { + rec = srv.doBearer(t, http.MethodPost, "/api/v1/sync/push", credValue, body) + } else { + rec = srv.do(t, http.MethodPost, "/api/v1/sync/push", credValue, body) + } + if rec.Code != http.StatusOK { + t.Fatalf("POST /sync/push (device_id=%s, %d mutation(s)): status = %d, want 200: %s", deviceID, len(mutations), rec.Code, rec.Body.String()) + } + var result pushIsoResponseWire + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatalf("decode sync push result: %v (body: %s)", err, rec.Body.String()) + } + return result, rec +} + +func pushIsoWatermark(t *testing.T, srv *liveServer, bearer bool, credValue, deviceID string) int64 { + t.Helper() + resp, _ := pushIsoPushOnce(t, srv, bearer, credValue, deviceID, nil) + return resp.NewWatermark +} + +func pushIsoConflictFieldByMutationID(conflicts []pushIsoConflictWire) map[string]string { + m := make(map[string]string, len(conflicts)) + for _, c := range conflicts { + m[c.MutationID] = c.FieldName + } + return m +} + +func pushIsoResponseBodyMustNotContain(t *testing.T, label string, rec *httptest.ResponseRecorder, needle string) { + t.Helper() + if needle == "" { + t.Fatalf("test bug: empty needle passed to pushIsoResponseBodyMustNotContain (%s)", label) + } + if strings.Contains(rec.Body.String(), needle) { + t.Fatalf("%s leaked %q: %s", label, needle, rec.Body.String()) + } +} + +func pushIsoItemCreateMutation(mutationID, entityID, name string) pushIsoMutationWire { + return pushIsoMutationWire{ + MutationID: mutationID, EntityType: "item", EntityID: entityID, BaseVersion: 0, + Fields: map[string]any{"name": name, "description": "", "location_id": "", "quantity": int64(1)}, + } +} + +func pushIsoItemUpdateMutation(mutationID, entityID string, baseVersion int64, name string) pushIsoMutationWire { + return pushIsoMutationWire{ + MutationID: mutationID, EntityType: "item", EntityID: entityID, BaseVersion: baseVersion, + Fields: map[string]any{"name": name}, + } +} + +func pushIsoWarrantyCreateMutation(mutationID, entityID, itemID, holder string) pushIsoMutationWire { + return pushIsoMutationWire{ + MutationID: mutationID, EntityType: "warranty_block", EntityID: entityID, BaseVersion: 0, + Fields: map[string]any{ + "item_id": itemID, "holder": holder, "provider": "", "starts_on": "", "expires_on": "", + "is_lifetime": false, "notes": "", + }, + } +} + +func pushIsoItemLabelCreateMutation(mutationID, entityID, itemID, labelID string) pushIsoMutationWire { + return pushIsoMutationWire{ + MutationID: mutationID, EntityType: "item_label", EntityID: entityID, BaseVersion: 0, + Fields: map[string]any{"item_id": itemID, "label_id": labelID}, + } +} + +func pushIsoItemLabelDeleteMutation(mutationID, itemID, labelID string) pushIsoMutationWire { + return pushIsoMutationWire{ + MutationID: mutationID, EntityType: "item_label", EntityID: "push-iso-delete-op-unused-entity-id", + BaseVersion: 0, Op: "delete", + Fields: map[string]any{"item_id": itemID, "label_id": labelID}, + } +} + +func pushIsoDo(t *testing.T, srv *liveServer, bearer bool, credValue, path string) *httptest.ResponseRecorder { + t.Helper() + if bearer { + return srv.doBearer(t, http.MethodGet, path, credValue, nil) + } + return srv.do(t, http.MethodGet, path, credValue, nil) +} + +func pushIsoGetItem(t *testing.T, srv *liveServer, bearer bool, credValue, itemID string) itemResponse { + t.Helper() + rec := pushIsoDo(t, srv, bearer, credValue, "/api/v1/items/"+itemID) + if rec.Code != http.StatusOK { + t.Fatalf("GET item %q: status = %d, want 200: %s", itemID, rec.Code, rec.Body.String()) + } + var it itemResponse + mustDecode(t, rec, &it) + return it +} + +func pushIsoWarrantyStatus(t *testing.T, srv *liveServer, bearer bool, credValue, itemID string) int { + t.Helper() + rec := pushIsoDo(t, srv, bearer, credValue, "/api/v1/items/"+itemID+"/warranty") + return rec.Code +} + +type pushIsoItemLabelListWire struct { + Labels []struct { + ID string `json:"id"` + } `json:"labels"` +} + +func pushIsoItemLabelIDs(t *testing.T, srv *liveServer, bearer bool, credValue, itemID string) []string { + t.Helper() + rec := pushIsoDo(t, srv, bearer, credValue, "/api/v1/items/"+itemID+"/labels") + if rec.Code != http.StatusOK { + t.Fatalf("GET item %q labels: status = %d, want 200: %s", itemID, rec.Code, rec.Body.String()) + } + var resp pushIsoItemLabelListWire + mustDecode(t, rec, &resp) + ids := make([]string, 0, len(resp.Labels)) + for _, l := range resp.Labels { + ids = append(ids, l.ID) + } + return ids +} + +func pushIsoChangesContainItemID(changes []syncIsoChangeWire, itemID string) bool { + for _, c := range changes { + if c.EntityType == "item" && c.ID == itemID { + return true + } + } + return false +} diff --git a/server/web/src/api/resources.ts b/server/web/src/api/resources.ts index 2252b23..fd14994 100644 --- a/server/web/src/api/resources.ts +++ b/server/web/src/api/resources.ts @@ -3,16 +3,25 @@ import type { Attachment, AttachmentCategory, AttachmentListResponse, + CustomFieldDef, + CustomFieldDefCreateRequest, CustomFieldDefListResponse, + CustomFieldDefUpdateRequest, CustomFieldType, DeviceTokenListResponse, GroupMembersResponse, GroupVisibility, + GroupVisibilityUpdateRequest, Identification, IdentificationKind, IdentificationListResponse, + ImportCommitResponse, + ImportPreviewResponse, + ImportUploadResponse, InviteCreateResponse, InviteListResponse, + InviteRedeemRequest, + InviteRedeemResponse, Item, ItemCustomField, ItemCustomFieldListResponse, @@ -54,8 +63,8 @@ export function logout(): Promise { return apiFetch('/auth/logout', { method: 'POST' }) } -export function probeSession(): Promise<{ readonly sessions: readonly unknown[] }> { - return apiFetch('/auth/sessions') +export function getCurrentUser(): Promise { + return apiFetch('/auth/me') } export interface ItemQuery { @@ -410,6 +419,31 @@ export function listCustomFieldDefs(): Promise { return apiFetch('/custom-field-defs') } +export function createCustomFieldDef(body: CustomFieldDefCreateRequest): Promise { + return apiFetch('/custom-field-defs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export function updateCustomFieldDef( + fieldDefID: string, + body: CustomFieldDefUpdateRequest, +): Promise { + return apiFetch(`/custom-field-defs/${encodeURIComponent(fieldDefID)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export function deleteCustomFieldDef(fieldDefID: string): Promise { + return apiFetch(`/custom-field-defs/${encodeURIComponent(fieldDefID)}`, { + method: 'DELETE', + }) +} + export function listItemAttachments(itemID: string): Promise { return apiFetch(`/items/${encodeURIComponent(itemID)}/attachments`) } @@ -447,6 +481,16 @@ export function getGroupDetailVisibility(): Promise { return apiFetch('/groups/detail-visibility') } +export function updateGroupDetailVisibility( + body: GroupVisibilityUpdateRequest, +): Promise { + return apiFetch('/groups/detail-visibility', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + export function listGroupMembers(): Promise { return apiFetch('/groups/members') } @@ -481,6 +525,14 @@ export function revokeInvite(inviteID: string): Promise { return apiFetch(`/invites/${encodeURIComponent(inviteID)}`, { method: 'DELETE' }) } +export function redeemInvite(request: InviteRedeemRequest): Promise { + return apiFetch('/invites/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }) +} + export type ReportGroupBy = 'location' | 'label' export function getReportValuation(groupBy: ReportGroupBy): Promise { @@ -535,3 +587,21 @@ export function exportBomCSVURL(selection: BomSelection): string { const query = params.toString() return `${API_BASE_URL}/export/bom${query ? `?${query}` : ''}` } + +export function importNativeUpload(csv: string): Promise { + return apiFetch('/import/native/upload', { + method: 'POST', + headers: { 'Content-Type': 'text/csv' }, + body: csv, + }) +} + +export function getImportPreview(importID: string): Promise { + return apiFetch(`/import/${encodeURIComponent(importID)}/preview`) +} + +export function postImportCommit(importID: string): Promise { + return apiFetch(`/import/${encodeURIComponent(importID)}/commit`, { + method: 'POST', + }) +} diff --git a/server/web/src/api/types.ts b/server/web/src/api/types.ts index 573151e..dc721ac 100644 --- a/server/web/src/api/types.ts +++ b/server/web/src/api/types.ts @@ -113,6 +113,16 @@ export interface CustomFieldDefListResponse { readonly custom_field_defs: readonly CustomFieldDef[] } +export interface CustomFieldDefCreateRequest { + readonly name: string + readonly field_type: CustomFieldType + readonly display_order: number +} + +export interface CustomFieldDefUpdateRequest extends CustomFieldDefCreateRequest { + readonly version: number +} + export interface ItemCustomField extends Versioned { readonly item_id?: string readonly field_def_id?: string @@ -135,6 +145,13 @@ export interface GroupVisibility { readonly version?: number } +export interface GroupVisibilityUpdateRequest { + readonly warranty_visible: boolean + readonly sale_visible: boolean + readonly purchase_visible: boolean + readonly version: number +} + export type AttachmentCategory = 'image' | 'manual' | 'warranty' | 'receipt' | 'general' export interface Attachment extends Versioned { @@ -215,6 +232,18 @@ export interface InviteCreateResponse { readonly expires_at: number } +export interface InviteRedeemRequest { + readonly token: string + readonly username: string + readonly password: string +} + +export interface InviteRedeemResponse { + readonly group_id: string + readonly user_id: string + readonly username: string +} + export interface StatusResponse { readonly status: 'ok' readonly version: string @@ -264,3 +293,72 @@ export interface ReportLocationItemCountRow { export interface ReportItemCountByLocationResponse { readonly rows: readonly ReportLocationItemCountRow[] } + +export interface ImportUploadResponse { + readonly import_id: string +} + +export interface ImportPreviewRowError { + readonly line: number + readonly column?: string + readonly message: string +} + +export type ImportRowAction = 'create' | 'update' | 'unchanged' | 'error' + +export type ImportRowChange = + | 'name' + | 'description' + | 'quantity' + | 'location_id' + | 'labels' + | 'identifications' + | 'warranty' + | 'purchase' + | 'sale' + | 'custom_fields' + +export interface ImportPreviewRow { + readonly line: number + readonly action: ImportRowAction + readonly item_id?: string + readonly name?: string + readonly changes?: readonly ImportRowChange[] + readonly errors?: readonly ImportPreviewRowError[] +} + +export interface ImportPreviewSummary { + readonly create: number + readonly update: number + readonly unchanged: number + readonly error: number +} + +export interface ImportPreviewResponse { + readonly import_id: string + readonly rows: readonly ImportPreviewRow[] + readonly summary: ImportPreviewSummary +} + +export interface ImportCommitSummary { + readonly created: number + readonly updated: number + readonly unchanged: number +} + +export interface ImportCommitCreatedItem { + readonly line: number + readonly item_id: string +} + +export interface ImportCommitResponse { + readonly import_id: string + readonly summary: ImportCommitSummary + readonly created_items: readonly ImportCommitCreatedItem[] +} + +export interface ImportCommitRejectedResponse { + readonly import_id: string + readonly rows: readonly ImportPreviewRow[] + readonly summary: ImportPreviewSummary +} diff --git a/server/web/src/components/AppNav.vue b/server/web/src/components/AppNav.vue index 26cd15e..592fd54 100644 --- a/server/web/src/components/AppNav.vue +++ b/server/web/src/components/AppNav.vue @@ -20,6 +20,8 @@ async function signOut(): Promise { Locations Labels Print labels + Import + Reports Settings diff --git a/server/web/src/components/__tests__/AppNav.spec.ts b/server/web/src/components/__tests__/AppNav.spec.ts index 46afbae..a4498c1 100644 --- a/server/web/src/components/__tests__/AppNav.spec.ts +++ b/server/web/src/components/__tests__/AppNav.spec.ts @@ -14,14 +14,16 @@ beforeEach(() => { router = createAppRouter() vi.stubGlobal( 'fetch', - vi.fn().mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify({ sessions: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), + vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), ), - ), ) }) @@ -57,6 +59,8 @@ describe('AppNav', () => { 'Locations', 'Labels', 'Print labels', + 'Import', + 'Reports', 'Settings', ]) expect(links.map((link) => link.attributes('href'))).toEqual([ @@ -64,6 +68,8 @@ describe('AppNav', () => { '/locations', '/labels', '/print-labels', + '/import', + '/reports', '/settings', ]) expect(links.map((link) => router.resolve(link.attributes('href')!).name)).toEqual([ @@ -71,6 +77,8 @@ describe('AppNav', () => { 'locations', 'labels', 'print-labels', + 'import', + 'reports', 'settings', ]) }) @@ -95,6 +103,7 @@ describe('AppNav', () => { const session = useSessionStore() session.status = 'authenticated' + session.user = null await wrapper.vm.$nextTick() expect(wrapper.find('.app-nav__user').exists()).toBe(false) expect(wrapper.text()).toContain('Sign out') diff --git a/server/web/src/router/index.ts b/server/web/src/router/index.ts index b8c9a10..4ab5134 100644 --- a/server/web/src/router/index.ts +++ b/server/web/src/router/index.ts @@ -15,6 +15,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/LoginView.vue'), meta: { public: true }, }, + { + path: '/join', + name: 'join', + component: () => import('@/views/JoinView.vue'), + meta: { public: true }, + }, { path: '/items', name: 'items', component: ItemsView }, { path: '/items/new', @@ -38,6 +44,16 @@ const routes: RouteRecordRaw[] = [ name: 'print-labels', component: () => import('@/views/LabelSheetView.vue'), }, + { + path: '/import', + name: 'import', + component: () => import('@/views/ImportView.vue'), + }, + { + path: '/reports', + name: 'reports', + component: () => import('@/views/ReportsView.vue'), + }, { path: '/settings', name: 'settings', @@ -56,7 +72,9 @@ const authGuard: NavigationGuard = async (to) => { await session.boot() if (to.meta.public === true) { - if (to.name === 'login' && session.isAuthenticated) return { path: '/items' } + if ((to.name === 'login' || to.name === 'join') && session.isAuthenticated) { + return { path: '/items' } + } return true } diff --git a/server/web/src/stores/__tests__/session.spec.ts b/server/web/src/stores/__tests__/session.spec.ts new file mode 100644 index 0000000..0437f2e --- /dev/null +++ b/server/web/src/stores/__tests__/session.spec.ts @@ -0,0 +1,79 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSessionStore } from '@/stores' +import { jsonResponse, stubRoutedFetch } from '@/test-support/routedFetch' + +const ME = 'GET /api/v1/auth/me' + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('session store boot()', () => { + it('sets user from GET /auth/me and makes isOwner true for an owner', async () => { + stubRoutedFetch({ + [ME]: () => + jsonResponse(200, { group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }), + }) + const session = useSessionStore() + + await session.boot() + + expect(session.status).toBe('authenticated') + expect(session.user).toEqual({ group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }) + expect(session.isOwner).toBe(true) + }) + + it('leaves isOwner false for a member', async () => { + stubRoutedFetch({ + [ME]: () => + jsonResponse(200, { group_id: 'g', user_id: 'u', username: 'jamie', role: 'member' }), + }) + const session = useSessionStore() + + await session.boot() + + expect(session.status).toBe('authenticated') + expect(session.isOwner).toBe(false) + }) + + it('resolves to anonymous with user null on a 401 (no cookie, or an expired one)', async () => { + stubRoutedFetch({ + [ME]: () => jsonResponse(401, { title: 'Unauthorized', status: 401 }), + }) + const session = useSessionStore() + + await session.boot() + + expect(session.status).toBe('anonymous') + expect(session.user).toBeNull() + expect(session.isOwner).toBe(false) + }) + + it('resolves to anonymous with user null on any other failure (server down, bad proxy)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network error'))) + const session = useSessionStore() + + await session.boot() + + expect(session.status).toBe('anonymous') + expect(session.user).toBeNull() + }) + + it('only probes the server once, however many times it is called', async () => { + const fetchMock = stubRoutedFetch({ + [ME]: () => + jsonResponse(200, { group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }), + }) + const session = useSessionStore() + + await session.boot() + await session.boot() + + expect(fetchMock.mock.calls.filter(([input]) => input === '/api/v1/auth/me')).toHaveLength(1) + }) +}) diff --git a/server/web/src/stores/session.ts b/server/web/src/stores/session.ts index 1b590f4..c30486b 100644 --- a/server/web/src/stores/session.ts +++ b/server/web/src/stores/session.ts @@ -2,9 +2,9 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { ApiError, + getCurrentUser, login as loginRequest, logout as logoutRequest, - probeSession, register as registerRequest, } from '@/api' import type { LoginResponse } from '@/api' @@ -22,7 +22,7 @@ export const useSessionStore = defineStore('session', () => { async function boot(): Promise { if (status.value !== 'unknown') return try { - await probeSession() + user.value = await getCurrentUser() status.value = 'authenticated' } catch { status.value = 'anonymous' diff --git a/server/web/src/views/ImportView.vue b/server/web/src/views/ImportView.vue new file mode 100644 index 0000000..7f1f1a9 --- /dev/null +++ b/server/web/src/views/ImportView.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/server/web/src/views/JoinView.vue b/server/web/src/views/JoinView.vue new file mode 100644 index 0000000..9d3fe01 --- /dev/null +++ b/server/web/src/views/JoinView.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/server/web/src/views/LoginView.vue b/server/web/src/views/LoginView.vue index 9c9c613..2332257 100644 --- a/server/web/src/views/LoginView.vue +++ b/server/web/src/views/LoginView.vue @@ -1,6 +1,6 @@ @@ -230,6 +416,13 @@ onMounted(() => { Dismiss +

Or send this link — it fills the token in for them:

+ {{ justCreatedJoinLink }} +
+ +

Expires {{ formatDate(justCreated.expiresAt) }}.

@@ -273,6 +466,119 @@ onMounted(() => { +
+

Item detail blocks

+

+ Choose which optional detail blocks appear on every item's page for the whole household. A + fresh household starts with all three hidden. +

+ + +

Loading…

+
+ + + + +
+
+ +
+

Custom fields

+

+ Field definitions standardize the custom fields available when entering an item's data. + Deleting one does not remove its value from items that already have it — only from the list + offered when adding a new custom field. +

+ + +

Loading…

+

No custom field definitions yet.

+
    +
  • + + +
  • +
+ +
+ + + +
+
+

Sessions

Every browser currently signed in as you.

@@ -433,4 +739,18 @@ onMounted(() => { .settings__invite-create { align-self: flex-start; } + +.settings__toggle { + gap: 0.5rem; + align-items: center; +} + +.settings__visibility-save { + align-self: flex-start; +} + +.settings__field-def-create { + align-items: center; + gap: 0.5rem; +} diff --git a/server/web/src/views/__tests__/ImportView.spec.ts b/server/web/src/views/__tests__/ImportView.spec.ts new file mode 100644 index 0000000..5c83db0 --- /dev/null +++ b/server/web/src/views/__tests__/ImportView.spec.ts @@ -0,0 +1,336 @@ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { API_BASE_URL } from '@/api/client' +import { jsonResponse, stubRoutedFetch, type Routes } from '@/test-support/routedFetch' +import ImportView from '@/views/ImportView.vue' + +const UPLOAD = 'POST /api/v1/import/native/upload' +const IMPORT_ID = '0191b1b2-3c4d-7c9a-8f2e-1a2b3c4d5e6f' +const PREVIEW = `GET /api/v1/import/${IMPORT_ID}/preview` +const COMMIT = `POST /api/v1/import/${IMPORT_ID}/commit` + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function mountImport() { + return mount(ImportView) +} + +async function setFile( + wrapper: ReturnType, + name = 'export.csv', + contents = 'x', +): Promise { + const input = wrapper.get('#import-file') + const file = new File([contents], name, { type: 'text/csv' }) + Object.defineProperty(input.element, 'files', { value: [file] }) + await input.trigger('change') + await wrapper.vm.$nextTick() +} + +async function uploadTo( + wrapper: ReturnType, + routes: Routes = {}, +): Promise> { + const fetchMock = stubRoutedFetch({ + [UPLOAD]: () => jsonResponse(201, { import_id: IMPORT_ID }), + [PREVIEW]: () => + jsonResponse(200, { + import_id: IMPORT_ID, + rows: [], + summary: { create: 0, update: 0, unchanged: 0, error: 0 }, + }), + ...routes, + }) + await setFile(wrapper) + await wrapper.get('button.btn--primary').trigger('click') + await flush() + await wrapper.vm.$nextTick() + return fetchMock +} + +describe('ImportView export link (FR-050)', () => { + it('links to the native CSV export as a plain download, member-readable and not role-gated', () => { + const wrapper = mountImport() + + const link = wrapper.get('a[download]') + expect(link.attributes('href')).toBe(`${API_BASE_URL}/export/items.csv`) + expect(link.attributes('download')).toBeDefined() + }) +}) + +describe('ImportView upload step', () => { + it('disables the submit button until a file is chosen', async () => { + const wrapper = mountImport() + + const submit = wrapper.get('button.btn--primary') + expect(submit.attributes('disabled')).toBeDefined() + + await setFile(wrapper) + await wrapper.vm.$nextTick() + expect(submit.attributes('disabled')).toBeUndefined() + }) + + it('uploads the file as a raw text/csv body (not multipart) and moves to preview on success', async () => { + const wrapper = mountImport() + const fetchMock = await uploadTo(wrapper) + + const uploadCall = fetchMock.mock.calls.find( + ([input]) => input === '/api/v1/import/native/upload', + )! + const init = uploadCall[1]! + expect(init.method).toBe('POST') + expect((init.headers as Record)['Content-Type']).toBe('text/csv') + expect(init.body).toBe('x') + + expect( + fetchMock.mock.calls.some(([input]) => input === `/api/v1/import/${IMPORT_ID}/preview`), + ).toBe(true) + expect(wrapper.find('[aria-label="Review the staged import"]').exists()).toBe(true) + }) + + it('shows the server detail on a 400 (malformed CSV) and stays on the upload step', async () => { + const wrapper = mountImport() + stubRoutedFetch({ + [UPLOAD]: () => + jsonResponse(400, { title: 'Bad Request', detail: 'unrecognised header row' }), + }) + await setFile(wrapper) + await wrapper.get('button.btn--primary').trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.get('.alert').text()).toContain('unrecognised header row') + expect(wrapper.find('[aria-label="Upload a CSV file"]').exists()).toBe(true) + }) + + it('shows a plain-language message on a 413 (over the 10 MiB cap), which carries no detail', async () => { + const wrapper = mountImport() + stubRoutedFetch({ + [UPLOAD]: () => jsonResponse(413, { title: 'Payload Too Large' }), + }) + await setFile(wrapper) + await wrapper.get('button.btn--primary').trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.get('.alert').text()).toContain('10 MiB upload limit') + }) +}) + +describe('ImportView preview step', () => { + function previewRoutes(overrides: Routes = {}): Routes { + return { + [UPLOAD]: () => jsonResponse(201, { import_id: IMPORT_ID }), + [PREVIEW]: () => + jsonResponse(200, { + import_id: IMPORT_ID, + rows: [ + { line: 2, action: 'create', name: 'Cordless drill' }, + { + line: 3, + action: 'update', + item_id: 'item-1', + name: 'Bench vice', + changes: ['quantity', 'location_id'], + }, + { line: 4, action: 'unchanged', item_id: 'item-2', name: 'Shop vac' }, + { + line: 5, + action: 'error', + name: 'Missing quantity', + errors: [ + { line: 5, column: 'quantity', message: 'quantity must be a non-negative integer' }, + ], + }, + ], + summary: { create: 1, update: 1, unchanged: 1, error: 1 }, + }), + ...overrides, + } + } + + it('renders every row with its own classification, changes and error detail', async () => { + const wrapper = mountImport() + await uploadTo(wrapper, previewRoutes()) + + expect(wrapper.text()).toContain('Create: 1 · Update: 1 · Unchanged: 1 · Error: 1') + + const rows = wrapper.findAll('.import__table tbody tr') + expect(rows).toHaveLength(4) + expect(rows[0]!.text()).toContain('Cordless drill') + expect(rows[0]!.text()).toContain('Create') + expect(rows[1]!.text()).toContain('Changed: quantity, location_id') + expect(rows[3]!.text()).toContain('quantity: quantity must be a non-negative integer') + }) + + it('disables Commit and explains why while any row classifies error', async () => { + const wrapper = mountImport() + await uploadTo(wrapper, previewRoutes()) + + const commit = wrapper.findAll('button').find((b) => b.text().startsWith('Commit'))! + expect(commit.attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('cannot be committed (all-or-nothing)') + }) + + it('Cancel abandons the wizard state and returns to the upload step, with no DELETE call', async () => { + const wrapper = mountImport() + const fetchMock = await uploadTo(wrapper, previewRoutes()) + + await wrapper + .findAll('button') + .find((b) => b.text() === 'Cancel')! + .trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.find('[aria-label="Upload a CSV file"]').exists()).toBe(true) + expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false) + }) + + it('commits successfully and shows the per-action counts and created item lines', async () => { + const wrapper = mountImport() + await uploadTo( + wrapper, + previewRoutes({ + [PREVIEW]: () => + jsonResponse(200, { + import_id: IMPORT_ID, + rows: [{ line: 2, action: 'create', name: 'Cordless drill' }], + summary: { create: 1, update: 0, unchanged: 0, error: 0 }, + }), + [COMMIT]: () => + jsonResponse(200, { + import_id: IMPORT_ID, + summary: { created: 1, updated: 0, unchanged: 0 }, + created_items: [{ line: 2, item_id: 'item-9' }], + }), + }), + ) + + await wrapper + .findAll('button') + .find((b) => b.text().startsWith('Commit'))! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.find('[aria-label="Import complete"]').text()).toContain( + 'Created: 1 · Updated: 0 · Unchanged: 0', + ) + expect(wrapper.text()).toContain('Line 2 → new item item-9') + + await wrapper.get('button.btn--primary').trigger('click') + await wrapper.vm.$nextTick() + expect(wrapper.find('[aria-label="Upload a CSV file"]').exists()).toBe(true) + }) + + it('a 422 on commit refreshes the rows from the rejection body (not a flat detail string)', async () => { + const wrapper = mountImport() + await uploadTo( + wrapper, + previewRoutes({ + [PREVIEW]: () => + jsonResponse(200, { + import_id: IMPORT_ID, + rows: [{ line: 2, action: 'create', name: 'Cordless drill' }], + summary: { create: 1, update: 0, unchanged: 0, error: 0 }, + }), + [COMMIT]: () => + jsonResponse(422, { + import_id: IMPORT_ID, + rows: [ + { + line: 2, + action: 'error', + name: 'Cordless drill', + errors: [{ line: 2, message: 'location_id no longer exists' }], + }, + ], + summary: { create: 0, update: 0, unchanged: 0, error: 1 }, + }), + }), + ) + + await wrapper + .findAll('button') + .find((b) => b.text().startsWith('Commit'))! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.find('[aria-label="Review the staged import"]').exists()).toBe(true) + expect(wrapper.get('.alert').text()).toContain('row errors and cannot be committed') + expect(wrapper.text()).toContain('location_id no longer exists') + expect(wrapper.text()).toContain('Create: 0 · Update: 0 · Unchanged: 0 · Error: 1') + }) +}) + +describe('ImportView network failure and empty-state handling', () => { + it('shows a generic message when the upload request itself fails (not an ApiError)', async () => { + const wrapper = mountImport() + stubRoutedFetch({ + [UPLOAD]: () => Promise.reject(new Error('network down')), + }) + await setFile(wrapper) + await wrapper.get('button.btn--primary').trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.get('.alert').text()).toContain('Could not reach the server') + expect(wrapper.find('[aria-label="Upload a CSV file"]').exists()).toBe(true) + }) + + it('shows the previewError alert (rather than crashing) when the preview GET fails after upload', async () => { + const wrapper = mountImport() + const fetchMock = stubRoutedFetch({ + [UPLOAD]: () => jsonResponse(201, { import_id: IMPORT_ID }), + [PREVIEW]: () => jsonResponse(500, { title: 'Internal Server Error' }), + }) + await setFile(wrapper) + await wrapper.get('button.btn--primary').trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.find('[aria-label="Review the staged import"]').exists()).toBe(true) + expect(wrapper.get('.alert').text()).toContain('Internal Server Error') + expect( + fetchMock.mock.calls.some(([input]) => input === `/api/v1/import/${IMPORT_ID}/preview`), + ).toBe(true) + }) + + it('shows "No data rows in this file." and a zeroed summary when the staged file has no rows', async () => { + const wrapper = mountImport() + await uploadTo(wrapper, { + [PREVIEW]: () => + jsonResponse(200, { + import_id: IMPORT_ID, + rows: [], + summary: { create: 0, update: 0, unchanged: 0, error: 0 }, + }), + }) + + expect(wrapper.text()).toContain('Create: 0 · Update: 0 · Unchanged: 0 · Error: 0') + expect(wrapper.text()).toContain('No data rows in this file.') + expect(wrapper.find('.import__table').exists()).toBe(false) + }) + + it('keeps Commit disabled when the preview failed to load', async () => { + const wrapper = mountImport() + stubRoutedFetch({ + [UPLOAD]: () => jsonResponse(201, { import_id: IMPORT_ID }), + [PREVIEW]: () => jsonResponse(500, { title: 'Internal Server Error' }), + }) + await setFile(wrapper) + await wrapper.get('button.btn--primary').trigger('click') + await flush() + await wrapper.vm.$nextTick() + + const commit = wrapper.findAll('button').find((b) => b.text().startsWith('Commit'))! + expect(commit.attributes('disabled')).toBeDefined() + }) +}) diff --git a/server/web/src/views/__tests__/ItemEditView.spec.ts b/server/web/src/views/__tests__/ItemEditView.spec.ts index ca299b6..f33e357 100644 --- a/server/web/src/views/__tests__/ItemEditView.spec.ts +++ b/server/web/src/views/__tests__/ItemEditView.spec.ts @@ -24,7 +24,8 @@ function allVisible() { function baseRoutes(overrides: Routes = {}): Routes { return { - 'GET /api/v1/auth/sessions': () => jsonResponse(200, { sessions: [] }), + 'GET /api/v1/auth/me': () => + jsonResponse(200, { group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }), 'GET /api/v1/locations/tree': () => jsonResponse(200, { tree: [] }), 'GET /api/v1/locations': () => jsonResponse(200, { locations: [] }), 'GET /api/v1/labels': () => jsonResponse(200, { labels: [] }), @@ -89,7 +90,8 @@ describe('ItemEditView detail blocks, all visible', () => { it('does not mount any detail block for a NEW item, which has no id to scope them to', async () => { stubRoutedFetch({ - 'GET /api/v1/auth/sessions': () => jsonResponse(200, { sessions: [] }), + 'GET /api/v1/auth/me': () => + jsonResponse(200, { group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }), 'GET /api/v1/locations/tree': () => jsonResponse(200, { tree: [] }), 'GET /api/v1/locations': () => jsonResponse(200, { locations: [] }), 'GET /api/v1/labels': () => jsonResponse(200, { labels: [] }), diff --git a/server/web/src/views/__tests__/JoinView.spec.ts b/server/web/src/views/__tests__/JoinView.spec.ts new file mode 100644 index 0000000..12ac74d --- /dev/null +++ b/server/web/src/views/__tests__/JoinView.spec.ts @@ -0,0 +1,185 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAppRouter } from '@/router' +import { jsonResponse, stubRoutedFetch, type Routes } from '@/test-support/routedFetch' +import JoinView from '@/views/JoinView.vue' + +const ME = 'GET /api/v1/auth/me' +const REDEEM = 'POST /api/v1/invites/redeem' +const LOGIN = 'POST /api/v1/auth/login' + +function baseRoutes(overrides: Routes = {}): Routes { + return { + [ME]: () => jsonResponse(401, { title: 'Unauthorized', status: 401 }), + ...overrides, + } +} + +let pinia: ReturnType +let router: ReturnType + +beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + router = createAppRouter() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +async function mountJoin(path: string, routes: Routes) { + const fetchMock = stubRoutedFetch(routes) + await router.push(path) + await router.isReady() + const wrapper = mount(JoinView, { global: { plugins: [router, pinia] } }) + return { wrapper, fetchMock } +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function submit( + wrapper: Awaited>['wrapper'], + overrides: { token?: string; username?: string; password?: string } = {}, +) { + if (overrides.token !== undefined) await wrapper.get('#token').setValue(overrides.token) + await wrapper.get('#username').setValue(overrides.username ?? 'newmember') + await wrapper.get('#password').setValue(overrides.password ?? 'correct-horse-battery-staple') + await wrapper.get('form').trigger('submit') + await flush() + await wrapper.vm.$nextTick() +} + +describe('JoinView token prefill', () => { + it('prefills the token field from ?token= in the URL', async () => { + const { wrapper } = await mountJoin('/join?token=tok-abc-123', baseRoutes()) + + expect((wrapper.get('#token').element as HTMLInputElement).value).toBe('tok-abc-123') + }) + + it('leaves the token field empty when the query carries none', async () => { + const { wrapper } = await mountJoin('/join', baseRoutes()) + + expect((wrapper.get('#token').element as HTMLInputElement).value).toBe('') + }) +}) + +describe('JoinView redeem request', () => { + it('sends the token, username and password exactly as entered', async () => { + const { wrapper, fetchMock } = await mountJoin( + '/join?token=tok-abc-123', + baseRoutes({ + [REDEEM]: () => + jsonResponse(201, { group_id: 'grp-1', user_id: 'usr-1', username: 'newmember' }), + [LOGIN]: () => + jsonResponse(200, { + group_id: 'grp-1', + user_id: 'usr-1', + username: 'newmember', + role: 'member', + }), + }), + ) + + await submit(wrapper, { username: 'newmember', password: 'correct-horse-battery-staple' }) + + const call = fetchMock.mock.calls.find(([input]) => input === '/api/v1/invites/redeem') + expect(call).toBeDefined() + const init = call![1] as RequestInit + expect(JSON.parse(init.body as string)).toEqual({ + token: 'tok-abc-123', + username: 'newmember', + password: 'correct-horse-battery-staple', + }) + }) + + it('logs the new member in and lands on /items after a successful redeem', async () => { + const { wrapper } = await mountJoin( + '/join?token=tok-abc-123', + baseRoutes({ + [REDEEM]: () => + jsonResponse(201, { group_id: 'grp-1', user_id: 'usr-1', username: 'newmember' }), + [LOGIN]: () => + jsonResponse(200, { + group_id: 'grp-1', + user_id: 'usr-1', + username: 'newmember', + role: 'member', + }), + }), + ) + + await submit(wrapper) + + expect(router.currentRoute.value.path).toBe('/items') + }) +}) + +describe('JoinView redeem errors', () => { + it('explains a 404 as an invalid/expired invite rather than showing "Not Found"', async () => { + const { wrapper } = await mountJoin( + '/join?token=tok-abc-123', + baseRoutes({ + [REDEEM]: () => jsonResponse(404, { title: 'Not Found', status: 404 }), + }), + ) + + await submit(wrapper) + + const alert = wrapper.get('[role="alert"]').text() + expect(alert).toContain('no longer valid') + expect(alert).not.toBe('Not Found') + }) + + it('explains a 409 as a taken username rather than showing "Conflict"', async () => { + const { wrapper } = await mountJoin( + '/join?token=tok-abc-123', + baseRoutes({ + [REDEEM]: () => jsonResponse(409, { title: 'Conflict', status: 409 }), + }), + ) + + await submit(wrapper) + + const alert = wrapper.get('[role="alert"]').text() + expect(alert).toContain('already taken') + expect(alert).not.toBe('Conflict') + }) + + it('passes a 400 validation detail through verbatim', async () => { + const { wrapper } = await mountJoin( + '/join?token=tok-abc-123', + baseRoutes({ + [REDEEM]: () => + jsonResponse(400, { + title: 'Bad Request', + detail: 'invite: password is invalid', + status: 400, + }), + }), + ) + + await submit(wrapper) + + expect(wrapper.get('[role="alert"]').text()).toContain('invite: password is invalid') + }) + + it('does not redeem the invite twice or route away after a failed attempt', async () => { + const { wrapper, fetchMock } = await mountJoin( + '/join?token=tok-abc-123', + baseRoutes({ + [REDEEM]: () => jsonResponse(404, { title: 'Not Found', status: 404 }), + }), + ) + + await submit(wrapper) + + expect(router.currentRoute.value.path).toBe('/join') + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/v1/invites/redeem'), + ).toHaveLength(1) + }) +}) diff --git a/server/web/src/views/__tests__/LabelSheetView.spec.ts b/server/web/src/views/__tests__/LabelSheetView.spec.ts index 73df3f3..5417825 100644 --- a/server/web/src/views/__tests__/LabelSheetView.spec.ts +++ b/server/web/src/views/__tests__/LabelSheetView.spec.ts @@ -276,6 +276,157 @@ describe('LabelSheetView sheet generation', () => { }) }) +describe('LabelSheetView preset geometry', () => { + it('sets the sheet grid geometry from the chosen preset, and updates it when the preset changes', async () => { + const batchItems = [{ id: 'a', short_code: 'AAA', name: 'Drill', qr_svg: '' }] + const { wrapper } = await mountSheet( + baseRoutes({ + [`GET /api/v1/items?limit=${PAGE_SIZE}&offset=0`]: () => + jsonResponse(200, { items: [item({ id: 'a', name: 'Drill' })] }), + [BATCH]: () => jsonResponse(200, { items: batchItems }), + }), + ) + + await toggleRow(wrapper, 'Drill') + await wrapper + .findAll('button') + .find((b) => b.text() === 'Generate sheet')! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + const style5160 = wrapper.get('.sheet').attributes('style')! + expect(style5160).toContain('--sheet-columns: 3') + expect(style5160).toContain('--sheet-rows: 10') + expect(style5160).toContain('--cell-w: 2.625in') + expect(style5160).toContain('--cell-h: 1in') + + await wrapper.get('#label-preset').setValue('avery-5163') + await wrapper.vm.$nextTick() + + const style5163 = wrapper.get('.sheet').attributes('style')! + expect(style5163).toContain('--sheet-columns: 2') + expect(style5163).toContain('--sheet-rows: 5') + expect(style5163).toContain('--cell-w: 4in') + expect(style5163).toContain('--cell-h: 2in') + }) + + it('chunks one item over the 30-per-sheet 5160 boundary into a second, single-item sheet', async () => { + const thirtyOne = Array.from({ length: 31 }, (_, i) => item({ id: `i${i}`, name: `Item ${i}` })) + const batchItems = thirtyOne.map((it) => ({ + id: it.id, + short_code: `CODE${it.id}`, + name: it.name, + qr_svg: ``, + })) + + const { wrapper } = await mountSheet( + baseRoutes({ + [`GET /api/v1/items?limit=${PAGE_SIZE}&offset=0`]: () => + jsonResponse(200, { items: thirtyOne }), + [BATCH]: () => jsonResponse(200, { items: batchItems }), + }), + ) + + await wrapper + .findAll('button') + .find((b) => b.text() === 'Select page')! + .trigger('click') + expect(selectionSummary(wrapper)).toContain('31 selected') + + await wrapper + .findAll('button') + .find((b) => b.text() === 'Generate sheet')! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + const sheets = wrapper.findAll('.sheet') + expect(sheets).toHaveLength(2) + expect(sheets[0]!.findAll('.sheet__cell')).toHaveLength(30) + expect(sheets[1]!.findAll('.sheet__cell')).toHaveLength(1) + expect(sheets[1]!.find('.sheet__cell').text()).toContain('Item 30') + }) +}) + +describe('LabelSheetView batch request count and print', () => { + it('issues exactly one batch request per Generate click', async () => { + const { wrapper, fetchMock } = await mountSheet( + baseRoutes({ + [`GET /api/v1/items?limit=${PAGE_SIZE}&offset=0`]: () => + jsonResponse(200, { items: [item({ id: 'a', name: 'Drill' })] }), + [BATCH]: () => + jsonResponse(200, { + items: [{ id: 'a', short_code: 'AAA', name: 'Drill', qr_svg: '' }], + }), + }), + ) + + await toggleRow(wrapper, 'Drill') + await wrapper + .findAll('button') + .find((b) => b.text() === 'Generate sheet')! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + const batchCalls = fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST') + expect(batchCalls).toHaveLength(1) + }) + + it('Print stays disabled until a sheet exists, then calls window.print', async () => { + const printSpy = vi.fn() + vi.stubGlobal('print', printSpy) + + const { wrapper } = await mountSheet( + baseRoutes({ + [`GET /api/v1/items?limit=${PAGE_SIZE}&offset=0`]: () => + jsonResponse(200, { items: [item({ id: 'a', name: 'Drill' })] }), + [BATCH]: () => + jsonResponse(200, { + items: [{ id: 'a', short_code: 'AAA', name: 'Drill', qr_svg: '' }], + }), + }), + ) + + const printButton = () => wrapper.findAll('button').find((b) => b.text() === 'Print')! + expect(printButton().attributes('disabled')).toBeDefined() + + await toggleRow(wrapper, 'Drill') + await wrapper + .findAll('button') + .find((b) => b.text() === 'Generate sheet')! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(printButton().attributes('disabled')).toBeUndefined() + await printButton().trigger('click') + expect(printSpy).toHaveBeenCalledTimes(1) + }) + + it('shows the server error and no sheet when Generate sheet fails', async () => { + const { wrapper } = await mountSheet( + baseRoutes({ + [`GET /api/v1/items?limit=${PAGE_SIZE}&offset=0`]: () => + jsonResponse(200, { items: [item({ id: 'a', name: 'Drill' })] }), + [BATCH]: () => jsonResponse(500, { title: 'Internal Server Error' }), + }), + ) + + await toggleRow(wrapper, 'Drill') + await wrapper + .findAll('button') + .find((b) => b.text() === 'Generate sheet')! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.get('.alert').text()).toContain('Internal Server Error') + expect(wrapper.find('.sheet').exists()).toBe(false) + }) +}) + describe('LabelSheetView batch-size ceiling', () => { it('disables Generate and explains why once the selection passes 200, the batch endpoint’s own ceiling', async () => { const page = (offset: number, count: number) => diff --git a/server/web/src/views/__tests__/LoginView.spec.ts b/server/web/src/views/__tests__/LoginView.spec.ts index d40f5af..6b0a69a 100644 --- a/server/web/src/views/__tests__/LoginView.spec.ts +++ b/server/web/src/views/__tests__/LoginView.spec.ts @@ -96,6 +96,17 @@ describe('LoginView first-run registration', () => { }) }) +describe('LoginView invite entry point', () => { + it('links to /join for someone who already holds an invite', async () => { + stubFetch(200, { sessions: [] }) + const wrapper = await mountLogin() + + const link = wrapper.get('.login__join a') + expect(link.attributes('href')).toBe('/join') + expect(link.text()).toBe('Join a household') + }) +}) + describe('LoginView mode discoverability', () => { it('offers both actions at equal weight from the first render', async () => { stubFetch(200, { sessions: [] }) diff --git a/server/web/src/views/__tests__/ReportsView.spec.ts b/server/web/src/views/__tests__/ReportsView.spec.ts index 395aa25..60abbd4 100644 --- a/server/web/src/views/__tests__/ReportsView.spec.ts +++ b/server/web/src/views/__tests__/ReportsView.spec.ts @@ -119,6 +119,65 @@ describe('ReportsView tabs', () => { }) }) +describe('ReportsView error and empty states', () => { + it('shows an alert on the Valuation tab when its report fails to load on mount', async () => { + const { wrapper } = await mountReports( + baseRoutes({ + [VALUATION_LOCATION]: () => jsonResponse(500, { title: 'Internal Server Error' }), + }), + ) + + expect(wrapper.get('.alert').text()).toContain('Internal Server Error') + expect(wrapper.find('.reports__table').exists()).toBe(false) + }) + + it('renders the empty-state message on the Valuation tab when there are no rows', async () => { + const { wrapper } = await mountReports(baseRoutes()) + + expect(wrapper.text()).toContain('No data for this report yet.') + expect(wrapper.find('.reports__table').exists()).toBe(false) + }) + + it('shows an alert (and no crash) when running the Purchases report fails', async () => { + const { wrapper } = await mountReports( + baseRoutes({ + 'GET /api/v1/reports/purchases?from=2026-01-01&to=2026-12-31': () => + jsonResponse(500, { title: 'Internal Server Error' }), + }), + ) + await selectTab(wrapper, 'Purchases') + + await wrapper.get('#purchases-from').setValue('2026-01-01') + await wrapper.get('#purchases-to').setValue('2026-12-31') + await wrapper.find('form').trigger('submit') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.get('.alert').text()).toContain('Internal Server Error') + expect(wrapper.find('.reports__table').exists()).toBe(false) + }) + + it('renders the empty-state message on Purchases only after Run comes back with no matches', async () => { + const { wrapper } = await mountReports( + baseRoutes({ + 'GET /api/v1/reports/purchases?from=2026-01-01&to=2026-12-31': () => + jsonResponse(200, { rows: [] }), + }), + ) + await selectTab(wrapper, 'Purchases') + + expect(wrapper.text()).not.toContain('Nothing purchased in this range.') + + await wrapper.get('#purchases-from').setValue('2026-01-01') + await wrapper.get('#purchases-to').setValue('2026-12-31') + await wrapper.find('form').trigger('submit') + await flush() + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Nothing purchased in this range.') + }) +}) + describe('ReportsView valuation (FR-060)', () => { it('renders the Unassigned bucket for an empty group_key, in location mode, without crashing', async () => { const { wrapper } = await mountReports( diff --git a/server/web/src/views/__tests__/SettingsView.spec.ts b/server/web/src/views/__tests__/SettingsView.spec.ts index 51abb51..84a07be 100644 --- a/server/web/src/views/__tests__/SettingsView.spec.ts +++ b/server/web/src/views/__tests__/SettingsView.spec.ts @@ -10,6 +10,10 @@ const SESSIONS = 'GET /api/v1/auth/sessions' const DEVICES = 'GET /api/v1/auth/device-tokens' const INVITES = 'GET /api/v1/invites' const CREATE_INVITE = 'POST /api/v1/invites' +const VISIBILITY = 'GET /api/v1/groups/detail-visibility' +const PUT_VISIBILITY = 'PUT /api/v1/groups/detail-visibility' +const FIELD_DEFS = 'GET /api/v1/custom-field-defs' +const CREATE_FIELD_DEF = 'POST /api/v1/custom-field-defs' function baseRoutes(overrides: Routes = {}): Routes { return { @@ -17,6 +21,14 @@ function baseRoutes(overrides: Routes = {}): Routes { [SESSIONS]: () => jsonResponse(200, { sessions: [] }), [DEVICES]: () => jsonResponse(200, { device_tokens: [] }), [INVITES]: () => jsonResponse(200, { invites: [] }), + [VISIBILITY]: () => + jsonResponse(200, { + warranty_visible: false, + sale_visible: false, + purchase_visible: false, + version: 1, + }), + [FIELD_DEFS]: () => jsonResponse(200, { custom_field_defs: [] }), ...overrides, } } @@ -45,6 +57,29 @@ async function mountSettings(routes: Routes, role: 'owner' | 'member' = 'owner') return { wrapper, fetchMock } } +describe('SettingsView after a reload (T154e, no logIn call)', () => { + it('shows an owner-only section once session.boot() alone has resolved the identity', async () => { + const fetchMock = stubRoutedFetch( + baseRoutes({ + ['GET /api/v1/auth/me']: () => + jsonResponse(200, { group_id: 'g', user_id: 'u', username: 'anton', role: 'owner' }), + }), + ) + const session = useSessionStore() + expect(session.user).toBeNull() + + await session.boot() + const wrapper = mount(SettingsView, { global: { plugins: [pinia] } }) + await flush() + await wrapper.vm.$nextTick() + + expect(fetchMock.mock.calls.some(([input]) => input === '/api/v1/auth/me')).toBe(true) + expect(session.isOwner).toBe(true) + expect(wrapper.text()).toContain('Backup and restore') + expect(wrapper.text()).toContain('Invite links let someone join') + }) +}) + describe('SettingsView placeholders (FR-105 gaps, A115)', () => { it('never renders a registration section — FR-105 dropped that sub-screen (A115)', async () => { const { wrapper } = await mountSettings(baseRoutes(), 'member') @@ -187,6 +222,24 @@ describe('SettingsView invites (owner-gated, FR-006)', () => { expect(wrapper.text()).toContain('active') }) + it('offers a copyable /join link built from the token, alongside the raw token', async () => { + const { wrapper } = await mountSettings( + baseRoutes({ + [CREATE_INVITE]: () => + jsonResponse(201, { id: 'inv-1', token: 'raw-token-xyz', expires_at: Date.now() + 1000 }), + }), + ) + + await wrapper.get('.settings__invite-create').trigger('click') + await flush() + await wrapper.vm.$nextTick() + + const codes = wrapper.findAll('.settings__invite-code') + expect(codes).toHaveLength(2) + expect(codes[0]!.text()).toBe('raw-token-xyz') + expect(codes[1]!.text()).toBe(`${window.location.origin}/join?token=raw-token-xyz`) + }) + it('classifies redeemed, expired, and still-active invites correctly, offering Revoke only for the active one', async () => { const now = Date.now() const { wrapper } = await mountSettings( @@ -350,3 +403,343 @@ describe('SettingsView sessions and devices (FR-004, self-scoped for either role expect(wrapper.text()).toContain('No registered devices.') }) }) + +describe('SettingsView item detail-block visibility (FR-011, owner-only, T154b)', () => { + it('renders no Item detail blocks section for a non-owner, and never calls GET /groups/detail-visibility', async () => { + const { wrapper, fetchMock } = await mountSettings(baseRoutes(), 'member') + + expect(wrapper.text()).not.toContain('Item detail blocks') + expect( + fetchMock.mock.calls.some(([input]) => input === '/api/v1/groups/detail-visibility'), + ).toBe(false) + }) + + it('loads the current flags into three checkboxes for an owner', async () => { + const { wrapper } = await mountSettings( + baseRoutes({ + [VISIBILITY]: () => + jsonResponse(200, { + warranty_visible: true, + sale_visible: false, + purchase_visible: true, + version: 3, + }), + }), + 'owner', + ) + + const card = wrapper + .findAll('.settings__section') + .find((s) => s.get('.settings__section-title').text() === 'Item detail blocks')! + const checkboxes = card.findAll('input[type="checkbox"]') + expect(checkboxes).toHaveLength(3) + expect((checkboxes[0]!.element as HTMLInputElement).checked).toBe(true) + expect((checkboxes[1]!.element as HTMLInputElement).checked).toBe(true) + expect((checkboxes[2]!.element as HTMLInputElement).checked).toBe(false) + }) + + it('sends the toggled flags and the read version on save', async () => { + const { wrapper, fetchMock } = await mountSettings( + baseRoutes({ + [PUT_VISIBILITY]: () => + jsonResponse(200, { + warranty_visible: true, + sale_visible: false, + purchase_visible: false, + version: 2, + }), + }), + 'owner', + ) + + const card = wrapper + .findAll('.settings__section') + .find((s) => s.get('.settings__section-title').text() === 'Item detail blocks')! + await card.findAll('input[type="checkbox"]')[0]!.setValue(true) + await card.get('form').trigger('submit') + await flush() + + const call = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/v1/groups/detail-visibility' && init?.method === 'PUT', + ) + expect(call).toBeDefined() + expect(JSON.parse(call![1]!.body as string)).toEqual({ + warranty_visible: true, + sale_visible: false, + purchase_visible: false, + version: 1, + }) + }) + + it('reloads from the server and reports a conflict on a 409', async () => { + const { wrapper } = await mountSettings(baseRoutes(), 'owner') + + stubRoutedFetch( + baseRoutes({ + [PUT_VISIBILITY]: () => jsonResponse(409, { title: 'Conflict', status: 409 }), + }), + ) + + const card = wrapper + .findAll('.settings__section') + .find((s) => s.get('.settings__section-title').text() === 'Item detail blocks')! + await card.get('form').trigger('submit') + await flush() + await wrapper.vm.$nextTick() + + expect(card.get('[role="alert"]').text()).toContain('Someone else changed') + }) +}) + +describe('SettingsView custom-field definitions (FR-017, owner-only, T154b)', () => { + function fieldDefsCard(wrapper: ReturnType) { + return wrapper + .findAll('.settings__section') + .find((s) => s.get('.settings__section-title').text() === 'Custom fields')! + } + + it('renders no Custom fields section for a non-owner, and never calls the writes', async () => { + const { wrapper, fetchMock } = await mountSettings(baseRoutes(), 'member') + + expect(wrapper.text()).not.toContain('Custom fields') + expect(fetchMock.mock.calls.some(([input]) => input === '/api/v1/custom-field-defs')).toBe( + false, + ) + }) + + it('lists existing definitions with their type', async () => { + const { wrapper } = await mountSettings( + baseRoutes({ + [FIELD_DEFS]: () => + jsonResponse(200, { + custom_field_defs: [ + { + id: 'f1', + name: 'Color', + field_type: 'text', + display_order: 0, + created_at: 0, + updated_at: 0, + version: 1, + }, + ], + }), + }), + 'owner', + ) + + const card = fieldDefsCard(wrapper) + expect(card.text()).toContain('Color') + expect(card.text()).toContain('text') + }) + + it('creates a new definition with the entered name and type', async () => { + const { wrapper, fetchMock } = await mountSettings( + baseRoutes({ + [CREATE_FIELD_DEF]: () => + jsonResponse(201, { + id: 'f2', + name: 'Room', + field_type: 'number', + display_order: 0, + created_at: 0, + updated_at: 0, + version: 1, + }), + }), + 'owner', + ) + + const card = fieldDefsCard(wrapper) + await card.get('[aria-label="New field name"]').setValue('Room') + await card.find('select').setValue('number') + await card.get('.settings__field-def-create').trigger('submit') + await flush() + await wrapper.vm.$nextTick() + + const call = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/v1/custom-field-defs' && init?.method === 'POST', + ) + expect(call).toBeDefined() + expect(JSON.parse(call![1]!.body as string)).toEqual({ + name: 'Room', + field_type: 'number', + display_order: 0, + }) + expect(card.text()).toContain('Room') + }) + + it('renames a definition, sending its current display_order and version', async () => { + const { wrapper } = await mountSettings( + baseRoutes({ + [FIELD_DEFS]: () => + jsonResponse(200, { + custom_field_defs: [ + { + id: 'f1', + name: 'Color', + field_type: 'text', + display_order: 5, + created_at: 0, + updated_at: 0, + version: 3, + }, + ], + }), + }), + 'owner', + ) + + const fetchMock = stubRoutedFetch( + baseRoutes({ + [FIELD_DEFS]: () => + jsonResponse(200, { + custom_field_defs: [ + { + id: 'f1', + name: 'Color', + field_type: 'text', + display_order: 5, + created_at: 0, + updated_at: 0, + version: 3, + }, + ], + }), + ['PUT /api/v1/custom-field-defs/f1']: () => + jsonResponse(200, { + id: 'f1', + name: 'Colour', + field_type: 'text', + display_order: 5, + created_at: 0, + updated_at: 0, + version: 4, + }), + }), + ) + + const card = fieldDefsCard(wrapper) + await card + .findAll('button') + .find((b) => b.text() === 'Rename')! + .trigger('click') + await wrapper.vm.$nextTick() + + await card.get('[aria-label="Name"]').setValue('Colour') + await card.get('form').trigger('submit') + await flush() + await wrapper.vm.$nextTick() + + const call = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/v1/custom-field-defs/f1' && init?.method === 'PUT', + ) + expect(call).toBeDefined() + expect(JSON.parse(call![1]!.body as string)).toEqual({ + name: 'Colour', + field_type: 'text', + display_order: 5, + version: 3, + }) + expect(card.text()).toContain('Colour') + }) + + it('shows an inline error, without removing the row, when a rename answers 409', async () => { + const { wrapper } = await mountSettings( + baseRoutes({ + [FIELD_DEFS]: () => + jsonResponse(200, { + custom_field_defs: [ + { + id: 'f1', + name: 'Color', + field_type: 'text', + display_order: 0, + created_at: 0, + updated_at: 0, + version: 1, + }, + ], + }), + }), + 'owner', + ) + + stubRoutedFetch( + baseRoutes({ + [FIELD_DEFS]: () => + jsonResponse(200, { + custom_field_defs: [ + { + id: 'f1', + name: 'Color', + field_type: 'text', + display_order: 0, + created_at: 0, + updated_at: 0, + version: 1, + }, + ], + }), + ['PUT /api/v1/custom-field-defs/f1']: () => + jsonResponse(409, { title: 'Conflict', status: 409 }), + }), + ) + + const card = fieldDefsCard(wrapper) + await card + .findAll('button') + .find((b) => b.text() === 'Rename')! + .trigger('click') + await wrapper.vm.$nextTick() + await card.get('form').trigger('submit') + await flush() + await wrapper.vm.$nextTick() + + expect(card.get('[role="alert"]').text()).toContain('Someone else changed this definition') + expect(card.findAll('.settings__row')).toHaveLength(1) + expect((card.get('[aria-label="Name"]').element as HTMLInputElement).value).toBe('Color') + }) + + it('deletes a definition after confirmation, removing exactly that row', async () => { + vi.stubGlobal( + 'confirm', + vi.fn(() => true), + ) + const { wrapper, fetchMock } = await mountSettings( + baseRoutes({ + [FIELD_DEFS]: () => + jsonResponse(200, { + custom_field_defs: [ + { + id: 'f1', + name: 'Color', + field_type: 'text', + display_order: 0, + created_at: 0, + updated_at: 0, + version: 1, + }, + ], + }), + ['DELETE /api/v1/custom-field-defs/f1']: () => jsonResponse(204), + }), + 'owner', + ) + + const card = fieldDefsCard(wrapper) + await card + .findAll('button') + .find((b) => b.text() === 'Delete')! + .trigger('click') + await flush() + await wrapper.vm.$nextTick() + + expect(card.find('.settings__row').exists()).toBe(false) + expect( + fetchMock.mock.calls.some( + ([input, init]) => input === '/api/v1/custom-field-defs/f1' && init?.method === 'DELETE', + ), + ).toBe(true) + }) +})