diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddc14b1c..3003de19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: outputs: app: ${{ steps.filter.outputs.app }} functions: ${{ steps.filter.outputs.functions }} + proto: ${{ steps.filter.outputs.proto }} steps: - name: Checkout uses: actions/checkout@v6 @@ -46,6 +47,8 @@ jobs: - 'mise.toml' functions: - 'functions/**' + proto: + - 'proto/**' # Check formatting before expensive jobs fmt: @@ -96,6 +99,30 @@ jobs: mapfile -t SH_FILES < <(find . -name '*.sh' -not -path './.git/*' -not -path './.mise/*' -not -path '*/node_modules/*' | sort) [[ ${#SH_FILES[@]} -gt 0 ]] && shfmt -d -i 0 -ci "${SH_FILES[@]}" + # Lint proto files and check for breaking changes against main. + # buf breaking runs on PRs only (bufbuild/buf-action skips it on push to main + # where the change is already merged). FILE stability level (configured in + # proto/buf.yaml) is appropriate for v1alpha; switch to WIRE_JSON_COMPATIBLE + # when the API graduates to v1. + proto: + needs: changes + runs-on: ubuntu-latest + if: | + github.event_name == 'workflow_dispatch' || + needs.changes.outputs.proto == 'true' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 # buf breaking needs full history to compare against main + + - uses: bufbuild/buf-action@v1 + with: + input: proto + push: false + pr_comment: false + breaking_against: "https://github.com/${{ github.repository }}.git#branch=main,subdir=proto" + # Analyze code in parallel with tests so builds can start sooner analyze: needs: changes diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 0e7ca18f..92dbe6c9 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -92,6 +92,71 @@ This endpoint: - Returns them as a sorted array - Caches the result for 1 hour +### "My festival" API (v1alpha) + +Personal drink reviews and shared aggregates, backed by D1 (SQLite). The first +step towards an online "my festival". The API is resource-oriented following +[Google's AIPs](https://google.aip.dev) — the proto contract is in `proto/` +and an OpenAPI spec can be generated from it (see `proto/README.md`). + +Writes are local-first on the client; the server holds the shared aggregate. +Every row and query is scoped by a `bucket` (`test` or `prod`, derived from the +request origin; only `https://cambeerfestival.app` → `prod`) so test traffic +never mixes with production data. A `RATINGS_BUCKET` worker var can pin it. + +The **Review** is a singleton per (caller, drink). Caller identity comes from +the `X-Device-Id` request header (anonymous phase); the device ID never appears +in resource names, so the sign-in upgrade is transparent to clients. + +| Method | Path | Purpose | +| -------- | ---------------------------------------------------------------- | -------------------------------- | +| `PATCH` | `/v1alpha/festivals/{f}/drinks/{d}/review` | Upsert review (`starRating` and/or `wouldRecommend`) | +| `GET` | `/v1alpha/festivals/{f}/drinks/{d}/review` | Get caller's review | +| `DELETE` | `/v1alpha/festivals/{f}/drinks/{d}/review` | Remove caller's review | +| `GET` | `/v1alpha/festivals/{f}/reviews` | List caller's reviews at festival | +| `GET` | `/v1alpha/festivals/{f}/reviewSummaries/{d}` | Aggregate for one drink | +| `GET` | `/v1alpha/festivals/{f}/reviewSummaries?page_size=&page_token=` | Paginated list of aggregates | + +`PATCH` body: `{ starRating?: 1-5, wouldRecommend?: bool, updateMask?: "starRating,wouldRecommend" }`. +Both signals are independently optional; use `updateMask` to update one without +clearing the other. `DELETE` is `NOT_FOUND` when the review is absent (AIP-135). +Errors use the structured `google.rpc.Status` shape (AIP-193). + +```bash +# Upsert a review (star rating + recommendation) +curl -X PATCH https://data.cambeerfestival.app/v1alpha/festivals/cbf2025/drinks/beer-1/review \ + -H 'Content-Type: application/json' \ + -H 'X-Device-Id: my-device-id' \ + -d '{"starRating":4,"wouldRecommend":true}' +# -> {"name":"festivals/cbf2025/drinks/beer-1/review","starRating":4,"wouldRecommend":true,"updateTime":"..."} + +# Update only the star rating (keep wouldRecommend as-is) +curl -X PATCH https://data.cambeerfestival.app/v1alpha/festivals/cbf2025/drinks/beer-1/review \ + -H 'Content-Type: application/json' \ + -H 'X-Device-Id: my-device-id' \ + -d '{"starRating":5,"updateMask":"starRating"}' + +# Aggregate for one drink +curl https://data.cambeerfestival.app/v1alpha/festivals/cbf2025/reviewSummaries/beer-1 +# -> {"name":"...","ratingCount":3,"averageRating":4.0,"responseCount":2,"recommendCount":1,"recommendRate":0.5} +``` + +#### D1 provisioning (one-time, before first deploy) + +The `database_id` in `wrangler.toml` is a placeholder. Local `wrangler dev` and +the vitest test pool use a simulated local D1 and ignore it, so the full test +suite runs with no real database. Before deploying: + +```bash +cd cloudflare-worker +wrangler d1 create cbf-myfestival # prints the database_id +# paste the id into wrangler.toml ([[d1_databases]].database_id) +wrangler d1 migrations apply cbf-myfestival # applies migrations/*.sql +``` + +The deploy `CLOUDFLARE_API_TOKEN` must include **D1: Edit** in addition to +Workers Scripts: Edit. To wipe test data: `DELETE FROM reviews WHERE bucket='test'`. + ### Health Check - `/health` - Returns `{"status": "ok"}` for monitoring diff --git a/cloudflare-worker/migrations/0001_create_reviews_table.sql b/cloudflare-worker/migrations/0001_create_reviews_table.sql new file mode 100644 index 00000000..33b99759 --- /dev/null +++ b/cloudflare-worker/migrations/0001_create_reviews_table.sql @@ -0,0 +1,28 @@ +-- Unified review table for the /v1alpha "my festival" API. +-- +-- One row per (bucket, festival, drink, device). The composite primary key +-- gives upsert semantics so re-reviewing never inflates counts. +-- +-- star_rating and recommend are independently nullable: a caller can set +-- a star rating without answering the recommendation question, or vice versa. +-- +-- `bucket` isolates data by environment ('test' vs 'prod'), so test traffic +-- never mixes with production data. A `RATINGS_BUCKET` worker var can pin it. +-- `user_id` is reserved for the sign-in upgrade (phase 3) and stays NULL +-- while the API is anonymous. + +CREATE TABLE IF NOT EXISTS reviews ( + bucket TEXT NOT NULL, + festival_id TEXT NOT NULL, + drink_id TEXT NOT NULL, + device_id TEXT NOT NULL, + user_id TEXT, + star_rating INTEGER CHECK (star_rating BETWEEN 1 AND 5), + recommend INTEGER CHECK (recommend IN (0, 1)), + updated_at INTEGER NOT NULL, + PRIMARY KEY (bucket, festival_id, drink_id, device_id) +); + +-- Aggregate reads always filter by (bucket, festival_id) and group by drink_id. +CREATE INDEX IF NOT EXISTS idx_reviews_aggregate + ON reviews (bucket, festival_id, drink_id); diff --git a/cloudflare-worker/package-lock.json b/cloudflare-worker/package-lock.json index bb0998a7..c7e25d3c 100644 --- a/cloudflare-worker/package-lock.json +++ b/cloudflare-worker/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.0", "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.13", + "@cloudflare/workers-types": "^4.20260613.1", "openapi-typescript": "^7.13.0", + "typescript": "^6.0.3", "vitest": "^4.1.8", "wrangler": "^4.88.0" } @@ -169,6 +171,13 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260613.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260613.1.tgz", + "integrity": "sha512-1mrgjE6epolwBhroeGAp5ud5H6Vyi6tl1o/NP0T4rXJ8bmEjmhHnbCzAhHTDHV0PIeip43wcuzHKJarvaGTaUA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -2720,12 +2729,11 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/cloudflare-worker/package.json b/cloudflare-worker/package.json index e936b55f..ba406435 100644 --- a/cloudflare-worker/package.json +++ b/cloudflare-worker/package.json @@ -7,11 +7,14 @@ "deploy": "wrangler deploy", "dev": "wrangler dev", "pretest": "cp ../data/festivals.json ./festivals.json", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.16.13", + "@cloudflare/workers-types": "^4.20260613.1", "openapi-typescript": "^7.13.0", + "typescript": "^6.0.3", "vitest": "^4.1.8", "wrangler": "^4.88.0" } diff --git a/cloudflare-worker/reviews.ts b/cloudflare-worker/reviews.ts new file mode 100644 index 00000000..bdaf8bfa --- /dev/null +++ b/cloudflare-worker/reviews.ts @@ -0,0 +1,569 @@ +/** + * Review resource handler for the /v1alpha "my festival" API. + * + * Routes (AIP resource-oriented, conforms to proto contract in proto/): + * GET /v1alpha/festivals/{f}/drinks/{d}/review get caller's review + * PATCH /v1alpha/festivals/{f}/drinks/{d}/review upsert caller's review + * DELETE /v1alpha/festivals/{f}/drinks/{d}/review remove caller's review + * GET /v1alpha/festivals/{f}/reviews list caller's reviews + * GET /v1alpha/festivals/{f}/reviewSummaries/{d} aggregate for one drink + * GET /v1alpha/festivals/{f}/reviewSummaries list aggregates (paginated) + * + * Response shapes are typed against the generated OpenAPI types in + * src/api-types.ts (generated from proto via proto:clients:types). TypeScript + * enforces that every response field matches the proto contract — a field + * rename in the proto surfaces here as a compile error. + * + * Caller identity comes from the X-Device-Id request header (anonymous phase). + * It never appears in resource names, so the sign-in upgrade is transparent. + */ + +import type { components } from "./src/api-types"; +import { + type CorsHeaders, + type Env, + resolveBucket, + rfc3339, + jsonResponse, + errorResponse, + encodePageToken, + decodePageToken, + resolvePageSize, +} from "./shared.js"; + +// Response shapes enforced by the proto contract. +type Review = components["schemas"]["Review"]; +type ReviewSummary = components["schemas"]["ReviewSummary"]; +type ListReviewsResponse = components["schemas"]["ListReviewsResponse"]; +type ListReviewSummariesResponse = + components["schemas"]["ListReviewSummariesResponse"]; + +const MAX_ID_LENGTH = 200; + +// D1 row shapes returned by SQL queries. +interface ReviewRow { + star_rating: number | null; + recommend: number | null; + updated_at: number; +} +interface ReviewListRow extends ReviewRow { + drink_id: string; +} +interface SummaryRow { + rating_count: number; + avg_rating: number | null; + response_count: number; + recommend_count: number | null; + drink_id?: string; +} +interface TotalRow { + n: number; +} + +interface ReviewCtx { + db: D1Database; + bucket: string; + festivalId: string; + drinkId: string; + deviceId: string; + corsHeaders: CorsHeaders; +} +interface SummaryCtx { + db: D1Database; + bucket: string; + festivalId: string; + drinkId: string; + corsHeaders: CorsHeaders; +} +interface ListCtx { + db: D1Database; + bucket: string; + festivalId: string; + deviceId: string; + url: URL; + corsHeaders: CorsHeaders; +} +interface ListSummaryCtx { + db: D1Database; + bucket: string; + festivalId: string; + url: URL; + corsHeaders: CorsHeaders; +} + +function isValidId(value: string | null): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_ID_LENGTH + ); +} + +function getDeviceId( + request: Request, + corsHeaders: CorsHeaders, +): { deviceId: string } | { error: Response } { + const deviceId = request.headers.get("X-Device-Id"); + if (!isValidId(deviceId)) { + return { + error: errorResponse( + 400, + "INVALID_ARGUMENT", + "X-Device-Id header is required (non-empty, max 200 chars)", + "MISSING_DEVICE_ID", + corsHeaders, + ), + }; + } + return { deviceId }; +} + +function parseV1alphaPath(pathname: string): string[] | null { + if (pathname !== "/v1alpha" && !pathname.startsWith("/v1alpha/")) return null; + return pathname + .slice("/v1alpha/".length) + .split("/") + .filter((s) => s.length > 0) + .map((s) => decodeURIComponent(s)); +} + +/** Route a request, or return null if the path doesn't match any review route. */ +export async function handleReviews( + request: Request, + url: URL, + env: Env, + corsHeaders: CorsHeaders, +): Promise { + const segments = parseV1alphaPath(url.pathname); + if (!segments || segments[0] !== "festivals" || segments.length < 3) { + return null; + } + + // /v1alpha/festivals/{f}/drinks/{d}/review + const isReviewRecord = + segments.length === 5 && + segments[2] === "drinks" && + segments[4] === "review"; + + // /v1alpha/festivals/{f}/reviews + const isReviewList = segments.length === 3 && segments[2] === "reviews"; + + // /v1alpha/festivals/{f}/reviewSummaries[/{drink}] + const isSummary = + (segments.length === 3 || segments.length === 4) && + segments[2] === "reviewSummaries"; + + if (!isReviewRecord && !isReviewList && !isSummary) return null; + + if (!env?.RATINGS_DB) { + return errorResponse( + 503, + "UNAVAILABLE", + "Storage is not configured", + "STORAGE_UNCONFIGURED", + corsHeaders, + ); + } + + const origin = request.headers.get("Origin") ?? ""; + const bucket = resolveBucket(origin, env); + const db = env.RATINGS_DB; + + if (isReviewRecord) { + const festivalId = segments[1]; + const drinkId = segments[3]; + if (!isValidId(festivalId) || !isValidId(drinkId)) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid resource name", + "INVALID_RESOURCE_NAME", + corsHeaders, + ); + } + const deviceResult = getDeviceId(request, corsHeaders); + if ("error" in deviceResult) return deviceResult.error; + + switch (request.method) { + case "GET": + return getReview({ db, bucket, festivalId, drinkId, deviceId: deviceResult.deviceId, corsHeaders }); + case "PATCH": + return upsertReview(request, { db, bucket, festivalId, drinkId, deviceId: deviceResult.deviceId, corsHeaders }); + case "DELETE": + return deleteReview({ db, bucket, festivalId, drinkId, deviceId: deviceResult.deviceId, corsHeaders }); + default: + return methodNotAllowed(corsHeaders); + } + } + + if (request.method !== "GET") return methodNotAllowed(corsHeaders); + + const festivalId = segments[1]; + if (!isValidId(festivalId)) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid resource name", + "INVALID_RESOURCE_NAME", + corsHeaders, + ); + } + + if (isReviewList) { + const deviceResult = getDeviceId(request, corsHeaders); + if ("error" in deviceResult) return deviceResult.error; + return listReviews({ db, bucket, festivalId, deviceId: deviceResult.deviceId, url, corsHeaders }); + } + + // isSummary + if (segments.length === 4) { + const drinkId = segments[3]; + if (!isValidId(drinkId)) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Invalid resource name", + "INVALID_RESOURCE_NAME", + corsHeaders, + ); + } + return getReviewSummary({ db, bucket, festivalId, drinkId, corsHeaders }); + } + return listReviewSummaries({ db, bucket, festivalId, url, corsHeaders }); +} + +function methodNotAllowed(corsHeaders: CorsHeaders): Response { + return errorResponse( + 405, + "UNIMPLEMENTED", + "Method not allowed for this resource", + "METHOD_NOT_ALLOWED", + corsHeaders, + ); +} + +function reviewName(festivalId: string, drinkId: string): string { + return `festivals/${festivalId}/drinks/${drinkId}/review`; +} + +function summaryName(festivalId: string, drinkId: string): string { + return `festivals/${festivalId}/reviewSummaries/${drinkId}`; +} + +function serializeReview(name: string, row: ReviewRow): Review { + const resource: Review = { name, updateTime: rfc3339(row.updated_at) }; + if (row.star_rating != null) resource.starRating = row.star_rating; + if (row.recommend != null) resource.wouldRecommend = Boolean(row.recommend); + return resource; +} + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function summaryFields(row: Partial): Omit { + const ratingCount = row.rating_count ?? 0; + const responseCount = row.response_count ?? 0; + const recommendCount = row.recommend_count ?? 0; + return { + ratingCount, + averageRating: ratingCount && row.avg_rating != null ? round1(row.avg_rating) : 0, + responseCount, + recommendCount, + recommendRate: responseCount ? round2(recommendCount / responseCount) : 0, + }; +} + +async function readRow( + db: D1Database, + bucket: string, + festivalId: string, + drinkId: string, + deviceId: string, +): Promise { + return db + .prepare( + "SELECT star_rating, recommend, updated_at FROM reviews " + + "WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?", + ) + .bind(bucket, festivalId, drinkId, deviceId) + .first(); +} + +async function getReview(ctx: ReviewCtx): Promise { + const { db, bucket, festivalId, drinkId, deviceId, corsHeaders } = ctx; + const row = await readRow(db, bucket, festivalId, drinkId, deviceId); + if (!row) { + return errorResponse(404, "NOT_FOUND", "No review found", "NOT_FOUND", corsHeaders); + } + return jsonResponse( + serializeReview(reviewName(festivalId, drinkId), row), + 200, + corsHeaders, + ); +} + +async function upsertReview(request: Request, ctx: ReviewCtx): Promise { + const { db, bucket, festivalId, drinkId, deviceId, corsHeaders } = ctx; + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse(400, "INVALID_ARGUMENT", "Invalid JSON body", "INVALID_BODY", corsHeaders); + } + if (body === null || typeof body !== "object") { + return errorResponse(400, "INVALID_ARGUMENT", "Body must be a JSON object", "INVALID_BODY", corsHeaders); + } + + // Parse updateMask: comma-separated field names. Absent/empty = all provided fields. + const KNOWN_FIELDS = new Set(["starRating", "wouldRecommend"]); + const patch = body as Record; + const maskRaw = patch.updateMask; + let mask: Set | null = null; + if (typeof maskRaw === "string" && maskRaw.length > 0) { + const fields = maskRaw.split(",").map((s) => s.trim()); + const unknown = fields.filter((f) => !KNOWN_FIELDS.has(f)); + if (unknown.length > 0) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + `Unknown updateMask field(s): ${unknown.join(", ")}`, + "UNKNOWN_FIELD_MASK", + corsHeaders, + ); + } + mask = new Set(fields); + } + + const updateStar = mask === null ? "starRating" in patch : mask.has("starRating"); + const updateRec = mask === null ? "wouldRecommend" in patch : mask.has("wouldRecommend"); + + if (!updateStar && !updateRec) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "Provide at least one of starRating or wouldRecommend", + "NO_FIELDS_TO_UPDATE", + corsHeaders, + ); + } + + let starRating: number | undefined; + if (updateStar) { + const v = patch.starRating; + if (!Number.isInteger(v) || (v as number) < 1 || (v as number) > 5) { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "starRating must be an integer between 1 and 5", + "STAR_RATING_OUT_OF_RANGE", + corsHeaders, + ); + } + starRating = v as number; + } + + let recommend: number | undefined; + if (updateRec) { + const v = patch.wouldRecommend; + if (typeof v !== "boolean") { + return errorResponse( + 400, + "INVALID_ARGUMENT", + "wouldRecommend must be a boolean", + "WOULD_RECOMMEND_INVALID", + corsHeaders, + ); + } + recommend = v ? 1 : 0; + } + + const existing = await readRow(db, bucket, festivalId, drinkId, deviceId); + const now = Date.now(); + + // Compute the final column values upfront so we can build the response + // without a second DB read — avoids a round trip and the race where a + // concurrent DELETE between write and re-read would make row! throw. + const finalStarRating = updateStar ? (starRating ?? null) : (existing?.star_rating ?? null); + const finalRecommend = updateRec ? (recommend ?? null) : (existing?.recommend ?? null); + + if (existing) { + await db + .prepare( + "UPDATE reviews SET star_rating = ?, recommend = ?, updated_at = ? " + + "WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?", + ) + .bind(finalStarRating, finalRecommend, now, bucket, festivalId, drinkId, deviceId) + .run(); + } else { + await db + .prepare( + "INSERT INTO reviews (bucket, festival_id, drink_id, device_id, star_rating, recommend, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(bucket, festivalId, drinkId, deviceId, finalStarRating, finalRecommend, now) + .run(); + } + + return jsonResponse( + serializeReview(reviewName(festivalId, drinkId), { + star_rating: finalStarRating, + recommend: finalRecommend, + updated_at: now, + }), + 200, + corsHeaders, + ); +} + +async function deleteReview(ctx: ReviewCtx): Promise { + const { db, bucket, festivalId, drinkId, deviceId, corsHeaders } = ctx; + const result = await db + .prepare( + "DELETE FROM reviews " + + "WHERE bucket = ? AND festival_id = ? AND drink_id = ? AND device_id = ?", + ) + .bind(bucket, festivalId, drinkId, deviceId) + .run(); + + const changes = result.meta?.changes ?? 0; + if (!changes) { + return errorResponse(404, "NOT_FOUND", "No review found", "NOT_FOUND", corsHeaders); + } + return jsonResponse({}, 200, corsHeaders); +} + +async function listReviews(ctx: ListCtx): Promise { + const { db, bucket, festivalId, deviceId, url, corsHeaders } = ctx; + + const sizeResult = resolvePageSize(url.searchParams.get("page_size")); + if ("error" in sizeResult) { + return errorResponse(400, "INVALID_ARGUMENT", "page_size must be >= 0", "INVALID_PAGE_SIZE", corsHeaders); + } + const pageSize = sizeResult.value; + + const cursor = decodePageToken(url.searchParams.get("page_token")); + if (cursor === undefined) { + return errorResponse(400, "INVALID_ARGUMENT", "Invalid page_token", "INVALID_PAGE_TOKEN", corsHeaders); + } + + const where = ["bucket = ?", "festival_id = ?", "device_id = ?"]; + const binds: unknown[] = [bucket, festivalId, deviceId]; + if (cursor !== null) { + where.push("drink_id > ?"); + binds.push(cursor); + } + + const { results } = await db + .prepare( + "SELECT drink_id, star_rating, recommend, updated_at FROM reviews " + + `WHERE ${where.join(" AND ")} ORDER BY drink_id LIMIT ?`, + ) + .bind(...binds, pageSize + 1) + .all(); + + const page = results.slice(0, pageSize); + const reviews: Review[] = page.map((row) => + serializeReview(reviewName(festivalId, row.drink_id), row), + ); + + let nextPageToken = ""; + if (results.length > pageSize) { + nextPageToken = encodePageToken(page[page.length - 1].drink_id); + } + + return jsonResponse( + { reviews, nextPageToken }, + 200, + corsHeaders, + ); +} + +async function getReviewSummary(ctx: SummaryCtx): Promise { + const { db, bucket, festivalId, drinkId, corsHeaders } = ctx; + const row = await db + .prepare( + "SELECT " + + "COUNT(star_rating) AS rating_count, " + + "AVG(star_rating) AS avg_rating, " + + "COUNT(recommend) AS response_count, " + + "SUM(CASE WHEN recommend = 1 THEN 1 ELSE 0 END) AS recommend_count " + + "FROM reviews WHERE bucket = ? AND festival_id = ? AND drink_id = ?", + ) + .bind(bucket, festivalId, drinkId) + .first(); + + return jsonResponse( + { name: summaryName(festivalId, drinkId), ...summaryFields(row ?? {}) }, + 200, + corsHeaders, + ); +} + +async function listReviewSummaries(ctx: ListSummaryCtx): Promise { + const { db, bucket, festivalId, url, corsHeaders } = ctx; + + const sizeResult = resolvePageSize(url.searchParams.get("page_size")); + if ("error" in sizeResult) { + return errorResponse(400, "INVALID_ARGUMENT", "page_size must be >= 0", "INVALID_PAGE_SIZE", corsHeaders); + } + const pageSize = sizeResult.value; + + const cursor = decodePageToken(url.searchParams.get("page_token")); + if (cursor === undefined) { + return errorResponse(400, "INVALID_ARGUMENT", "Invalid page_token", "INVALID_PAGE_TOKEN", corsHeaders); + } + + const where = ["bucket = ?", "festival_id = ?"]; + const binds: unknown[] = [bucket, festivalId]; + if (cursor !== null) { + where.push("drink_id > ?"); + binds.push(cursor); + } + + const { results } = await db + .prepare( + "SELECT drink_id, " + + "COUNT(star_rating) AS rating_count, " + + "AVG(star_rating) AS avg_rating, " + + "COUNT(recommend) AS response_count, " + + "SUM(CASE WHEN recommend = 1 THEN 1 ELSE 0 END) AS recommend_count " + + "FROM reviews " + + `WHERE ${where.join(" AND ")} GROUP BY drink_id ORDER BY drink_id LIMIT ?`, + ) + .bind(...binds, pageSize + 1) + .all(); + + const page = results.slice(0, pageSize); + const reviewSummaries: ReviewSummary[] = page.map((row) => ({ + name: summaryName(festivalId, row.drink_id), + ...summaryFields(row), + })); + + let nextPageToken = ""; + if (results.length > pageSize) { + nextPageToken = encodePageToken(page[page.length - 1].drink_id); + } + + const totalRow = await db + .prepare( + "SELECT COUNT(DISTINCT drink_id) AS n FROM reviews WHERE bucket = ? AND festival_id = ?", + ) + .bind(bucket, festivalId) + .first(); + + return jsonResponse( + { + reviewSummaries, + nextPageToken, + totalSize: totalRow?.n ?? 0, + }, + 200, + corsHeaders, + ); +} diff --git a/cloudflare-worker/shared.ts b/cloudflare-worker/shared.ts new file mode 100644 index 00000000..beadf452 --- /dev/null +++ b/cloudflare-worker/shared.ts @@ -0,0 +1,115 @@ +/** + * Shared utilities for the /v1alpha "my festival" API. + * + * Bucket resolution, structured error responses (AIP-193), and opaque keyset + * page tokens (AIP-158). + */ + +const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGE_SIZE = 1000; +const ERROR_DOMAIN = "cambeerfestival.app"; + +export type CorsHeaders = Record; + +export interface Env { + RATINGS_DB: D1Database; + RATINGS_BUCKET?: string; +} + +export function isProductionOrigin(origin: string): boolean { + return origin === "https://cambeerfestival.app"; +} + +export function resolveBucket(origin: string, env: Partial): string { + if (env && typeof env.RATINGS_BUCKET === "string" && env.RATINGS_BUCKET) { + return env.RATINGS_BUCKET; + } + return isProductionOrigin(origin) ? "prod" : "test"; +} + +export function rfc3339(epochMillis: number): string { + return new Date(epochMillis).toISOString(); +} + +// --- Responses (AIP-193) --------------------------------------------------- + +export function jsonResponse( + body: T, + status: number, + corsHeaders: CorsHeaders, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...corsHeaders, + }, + }); +} + +interface ErrorInfo { + "@type": string; + reason: string; + domain: string; + metadata?: Record; +} + +interface ErrorBody { + error: { + code: number; + message: string; + status: string; + details: ErrorInfo[]; + }; +} + +/** Structured error body per AIP-193 (google.rpc.Status + ErrorInfo). */ +export function errorResponse( + httpCode: number, + status: string, + message: string, + reason: string, + corsHeaders: CorsHeaders, + metadata?: Record, +): Response { + const errorInfo: ErrorInfo = { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason, + domain: ERROR_DOMAIN, + }; + if (metadata) errorInfo.metadata = metadata; + const body: ErrorBody = { + error: { code: httpCode, message, status, details: [errorInfo] }, + }; + return jsonResponse(body, httpCode, corsHeaders); +} + +// --- Pagination (AIP-158) -------------------------------------------------- + +/** Encode a keyset cursor (last drink id) as an opaque URL-safe token. */ +export function encodePageToken(drinkId: string): string { + return btoa(unescape(encodeURIComponent(drinkId))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** Decode a page token back to its cursor, or null if absent. */ +export function decodePageToken(token: string | null): string | null | undefined { + if (!token) return null; + try { + const b64 = token.replace(/-/g, "+").replace(/_/g, "/"); + return decodeURIComponent(escape(atob(b64))); + } catch { + return undefined; // signal "invalid token" + } +} + +/** Resolve an effective page size, or { error } for a bad value. */ +export function resolvePageSize(raw: string | null): { value: number } | { error: true } { + if (raw == null || raw === "") return { value: DEFAULT_PAGE_SIZE }; + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) return { error: true }; + if (n === 0) return { value: DEFAULT_PAGE_SIZE }; + return { value: Math.min(n, MAX_PAGE_SIZE) }; +} diff --git a/cloudflare-worker/test/apply-migrations.js b/cloudflare-worker/test/apply-migrations.js new file mode 100644 index 00000000..63d77102 --- /dev/null +++ b/cloudflare-worker/test/apply-migrations.js @@ -0,0 +1,5 @@ +import { applyD1Migrations, env } from "cloudflare:test"; + +// Apply the reviews schema to the per-test simulated D1 before any test runs. +// `TEST_MIGRATIONS` is provided by vitest.config.js via readD1Migrations(). +await applyD1Migrations(env.RATINGS_DB, env.TEST_MIGRATIONS); diff --git a/cloudflare-worker/test/cors.test.js b/cloudflare-worker/test/cors.test.js index 4352f587..9c9895a2 100644 --- a/cloudflare-worker/test/cors.test.js +++ b/cloudflare-worker/test/cors.test.js @@ -160,10 +160,10 @@ describe("CORS preflight (OPTIONS)", () => { "OPTIONS", ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( - "GET, OPTIONS", + "GET, PATCH, DELETE, OPTIONS", ); expect(response.headers.get("Access-Control-Allow-Headers")).toBe( - "Content-Type", + "Content-Type, X-Device-Id", ); }); diff --git a/cloudflare-worker/test/reviews.test.js b/cloudflare-worker/test/reviews.test.js new file mode 100644 index 00000000..9be7cbdb --- /dev/null +++ b/cloudflare-worker/test/reviews.test.js @@ -0,0 +1,417 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; +import { + isProductionOrigin, + resolveBucket, + resolvePageSize, + encodePageToken, + decodePageToken, +} from "../shared.js"; + +const TEST_ORIGIN = "http://localhost:8080"; // non-prod → 'test' bucket +const PROD_ORIGIN = "https://cambeerfestival.app"; // → 'prod' bucket +const DEVICE = "dev-1"; + +async function send( + method, + path, + { body, origin = TEST_ORIGIN, device = DEVICE } = {}, +) { + const init = { + method, + headers: { Origin: origin, "X-Device-Id": device }, + }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = typeof body === "string" ? body : JSON.stringify(body); + } + const request = new Request(`https://worker.example.com${path}`, init); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; +} + +const reviewPath = (f, d) => `/v1alpha/festivals/${f}/drinks/${d}/review`; +const patch = (f, d, body, opts) => + send("PATCH", reviewPath(f, d), { body, ...opts }); + +beforeEach(async () => { + await env.RATINGS_DB.prepare("DELETE FROM reviews").run(); +}); + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +describe("shared — pure helpers", () => { + it("resolves the bucket from origin, with override", () => { + expect(isProductionOrigin(PROD_ORIGIN)).toBe(true); + expect(resolveBucket(PROD_ORIGIN, {})).toBe("prod"); + expect(resolveBucket(TEST_ORIGIN, {})).toBe("test"); + expect(resolveBucket(PROD_ORIGIN, { RATINGS_BUCKET: "test" })).toBe("test"); + }); + + it("resolvePageSize applies defaults, caps, and rejects negatives", () => { + expect(resolvePageSize(null).value).toBe(100); + expect(resolvePageSize("").value).toBe(100); + expect(resolvePageSize("0").value).toBe(100); + expect(resolvePageSize("25").value).toBe(25); + expect(resolvePageSize("9999").value).toBe(1000); + expect(resolvePageSize("-1").error).toBe(true); + }); + + it("page tokens round-trip and reject garbage", () => { + expect(decodePageToken(encodePageToken("beer-1"))).toBe("beer-1"); + expect(decodePageToken("")).toBe(null); + expect(decodePageToken(null)).toBe(null); + expect(decodePageToken("!!!not-base64!!!")).toBe(undefined); + }); +}); + +// --------------------------------------------------------------------------- +// PATCH — upsert +// --------------------------------------------------------------------------- + +describe("reviews — PATCH (upsert)", () => { + it("creates a review with starRating and returns the resource", async () => { + const response = await patch("cbf2025", "beer-1", { starRating: 4 }); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.name).toBe("festivals/cbf2025/drinks/beer-1/review"); + expect(data.starRating).toBe(4); + expect(data.wouldRecommend).toBeUndefined(); + expect(typeof data.updateTime).toBe("string"); + expect(Number.isNaN(Date.parse(data.updateTime))).toBe(false); + }); + + it("creates a review with wouldRecommend only", async () => { + const response = await patch("cbf2025", "beer-1", { wouldRecommend: true }); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.wouldRecommend).toBe(true); + expect(data.starRating).toBeUndefined(); + }); + + it("creates a review with both signals at once", async () => { + const response = await patch("cbf2025", "beer-1", { + starRating: 3, + wouldRecommend: false, + }); + const data = await response.json(); + expect(data.starRating).toBe(3); + expect(data.wouldRecommend).toBe(false); + }); + + it("updateMask restricts which field is written", async () => { + // Set both signals first + await patch("cbf2025", "beer-1", { starRating: 2, wouldRecommend: true }); + // Update only starRating — wouldRecommend must survive + const response = await patch("cbf2025", "beer-1", { + starRating: 5, + wouldRecommend: false, + updateMask: "starRating", + }); + const data = await response.json(); + expect(data.starRating).toBe(5); + expect(data.wouldRecommend).toBe(true); // unchanged + }); + + it("re-rating updates in place without inflating aggregates", async () => { + await patch("cbf2025", "beer-1", { starRating: 2 }); + await patch("cbf2025", "beer-1", { starRating: 5 }); + const summary = await send( + "GET", + "/v1alpha/festivals/cbf2025/reviewSummaries/beer-1", + ); + const data = await summary.json(); + expect(data.ratingCount).toBe(1); + expect(data.averageRating).toBe(5); + }); + + it("rejects an unknown updateMask field with a structured error", async () => { + const response = await patch("cbf2025", "beer-1", { + starRating: 3, + updateMask: "starRating,bogusField", + }); + expect(response.status).toBe(400); + const { error } = await response.json(); + expect(error.status).toBe("INVALID_ARGUMENT"); + expect(error.details[0].reason).toBe("UNKNOWN_FIELD_MASK"); + }); + + it("rejects an out-of-range starRating with a structured error", async () => { + const response = await patch("cbf2025", "beer-1", { starRating: 9 }); + expect(response.status).toBe(400); + const { error } = await response.json(); + expect(error.status).toBe("INVALID_ARGUMENT"); + expect(error.details[0].reason).toBe("STAR_RATING_OUT_OF_RANGE"); + expect(error.details[0].domain).toBe("cambeerfestival.app"); + }); + + it("rejects a non-boolean wouldRecommend", async () => { + const response = await patch("cbf2025", "beer-1", { wouldRecommend: "yes" }); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "WOULD_RECOMMEND_INVALID", + ); + }); + + it("rejects a body with no recognised fields", async () => { + const response = await patch("cbf2025", "beer-1", { something: "else" }); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "NO_FIELDS_TO_UPDATE", + ); + }); + + it("rejects malformed JSON", async () => { + const response = await send("PATCH", reviewPath("cbf2025", "beer-1"), { + body: "{not json", + }); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe("INVALID_BODY"); + }); +}); + +// --------------------------------------------------------------------------- +// GET / DELETE +// --------------------------------------------------------------------------- + +describe("reviews — GET and DELETE", () => { + it("gets a review back after creating it", async () => { + await patch("cbf2025", "beer-1", { starRating: 3, wouldRecommend: true }); + const response = await send("GET", reviewPath("cbf2025", "beer-1")); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.starRating).toBe(3); + expect(data.wouldRecommend).toBe(true); + }); + + it("returns 404 for a missing review", async () => { + const response = await send("GET", reviewPath("cbf2025", "beer-ghost")); + expect(response.status).toBe(404); + expect((await response.json()).error.status).toBe("NOT_FOUND"); + }); + + it("deletes a review then reads 404", async () => { + await patch("cbf2025", "beer-1", { starRating: 4 }); + const del = await send("DELETE", reviewPath("cbf2025", "beer-1")); + expect(del.status).toBe(200); + expect(await del.json()).toEqual({}); + const after = await send("GET", reviewPath("cbf2025", "beer-1")); + expect(after.status).toBe(404); + }); + + it("deleting a missing review is 404 (AIP-135)", async () => { + const response = await send("DELETE", reviewPath("cbf2025", "beer-ghost")); + expect(response.status).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// List reviews (caller's own) +// --------------------------------------------------------------------------- + +describe("reviews — list caller reviews", () => { + it("lists reviews for the caller only", async () => { + await patch("cbf2025", "beer-1", { starRating: 4 }); + await patch("cbf2025", "beer-2", { wouldRecommend: true }); + // Different device — should not appear + await patch("cbf2025", "beer-3", { starRating: 2 }, { device: "dev-other" }); + + const response = await send( + "GET", + "/v1alpha/festivals/cbf2025/reviews", + ); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.reviews).toHaveLength(2); + const names = data.reviews.map((r) => r.name); + expect(names).toContain("festivals/cbf2025/drinks/beer-1/review"); + expect(names).toContain("festivals/cbf2025/drinks/beer-2/review"); + }); + + it("returns empty list when caller has no reviews", async () => { + const response = await send("GET", "/v1alpha/festivals/cbf2025/reviews"); + const data = await response.json(); + expect(data.reviews).toHaveLength(0); + expect(data.nextPageToken).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// Review summaries +// --------------------------------------------------------------------------- + +describe("reviews — summaries", () => { + it("aggregates starRating across devices", async () => { + await patch("cbf2025", "beer-1", { starRating: 4 }, { device: "d1" }); + await patch("cbf2025", "beer-1", { starRating: 5 }, { device: "d2" }); + await patch("cbf2025", "beer-1", { starRating: 3 }, { device: "d3" }); + const response = await send( + "GET", + "/v1alpha/festivals/cbf2025/reviewSummaries/beer-1", + ); + const data = await response.json(); + expect(data.name).toBe("festivals/cbf2025/reviewSummaries/beer-1"); + expect(data.ratingCount).toBe(3); + expect(data.averageRating).toBe(4); + }); + + it("aggregates wouldRecommend across devices", async () => { + await patch("cbf2025", "beer-1", { wouldRecommend: true }, { device: "d1" }); + await patch("cbf2025", "beer-1", { wouldRecommend: true }, { device: "d2" }); + await patch("cbf2025", "beer-1", { wouldRecommend: false }, { device: "d3" }); + const data = await ( + await send("GET", "/v1alpha/festivals/cbf2025/reviewSummaries/beer-1") + ).json(); + expect(data.responseCount).toBe(3); + expect(data.recommendCount).toBe(2); + expect(data.recommendRate).toBe(0.67); + }); + + it("counts rating and recommendation independently when only one signal is set", async () => { + // d1 sets both; d2 sets only starRating + await patch("cbf2025", "beer-1", { starRating: 4, wouldRecommend: true }, { device: "d1" }); + await patch("cbf2025", "beer-1", { starRating: 2 }, { device: "d2" }); + const data = await ( + await send("GET", "/v1alpha/festivals/cbf2025/reviewSummaries/beer-1") + ).json(); + expect(data.ratingCount).toBe(2); + expect(data.responseCount).toBe(1); // only d1 answered recommendation + }); + + it("returns zero summary for a drink with no reviews", async () => { + const response = await send( + "GET", + "/v1alpha/festivals/cbf2025/reviewSummaries/never", + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ratingCount: 0, + averageRating: 0, + responseCount: 0, + recommendCount: 0, + recommendRate: 0, + }); + }); + + it("lists summaries with total size", async () => { + await patch("cbf2025", "beer-1", { starRating: 4 }, { device: "d1" }); + await patch("cbf2025", "beer-2", { wouldRecommend: false }, { device: "d1" }); + const response = await send( + "GET", + "/v1alpha/festivals/cbf2025/reviewSummaries", + ); + const data = await response.json(); + expect(data.totalSize).toBe(2); + expect(data.nextPageToken).toBe(""); + expect(data.reviewSummaries.map((s) => s.name)).toEqual([ + "festivals/cbf2025/reviewSummaries/beer-1", + "festivals/cbf2025/reviewSummaries/beer-2", + ]); + }); + + it("paginates summaries with opaque tokens", async () => { + for (const d of ["beer-1", "beer-2", "beer-3"]) { + await patch("cbf2025", d, { starRating: 4 }, { device: "d1" }); + } + const first = await ( + await send( + "GET", + "/v1alpha/festivals/cbf2025/reviewSummaries?page_size=2", + ) + ).json(); + expect(first.reviewSummaries).toHaveLength(2); + expect(first.nextPageToken).not.toBe(""); + + const second = await ( + await send( + "GET", + `/v1alpha/festivals/cbf2025/reviewSummaries?page_size=2&page_token=${first.nextPageToken}`, + ) + ).json(); + expect(second.reviewSummaries).toHaveLength(1); + expect(second.reviewSummaries[0].name).toBe( + "festivals/cbf2025/reviewSummaries/beer-3", + ); + expect(second.nextPageToken).toBe(""); + }); + + it("rejects a negative page_size", async () => { + const response = await send( + "GET", + "/v1alpha/festivals/cbf2025/reviewSummaries?page_size=-1", + ); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "INVALID_PAGE_SIZE", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Routing +// --------------------------------------------------------------------------- + +describe("reviews — routing", () => { + it("returns 405 for an unsupported method on a review record", async () => { + const response = await send("POST", reviewPath("cbf2025", "beer-1"), { + body: { starRating: 3 }, + }); + expect(response.status).toBe(405); + expect((await response.json()).error.status).toBe("UNIMPLEMENTED"); + }); + + it("returns 404 for an unknown /v1alpha route", async () => { + const response = await send("GET", "/v1alpha/festivals/cbf2025/bogus"); + expect(response.status).toBe(404); + expect((await response.json()).error.details[0].reason).toBe( + "ROUTE_NOT_FOUND", + ); + }); + + it("returns 400 when X-Device-Id header is missing", async () => { + const request = new Request( + "https://worker.example.com" + reviewPath("cbf2025", "beer-1"), + { method: "GET", headers: { Origin: TEST_ORIGIN } }, + ); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(400); + expect((await response.json()).error.details[0].reason).toBe( + "MISSING_DEVICE_ID", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Bucket isolation +// --------------------------------------------------------------------------- + +describe("reviews — bucket isolation", () => { + it("keeps test and prod traffic separate", async () => { + await patch("cbf2025", "beer-1", { starRating: 5 }, { origin: PROD_ORIGIN }); + await patch("cbf2025", "beer-1", { starRating: 1 }, { origin: TEST_ORIGIN }); + + const prod = await ( + await send("GET", "/v1alpha/festivals/cbf2025/reviewSummaries/beer-1", { + origin: PROD_ORIGIN, + }) + ).json(); + const test = await ( + await send("GET", "/v1alpha/festivals/cbf2025/reviewSummaries/beer-1", { + origin: TEST_ORIGIN, + }) + ).json(); + expect(prod.averageRating).toBe(5); + expect(test.averageRating).toBe(1); + }); +}); diff --git a/cloudflare-worker/tsconfig.json b/cloudflare-worker/tsconfig.json new file mode 100644 index 00000000..e70b0877 --- /dev/null +++ b/cloudflare-worker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", ".wrangler"] +} diff --git a/cloudflare-worker/vitest.config.js b/cloudflare-worker/vitest.config.js index adce4cc5..6374e7e6 100644 --- a/cloudflare-worker/vitest.config.js +++ b/cloudflare-worker/vitest.config.js @@ -1,11 +1,26 @@ -import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { + cloudflareTest, + readD1Migrations, +} from "@cloudflare/vitest-pool-workers"; import { defineConfig } from "vitest/config"; -export default defineConfig({ - plugins: [ - cloudflareTest({ - wrangler: { configPath: "./wrangler.toml" }, - }), - ], - test: {}, +export default defineConfig(async () => { + // Read the SQL migrations once at config time. They are exposed to tests as + // the TEST_MIGRATIONS binding and applied to the simulated D1 in a setup file + // (see test/apply-migrations.js), so no real database is needed. + const migrations = await readD1Migrations("./migrations"); + + return { + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.toml" }, + miniflare: { + bindings: { TEST_MIGRATIONS: migrations }, + }, + }), + ], + test: { + setupFiles: ["./test/apply-migrations.js"], + }, + }; }); diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index 34ae2d4d..85c48cf5 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -15,6 +15,8 @@ // Import festivals data directly - copied from data/festivals.json during build import festivalsData from "./festivals.json"; +import { handleReviews } from "./reviews.js"; +import { errorResponse } from "./shared.js"; const UPSTREAM_URL = "https://data.cambridgebeerfestival.com"; @@ -65,6 +67,29 @@ export default { }); } + // "My festival" API (/v1alpha/...). Handled before the proxy fall-through + // so these paths are never forwarded upstream. + const reviewsResponse = await handleReviews( + request, + url, + env, + getCorsHeaders(request), + ); + if (reviewsResponse) { + return reviewsResponse; + } + + // Any other /v1alpha path is an unknown API route — don't proxy upstream. + if (url.pathname === "/v1alpha" || url.pathname.startsWith("/v1alpha/")) { + return errorResponse( + 404, + "NOT_FOUND", + "Unknown API route", + "ROUTE_NOT_FOUND", + getCorsHeaders(request), + ); + } + // Handle dynamic available_beverage_types.json endpoint // Pattern: /{festivalId}/available_beverage_types.json const availableTypesMatch = url.pathname.match( @@ -244,8 +269,8 @@ function handleCorsPreflight(request) { status: 204, headers: { ...getCorsHeaders(request), - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, X-Device-Id", "Access-Control-Max-Age": maxAge, }, }); diff --git a/cloudflare-worker/wrangler.toml b/cloudflare-worker/wrangler.toml index 9284424e..cd9e7811 100644 --- a/cloudflare-worker/wrangler.toml +++ b/cloudflare-worker/wrangler.toml @@ -7,3 +7,21 @@ compatibility_date = "2024-01-01" [vars] ENVIRONMENT = "production" + +# "My festival" aggregate storage (D1 — reviews, tastings, bookmarks). +# +# `database_id` is a placeholder until the database is provisioned in the +# Cloudflare account. Local dev (`wrangler dev`) and the vitest test pool use a +# simulated local D1 and ignore this id, so the whole test suite runs without a +# real database. Before the first `wrangler deploy`, run: +# +# wrangler d1 create cbf-myfestival +# +# then paste the returned id below and apply migrations with: +# +# wrangler d1 migrations apply cbf-myfestival +[[d1_databases]] +binding = "RATINGS_DB" +database_name = "cbf-myfestival" +database_id = "00000000-0000-0000-0000-000000000000" +migrations_dir = "migrations" diff --git a/mise.dev.lock b/mise.dev.lock index 3b4ea72a..12d5e0b9 100644 --- a/mise.dev.lock +++ b/mise.dev.lock @@ -1,5 +1,37 @@ # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html +[[tools.buf]] +version = "1.70.0" +backend = "aqua:bufbuild/buf" + +[tools.buf."platforms.linux-arm64"] +checksum = "sha256:ff3c79a6bb44c41a8053ec0d863234cee82378ea4c29ed5292c852dbf829fec0" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Linux-aarch64.tar.gz" + +[tools.buf."platforms.linux-arm64-musl"] +checksum = "sha256:ff3c79a6bb44c41a8053ec0d863234cee82378ea4c29ed5292c852dbf829fec0" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Linux-aarch64.tar.gz" + +[tools.buf."platforms.linux-x64"] +checksum = "sha256:18498bdadbdce80792ce08c2c59e0c49f157aa3f5d9b0f9c6e3db795be208b9d" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Linux-x86_64.tar.gz" + +[tools.buf."platforms.linux-x64-musl"] +checksum = "sha256:18498bdadbdce80792ce08c2c59e0c49f157aa3f5d9b0f9c6e3db795be208b9d" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Linux-x86_64.tar.gz" + +[tools.buf."platforms.macos-arm64"] +checksum = "sha256:9cec23658df093d50f3318580e17f1e309d45b817927f22e9de30f78f3d4d45b" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Darwin-arm64.tar.gz" + +[tools.buf."platforms.macos-x64"] +checksum = "sha256:ebeed1af15bcb145c35c5e51eadc8fe0bdba9311e08fd6abca8c23294929d20b" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Darwin-x86_64.tar.gz" + +[tools.buf."platforms.windows-x64"] +checksum = "sha256:96422e4313efe2741946f31f57ab4000c440b48d14936992ab98e94c16322f6f" +url = "https://github.com/bufbuild/buf/releases/download/v1.70.0/buf-Windows-x86_64.zip" + [[tools."github:googleapis/api-linter"]] version = "2.3.1" backend = "github:googleapis/api-linter" diff --git a/mise.toml b/mise.toml index 02d3fb04..0d0783d0 100644 --- a/mise.toml +++ b/mise.toml @@ -82,14 +82,16 @@ sources = ['data/festivals.json', 'docs/code/api/festival-registry-schema.json'] run = 'npm ci && node ../scripts/validate-festivals.js' [tasks."test:worker"] -description = "Run Cloudflare Worker tests (Vitest + workerd)" +description = "Run Cloudflare Worker type check and tests (tsc + Vitest + workerd)" dir = "cloudflare-worker" sources = [ 'cloudflare-worker/package.json', 'cloudflare-worker/package-lock.json', + 'cloudflare-worker/tsconfig.json', 'cloudflare-worker/worker.js', + 'cloudflare-worker/**/*.ts', 'cloudflare-worker/test/**/*.js', 'cloudflare-worker/vitest.config.js', 'data/festivals.json', ] -run = 'npm ci && npm test' +run = 'npm ci && npm run typecheck && npm test'