diff --git a/.agents/skills/life-ustc-implement/SKILL.md b/.agents/skills/life-ustc-implement/SKILL.md index b58ad9331..dba1d39d6 100644 --- a/.agents/skills/life-ustc-implement/SKILL.md +++ b/.agents/skills/life-ustc-implement/SKILL.md @@ -6,7 +6,7 @@ description: "Add or change Life@USTC server behavior end-to-end: contracts, src # Implement a Life@USTC server change How to land a behavior change in this repo. Layout and local checks: root -`AGENTS.md`. Git / PR / CI / merge: global skills, not this file. +`AGENTS.md`. ## When to use diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..850830b09 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b66da0f69..dd559ff2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ jobs: with: database-name: life_ustc_ci job-phase: ci:verify + upload-artifact-name: vitest-coverage + upload-artifact-path: coverage + upload-artifact-no-files-found: warn test-integration: name: MCP Integration Test diff --git a/.github/workflows/db-backed-bun-job.yml b/.github/workflows/db-backed-bun-job.yml index 5f6ab29a4..3eb0fc9f0 100644 --- a/.github/workflows/db-backed-bun-job.yml +++ b/.github/workflows/db-backed-bun-job.yml @@ -156,7 +156,7 @@ jobs: bunx tsc --noEmit -p tsconfig.typecheck.json bunx tsc --noEmit -p tsconfig.typecheck.tests.json bunx tsc --noEmit -p tsconfig.typecheck.operational.json - bunx vitest run + bunx vitest run --coverage ;; ci:integration) # Match wrangler.e2e.jsonc so JWKS rows seeded here decrypt when @@ -231,22 +231,7 @@ jobs: tests/integration/function-owner-role-contract.test.ts bunx vitest run \ --config vitest.integration.config.ts \ - tests/integration/todo-rls.test.ts - bunx vitest run \ - --config vitest.integration.config.ts \ - tests/integration/personal-preferences-rls.test.ts - bunx vitest run \ - --config vitest.integration.config.ts \ - tests/integration/comment-reaction-rls.test.ts - bunx vitest run \ - --config vitest.integration.config.ts \ - tests/integration/comment-rls.test.ts - bunx vitest run \ - --config vitest.integration.config.ts \ - tests/integration/upload-rls.test.ts - bunx vitest run \ - --config vitest.integration.config.ts \ - tests/integration/homework-completion-rls.test.ts + tests/integration/*-rls.test.ts ;; ci:e2e:build-artifacts) bun run app:prepare diff --git a/AGENTS.md b/AGENTS.md index eb8e69e17..68c3bf43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Start here instead of grepping the whole tree. Nested `AGENTS.md` files go deeper on one area (closest file wins). Shared names live in `docs/interface-hierarchy.md` and `docs/contracts/`. To add or change behavior, -use `$life-ustc-implement`. Git / PR / CI / merge: global skills, not this repo. +use `$life-ustc-implement`. ## How the system fits together diff --git a/bun.lock b/bun.lock index a17766edf..098933b48 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.0", "@types/node": "^25.9.1", + "@vitest/coverage-v8": "4.1.8", "ajv": "^8.20.0", "bits-ui": "^2.18.1", "mode-watcher": "^1.1.0", @@ -107,6 +108,8 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@better-auth/cimd": ["@better-auth/cimd@1.7.0-rc.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.0-rc.2", "@better-auth/oauth-provider": "^1.7.0-rc.2", "better-auth": "^1.7.0-rc.2", "better-call": "1.3.7" } }, "sha512-zIowM8JDk7N9tx7n0G1f1MoPiIKHrGQrQ1Vvv2ylUaxxz0YjiBnRNQQlLlONee/eziebrxlneVSMXdQMXY9lfw=="], "@better-auth/core": ["@better-auth/core@1.7.0-rc.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-NreNGg68j4qUVVYTcC1DtvRTwSJdCavH5igrMyTO5ghZxnzL4G539uRIzOZmJ64MLzOyOwzWH+JHqpVaj0ZRxw=="], @@ -809,6 +812,8 @@ "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], + "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], @@ -897,6 +902,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -1297,6 +1304,8 @@ "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-whitespace-sensitive-tag-names": ["html-whitespace-sensitive-tag-names@3.0.1", "", {}, "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA=="], @@ -1385,6 +1394,12 @@ "issue-parser": ["issue-parser@7.0.2", "", { "dependencies": { "lodash.capitalize": "^4.2.1", "lodash.escaperegexp": "^4.1.2", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.uniqby": "^4.7.0" } }, "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "java-properties": ["java-properties@1.0.2", "", {}, "sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -1393,7 +1408,7 @@ "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], @@ -1483,8 +1498,12 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], + "make-asynchronous": ["make-asynchronous@1.1.0", "", { "dependencies": { "p-event": "^6.0.0", "type-fest": "^4.6.0", "web-worker": "^1.5.0" } }, "sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg=="], + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -2191,6 +2210,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], diff --git a/docs/contracts/ical.json b/docs/contracts/ical.json index 965e635be..aae3884a4 100644 --- a/docs/contracts/ical.json +++ b/docs/contracts/ical.json @@ -10,7 +10,7 @@ "copyable-links": "iCal links should be presented as copyable links rather than requiring users to manually construct them.", "no-second-model": "iCal is an export / subscription capability and should not carry a second business model.", "rfc5545-dtstamp-utc": "Every VEVENT in section, multi-section, and personal feeds serializes DTSTAMP as a UTC DATE-TIME with a Z suffix while retaining Asia/Shanghai TZID values for event start and end times.", - "personal-feed-cache": "Personal feed responses may be served from a private Workers KV-backed server-side cache with a five-minute fresh window and a bounded stale fallback during refresh failures, include ETag, and return 304 Not Modified for matching If-None-Match. Feed-token authorization is checked against the current user record before any cached export is returned. Cache telemetry must never include user IDs or feed tokens.", + "personal-feed-cache": "Personal feeds may use a private Workers KV cache (30m fresh / 24h stale). Fresh hits serve immediately; stale hits serve cached ICS and enqueue CALENDAR_EXPORT_REBUILD. True misses rebuild on the request path (KV put may defer). Write-time invalidation enqueues rebuilds instead of waitUntil deletes. Include ETag/304. Check the current feed token before any cache read; revoked tokens for an existing user return 410 with short Cache-Control. Never put user IDs or feed tokens in cache telemetry.", "static-json-resilient": "Calendar location and building image enhancements only read static JSON published at https://static.life-ustc.tiankaima.dev; when loading fails or data is corrupted, diagnostic information should be logged and a valid iCal should still be returned." }, "capabilities": { @@ -61,7 +61,8 @@ "notes": [ "The opaque credential authorizes feed retrieval.", "The credential is carried only in the canonical path segment.", - "An existing user with a valid feed token receives a valid empty VCALENDAR when no items are currently available, so calendar clients can keep the subscription active." + "An existing user with a valid feed token receives a valid empty VCALENDAR when no items are currently available, so calendar clients can keep the subscription active.", + "When the user exists but the feed token is wrong or revoked, the route returns 410 Gone with a short Cache-Control so calendar clients stop aggressive re-polling; unknown users return 404." ] }, { diff --git a/docs/index.md b/docs/index.md index 996eed7ef..e47e8767b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,6 @@ Start with root [`AGENTS.md`](../AGENTS.md). For an end-to-end change, use [`$life-ustc-implement`](../.agents/skills/life-ustc-implement/SKILL.md). -Git / PR / CI / merge: global agent skills. ## Start here diff --git a/package.json b/package.json index f49fab6c1..d1df9c5ce 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tailwindcss/vite": "^4.3.0", "@types/node": "^25.9.1", + "@vitest/coverage-v8": "4.1.8", "ajv": "^8.20.0", "bits-ui": "^2.18.1", "mode-watcher": "^1.1.0", diff --git a/public/openapi.generated.json b/public/openapi.generated.json index eb217230f..622709945 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -1268,6 +1268,16 @@ } } } + }, + "410": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } } } } diff --git a/src/features/calendar/server/calendar-export-cache.ts b/src/features/calendar/server/calendar-export-cache.ts index b3b5bb97d..2003e49c0 100644 --- a/src/features/calendar/server/calendar-export-cache.ts +++ b/src/features/calendar/server/calendar-export-cache.ts @@ -1,7 +1,5 @@ -import { - getCloudflareCalendarExportsNamespace, - getCloudflareRuntimeTaskScheduler, -} from "@/lib/adapters/cloudflare-runtime"; +import { enqueueUserCalendarExportRebuild } from "@/features/calendar/server/calendar-export-queue"; +import { getCloudflareCalendarExportsNamespace } from "@/lib/adapters/cloudflare-runtime"; import { sha256Base64Url } from "@/lib/crypto/web-crypto"; import { writeCalendarFeedCacheAnalytics } from "@/lib/metrics/analytics-engine"; @@ -150,6 +148,32 @@ async function persistStoredCalendar( } } +/** + * Persist a built ICS export to isolate memory + KV (queue rebuild / sync miss). + */ +export async function storeBuiltUserCalendarExport( + userId: string, + calendar: UserCalendarExport, + options: UserCalendarExportCacheOptions = {}, +) { + const stored: StoredUserCalendarExport = { + ...calendar, + etag: await createCalendarEtag(calendar.text), + generatedAtMs: Date.now(), + version: USER_CALENDAR_EXPORT_CACHE_VERSION, + }; + userCalendarExportCache.set(userId, stored); + pruneOldestEntries(); + const persistence = persistStoredCalendar(userId, stored); + if (options.defer) { + options.defer(persistence); + } else { + await persistence; + } + recordCalendarFeedCacheStatus("refresh_success"); + return stored; +} + function refreshUserCalendarExport( userId: string, buildExport: () => Promise, @@ -161,23 +185,7 @@ function refreshUserCalendarExport( const refresh = (async () => { const calendar = await buildExport(); if (!calendar) return null; - - const stored: StoredUserCalendarExport = { - ...calendar, - etag: await createCalendarEtag(calendar.text), - generatedAtMs: Date.now(), - version: USER_CALENDAR_EXPORT_CACHE_VERSION, - }; - userCalendarExportCache.set(userId, stored); - pruneOldestEntries(); - const persistence = persistStoredCalendar(userId, stored); - if (defer) { - defer(persistence); - } else { - await persistence; - } - recordCalendarFeedCacheStatus("refresh_success"); - return stored; + return storeBuiltUserCalendarExport(userId, calendar, { defer }); })(); userCalendarExportRefreshes.set(userId, refresh); @@ -188,6 +196,12 @@ function refreshUserCalendarExport( return refresh; } +function scheduleStaleCalendarExportRebuild(userId: string) { + void enqueueUserCalendarExportRebuild(userId).catch(() => { + // Stale-serve path must never fail because enqueue failed. + }); +} + export async function getCachedUserCalendarExport( userId: string, buildExport: () => Promise, @@ -208,31 +222,9 @@ export async function getCachedUserCalendarExport( } if (ageMs <= USER_CALENDAR_EXPORT_STALE_TTL_MS) { - // Do not rebuild ICS inside waitUntil/defer. Production showed Worker wall - // ~12s / CPU p95 ~3s on this path (cpu_ms limit 1000) while the response - // itself returned in ~100ms — calendar clients re-poll and the deferred - // rebuilds cancel or hit the CPU limit. Freshness comes from write-time - // invalidation plus a sync rebuild once the entry is past STALE_TTL. - if (options.defer) { - recordCalendarFeedCacheStatus("stale"); - return { - calendar: cached, - status: "stale" satisfies UserCalendarExportCacheStatus, - }; - } - - try { - const refreshed = await refreshUserCalendarExport(userId, buildExport); - if (refreshed) { - return { - calendar: refreshed, - status: "miss" satisfies UserCalendarExportCacheStatus, - }; - } - } catch { - recordCalendarFeedCacheStatus("refresh_error"); - } - + // Serve stale immediately and enqueue a Queue rebuild. Do not rebuild ICS + // on the request path (or inside waitUntil) — that path hit cpu_ms / cancel. + scheduleStaleCalendarExportRebuild(userId); recordCalendarFeedCacheStatus("stale"); return { calendar: cached, @@ -273,13 +265,3 @@ export async function invalidateUserCalendarExportCache(userId: string) { recordCalendarFeedCacheStatus("store_error"); } } - -export function scheduleInvalidateUserCalendarExportCache(userId: string) { - const scheduleTask = getCloudflareRuntimeTaskScheduler(); - const work = invalidateUserCalendarExportCache(userId); - if (scheduleTask) { - scheduleTask(work); - return; - } - void work; -} diff --git a/src/features/calendar/server/calendar-export-invalidation.ts b/src/features/calendar/server/calendar-export-invalidation.ts index e27ffb34d..84160f842 100644 --- a/src/features/calendar/server/calendar-export-invalidation.ts +++ b/src/features/calendar/server/calendar-export-invalidation.ts @@ -1,15 +1,23 @@ +import { invalidateUserCalendarExportCache } from "@/features/calendar/server/calendar-export-cache"; import { - invalidateUserCalendarExportCache, - scheduleInvalidateUserCalendarExportCache, -} from "@/features/calendar/server/calendar-export-cache"; -import { getCloudflareRuntimeTaskScheduler } from "@/lib/adapters/cloudflare-runtime"; + scheduleSectionCalendarExportRebuild, + scheduleUserCalendarExportRebuild, +} from "@/features/calendar/server/calendar-export-queue"; import { prisma } from "@/lib/db/prisma"; -export { - invalidateUserCalendarExportCache, - scheduleInvalidateUserCalendarExportCache, -}; +export { invalidateUserCalendarExportCache }; +/** + * After a user-scoped write, enqueue an ICS rebuild (overwrite KV) instead of + * waitUntil delete/fan-out. + */ +export function scheduleInvalidateUserCalendarExportCache(userId: string) { + scheduleUserCalendarExportRebuild(userId); +} + +/** + * Sync helper for tests/local: delete cached exports for every subscriber. + */ export async function invalidateCalendarExportsForSection(sectionId: number) { const subscribers = await prisma.userSectionSubscription.findMany({ where: { sectionId }, @@ -22,12 +30,10 @@ export async function invalidateCalendarExportsForSection(sectionId: number) { ); } +/** + * After a section-scoped write, enqueue one section rebuild message. The queue + * consumer expands subscribers and rebuilds (no N deletes in waitUntil). + */ export function scheduleInvalidateCalendarExportsForSection(sectionId: number) { - const scheduleTask = getCloudflareRuntimeTaskScheduler(); - const work = invalidateCalendarExportsForSection(sectionId); - if (scheduleTask) { - scheduleTask(work); - return; - } - void work; + scheduleSectionCalendarExportRebuild(sectionId); } diff --git a/src/features/calendar/server/calendar-export-queue.ts b/src/features/calendar/server/calendar-export-queue.ts new file mode 100644 index 000000000..373b7b520 --- /dev/null +++ b/src/features/calendar/server/calendar-export-queue.ts @@ -0,0 +1,98 @@ +import { getCloudflareCalendarExportRebuildQueue } from "@/lib/adapters/cloudflare-runtime"; +import { writeCalendarExportRebuildAnalytics } from "@/lib/metrics/analytics-engine"; + +export type CalendarExportRebuildUserMessage = { + type: "user"; + userId: string; +}; + +export type CalendarExportRebuildSectionMessage = { + type: "section"; + sectionId: number; +}; + +export type CalendarExportRebuildMessage = + | CalendarExportRebuildUserMessage + | CalendarExportRebuildSectionMessage; + +type CalendarExportRebuildSender = ( + message: CalendarExportRebuildMessage, +) => Promise; + +let senderForTest: CalendarExportRebuildSender | undefined; + +export function setCalendarExportRebuildSenderForTest( + sender?: CalendarExportRebuildSender, +) { + senderForTest = sender; +} + +export function parseCalendarExportRebuildMessage( + value: unknown, +): CalendarExportRebuildMessage | null { + if (!value || typeof value !== "object") return null; + const entry = value as Partial; + if ( + entry.type === "user" && + typeof entry.userId === "string" && + entry.userId + ) { + return { type: "user", userId: entry.userId }; + } + if ( + entry.type === "section" && + typeof entry.sectionId === "number" && + Number.isInteger(entry.sectionId) && + entry.sectionId > 0 + ) { + return { type: "section", sectionId: entry.sectionId }; + } + return null; +} + +async function deliverCalendarExportRebuildMessage( + message: CalendarExportRebuildMessage, +) { + if (senderForTest) { + await senderForTest(message); + writeCalendarExportRebuildAnalytics({ status: "enqueued" }); + return; + } + + const queue = getCloudflareCalendarExportRebuildQueue(); + if (queue) { + await queue.send(message); + writeCalendarExportRebuildAnalytics({ status: "enqueued" }); + return; + } + + // Node / vitest without a Queue binding: rebuild in-process. + const { processCalendarExportRebuildMessage } = await import( + "./calendar-export-rebuild" + ); + await processCalendarExportRebuildMessage(message); + writeCalendarExportRebuildAnalytics({ status: "enqueued" }); +} + +export async function enqueueUserCalendarExportRebuild(userId: string) { + const trimmed = userId.trim(); + if (!trimmed) return; + await deliverCalendarExportRebuildMessage({ type: "user", userId: trimmed }); +} + +export async function enqueueSectionCalendarExportRebuild(sectionId: number) { + if (!Number.isInteger(sectionId) || sectionId <= 0) return; + await deliverCalendarExportRebuildMessage({ type: "section", sectionId }); +} + +export function scheduleUserCalendarExportRebuild(userId: string) { + void enqueueUserCalendarExportRebuild(userId).catch(() => { + // Enqueue failures must not fail the write path. + }); +} + +export function scheduleSectionCalendarExportRebuild(sectionId: number) { + void enqueueSectionCalendarExportRebuild(sectionId).catch(() => { + // Enqueue failures must not fail the write path. + }); +} diff --git a/src/features/calendar/server/calendar-export-rebuild.ts b/src/features/calendar/server/calendar-export-rebuild.ts new file mode 100644 index 000000000..11db5ef8c --- /dev/null +++ b/src/features/calendar/server/calendar-export-rebuild.ts @@ -0,0 +1,111 @@ +import { storeBuiltUserCalendarExport } from "@/features/calendar/server/calendar-export-cache"; +import { getUserCalendarRecord } from "@/features/calendar/server/calendar-export-data"; +import { + type CalendarExportRebuildMessage, + parseCalendarExportRebuildMessage, +} from "@/features/calendar/server/calendar-export-queue"; +import { buildUserCalendarExport } from "@/features/calendar/server/calendar-export-service"; +import { prisma } from "@/lib/db/prisma"; +import { writeCalendarExportRebuildAnalytics } from "@/lib/metrics/analytics-engine"; + +export async function rebuildUserCalendarExport(userId: string) { + const user = await getUserCalendarRecord(userId); + if (!user) return null; + const calendar = await buildUserCalendarExport(user, userId); + return storeBuiltUserCalendarExport(userId, calendar); +} + +async function listSectionSubscriberUserIds(sectionId: number) { + const subscribers = await prisma.userSectionSubscription.findMany({ + where: { sectionId }, + select: { userId: true }, + }); + return subscribers.map((subscriber) => subscriber.userId); +} + +export async function collectCalendarExportRebuildUserIds( + messages: CalendarExportRebuildMessage[], +) { + const userIds = new Set(); + const sectionIds = new Set(); + + for (const message of messages) { + if (message.type === "user") { + userIds.add(message.userId); + continue; + } + sectionIds.add(message.sectionId); + } + + for (const sectionId of sectionIds) { + for (const userId of await listSectionSubscriberUserIds(sectionId)) { + userIds.add(userId); + } + } + + return [...userIds]; +} + +export async function processCalendarExportRebuildMessage( + message: CalendarExportRebuildMessage, +) { + await processCalendarExportRebuildMessages([message]); +} + +export async function processCalendarExportRebuildMessages( + messages: CalendarExportRebuildMessage[], +) { + const userIds = await collectCalendarExportRebuildUserIds(messages); + for (const userId of userIds) { + try { + await rebuildUserCalendarExport(userId); + writeCalendarExportRebuildAnalytics({ status: "ok" }); + } catch { + writeCalendarExportRebuildAnalytics({ status: "error" }); + throw new Error("calendar_export_rebuild_failed"); + } + } +} + +export type CalendarExportRebuildQueueMessage = { + ack(): void; + body: unknown; + retry(): void; +}; + +export type CalendarExportRebuildQueueBatch = { + messages: readonly CalendarExportRebuildQueueMessage[]; +}; + +/** + * Worker queue entrypoint: parse, coalesce, rebuild, ack/retry per message. + */ +export async function handleCalendarExportRebuildBatch( + batch: CalendarExportRebuildQueueBatch, +) { + const parsed: CalendarExportRebuildMessage[] = []; + const validMessages: CalendarExportRebuildQueueMessage[] = []; + + for (const message of batch.messages) { + const body = parseCalendarExportRebuildMessage(message.body); + if (!body) { + message.ack(); + continue; + } + parsed.push(body); + validMessages.push(message); + } + + if (parsed.length === 0) return; + + try { + await processCalendarExportRebuildMessages(parsed); + for (const message of validMessages) { + message.ack(); + } + } catch { + for (const message of validMessages) { + message.retry(); + } + } +} diff --git a/src/features/comments/server/comment-read-model.ts b/src/features/comments/server/comment-read-model.ts index 9e9a905f5..3508ed78f 100644 --- a/src/features/comments/server/comment-read-model.ts +++ b/src/features/comments/server/comment-read-model.ts @@ -5,6 +5,8 @@ import { } from "@/lib/auth/viewer-context"; import { authPrisma } from "@/lib/db/auth-prisma"; import { prisma, withUserDbContext } from "@/lib/db/prisma"; +import { logAppEvent } from "@/lib/log/app-logger"; +import { getSafeDatabaseErrorCode } from "@/lib/log/app-logger-core"; import { withCommentDbContext } from "./comment-db-context"; import { buildCommentNodes, @@ -129,6 +131,51 @@ async function loadCommentAttachmentSummaries( return viewerUserId ? withUserDbContext(viewerUserId, query) : query(prisma); } +function logCommentSummaryFailure( + event: + | "comment.reaction-summaries.failed" + | "comment.attachment-summaries.failed", + error: unknown, +) { + const code = getSafeDatabaseErrorCode(error); + // Summary RPCs are optional for the thread list; grant/query failures must + // not 500 the whole comments endpoint (historically the dominant 500 source). + logAppEvent( + "warn", + event, + { + event, + source: "comments", + ...(code ? { code } : {}), + }, + error, + ); +} + +async function loadCommentReactionSummariesOrEmpty( + commentIds: string[], + viewerUserId: string | null, +): Promise { + try { + return await loadCommentReactionSummaries(commentIds, viewerUserId); + } catch (error) { + logCommentSummaryFailure("comment.reaction-summaries.failed", error); + return []; + } +} + +async function loadCommentAttachmentSummariesOrEmpty( + commentIds: string[], + viewerUserId: string | null, +): Promise { + try { + return await loadCommentAttachmentSummaries(commentIds, viewerUserId); + } catch (error) { + logCommentSummaryFailure("comment.attachment-summaries.failed", error); + return []; + } +} + export async function withCommentReadMetadata( comments: RawComment[], viewerUserId: string | null, @@ -137,8 +184,8 @@ export async function withCommentReadMetadata( const [commentsWithProviders, reactionRows, attachmentRows] = await Promise.all([ withCommentAuthorProviders(comments), - loadCommentReactionSummaries(commentIds, viewerUserId), - loadCommentAttachmentSummaries(commentIds, viewerUserId), + loadCommentReactionSummariesOrEmpty(commentIds, viewerUserId), + loadCommentAttachmentSummariesOrEmpty(commentIds, viewerUserId), ]); const reactionsByCommentId = new Map< string, diff --git a/src/lib/adapters/cloudflare-runtime.ts b/src/lib/adapters/cloudflare-runtime.ts index c9473f651..2b952b33a 100644 --- a/src/lib/adapters/cloudflare-runtime.ts +++ b/src/lib/adapters/cloudflare-runtime.ts @@ -68,6 +68,18 @@ type CloudflareTracing = { enterSpan(name: string, callback: (span: CloudflareSpan) => T): T; }; +export type CloudflareQueueSendOptions = { + contentType?: string; + delaySeconds?: number; +}; + +export type CloudflareQueue = { + send(message: unknown, options?: CloudflareQueueSendOptions): Promise; + sendBatch?( + messages: Array<{ body: unknown; options?: CloudflareQueueSendOptions }>, + ): Promise; +}; + export type CloudflareKVNamespace = { delete(key: string): Promise; get( @@ -84,6 +96,7 @@ export type CloudflareKVNamespace = { type CloudflareRuntimeEnv = Record & { ANALYTICS?: CloudflareAnalyticsEngineDataset; ASSETS?: CloudflareAssetsBinding; + CALENDAR_EXPORT_REBUILD?: CloudflareQueue; CALENDAR_EXPORTS?: CloudflareKVNamespace; CATALOG_DETAIL_CORE?: CloudflareKVNamespace; HYPERDRIVE?: { @@ -273,6 +286,10 @@ export function getCloudflareCalendarExportsNamespace() { return getCurrentCloudflareRuntimeEnv()?.CALENDAR_EXPORTS; } +export function getCloudflareCalendarExportRebuildQueue() { + return getCurrentCloudflareRuntimeEnv()?.CALENDAR_EXPORT_REBUILD; +} + export function getCloudflareCatalogDetailCoreNamespace() { return getCurrentCloudflareRuntimeEnv()?.CATALOG_DETAIL_CORE; } diff --git a/src/lib/api/responses.ts b/src/lib/api/responses.ts index e5b5f2b44..b7bc759c8 100644 --- a/src/lib/api/responses.ts +++ b/src/lib/api/responses.ts @@ -60,6 +60,16 @@ export function notFound(message = "Not found") { return errorResponse(message, 404); } +export function gone(message = "Gone") { + return jsonResponse( + { error: message }, + { + status: 410, + headers: { "Cache-Control": "private, max-age=60" }, + }, + ); +} + export function notFoundText() { return new Response("Not found\n", { status: 404 }); } diff --git a/src/lib/api/routes/auth-token.ts b/src/lib/api/routes/auth-token.ts index ebc5b3afb..b10edc9c2 100644 --- a/src/lib/api/routes/auth-token.ts +++ b/src/lib/api/routes/auth-token.ts @@ -123,8 +123,22 @@ async function runObservedTokenHandler( ? [...new Set((params.get("scope") ?? "").split(/\s+/).filter(Boolean))] : undefined, ); + const errorBody = response.ok ? undefined : await parseJsonBody(response); + const errorCode = + errorBody && + typeof errorBody === "object" && + "error" in errorBody && + typeof errorBody.error === "string" + ? errorBody.error + : undefined; writeOAuthEventAnalytics({ - event: "token.response", + event: response.ok + ? "token.response" + : errorCode === "invalid_grant" + ? "oauth.token.invalid_grant" + : errorCode === "invalid_request" + ? "oauth.token.invalid_request" + : "oauth.token.error_response", ioObservedDurationMs: Date.now() - start, grantType, hasResource: params.has("resource"), @@ -132,6 +146,7 @@ async function runObservedTokenHandler( path: url.pathname, resourceCount: params.getAll("resource").length, status: response.status, + statusReason: errorCode, }); return response; } catch (err) { diff --git a/src/lib/api/routes/calendar-route-user-access.ts b/src/lib/api/routes/calendar-route-user-access.ts index f07fb5a3f..dfc28191a 100644 --- a/src/lib/api/routes/calendar-route-user-access.ts +++ b/src/lib/api/routes/calendar-route-user-access.ts @@ -1,5 +1,5 @@ import { getUserCalendarAccessRecord } from "@/features/calendar/server/calendar-export-data"; -import { forbidden, notFound, unauthorized } from "@/lib/api/helpers"; +import { forbidden, gone, notFound, unauthorized } from "@/lib/api/helpers"; import { resolveApiUserId } from "@/lib/auth/api-auth"; import { parseUserCalendarIdentifier } from "./calendar-route-utils"; @@ -18,10 +18,13 @@ export async function resolveUserCalendarAccess({ const user = await getUserCalendarAccessRecord(userId); if (token) { - if (!user || user.calendarFeedToken !== token) { + if (!user) { + return { ok: false as const, response: notFound("User not found") }; + } + if (user.calendarFeedToken !== token) { return { ok: false as const, - response: forbidden("Invalid or unauthorized token"), + response: gone("Calendar feed token revoked"), }; } } else { diff --git a/src/lib/auth/better-auth-api-errors.ts b/src/lib/auth/better-auth-api-errors.ts index 150ec0fdb..2ec9450e0 100644 --- a/src/lib/auth/better-auth-api-errors.ts +++ b/src/lib/auth/better-auth-api-errors.ts @@ -3,18 +3,35 @@ import { logAppEvent } from "@/lib/log/app-logger"; import { isOAuthDebugLogging, logOAuthDebug } from "@/lib/log/oauth-debug"; import { getSafeErrorName } from "@/lib/log/safe-error-name"; +function oauthApiErrorEventKey(error: unknown): string { + if (!error || typeof error !== "object") return "better-auth.api-error"; + const code = + "code" in error && typeof error.code === "string" + ? error.code + : "error" in error && typeof error.error === "string" + ? error.error + : undefined; + if (code === "state_mismatch" || code === "state_not_found") { + return "oauth.callback.state_mismatch"; + } + if (code === "invalid_grant") return "oauth.token.invalid_grant"; + if (code === "invalid_request") return "oauth.token.invalid_request"; + return "better-auth.api-error"; +} + export const betterAuthApiErrorHandler = { onError(error: unknown) { + const event = oauthApiErrorEventKey(error); if (isDevelopment()) { logAppEvent( "error", "Better Auth API error", - { source: "auth", event: "better-auth.api-error" }, + { source: "auth", event }, error, ); } if (isOAuthDebugLogging()) { - logOAuthDebug("better-auth.api-error", undefined, { + logOAuthDebug(event, undefined, { errorName: getSafeErrorName(error), }); } diff --git a/src/lib/log/app-logger-core.ts b/src/lib/log/app-logger-core.ts index c88d6150b..953a33149 100644 --- a/src/lib/log/app-logger-core.ts +++ b/src/lib/log/app-logger-core.ts @@ -42,7 +42,10 @@ const SQLSTATE_CODE_PATTERN = /^[0-9A-Z]{5}$/; * Walk `cause` / Prisma `meta` because driver-adapter failures often nest the * real SQLSTATE under the top-level Prisma wrapper. */ -function safeDatabaseErrorCode(error: unknown, depth = 0): string | undefined { +export function getSafeDatabaseErrorCode( + error: unknown, + depth = 0, +): string | undefined { if (depth > 5 || typeof error !== "object" || error === null) { return undefined; } @@ -56,7 +59,7 @@ function safeDatabaseErrorCode(error: unknown, depth = 0): string | undefined { } if ("meta" in error) { - const fromMeta = safeDatabaseErrorCode( + const fromMeta = getSafeDatabaseErrorCode( (error as { meta: unknown }).meta, depth + 1, ); @@ -64,7 +67,7 @@ function safeDatabaseErrorCode(error: unknown, depth = 0): string | undefined { } if ("cause" in error) { - return safeDatabaseErrorCode( + return getSafeDatabaseErrorCode( (error as { cause: unknown }).cause, depth + 1, ); @@ -77,7 +80,7 @@ export function serializeError(error: unknown) { if (!error) return undefined; if (isProductionEnvironment()) { - const code = safeDatabaseErrorCode(error); + const code = getSafeDatabaseErrorCode(error); return { name: getSafeErrorName(error), ...(code ? { code } : {}) }; } diff --git a/src/lib/log/oauth-debug-wrapper.ts b/src/lib/log/oauth-debug-wrapper.ts index 447858d4b..f52580529 100644 --- a/src/lib/log/oauth-debug-wrapper.ts +++ b/src/lib/log/oauth-debug-wrapper.ts @@ -23,18 +23,29 @@ function shouldLogBetterAuthPath( return pathname.includes("/oauth2"); } +function stableBetterAuthResponseEvent(path: string, status: number) { + if (status < 400) return "better-auth.response"; + if (path.includes("/callback")) return "oauth.callback.error"; + if (path === OAUTH_TOKEN_ENDPOINT_PATH || path.endsWith("/token")) { + return "oauth.token.error_response"; + } + return "better-auth.response"; +} + function recordBetterAuthResponseAnalytics(input: { method: string; path: string; start: number; status: number; + statusReason?: string; }) { writeOAuthEventAnalytics({ - event: "better-auth.response", + event: stableBetterAuthResponseEvent(input.path, input.status), ioObservedDurationMs: Date.now() - input.start, method: input.method, path: input.path, status: input.status, + statusReason: input.statusReason, }); } @@ -143,6 +154,8 @@ export async function withBetterAuthOAuthDebug( path, start, status: res.status, + statusReason: + typeof errorBody?.error === "string" ? errorBody.error : undefined, }); return res; } catch (err) { diff --git a/src/lib/log/safe-error-name.ts b/src/lib/log/safe-error-name.ts index a5f6cf6e5..c90a49844 100644 --- a/src/lib/log/safe-error-name.ts +++ b/src/lib/log/safe-error-name.ts @@ -1,12 +1,14 @@ const SAFE_ERROR_NAMES = new Set([ "AbortError", "APIError", + "ActionFailure", "AggregateError", "DOMException", "DevalueError", "DriverAdapterError", "Error", "EvalError", + "HttpError", "McpError", "PostgresError", "PrismaClientInitializationError", @@ -15,14 +17,21 @@ const SAFE_ERROR_NAMES = new Set([ "PrismaClientUnknownRequestError", "PrismaClientValidationError", "RangeError", + "Redirect", "ReferenceError", + "SvelteKitError", "SyntaxError", "TimeoutError", "TypeError", "URIError", + "ValidationError", "ZodError", ]); +/** Identifier-shaped constructor names only — no hyphens, spaces, or punctuation. */ +const SAFE_CONSTRUCTOR_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]+$/; +const MAX_SAFE_CONSTRUCTOR_NAME_LENGTH = 64; + function readErrorName(error: unknown): string | undefined { if (error instanceof Error) { return error.name; @@ -48,6 +57,27 @@ function normalizeSafeErrorName(name: string) { return name; } +/** + * Fallback when `.name` is missing or not allowlisted (e.g. SvelteKit + * `HttpError` / `Redirect` have no `.name`). Constructor names are class + * identifiers — still no message/stack. + */ +function readSafeConstructorName(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + + const ctor = (error as { constructor?: unknown }).constructor; + if (typeof ctor !== "function") return undefined; + + const { name } = ctor; + if (typeof name !== "string") return undefined; + if (name.length === 0 || name.length > MAX_SAFE_CONSTRUCTOR_NAME_LENGTH) { + return undefined; + } + if (!SAFE_CONSTRUCTOR_NAME_PATTERN.test(name)) return undefined; + + return normalizeSafeErrorName(name); +} + export function getSafeErrorName(error: unknown, depth = 0): string { if (depth > 5) return "UnknownError"; @@ -55,6 +85,14 @@ export function getSafeErrorName(error: unknown, depth = 0): string { if (name) { const normalized = normalizeSafeErrorName(name); if (SAFE_ERROR_NAMES.has(normalized)) { + // Bun/Node often leave Error subclasses with `.name === "Error"` + // (e.g. SvelteKitError). Prefer a more specific safe constructor name. + if (normalized === "Error") { + const ctorName = readSafeConstructorName(error); + if (ctorName && ctorName !== "Error") { + return ctorName; + } + } return normalized; } } @@ -69,5 +107,5 @@ export function getSafeErrorName(error: unknown, depth = 0): string { } } - return "UnknownError"; + return readSafeConstructorName(error) ?? "UnknownError"; } diff --git a/src/lib/metrics/analytics-engine.ts b/src/lib/metrics/analytics-engine.ts index dd04e5820..fb3d737b8 100644 --- a/src/lib/metrics/analytics-engine.ts +++ b/src/lib/metrics/analytics-engine.ts @@ -146,6 +146,10 @@ type CalendarFeedCacheAnalyticsInput = { ttlMs: number; }; +type CalendarExportRebuildAnalyticsInput = { + status: "enqueued" | "ok" | "error"; +}; + type GraphqlOperationAnalyticsInput = { authMode: string; errorCount: number; @@ -394,6 +398,16 @@ export function writeCalendarFeedCacheAnalytics( }); } +export function writeCalendarExportRebuildAnalytics( + input: CalendarExportRebuildAnalyticsInput, +) { + writeAnalyticsDataPoint({ + indexes: [`calendar_export_rebuild_${input.status}`], + blobs: ["calendar_export_rebuild", input.status], + doubles: [1], + }); +} + export function writeWorkspaceOverviewStageAnalytics( input: WorkspaceOverviewStageAnalyticsInput, ) { diff --git a/src/routes/api/calendar-feeds/[credential].ics/+server.ts b/src/routes/api/calendar-feeds/[credential].ics/+server.ts index 48e8d5df9..ad241d128 100644 --- a/src/routes/api/calendar-feeds/[credential].ics/+server.ts +++ b/src/routes/api/calendar-feeds/[credential].ics/+server.ts @@ -10,6 +10,7 @@ import { observedApiRoute } from "@/lib/log/api-observability"; * @response 401:openApiErrorSchema * @response 403:openApiErrorSchema * @response 404:openApiErrorSchema + * @response 410:openApiErrorSchema */ export const GET: RequestHandler = ({ request, params, platform }) => observedApiRoute(() => diff --git a/src/worker.js b/src/worker.js index 80d2915ef..7737b5019 100644 --- a/src/worker.js +++ b/src/worker.js @@ -1,10 +1,12 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import svelteKitWorker from "life-ustc-sveltekit-worker"; +import { handleCalendarExportRebuildBatch } from "./features/calendar/server/calendar-export-rebuild"; import { isCatalogListPath, normalizeCatalogListQuery, resolveCatalogListPublicSsrMode, } from "./features/catalog/lib/catalog-list-query"; +import { runWithCloudflareRuntimeEnv } from "./lib/adapters/cloudflare-runtime"; import { CATALOG_EDGE_CACHE_TAG } from "./lib/catalog-edge-cache-tag"; import { buildPublicNotFoundHtml, @@ -281,4 +283,11 @@ export default { }); return personalizeCachedResponse(response); }, + async queue(batch, env, context) { + await runWithCloudflareRuntimeEnv( + env, + () => handleCalendarExportRebuildBatch(batch), + context, + ); + }, }; diff --git a/svelte.config.js b/svelte.config.js index cfb3c3560..f0fbcc6b1 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,8 +1,6 @@ import adapterCloudflare from "@sveltejs/adapter-cloudflare"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; -const useNodePrismaClient = process.env.NODE_ENV !== "production"; - /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), @@ -18,9 +16,11 @@ const config = { trustedOrigins: ["*"], }, alias: { - "@/generated/prisma/client": useNodePrismaClient - ? "./src/generated/prisma-node/client" - : "./src/generated/prisma/client", + // Always the Cloudflare/wasm client. Vitest aliases to prisma-node in + // vitest.base.ts; CLI/scripts import prisma-node directly. A NODE_ENV-based + // switch here broke E2E: per-shard `app:prepare` rewrote the Kit alias to + // prisma-node and wrangler rebundled that into the worker. + "@/generated/prisma/client": "./src/generated/prisma/client", "@/*": "./src/*", }, files: { diff --git a/tests/ci/e2e-full-suite-parity.sh b/tests/ci/e2e-full-suite-parity.sh index 2053c1e55..b0f4391d9 100755 --- a/tests/ci/e2e-full-suite-parity.sh +++ b/tests/ci/e2e-full-suite-parity.sh @@ -29,6 +29,9 @@ failed_shards=() for shard in $(seq 1 "$E2E_SHARD_TOTAL"); do echo "=== E2E shard ${shard}/${E2E_SHARD_TOTAL} ===" + # Keep prepare on the wasm Prisma client alias (see svelte.config.js). A + # non-production sync previously retargeted Kit aliases to prisma-node and + # wrangler then rebundled a broken worker for Playwright. bun run app:prepare bun run db:migrate:deploy bunx prisma db seed diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index 2dec53d15..eeb3e8eca 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -36,3 +36,18 @@ Helpers: `signInAsDebugUser`, `gotoAndWaitForReady`, `DEV_SEED` under `utils/`. - One worker per shard; shared-state files use `test.describe.configure({ mode: "serial" })` and restore seed in `finally` (e.g. `tests/e2e/src/app/test.ts`, welcome/settings, `dashboard/**`, MCP UI). + + +## Page inventory (L0 / L1 / L2) + +- **L0 — inventory gate:** `tests/e2e/src/app/_shared/page-inventory.ts` lists every + `src/routes/**/+page.svelte`. `tests/unit/page-inventory.test.ts` fails if a + new page is orphaned or a `primaryActions` entry lacks a spec / exemption. +- **L1 — page identity:** call `assertPageContract` from the page’s + `tests/e2e/src/app/**/test.ts` (reuse `gotoAndWaitForReady` / role labels). +- **L2 — primary actions:** each actionable control that changes state or + navigates needs a role/label case in the page spec, **or** an inventory + exemption: `decorative` | `live-oauth` | `covered-by:`. +- Prefer `getByRole` / bilingual labels. Do not soft-skip expected product + controls with `test.skip` when `count() === 0` — use `expect(...).toBeVisible()`. +- Mobile screenshot paths come from `mobileScreenshotPaths()` in the inventory. diff --git a/tests/e2e/mobile-screenshots/screenshots.spec.ts b/tests/e2e/mobile-screenshots/screenshots.spec.ts index 4b38af716..0f0530630 100644 --- a/tests/e2e/mobile-screenshots/screenshots.spec.ts +++ b/tests/e2e/mobile-screenshots/screenshots.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; import { DEV_SEED } from "../../fixtures/dev-seed"; +import { mobileScreenshotPaths } from "../src/app/_shared/page-inventory"; import { signInAsDebugUser, signInAsDevAdmin } from "../utils/auth"; import { getCurrentSessionUser, @@ -14,28 +15,16 @@ import { function screenshotRoute(name: string, path: string) { test(name, async ({ page }) => { - await gotoAndWaitForReady(page, path); + const response = await gotoAndWaitForReady(page, path); + if (response) { + expect(response.status()).toBeLessThan(500); + } }); } test.describe("移动端截图", () => { test.describe("公开页面", () => { - for (const path of [ - "/", - "/catalog/courses", - `/catalog/courses/${DEV_SEED.course.jwId}`, - "/catalog/sections", - `/catalog/sections/${DEV_SEED.section.jwId}`, - "/catalog/teachers", - "/catalog/bus/map", - "/community/comments/guide", - "/guides/markdown-support", - "/privacy", - "/terms", - "/mobile-app", - "/account/sign-in", - "/oauth/device", - ]) { + for (const path of mobileScreenshotPaths("public")) { screenshotRoute(path, path); } }); @@ -45,19 +34,7 @@ test.describe("移动端截图", () => { await signInAsDebugUser(page, "/"); }); - for (const path of [ - "/workspace/overview", - "/workspace/homeworks", - "/workspace/todos", - "/workspace/calendar", - "/workspace/exams", - "/catalog/links", - "/workspace/subscriptions", - "/account/settings/profile", - "/account/settings/accounts", - "/account/settings/danger", - `/community/users/${DEV_SEED.debugUsername}`, - ]) { + for (const path of mobileScreenshotPaths("authed")) { screenshotRoute(path, path); } @@ -120,12 +97,7 @@ test.describe("移动端截图", () => { await signInAsDevAdmin(page, "/admin/users"); }); - for (const path of [ - "/admin/users", - "/admin/moderation", - "/admin/oauth", - "/admin/bus", - ]) { + for (const path of mobileScreenshotPaths("admin")) { screenshotRoute(path, path); } }); diff --git a/tests/e2e/src/app/_shared/page-contract.ts b/tests/e2e/src/app/_shared/page-contract.ts index c28c65bac..26654ebca 100644 --- a/tests/e2e/src/app/_shared/page-contract.ts +++ b/tests/e2e/src/app/_shared/page-contract.ts @@ -94,16 +94,26 @@ export async function assertPageContract( name: /已授权的 OAuth 应用|Authorized OAuth applications/i, }) : expectedTab === "danger" - ? page.getByRole("heading", { name: /危险|Danger/i }) + ? page.getByRole("region", { + name: /删除账户|Delete Account/i, + }) : page.getByRole("heading", { name: /设置|Settings/i }); await expect( - page.getByRole("link", { name: /设置|Settings/i }), + page.getByRole("heading", { name: /设置|Settings/i, level: 1 }), ).toBeVisible(); await expect(tabMarker).toBeVisible(); return; } } + if (routePath === "/workspace/subscriptions/sections") { + await signInAsDebugUser(page, "/workspace/subscriptions"); + await gotoContractPage(page, routePath, testInfo); + await expect(page).toHaveURL(/\/workspace\/subscriptions(?:\?.*)?$/); + await expectMainContent(page); + return; + } + if ( routePath === "/workspace/[tab]" || routePath.startsWith("/workspace/") || @@ -176,8 +186,9 @@ export async function assertPageContract( await expect( page.getByRole("heading", { name: /OAuth|OAuth 客户端/i }), ).toBeVisible(); + // Header + empty-state both expose Create Client; L1 only needs one. await expect( - page.getByRole("button", { name: /创建客户端|Create Client/i }), + page.getByRole("button", { name: /创建客户端|Create Client/i }).first(), ).toBeVisible(); await maybeCapture(page, testInfo, "admin-oauth"); return; @@ -314,6 +325,23 @@ export async function assertPageContract( return; } + case "/catalog/bus": { + await gotoContractPage(page, routePath, testInfo); + await expectMainContent(page); + await expect( + page.getByRole("heading", { level: 1, name: /校车|Shuttle Bus/i }), + ).toBeVisible(); + // Mobile-only collapsible triggers are lg:hidden; assert desktop planner. + await expect( + page.locator("[data-testid='bus-start-stop-group']"), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Reverse|反向/i }), + ).toBeVisible(); + await maybeCapture(page, testInfo, "bus"); + return; + } + case "/catalog/bus/map": { await gotoContractPage(page, routePath, testInfo); await expectMainContent(page); @@ -325,6 +353,82 @@ export async function assertPageContract( return; } + case "/catalog/links": { + await gotoContractPage(page, routePath, testInfo); + await expectMainContent(page); + await expect( + page.getByRole("searchbox", { + name: /搜索网站名称或描述|Search by name or description/i, + }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: /教务系统|Academic Affairs/i }).first(), + ).toBeVisible(); + await maybeCapture(page, testInfo, "links"); + return; + } + + case "/search": { + await gotoContractPage(page, routePath, testInfo); + await expectMainContent(page); + await expect( + page.getByRole("heading", { name: /搜索|Search/i }), + ).toBeVisible(); + await expect(page.getByRole("combobox")).toBeVisible(); + await maybeCapture(page, testInfo, "search"); + return; + } + + case "/api/docs": { + await gotoContractPage(page, routePath, testInfo); + await expect(page).toHaveURL( + /\/api\/docs\/tag\/catalog-section(?:\?.*)?$/, + ); + await expectMainContent(page); + await maybeCapture(page, testInfo, "api-docs-redirect"); + return; + } + + case "/catalog/courses/[jwId]/[section]": { + await gotoContractPage( + page, + `/catalog/courses/${DEV_SEED.course.jwId}/introduction`, + testInfo, + ); + await expect(page).toHaveURL( + new RegExp(`/catalog/courses/${DEV_SEED.course.jwId}#introduction$`), + ); + await expectMainContent(page); + return; + } + + case "/catalog/sections/[jwId]/[section]": { + await gotoContractPage( + page, + `/catalog/sections/${DEV_SEED.section.jwId}/introduction`, + testInfo, + ); + await expect(page).toHaveURL( + new RegExp(`/catalog/sections/${DEV_SEED.section.jwId}#introduction$`), + ); + await expectMainContent(page); + return; + } + + case "/catalog/teachers/[id]/[section]": { + const teacherId = await resolveSeedTeacherId(page); + await gotoContractPage( + page, + `/catalog/teachers/${teacherId}/introduction`, + testInfo, + ); + await expect(page).toHaveURL( + new RegExp(`/catalog/teachers/${teacherId}#introduction$`), + ); + await expectMainContent(page); + return; + } + case "/catalog/sections": { await gotoContractPage( page, @@ -413,10 +517,11 @@ export async function assertPageContract( } case "/oauth/authorize": { + // Bare authorize URL (no client_id / PKCE) redirects to sign-in. await gotoContractPage(page, routePath, testInfo); await expectMainContent(page); await expect( - page.getByRole("heading", { name: /OAuth|授权|Authorize/i }), + page.getByRole("heading", { name: /登录|Sign In/i }), ).toBeVisible(); await maybeCapture(page, testInfo, "oauth-authorize"); return; diff --git a/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts new file mode 100644 index 000000000..3c17c15f0 --- /dev/null +++ b/tests/e2e/src/app/_shared/page-inventory.ts @@ -0,0 +1,829 @@ +/** + * Canonical page inventory for L0/L1/L2 UI coverage. + * + * Derived from src/routes/+page.svelte trees. Keep this map complete — the unit + * gate in tests/unit/page-inventory.test.ts fails when a route is orphaned. + */ +import { workspaceTabIds } from "@/features/dashboard/lib/dashboard-nav"; +import { SETTINGS_TABS } from "@/features/settings/lib/settings-tabs"; +import { DEV_SEED } from "../../../../fixtures/dev-seed"; + +export type PageAuth = "public" | "user" | "admin"; +export type PageKind = "page" | "redirect"; +export type MobileScreenshotGroup = "public" | "authed" | "admin"; + +export type PrimaryActionExemption = + | "decorative" + | "live-oauth" + | `covered-by:${string}`; + +export type PrimaryAction = { + /** Stable id for the gate (not a DOM selector). */ + id: string; + /** Role/name pair preferred by e2e/AGENTS.md, when applicable. */ + role?: "button" | "link" | "tab" | "textbox" | "combobox" | "searchbox"; + name?: string; + testId?: string; + /** Spec path relative to tests/e2e/ that exercises this action. */ + e2eSpec?: string; + /** Explicit exemption when no dedicated L2 assertion is required. */ + exemption?: PrimaryActionExemption; +}; + +export type PageInventoryEntry = { + routeId: string; + samplePath: string; + kind: PageKind; + auth: PageAuth; + /** Path passed to assertPageContract (may differ for redirects). */ + contractPath: string; + /** + * Spec under tests/e2e/ that calls assertPageContract for this entry, + * or coveredBy when a sibling rich spec already owns the contract call. + */ + e2eSpec?: string; + coveredBy?: string; + primaryActions?: PrimaryAction[]; + /** Drive mobile-screenshots goto list from inventory. */ + mobileScreenshot?: MobileScreenshotGroup; +}; + +const E2E = { + home: "src/app/test.ts", + admin: "src/app/admin/test.ts", + adminUsers: "src/app/admin/users/test.ts", + adminModeration: "src/app/admin/moderation/test.ts", + adminOauth: "src/app/admin/oauth/test.ts", + adminBus: "src/app/admin/bus/test.ts", + apiDocs: "src/app/api/docs/test.ts", + bus: "src/app/bus/test.ts", + busMap: "src/app/bus-map/test.ts", + commentsGuide: "src/app/comments/guide/test.ts", + commentsId: "src/app/comments/[id]/test.ts", + courses: "src/app/courses/test.ts", + coursesJwId: "src/app/courses/[jwId]/test.ts", + dashboard: "src/app/dashboard/test.ts", + dashboardTab: "src/app/dashboard/[tab]/test.ts", + dashboardLinks: "src/app/dashboard/links/test.ts", + dashboardCalendar: "src/app/dashboard/calendar/test.ts", + dashboardHomeworks: "src/app/dashboard/homeworks/test.ts", + dashboardTodos: "src/app/dashboard/todos/test.ts", + dashboardExams: "src/app/dashboard/exams/test.ts", + dashboardSubscriptions: "src/app/dashboard/subscriptions/sections/test.ts", + e2eOauthCallback: "src/app/e2e/oauth/callback/test.ts", + error: "src/app/error/test.ts", + guidesMarkdown: "src/app/guides/markdown-support/test.ts", + mobileApp: "src/app/mobile-app/test.ts", + oauthAuthorize: "src/app/oauth/authorize/test.ts", + oauthDevice: "src/app/oauth/device/test.ts", + privacy: "src/app/privacy/test.ts", + search: "src/app/search/test.ts", + sections: "src/app/sections/test.ts", + sectionsJwId: "src/app/sections/[jwId]/test.ts", + settings: "src/app/settings/test.ts", + settingsProfile: "src/app/settings/profile/test.ts", + settingsPreferences: "src/app/settings/preferences/test.ts", + settingsAccounts: "src/app/settings/accounts/test.ts", + settingsAuthorizations: "src/app/settings/authorizations/test.ts", + settingsDanger: "src/app/settings/danger/test.ts", + settingsPasskeys: "src/app/settings/passkeys/test.ts", + signin: "src/app/signin/test.ts", + teachers: "src/app/teachers/test.ts", + teachersId: "src/app/teachers/[id]/test.ts", + terms: "src/app/terms/test.ts", + communityUser: "src/app/u/[username]/test.ts", + welcome: "src/app/welcome/test.ts", +} as const; + +/** + * Tab ids reused so settings / workspace inventory stays DRY with product code. + * Exported for unit gate cross-checks. + */ +export const INVENTORY_SETTINGS_TABS = SETTINGS_TABS; +export const INVENTORY_WORKSPACE_TABS = workspaceTabIds; + +export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ + { + routeId: "/", + samplePath: "/", + kind: "page", + auth: "public", + contractPath: "/", + e2eSpec: E2E.home, + mobileScreenshot: "public", + primaryActions: [ + { + id: "nav-courses", + role: "link", + name: "/^(课程|Courses)$/i", + e2eSpec: E2E.home, + }, + { + id: "theme-menu", + role: "button", + e2eSpec: E2E.home, + }, + ], + }, + { + routeId: "/account/settings", + samplePath: "/account/settings", + kind: "redirect", + auth: "user", + contractPath: "/account/settings", + e2eSpec: E2E.settings, + }, + { + routeId: "/account/settings/[tab]", + samplePath: "/account/settings/profile", + kind: "page", + auth: "user", + contractPath: "/account/settings/profile", + e2eSpec: E2E.settingsProfile, + coveredBy: E2E.settings, + mobileScreenshot: "authed", + primaryActions: [ + { + id: "save-profile", + role: "button", + name: "/保存|Save/i", + e2eSpec: E2E.settingsProfile, + }, + { + id: "preferences-appearance", + e2eSpec: E2E.settingsPreferences, + }, + { + id: "accounts-providers", + e2eSpec: E2E.settingsAccounts, + }, + { + id: "accounts-ustc-connect", + role: "button", + name: "/连接|Connect/i", + exemption: "live-oauth", + }, + { + id: "authorizations-list", + e2eSpec: E2E.settingsAuthorizations, + }, + { + id: "danger-delete", + e2eSpec: E2E.settingsDanger, + }, + { + id: "passkeys-ui", + e2eSpec: E2E.settingsPasskeys, + }, + ...SETTINGS_TABS.map( + (tab): PrimaryAction => ({ + id: `settings-tab-${tab}`, + role: "link", + e2eSpec: E2E.settings, + }), + ), + ], + }, + { + routeId: "/account/sign-in", + samplePath: "/account/sign-in", + kind: "page", + auth: "public", + contractPath: "/account/sign-in", + e2eSpec: E2E.signin, + mobileScreenshot: "public", + primaryActions: [ + { + id: "provider-ustc", + role: "button", + name: "/USTC/i", + e2eSpec: E2E.signin, + }, + { + id: "provider-github", + role: "button", + name: "/GitHub/i", + e2eSpec: E2E.signin, + }, + { + id: "provider-google", + role: "button", + name: "/Google/i", + e2eSpec: E2E.signin, + }, + ], + }, + { + routeId: "/account/welcome", + samplePath: "/account/welcome", + kind: "page", + auth: "user", + contractPath: "/account/welcome", + e2eSpec: E2E.welcome, + primaryActions: [ + { + id: "welcome-name", + role: "textbox", + name: "/^(姓名|Name)\\b/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-username", + role: "textbox", + name: "/^(用户名|Username)\\b/i", + e2eSpec: E2E.welcome, + }, + { + id: "bulk-import", + role: "button", + name: "/批量添加订阅|Bulk Add Subscriptions/i", + e2eSpec: E2E.welcome, + }, + ], + }, + { + routeId: "/admin", + samplePath: "/admin", + kind: "redirect", + auth: "admin", + contractPath: "/admin", + e2eSpec: E2E.admin, + }, + { + routeId: "/admin/bus", + samplePath: "/admin/bus", + kind: "page", + auth: "admin", + contractPath: "/admin/bus", + e2eSpec: E2E.adminBus, + mobileScreenshot: "admin", + primaryActions: [ + { + id: "import-bus", + role: "button", + name: "/导入|Import/i", + e2eSpec: E2E.adminBus, + }, + ], + }, + { + routeId: "/admin/moderation", + samplePath: "/admin/moderation", + kind: "page", + auth: "admin", + contractPath: "/admin/moderation", + e2eSpec: E2E.adminModeration, + mobileScreenshot: "admin", + primaryActions: [ + { + id: "status-filter", + role: "combobox", + e2eSpec: E2E.adminModeration, + }, + { + id: "comments-link", + role: "link", + name: "/评论|Comments/i", + e2eSpec: E2E.adminModeration, + }, + ], + }, + { + routeId: "/admin/oauth", + samplePath: "/admin/oauth", + kind: "page", + auth: "admin", + contractPath: "/admin/oauth", + e2eSpec: E2E.adminOauth, + mobileScreenshot: "admin", + primaryActions: [ + { + id: "create-client", + role: "button", + name: "/创建客户端|Create Client/i", + e2eSpec: E2E.adminOauth, + }, + ], + }, + { + routeId: "/admin/users", + samplePath: "/admin/users", + kind: "page", + auth: "admin", + contractPath: "/admin/users", + e2eSpec: E2E.adminUsers, + mobileScreenshot: "admin", + primaryActions: [ + { + id: "manage-user", + role: "button", + name: "/管理用户|Manage User/i", + e2eSpec: E2E.adminUsers, + }, + ], + }, + { + routeId: "/api/docs", + samplePath: "/api/docs", + kind: "redirect", + auth: "public", + contractPath: "/api/docs", + e2eSpec: E2E.apiDocs, + }, + { + routeId: "/api/docs/[...path]", + samplePath: "/api/docs/tag/catalog-section", + kind: "page", + auth: "public", + contractPath: "/api/docs/tag/catalog-section", + e2eSpec: E2E.apiDocs, + primaryActions: [ + { + id: "scalar-nav", + e2eSpec: E2E.apiDocs, + }, + ], + }, + { + routeId: "/catalog/bus", + samplePath: "/catalog/bus", + kind: "page", + auth: "public", + contractPath: "/catalog/bus", + e2eSpec: E2E.bus, + mobileScreenshot: "public", + primaryActions: [ + { + id: "change-route", + role: "button", + name: "/Change route|调整路线/", + e2eSpec: E2E.bus, + }, + { + id: "full-timetable", + role: "button", + name: "/Full timetable|完整时刻表/", + e2eSpec: E2E.bus, + }, + ], + }, + { + routeId: "/catalog/bus/map", + samplePath: "/catalog/bus/map", + kind: "page", + auth: "public", + contractPath: "/catalog/bus/map", + e2eSpec: E2E.busMap, + mobileScreenshot: "public", + primaryActions: [ + { + id: "refresh-map", + role: "button", + name: "/Refresh|刷新/i", + e2eSpec: E2E.busMap, + }, + ], + }, + { + routeId: "/catalog/courses", + samplePath: "/catalog/courses", + kind: "page", + auth: "public", + contractPath: "/catalog/courses", + e2eSpec: E2E.courses, + mobileScreenshot: "public", + primaryActions: [ + { + id: "search-courses", + e2eSpec: E2E.courses, + }, + ], + }, + { + routeId: "/catalog/courses/[jwId]", + samplePath: `/catalog/courses/${DEV_SEED.course.jwId}`, + kind: "page", + auth: "public", + contractPath: "/catalog/courses/[jwId]", + e2eSpec: E2E.coursesJwId, + mobileScreenshot: "public", + primaryActions: [ + { + id: "teaching-sections", + e2eSpec: E2E.coursesJwId, + }, + ], + }, + { + routeId: "/catalog/courses/[jwId]/[section]", + samplePath: `/catalog/courses/${DEV_SEED.course.jwId}/introduction`, + kind: "redirect", + auth: "public", + contractPath: "/catalog/courses/[jwId]/[section]", + e2eSpec: E2E.coursesJwId, + }, + { + routeId: "/catalog/links", + samplePath: "/catalog/links", + kind: "page", + auth: "public", + contractPath: "/catalog/links", + e2eSpec: E2E.dashboardLinks, + mobileScreenshot: "authed", + primaryActions: [ + { + id: "search-links", + role: "searchbox", + name: "/搜索网站名称或描述|Search by name or description/i", + e2eSpec: E2E.dashboardLinks, + }, + { + id: "pin-link", + role: "button", + name: "/^(?:置顶|Pin)$/i", + e2eSpec: E2E.dashboardLinks, + }, + ], + }, + { + routeId: "/catalog/sections", + samplePath: "/catalog/sections", + kind: "page", + auth: "public", + contractPath: "/catalog/sections", + e2eSpec: E2E.sections, + mobileScreenshot: "public", + primaryActions: [ + { + id: "search-sections", + e2eSpec: E2E.sections, + }, + ], + }, + { + routeId: "/catalog/sections/[jwId]", + samplePath: `/catalog/sections/${DEV_SEED.section.jwId}`, + kind: "page", + auth: "public", + contractPath: "/catalog/sections/[jwId]", + e2eSpec: E2E.sectionsJwId, + mobileScreenshot: "public", + primaryActions: [ + { + id: "subscribe", + role: "button", + name: "/订阅教学班|Subscribe to section/i", + e2eSpec: E2E.sectionsJwId, + }, + { + id: "unsubscribe", + role: "button", + name: "/取消订阅|Unsubscribe from section/i", + e2eSpec: E2E.sectionsJwId, + }, + { + id: "add-to-calendar", + role: "button", + name: "/添加到日历|Add to calendar/i", + e2eSpec: E2E.sectionsJwId, + }, + ], + }, + { + routeId: "/catalog/sections/[jwId]/[section]", + samplePath: `/catalog/sections/${DEV_SEED.section.jwId}/introduction`, + kind: "redirect", + auth: "public", + contractPath: "/catalog/sections/[jwId]/[section]", + e2eSpec: E2E.sectionsJwId, + }, + { + routeId: "/catalog/teachers", + samplePath: "/catalog/teachers", + kind: "page", + auth: "public", + contractPath: "/catalog/teachers", + e2eSpec: E2E.teachers, + mobileScreenshot: "public", + primaryActions: [ + { + id: "search-teachers", + e2eSpec: E2E.teachers, + }, + ], + }, + { + routeId: "/catalog/teachers/[id]", + samplePath: "/catalog/teachers/[id]", + kind: "page", + auth: "public", + contractPath: "/catalog/teachers/[id]", + e2eSpec: E2E.teachersId, + primaryActions: [ + { + id: "teaching-sections", + e2eSpec: E2E.teachersId, + }, + ], + }, + { + routeId: "/catalog/teachers/[id]/[section]", + samplePath: "/catalog/teachers/[id]/introduction", + kind: "redirect", + auth: "public", + contractPath: "/catalog/teachers/[id]/[section]", + e2eSpec: E2E.teachersId, + }, + { + routeId: "/community/comments/[id]", + samplePath: "/community/comments/[id]", + kind: "redirect", + auth: "public", + contractPath: "/community/comments/[id]", + e2eSpec: E2E.commentsId, + }, + { + routeId: "/community/comments/guide", + samplePath: "/community/comments/guide", + kind: "redirect", + auth: "public", + contractPath: "/community/comments/guide", + e2eSpec: E2E.commentsGuide, + mobileScreenshot: "public", + }, + { + routeId: "/community/users/[identifier]", + samplePath: `/community/users/${DEV_SEED.debugUsername}`, + kind: "page", + auth: "public", + contractPath: "/community/users/[identifier]", + e2eSpec: E2E.communityUser, + mobileScreenshot: "authed", + primaryActions: [ + { + id: "profile-identity", + exemption: "decorative", + }, + ], + }, + { + routeId: "/e2e/oauth/callback", + samplePath: "/e2e/oauth/callback?code=e2e-test-code&state=e2e-test-state", + kind: "page", + auth: "public", + contractPath: "/e2e/oauth/callback", + e2eSpec: E2E.e2eOauthCallback, + primaryActions: [ + { + id: "callback-payload", + exemption: "decorative", + }, + ], + }, + { + routeId: "/error", + samplePath: "/error?error=consent_failed", + kind: "page", + auth: "public", + contractPath: "/error", + e2eSpec: E2E.error, + primaryActions: [ + { + id: "return-home", + role: "link", + name: "/返回首页|Return home/i", + e2eSpec: E2E.error, + }, + ], + }, + { + routeId: "/guides/markdown-support", + samplePath: "/guides/markdown-support", + kind: "page", + auth: "public", + contractPath: "/guides/markdown-support", + e2eSpec: E2E.guidesMarkdown, + mobileScreenshot: "public", + primaryActions: [ + { + id: "markdown-preview", + exemption: "decorative", + }, + ], + }, + { + routeId: "/mobile-app", + samplePath: "/mobile-app", + kind: "page", + auth: "public", + contractPath: "/mobile-app", + e2eSpec: E2E.mobileApp, + mobileScreenshot: "public", + primaryActions: [ + { + id: "app-store", + role: "link", + name: "/App Store|下载/i", + e2eSpec: E2E.mobileApp, + }, + ], + }, + { + routeId: "/oauth/authorize", + samplePath: "/oauth/authorize", + kind: "page", + auth: "public", + contractPath: "/oauth/authorize", + e2eSpec: E2E.oauthAuthorize, + primaryActions: [ + { + id: "authorize-flow", + e2eSpec: E2E.oauthAuthorize, + }, + ], + }, + { + routeId: "/oauth/device", + samplePath: "/oauth/device", + kind: "page", + auth: "public", + contractPath: "/oauth/device", + e2eSpec: E2E.oauthDevice, + mobileScreenshot: "public", + primaryActions: [ + { + id: "device-code", + e2eSpec: E2E.oauthDevice, + }, + ], + }, + { + routeId: "/privacy", + samplePath: "/privacy", + kind: "page", + auth: "public", + contractPath: "/privacy", + e2eSpec: E2E.privacy, + mobileScreenshot: "public", + primaryActions: [ + { + id: "policy-body", + exemption: "decorative", + }, + ], + }, + { + routeId: "/search", + samplePath: "/search", + kind: "page", + auth: "public", + contractPath: "/search", + e2eSpec: E2E.search, + mobileScreenshot: "public", + primaryActions: [ + { + id: "search-input", + role: "combobox", + e2eSpec: E2E.search, + }, + { + id: "keyboard-nav", + e2eSpec: E2E.search, + }, + ], + }, + { + routeId: "/terms", + samplePath: "/terms", + kind: "page", + auth: "public", + contractPath: "/terms", + e2eSpec: E2E.terms, + mobileScreenshot: "public", + primaryActions: [ + { + id: "terms-body", + exemption: "decorative", + }, + ], + }, + { + routeId: "/workspace", + samplePath: "/workspace", + kind: "redirect", + auth: "user", + contractPath: "/workspace", + e2eSpec: E2E.dashboardTab, + }, + { + routeId: "/workspace/[tab]", + samplePath: "/workspace/overview", + kind: "page", + auth: "user", + contractPath: "/workspace/overview", + e2eSpec: E2E.dashboardTab, + mobileScreenshot: "authed", + primaryActions: [ + ...workspaceTabIds.map( + (tab): PrimaryAction => ({ + id: `workspace-tab-${tab}`, + role: "link", + e2eSpec: E2E.dashboardTab, + }), + ), + { + id: "overview-now-next", + e2eSpec: E2E.dashboard, + }, + { + id: "calendar-export", + e2eSpec: E2E.dashboardCalendar, + }, + { + id: "homework-crud", + e2eSpec: E2E.dashboardHomeworks, + }, + { + id: "todo-crud", + e2eSpec: E2E.dashboardTodos, + }, + { + id: "exams-view", + e2eSpec: E2E.dashboardExams, + }, + { + id: "subscriptions-bulk-import", + e2eSpec: E2E.dashboardSubscriptions, + }, + ], + }, + { + routeId: "/workspace/subscriptions", + samplePath: "/workspace/subscriptions", + kind: "page", + auth: "user", + contractPath: "/workspace/subscriptions", + e2eSpec: E2E.dashboardSubscriptions, + mobileScreenshot: "authed", + primaryActions: [ + { + id: "bulk-import", + e2eSpec: E2E.dashboardSubscriptions, + }, + { + id: "unsubscribe-row", + e2eSpec: E2E.dashboardSubscriptions, + }, + { + id: "calendar-feed-copy", + e2eSpec: E2E.dashboardSubscriptions, + }, + ], + }, + { + routeId: "/workspace/subscriptions/sections", + samplePath: "/workspace/subscriptions/sections", + kind: "redirect", + auth: "user", + contractPath: "/workspace/subscriptions/sections", + e2eSpec: E2E.dashboardSubscriptions, + }, +] as const satisfies readonly PageInventoryEntry[]; + +export function inventoryByRouteId( + routeId: string, +): PageInventoryEntry | undefined { + return PAGE_INVENTORY.find((entry) => entry.routeId === routeId); +} + +export function mobileScreenshotPaths(group: MobileScreenshotGroup): string[] { + const paths = PAGE_INVENTORY.filter( + (entry) => entry.mobileScreenshot === group, + ).map((entry) => entry.samplePath); + + if (group === "authed") { + for (const tab of workspaceTabIds) { + const path = `/workspace/${tab}`; + if (!paths.includes(path)) { + paths.push(path); + } + } + for (const tab of ["profile", "accounts", "danger"] as const) { + const path = `/account/settings/${tab}`; + if (!paths.includes(path)) { + paths.push(path); + } + } + } + + return paths; +} + +/** Map src/routes/.../+page.svelte relative path to SvelteKit route id. */ +export function routeIdFromPageFile(relativeFromRoutes: string): string { + const withoutPage = relativeFromRoutes.replace(/\/?\+page\.svelte$/, ""); + if (!withoutPage || withoutPage === ".") { + return "/"; + } + return `/${withoutPage}`; +} diff --git a/tests/e2e/src/app/admin/bus/test.ts b/tests/e2e/src/app/admin/bus/test.ts index baba90007..0330e6a37 100644 --- a/tests/e2e/src/app/admin/bus/test.ts +++ b/tests/e2e/src/app/admin/bus/test.ts @@ -29,6 +29,7 @@ import { import { DEV_SEED } from "../../../../utils/dev-seed"; import { visibleText } from "../../../../utils/locators"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe.configure({ mode: "serial" }); @@ -157,3 +158,7 @@ test("/admin/bus 移动端首条版本操作可达", async ({ page }, testInfo) await captureStepScreenshot(page, testInfo, "admin-bus/mobile-workspace"); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/admin/bus", testInfo }); +}); diff --git a/tests/e2e/src/app/admin/moderation/test.ts b/tests/e2e/src/app/admin/moderation/test.ts index 67ac13062..dcbaef00b 100644 --- a/tests/e2e/src/app/admin/moderation/test.ts +++ b/tests/e2e/src/app/admin/moderation/test.ts @@ -15,6 +15,7 @@ import { visibleText } from "../../../../utils/locators"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; import { resolveSeedSectionId } from "../../../../utils/subscriptions"; +import { assertPageContract } from "../../_shared/page-contract"; function moderationTableRow(page: Page, text: string) { return page.locator("tbody tr:visible").filter({ hasText: text }).first(); @@ -254,15 +255,9 @@ test("/admin/moderation 可切换状态筛选下拉", async ({ page }, testInfo) await signInAsDevAdmin(page, "/admin/moderation"); const filter = page.getByRole("combobox").first(); - test.skip( - (await filter.count()) === 0, - "moderation page rendered without a status filter", - ); + await expect(filter).toBeVisible(); const option = page.getByRole("option", { name: /已删除|Deleted/i }).first(); - test.skip( - (await option.count()) === 0, - "status filter rendered without a Deleted option", - ); + await expect(option).toBeAttached(); await filter.selectOption("deleted"); await expect(filter).toHaveValue("deleted"); await captureStepScreenshot( @@ -551,3 +546,7 @@ test("/admin/moderation 作业治理可访问", async ({ page }, testInfo) => { "admin-moderation/homework-governance", ); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/admin/moderation", testInfo }); +}); diff --git a/tests/e2e/src/app/admin/oauth/test.ts b/tests/e2e/src/app/admin/oauth/test.ts index 58aa1195f..b157bff6d 100644 --- a/tests/e2e/src/app/admin/oauth/test.ts +++ b/tests/e2e/src/app/admin/oauth/test.ts @@ -22,6 +22,7 @@ import { gotoAndWaitForReady, } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe.configure({ mode: "serial" }); @@ -354,3 +355,7 @@ test("/admin/oauth 移动端使用紧凑列表且无页面横向溢出", async ( await deleteOAuthClientsByName(name); } }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/admin/oauth", testInfo }); +}); diff --git a/tests/e2e/src/app/admin/test.ts b/tests/e2e/src/app/admin/test.ts index b7bab93d4..5fb2ba02f 100644 --- a/tests/e2e/src/app/admin/test.ts +++ b/tests/e2e/src/app/admin/test.ts @@ -14,6 +14,7 @@ import { } from "../../../utils/auth"; import { gotoAndWaitForReady } from "../../../utils/page-ready"; import { captureStepScreenshot } from "../../../utils/screenshot"; +import { assertPageContract } from "../_shared/page-contract"; function adminPrimaryNav(page: import("@playwright/test").Page) { return page.getByTestId("app-sidebar").getByRole("navigation", { @@ -129,3 +130,7 @@ test("/admin 主导航可跳转到各管理工具", async ({ page }, testInfo) = await captureStepScreenshot(page, testInfo, shot); } }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/admin", testInfo }); +}); diff --git a/tests/e2e/src/app/admin/users/test.ts b/tests/e2e/src/app/admin/users/test.ts index 6e4806c21..8c2614628 100644 --- a/tests/e2e/src/app/admin/users/test.ts +++ b/tests/e2e/src/app/admin/users/test.ts @@ -12,6 +12,7 @@ import { import { visibleText } from "../../../../utils/locators"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe.configure({ mode: "serial" }); @@ -318,3 +319,7 @@ test("/admin/users 可创建默认时长封禁并通过 API 解除", async ({ await deleteUsersByPrefix(prefix); } }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/admin/users", testInfo }); +}); diff --git a/tests/e2e/src/app/api/docs/test.ts b/tests/e2e/src/app/api/docs/test.ts index e28106f15..95c105be9 100644 --- a/tests/e2e/src/app/api/docs/test.ts +++ b/tests/e2e/src/app/api/docs/test.ts @@ -180,3 +180,7 @@ test.describe("/api-docs 页面", () => { await expect(page).toHaveURL(/\/api\/docs\/tag\/catalog-section$/); }); }); + +test("页面契约 /api/docs", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/api/docs", testInfo }); +}); diff --git a/tests/e2e/src/app/bus-map/test.ts b/tests/e2e/src/app/bus-map/test.ts index 2eebe1f40..292f0ab23 100644 --- a/tests/e2e/src/app/bus-map/test.ts +++ b/tests/e2e/src/app/bus-map/test.ts @@ -20,6 +20,7 @@ import { gotoAndWaitForReady, } from "../../../utils/page-ready"; import { captureStepScreenshot } from "../../../utils/screenshot"; +import { assertPageContract } from "../_shared/page-contract"; test.describe("校车线路图", () => { test("SVG 中渲染校区节点与线路", async ({ page }, testInfo) => { @@ -174,3 +175,7 @@ test.describe("校车线路图", () => { await expect(refreshBtn).toBeVisible(); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/catalog/bus/map", testInfo }); +}); diff --git a/tests/e2e/src/app/bus/test.ts b/tests/e2e/src/app/bus/test.ts index a3b410deb..6f7bc15c9 100644 --- a/tests/e2e/src/app/bus/test.ts +++ b/tests/e2e/src/app/bus/test.ts @@ -20,6 +20,7 @@ import { } from "../../../utils/page-ready"; import { absoluteTestUrl } from "../../../utils/request-url"; import { captureStepScreenshot } from "../../../utils/screenshot"; +import { assertPageContract } from "../_shared/page-contract"; async function setLocale( page: Page, @@ -552,3 +553,7 @@ test.describe("校车面板标签页", () => { } }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/catalog/bus", testInfo }); +}); diff --git a/tests/e2e/src/app/comments/guide/test.ts b/tests/e2e/src/app/comments/guide/test.ts index f17ecbba5..b1be1a617 100644 --- a/tests/e2e/src/app/comments/guide/test.ts +++ b/tests/e2e/src/app/comments/guide/test.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; +import { assertPageContract } from "../../_shared/page-contract"; test("/community/comments/guide 重定向到标准 Markdown 指南", async ({ page, @@ -9,3 +10,10 @@ test("/community/comments/guide 重定向到标准 Markdown 指南", async ({ }); await expect(page).toHaveURL(/\/guides\/markdown-support$/); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/community/comments/guide", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/courses/[jwId]/test.ts b/tests/e2e/src/app/courses/[jwId]/test.ts index c8ea87008..34df60186 100644 --- a/tests/e2e/src/app/courses/[jwId]/test.ts +++ b/tests/e2e/src/app/courses/[jwId]/test.ts @@ -434,3 +434,10 @@ test.describe("/catalog/courses/[jwId]/introduction 无 JavaScript", () => { await expect(page.locator("#introduction .markdown-preview")).toBeVisible(); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/catalog/courses/[jwId]/[section]", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/dashboard/[tab]/test.ts b/tests/e2e/src/app/dashboard/[tab]/test.ts index daea65be0..447f9369f 100644 --- a/tests/e2e/src/app/dashboard/[tab]/test.ts +++ b/tests/e2e/src/app/dashboard/[tab]/test.ts @@ -13,6 +13,7 @@ import { import { sidebarNavigationLink } from "../../../../utils/locators"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; const dashboardTabRoutes = { overview: "/workspace/overview", @@ -134,3 +135,14 @@ test("查询参数别名永久跳转后使用规范化的工作台页面身份", await expect(page).toHaveURL(/\/workspace\/todos$/); await expectDashboardPageIdentity(page, "zh-cn", "待办"); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/workspace/overview", + testInfo, + }); +}); + +test("页面契约 /workspace", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/workspace", testInfo }); +}); diff --git a/tests/e2e/src/app/dashboard/links/test.ts b/tests/e2e/src/app/dashboard/links/test.ts index f953eb001..d3510a415 100644 --- a/tests/e2e/src/app/dashboard/links/test.ts +++ b/tests/e2e/src/app/dashboard/links/test.ts @@ -28,6 +28,7 @@ import { } from "../../../../utils/locators"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe.configure({ mode: "serial" }); @@ -351,3 +352,7 @@ test.describe("仪表盘网站链接", () => { } }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/catalog/links", testInfo }); +}); diff --git a/tests/e2e/src/app/dashboard/subscriptions/sections/test.ts b/tests/e2e/src/app/dashboard/subscriptions/sections/test.ts index cca85127a..2421fc324 100644 --- a/tests/e2e/src/app/dashboard/subscriptions/sections/test.ts +++ b/tests/e2e/src/app/dashboard/subscriptions/sections/test.ts @@ -33,6 +33,7 @@ import { absoluteTestUrl } from "../../../../../utils/request-url"; import { captureStepScreenshot } from "../../../../../utils/screenshot"; import { resolveSeedSectionMatches } from "../../../../../utils/seed-lookups"; import { ensureSeedSectionSubscription } from "../../../../../utils/subscriptions"; +import { assertPageContract } from "../../../_shared/page-contract"; function escapeForRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -696,3 +697,19 @@ test.describe("仪表盘教学班订阅", () => { ); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/workspace/subscriptions", + testInfo, + }); +}); + +test("页面契约 /workspace/subscriptions/sections", async ({ + page, +}, testInfo) => { + await assertPageContract(page, { + routePath: "/workspace/subscriptions/sections", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/oauth/authorize/test.ts b/tests/e2e/src/app/oauth/authorize/test.ts index d0db053df..cc7d91273 100644 --- a/tests/e2e/src/app/oauth/authorize/test.ts +++ b/tests/e2e/src/app/oauth/authorize/test.ts @@ -9,6 +9,7 @@ import { signInAsDebugUser } from "../../../../utils/auth"; import { PLAYWRIGHT_BASE_URL } from "../../../../utils/e2e-db"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; async function generateCodeChallenge(codeVerifier: string) { return sha256Base64Url(codeVerifier); @@ -209,3 +210,7 @@ test("/oauth/authorize 允许授权时带 code 回跳", async ({ page }, testInf await captureStepScreenshot(page, testInfo, "oauth-authorize-allowed"); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/oauth/authorize", testInfo }); +}); diff --git a/tests/e2e/src/app/oauth/device/test.ts b/tests/e2e/src/app/oauth/device/test.ts index 936c90263..9a1a16ca7 100644 --- a/tests/e2e/src/app/oauth/device/test.ts +++ b/tests/e2e/src/app/oauth/device/test.ts @@ -43,6 +43,7 @@ import { capturePageScreenshot, captureStepScreenshot, } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; type DeviceAuthorizationResult = { clientId: string; @@ -576,3 +577,7 @@ test("/oauth/device 发现文档包含设备授权端点", async ({ request }) = ), ).toBe(true); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/oauth/device", testInfo }); +}); diff --git a/tests/e2e/src/app/search/test.ts b/tests/e2e/src/app/search/test.ts index dec2538eb..6d2a0054e 100644 --- a/tests/e2e/src/app/search/test.ts +++ b/tests/e2e/src/app/search/test.ts @@ -1,5 +1,6 @@ import { expect, test } from "@playwright/test"; import { gotoAndWaitForReady } from "../../../utils/page-ready"; +import { assertPageContract } from "../_shared/page-contract"; test("search page returns catalog and link results", async ({ page }) => { const searchResponse = page.waitForResponse( @@ -38,3 +39,7 @@ test("search page supports keyboard navigation into results", async ({ await expect(page.getByRole("option").first()).toBeFocused(); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/search", testInfo }); +}); diff --git a/tests/e2e/src/app/sections/[jwId]/test.ts b/tests/e2e/src/app/sections/[jwId]/test.ts index 3f28bc4e9..cd0f0aded 100644 --- a/tests/e2e/src/app/sections/[jwId]/test.ts +++ b/tests/e2e/src/app/sections/[jwId]/test.ts @@ -770,10 +770,7 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { const subscribeButton = page .getByRole("button", { name: /订阅教学班|Subscribe to section/i }) .first(); - test.skip( - (await subscribeButton.count()) === 0, - "section page rendered without a subscribe control", - ); + await expect(subscribeButton).toBeVisible(); await subscribeButton.click(); const loginDialog = page @@ -799,10 +796,7 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { name: /取消订阅|Unsubscribe from section/i, }); - test.skip( - (await subscribe.count()) === 0 && (await unsubscribe.count()) === 0, - "section page rendered without subscribe/unsubscribe controls", - ); + await expect(subscribe.or(unsubscribe).first()).toBeVisible(); if ((await subscribe.count()) > 0) { await subscribe.first().click(); @@ -842,10 +836,7 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { .getByTestId("detail-pinned-summary") .getByRole("button", { name: /添加到日历|Add to calendar/i }) .first(); - test.skip( - (await calendarButton.count()) === 0, - "section page rendered without calendar export control", - ); + await expect(calendarButton).toBeVisible(); await calendarButton.click(); const calDialog = page.locator('[data-slot="dialog-content"]').first(); @@ -1699,3 +1690,10 @@ test.describe("/catalog/sections/[jwId] 班级详情页", () => { } }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/catalog/sections/[jwId]/[section]", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/settings/accounts/test.ts b/tests/e2e/src/app/settings/accounts/test.ts index e13282168..2bafcd88c 100644 --- a/tests/e2e/src/app/settings/accounts/test.ts +++ b/tests/e2e/src/app/settings/accounts/test.ts @@ -35,6 +35,7 @@ import { } from "../../../../utils/e2e-db"; import { waitForUiSettled } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe("/account/settings/accounts 关联账号设置", () => { test("需要登录", async ({ page }, testInfo) => { @@ -70,10 +71,8 @@ test.describe("/account/settings/accounts 关联账号设置", () => { const connectButton = providerCard.getByRole("button", { name: /连接|Connect/i, }); - test.skip( - (await providerCard.count()) === 0 || (await connectButton.count()) === 0, - "settings accounts page rendered without a USTC connect action", - ); + await expect(providerCard).toBeVisible(); + await expect(connectButton).toBeVisible(); await waitForUiSettled(page); await expect(connectButton).toBeEnabled(); @@ -182,3 +181,10 @@ test.describe("/account/settings/accounts 关联账号设置", () => { } }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/account/settings/accounts", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/settings/authorizations/test.ts b/tests/e2e/src/app/settings/authorizations/test.ts index 32596ad78..2b3403bcb 100644 --- a/tests/e2e/src/app/settings/authorizations/test.ts +++ b/tests/e2e/src/app/settings/authorizations/test.ts @@ -10,6 +10,7 @@ import { } from "../../../../utils/e2e-db"; import { gotoAndWaitForReady } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe.configure({ mode: "serial" }); @@ -107,3 +108,10 @@ test.describe("/account/settings/authorizations OAuth 授权", () => { } }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/account/settings/authorizations", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/settings/danger/test.ts b/tests/e2e/src/app/settings/danger/test.ts index f282955b1..fd182176d 100644 --- a/tests/e2e/src/app/settings/danger/test.ts +++ b/tests/e2e/src/app/settings/danger/test.ts @@ -30,6 +30,7 @@ import { signInAsDebugUser, } from "../../../../utils/auth"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe("/account/settings/danger 危险区设置", () => { test("需要登录", async ({ page }, testInfo) => { @@ -154,3 +155,10 @@ test.describe("/account/settings/danger 危险区设置", () => { await expect(page.locator("#app-user-menu")).toBeVisible(); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/account/settings/danger", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/settings/preferences/test.ts b/tests/e2e/src/app/settings/preferences/test.ts index c6fc66ea5..6b2bee221 100644 --- a/tests/e2e/src/app/settings/preferences/test.ts +++ b/tests/e2e/src/app/settings/preferences/test.ts @@ -5,6 +5,7 @@ import { } from "../../../../utils/auth"; import { expectNoPageHorizontalOverflow } from "../../../../utils/page-ready"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe("/account/settings/preferences 外观与语言偏好", () => { test("canonical 路径需要登录", async ({ page }, testInfo) => { @@ -89,3 +90,10 @@ test.describe("/account/settings/preferences 外观与语言偏好", () => { await captureStepScreenshot(page, testInfo, "settings-preferences-english"); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/account/settings/preferences", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/settings/profile/test.ts b/tests/e2e/src/app/settings/profile/test.ts index 9affea8a7..ffd963d95 100644 --- a/tests/e2e/src/app/settings/profile/test.ts +++ b/tests/e2e/src/app/settings/profile/test.ts @@ -27,6 +27,7 @@ import { } from "../../../../utils/auth"; import { DEV_SEED } from "../../../../utils/dev-seed"; import { captureStepScreenshot } from "../../../../utils/screenshot"; +import { assertPageContract } from "../../_shared/page-contract"; test.describe("/account/settings/profile 个人资料设置", () => { // Serial mode avoids intra-file contention on the shared debug user profile. @@ -129,3 +130,10 @@ test.describe("/account/settings/profile 个人资料设置", () => { ); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/account/settings/profile", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/settings/test.ts b/tests/e2e/src/app/settings/test.ts index 96d69fc2b..cb63c1139 100644 --- a/tests/e2e/src/app/settings/test.ts +++ b/tests/e2e/src/app/settings/test.ts @@ -21,6 +21,7 @@ import { expectRequiresSignIn, signInAsDebugUser } from "../../../utils/auth"; import { DEV_SEED } from "../../../utils/dev-seed"; import { gotoAndWaitForReady } from "../../../utils/page-ready"; import { captureStepScreenshot } from "../../../utils/screenshot"; +import { assertPageContract } from "../_shared/page-contract"; test.describe("/account/settings 设置中心", () => { test("需要登录", async ({ page }, testInfo) => { @@ -196,3 +197,7 @@ test.describe("/account/settings 设置中心", () => { await captureStepScreenshot(page, testInfo, "settings-path-profile"); }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/account/settings", testInfo }); +}); diff --git a/tests/e2e/src/app/teachers/[id]/test.ts b/tests/e2e/src/app/teachers/[id]/test.ts index f052af81e..28fac6cda 100644 --- a/tests/e2e/src/app/teachers/[id]/test.ts +++ b/tests/e2e/src/app/teachers/[id]/test.ts @@ -434,3 +434,10 @@ test.describe("/catalog/teachers/[id] 教师详情页", () => { } }); }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { + routePath: "/catalog/teachers/[id]/[section]", + testInfo, + }); +}); diff --git a/tests/e2e/src/app/welcome/test.ts b/tests/e2e/src/app/welcome/test.ts index f4c63ad8e..9fc3b0748 100644 --- a/tests/e2e/src/app/welcome/test.ts +++ b/tests/e2e/src/app/welcome/test.ts @@ -30,6 +30,7 @@ import { } from "../../../utils/e2e-db"; import { gotoAndWaitForReady } from "../../../utils/page-ready"; import { captureStepScreenshot } from "../../../utils/screenshot"; +import { assertPageContract } from "../_shared/page-contract"; // These tests mutate the shared debug user profile. test.describe.configure({ mode: "serial" }); @@ -260,3 +261,7 @@ test("/account/welcome 提供浏览班级与批量匹配入口", async ({ }); } }); + +test("页面契约", async ({ page }, testInfo) => { + await assertPageContract(page, { routePath: "/account/welcome", testInfo }); +}); diff --git a/tests/integration/rest/users/[userId]/calendar.ics/test.ts b/tests/integration/rest/users/[userId]/calendar.ics/test.ts index ba77253e6..1ca623e8b 100644 --- a/tests/integration/rest/users/[userId]/calendar.ics/test.ts +++ b/tests/integration/rest/users/[userId]/calendar.ics/test.ts @@ -12,18 +12,20 @@ * ## Response * - 200: `text/calendar; charset=utf-8` with iCalendar data * - 401: unauthorized (no session and no token) - * - 403: forbidden (wrong user or invalid token) - * - 404: user not found or no calendar items + * - 403: forbidden (session accessing another user's calendar) + * - 404: unknown user (including unknown user with a token) + * - 410: existing user with wrong/revoked feed token * * ## Content * - Includes subscribed section schedules and exams * - Includes incomplete homework with due dates * - Includes todos with due dates (excludes completed) - * - Returns 404 if user has no calendar items at all + * - Empty calendars still return 200 with an empty VCALENDAR * * ## Edge Cases * - Path token format: `userId:token` in the [userId] segment - * - Invalid token for an existing user returns 403 + * - Invalid token for an existing user returns 410 Gone + * - Unknown user with a token returns 404 * - Accessing another user's calendar via session returns 403 */ import { expect, test } from "@playwright/test"; @@ -58,11 +60,11 @@ test.describe("GET /api/calendar-feeds/[credential].ics", () => { expect(response.status()).toBe(401); }); - test("无效 token 返回 403", async ({ request }) => { + test("未知用户带 token 返回 404", async ({ request }) => { const response = await request.get( "/api/calendar-feeds/invalid-e2e.ics?token=invalid-token", ); - expect(response.status()).toBe(403); + expect(response.status()).toBe(404); }); test("访问其他用户日历时返回 403", async ({ request }) => { @@ -163,14 +165,15 @@ test.describe("GET /api/calendar-feeds/[credential].ics", () => { expect(body).toContain("BEGIN:VCALENDAR"); }); - test("现有用户无效 token 返回 403", async ({ request }) => { + test("现有用户无效 token 返回 410", async ({ request }) => { await signInAsDebugUserApi(request, "/"); const { id: userId } = await getCurrentSessionUser(request); const response = await request.get( `/api/calendar-feeds/${userId}.ics?token=bogus-token-e2e`, ); - expect(response.status()).toBe(403); + expect(response.status()).toBe(410); + expect(response.headers()["cache-control"]).toBe("private, max-age=60"); }); test("有效 token 在没有日历项目时返回空 iCalendar", async ({ request }) => { diff --git a/tests/unit/better-auth-api-errors.test.ts b/tests/unit/better-auth-api-errors.test.ts new file mode 100644 index 000000000..487d9428a --- /dev/null +++ b/tests/unit/better-auth-api-errors.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { isOAuthDebugLoggingMock, logOAuthDebugMock } = vi.hoisted(() => ({ + isOAuthDebugLoggingMock: vi.fn(), + logOAuthDebugMock: vi.fn(), +})); + +vi.mock("@/lib/auth/auth-config", () => ({ + isDevelopment: () => false, +})); + +vi.mock("@/lib/log/oauth-debug", async (importOriginal) => ({ + ...(await importOriginal()), + isOAuthDebugLogging: isOAuthDebugLoggingMock, + logOAuthDebug: logOAuthDebugMock, +})); + +import { betterAuthApiErrorHandler } from "@/lib/auth/better-auth-api-errors"; + +describe("better-auth OAuth API error event keys", () => { + beforeEach(() => { + isOAuthDebugLoggingMock.mockReset().mockReturnValue(true); + logOAuthDebugMock.mockReset(); + }); + + it.each([ + [{ code: "state_mismatch" }, "oauth.callback.state_mismatch"], + [{ code: "state_not_found" }, "oauth.callback.state_mismatch"], + [{ code: "invalid_grant" }, "oauth.token.invalid_grant"], + [{ code: "invalid_request" }, "oauth.token.invalid_request"], + [{ error: "invalid_grant" }, "oauth.token.invalid_grant"], + ] as const)("maps %j to stable event %s", (error, event) => { + betterAuthApiErrorHandler.onError(error); + + expect(logOAuthDebugMock).toHaveBeenCalledWith( + event, + undefined, + expect.objectContaining({ errorName: expect.any(String) }), + ); + }); +}); diff --git a/tests/unit/calendar-export-cache.test.ts b/tests/unit/calendar-export-cache.test.ts index 74ecedd08..c62422c99 100644 --- a/tests/unit/calendar-export-cache.test.ts +++ b/tests/unit/calendar-export-cache.test.ts @@ -6,6 +6,7 @@ import { resetUserCalendarExportCacheForTest, USER_CALENDAR_EXPORT_FRESH_TTL_MS, } from "@/features/calendar/server/calendar-export-cache"; +import { setCalendarExportRebuildSenderForTest } from "@/features/calendar/server/calendar-export-queue"; import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; const calendarExport = { @@ -33,6 +34,7 @@ function kvNamespace() { describe("用户 iCal 导出缓存", () => { afterEach(() => { resetUserCalendarExportCacheForTest(); + setCalendarExportRebuildSenderForTest(undefined); setCloudflareRuntimeEnv(undefined); vi.useRealTimers(); vi.restoreAllMocks(); @@ -85,13 +87,11 @@ describe("用户 iCal 导出缓存", () => { vi.setSystemTime(new Date("2026-06-07T00:00:00.000Z")); const namespace = kvNamespace(); setCloudflareRuntimeEnv({ CALENDAR_EXPORTS: namespace }); - const buildExport = vi - .fn() - .mockResolvedValueOnce(calendarExport) - .mockResolvedValueOnce({ - ...calendarExport, - text: "BEGIN:VCALENDAR\nX-UPDATED:1\nEND:VCALENDAR", - }); + const enqueued: unknown[] = []; + setCalendarExportRebuildSenderForTest(async (message) => { + enqueued.push(message); + }); + const buildExport = vi.fn().mockResolvedValue(calendarExport); await getCachedUserCalendarExport("user-1", buildExport); vi.advanceTimersByTime(USER_CALENDAR_EXPORT_FRESH_TTL_MS + 1); @@ -104,12 +104,16 @@ describe("用户 iCal 导出缓存", () => { expect(stale.calendar?.text).toBe(calendarExport.text); expect(tasks).toHaveLength(0); expect(buildExport).toHaveBeenCalledTimes(1); - - // Without defer (e.g. local/Node), stale still rebuilds synchronously. - const refreshed = await getCachedUserCalendarExport("user-1", buildExport); - expect(refreshed.status).toBe("miss"); - expect(refreshed.calendar?.text).toContain("X-UPDATED:1"); - expect(buildExport).toHaveBeenCalledTimes(2); + await vi.waitFor(() => expect(enqueued).toHaveLength(1)); + expect(enqueued[0]).toEqual({ type: "user", userId: "user-1" }); + + // Without defer, stale still serves immediately and enqueues rebuild — + // never rebuilds ICS on the request path. + const stillStale = await getCachedUserCalendarExport("user-1", buildExport); + expect(stillStale.status).toBe("stale"); + expect(stillStale.calendar?.text).toBe(calendarExport.text); + expect(buildExport).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(enqueued).toHaveLength(2)); }); it("cold miss 将 KV 写入移出响应关键路径", async () => { @@ -161,13 +165,14 @@ describe("用户 iCal 导出缓存", () => { expect(namespace.put).toHaveBeenCalledTimes(1); }); - it("无 defer 时同步刷新失败仍返回 stale 导出", async () => { + it("无 defer 时仍立即返回 stale 且不在请求路径重建", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-07T00:00:00.000Z")); - const buildExport = vi - .fn() - .mockResolvedValueOnce(calendarExport) - .mockRejectedValueOnce(new Error("database unavailable")); + const enqueued: unknown[] = []; + setCalendarExportRebuildSenderForTest(async (message) => { + enqueued.push(message); + }); + const buildExport = vi.fn().mockResolvedValue(calendarExport); await getCachedUserCalendarExport("user-1", buildExport); vi.advanceTimersByTime(USER_CALENDAR_EXPORT_FRESH_TTL_MS + 1); @@ -175,7 +180,10 @@ describe("用户 iCal 导出缓存", () => { expect(stale.status).toBe("stale"); expect(stale.calendar?.text).toBe(calendarExport.text); - expect(buildExport).toHaveBeenCalledTimes(2); + // Request path must not call buildExport again; rebuild is queued. + expect(buildExport).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(enqueued).toHaveLength(1)); + expect(enqueued[0]).toEqual({ type: "user", userId: "user-1" }); }); it("KV 不可用时仍使用 isolate 内存缓存", async () => { diff --git a/tests/unit/calendar-export-queue.test.ts b/tests/unit/calendar-export-queue.test.ts new file mode 100644 index 000000000..185e3f459 --- /dev/null +++ b/tests/unit/calendar-export-queue.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + enqueueSectionCalendarExportRebuild, + enqueueUserCalendarExportRebuild, + parseCalendarExportRebuildMessage, + setCalendarExportRebuildSenderForTest, +} from "@/features/calendar/server/calendar-export-queue"; +import { setCloudflareRuntimeEnv } from "@/lib/adapters/cloudflare-runtime"; + +describe("calendar export rebuild queue helpers", () => { + afterEach(() => { + setCalendarExportRebuildSenderForTest(undefined); + setCloudflareRuntimeEnv(undefined); + vi.restoreAllMocks(); + }); + + it("parses user and section messages and rejects invalid payloads", () => { + expect( + parseCalendarExportRebuildMessage({ type: "user", userId: "u1" }), + ).toEqual({ + type: "user", + userId: "u1", + }); + expect( + parseCalendarExportRebuildMessage({ type: "section", sectionId: 42 }), + ).toEqual({ type: "section", sectionId: 42 }); + expect( + parseCalendarExportRebuildMessage({ type: "user", userId: "" }), + ).toBeNull(); + expect( + parseCalendarExportRebuildMessage({ type: "section", sectionId: 0 }), + ).toBeNull(); + expect(parseCalendarExportRebuildMessage(null)).toBeNull(); + }); + + it("enqueues user and section rebuild messages through the test sender", async () => { + const enqueued: unknown[] = []; + setCalendarExportRebuildSenderForTest(async (message) => { + enqueued.push(message); + }); + + await enqueueUserCalendarExportRebuild("user-1"); + await enqueueSectionCalendarExportRebuild(7); + + expect(enqueued).toEqual([ + { type: "user", userId: "user-1" }, + { type: "section", sectionId: 7 }, + ]); + }); + + it("sends through the Cloudflare Queue binding when no test sender is set", async () => { + const send = vi.fn().mockResolvedValue(undefined); + setCloudflareRuntimeEnv({ + CALENDAR_EXPORT_REBUILD: { send }, + }); + + await enqueueUserCalendarExportRebuild("user-2"); + + expect(send).toHaveBeenCalledWith({ type: "user", userId: "user-2" }); + }); + + it("ignores empty user ids and non-positive section ids", async () => { + const enqueued: unknown[] = []; + setCalendarExportRebuildSenderForTest(async (message) => { + enqueued.push(message); + }); + + await enqueueUserCalendarExportRebuild(" "); + await enqueueSectionCalendarExportRebuild(-1); + await enqueueSectionCalendarExportRebuild(1.5); + + expect(enqueued).toEqual([]); + }); +}); diff --git a/tests/unit/calendar-export-rebuild.test.ts b/tests/unit/calendar-export-rebuild.test.ts new file mode 100644 index 000000000..d442f5a81 --- /dev/null +++ b/tests/unit/calendar-export-rebuild.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + findManyMock, + getUserCalendarRecordMock, + buildUserCalendarExportMock, + storeBuiltUserCalendarExportMock, +} = vi.hoisted(() => ({ + findManyMock: vi.fn(), + getUserCalendarRecordMock: vi.fn(), + buildUserCalendarExportMock: vi.fn(), + storeBuiltUserCalendarExportMock: vi.fn(), +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + userSectionSubscription: { + findMany: findManyMock, + }, + }, +})); + +vi.mock("@/features/calendar/server/calendar-export-data", () => ({ + getUserCalendarRecord: getUserCalendarRecordMock, +})); + +vi.mock("@/features/calendar/server/calendar-export-service", () => ({ + buildUserCalendarExport: buildUserCalendarExportMock, +})); + +vi.mock("@/features/calendar/server/calendar-export-cache", () => ({ + storeBuiltUserCalendarExport: storeBuiltUserCalendarExportMock, +})); + +import { + collectCalendarExportRebuildUserIds, + handleCalendarExportRebuildBatch, + processCalendarExportRebuildMessages, +} from "@/features/calendar/server/calendar-export-rebuild"; + +describe("calendar export rebuild fan-out", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("coalesces duplicate user ids across user and section messages", async () => { + findManyMock.mockResolvedValue([ + { userId: "user-1" }, + { userId: "user-2" }, + { userId: "user-1" }, + ]); + + const userIds = await collectCalendarExportRebuildUserIds([ + { type: "user", userId: "user-1" }, + { type: "user", userId: "user-3" }, + { type: "section", sectionId: 10 }, + { type: "section", sectionId: 10 }, + ]); + + expect(findManyMock).toHaveBeenCalledTimes(1); + expect(findManyMock).toHaveBeenCalledWith({ + where: { sectionId: 10 }, + select: { userId: true }, + }); + expect(userIds.sort()).toEqual(["user-1", "user-2", "user-3"]); + }); + + it("rebuilds each coalesced user once for a mixed batch", async () => { + findManyMock.mockResolvedValue([ + { userId: "user-1" }, + { userId: "user-2" }, + ]); + getUserCalendarRecordMock.mockImplementation(async (userId: string) => ({ + id: userId, + sectionSubscriptions: [], + todos: [], + })); + buildUserCalendarExportMock.mockResolvedValue({ + cacheControl: "private, max-age=1800", + filename: "life-ustc-subscriptions.ics", + text: "BEGIN:VCALENDAR\nEND:VCALENDAR", + }); + storeBuiltUserCalendarExportMock.mockResolvedValue({ + cacheControl: "private, max-age=1800", + etag: '"etag"', + filename: "life-ustc-subscriptions.ics", + generatedAtMs: Date.now(), + text: "BEGIN:VCALENDAR\nEND:VCALENDAR", + version: 1, + }); + + await processCalendarExportRebuildMessages([ + { type: "user", userId: "user-1" }, + { type: "section", sectionId: 5 }, + ]); + + expect(getUserCalendarRecordMock).toHaveBeenCalledTimes(2); + expect(getUserCalendarRecordMock).toHaveBeenCalledWith("user-1"); + expect(getUserCalendarRecordMock).toHaveBeenCalledWith("user-2"); + expect(storeBuiltUserCalendarExportMock).toHaveBeenCalledTimes(2); + }); + + it("acks valid messages after a successful batch and drops invalid ones", async () => { + findManyMock.mockResolvedValue([]); + getUserCalendarRecordMock.mockResolvedValue(null); + + const valid = { + ack: vi.fn(), + body: { type: "user", userId: "user-1" }, + retry: vi.fn(), + }; + const invalid = { + ack: vi.fn(), + body: { type: "nope" }, + retry: vi.fn(), + }; + + await handleCalendarExportRebuildBatch({ messages: [valid, invalid] }); + + expect(invalid.ack).toHaveBeenCalledOnce(); + expect(invalid.retry).not.toHaveBeenCalled(); + expect(valid.ack).toHaveBeenCalledOnce(); + expect(valid.retry).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/calendar-route-user-access.test.ts b/tests/unit/calendar-route-user-access.test.ts index af8738778..7f38276b8 100644 --- a/tests/unit/calendar-route-user-access.test.ts +++ b/tests/unit/calendar-route-user-access.test.ts @@ -55,7 +55,29 @@ describe("personal calendar access", () => { }); expect(access.ok).toBe(false); - if (!access.ok) expect(access.response.status).toBe(403); + if (!access.ok) { + expect(access.response.status).toBe(410); + expect(access.response.headers.get("Cache-Control")).toBe( + "private, max-age=60", + ); + } expect(resolveApiUserIdMock).not.toHaveBeenCalled(); }); + + it("returns 404 for an unknown user even when a feed token is present", async () => { + getAccessRecordMock.mockResolvedValue(null); + const { resolveUserCalendarAccess } = await import( + "@/lib/api/routes/calendar-route-user-access" + ); + + const access = await resolveUserCalendarAccess({ + rawUserId: "missing-user", + request: new Request( + "https://example.test/api/calendar-feeds/missing-user.ics?token=any-token", + ), + }); + + expect(access.ok).toBe(false); + if (!access.ok) expect(access.response.status).toBe(404); + }); }); diff --git a/tests/unit/comment-read-model-pagination.test.ts b/tests/unit/comment-read-model-pagination.test.ts index 492838120..5587f3363 100644 --- a/tests/unit/comment-read-model-pagination.test.ts +++ b/tests/unit/comment-read-model-pagination.test.ts @@ -6,6 +6,7 @@ const { commentCountMock, commentFindManyMock, contextQueryMock, + logAppEventMock, publicAttachmentSummaryQueryMock, publicQueryMock, publicReactionSummaryQueryMock, @@ -17,6 +18,7 @@ const { commentCountMock: vi.fn(), commentFindManyMock: vi.fn(), contextQueryMock: vi.fn(), + logAppEventMock: vi.fn(), publicAttachmentSummaryQueryMock: vi.fn(), publicQueryMock: vi.fn(), publicReactionSummaryQueryMock: vi.fn(), @@ -43,6 +45,10 @@ vi.mock("@/lib/db/prisma", () => ({ withUserDbContext: withUserDbContextMock, })); +vi.mock("@/lib/log/app-logger", () => ({ + logAppEvent: logAppEventMock, +})); + import { loadCommentThread } from "@/features/comments/server/comment-read-model"; const now = new Date("2026-01-01T00:00:00.000Z"); @@ -120,6 +126,7 @@ describe("loadCommentThread pagination", () => { commentCountMock.mockReset(); commentCountMock.mockResolvedValue(3); accountFindManyMock.mockResolvedValue([]); + logAppEventMock.mockReset(); publicReactionSummaryQueryMock.mockResolvedValue([]); publicAttachmentSummaryQueryMock.mockResolvedValue([]); reactionSummaryQueryMock.mockResolvedValue([]); @@ -429,6 +436,107 @@ describe("loadCommentThread pagination", () => { expect(commentFindManyMock).toHaveBeenCalledTimes(1); }); + it("degrades to empty reactions when comment_reaction_summaries throws P2010", async () => { + commentFindManyMock + .mockResolvedValueOnce([{ id: "root-2" }]) + .mockResolvedValueOnce([comment("root-2")]); + attachmentSummaryQueryMock.mockResolvedValue([ + { + commentId: "root-2", + contentType: "text/plain", + filename: "note.txt", + id: "attachment-1", + size: 12, + uploadId: "upload-1", + }, + ]); + const prismaError = Object.assign(new Error("raw query failed"), { + cause: Object.assign(new Error("permission denied for function"), { + code: "42501", + name: "error", + }), + code: "P2010", + name: "PrismaClientKnownRequestError", + }); + reactionSummaryQueryMock.mockRejectedValue(prismaError); + + const result = await loadCommentThread({ + pagination: { pageSize: 1, skip: 1 }, + target: target({ sectionId: 7 }), + viewer, + viewerUserId: viewer.userId, + }); + + expect(result.comments).toHaveLength(1); + expect(result.comments[0]).toMatchObject({ + id: "root-2", + reactions: [], + attachments: [ + expect.objectContaining({ + id: "attachment-1", + uploadId: "upload-1", + }), + ], + }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "comment.reaction-summaries.failed", + { + code: "P2010", + event: "comment.reaction-summaries.failed", + source: "comments", + }, + prismaError, + ); + }); + + it("degrades to empty attachments when comment_attachment_summaries throws SQLSTATE 42501", async () => { + commentFindManyMock + .mockResolvedValueOnce([{ id: "root-2" }]) + .mockResolvedValueOnce([comment("root-2")]); + reactionSummaryQueryMock.mockResolvedValue([ + { + commentId: "root-2", + count: 1n, + type: "heart", + viewerHasReacted: false, + }, + ]); + const cause = Object.assign(new Error("permission denied for function"), { + code: "42501", + name: "error", + }); + const wrapper = Object.assign(new Error("wrapped"), { + cause, + name: "DriverAdapterError", + }); + attachmentSummaryQueryMock.mockRejectedValue(wrapper); + + const result = await loadCommentThread({ + pagination: { pageSize: 1, skip: 1 }, + target: target({ sectionId: 7 }), + viewer, + viewerUserId: viewer.userId, + }); + + expect(result.comments).toHaveLength(1); + expect(result.comments[0]).toMatchObject({ + id: "root-2", + reactions: [{ count: 1, type: "heart", viewerHasReacted: false }], + attachments: [], + }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "comment.attachment-summaries.failed", + { + code: "42501", + event: "comment.attachment-summaries.failed", + source: "comments", + }, + wrapper, + ); + }); + it("counts anonymous hidden comments across the target without paging them", async () => { commentCountMock.mockReset(); publicQueryMock.mockReset(); diff --git a/tests/unit/mcp-request-rate-limit.test.ts b/tests/unit/mcp-request-rate-limit.test.ts index b57749128..eea5b81d8 100644 --- a/tests/unit/mcp-request-rate-limit.test.ts +++ b/tests/unit/mcp-request-rate-limit.test.ts @@ -277,7 +277,7 @@ describe("MCP mutation rate limits", () => { expect(recordAndLogMcpResponseMock).toHaveBeenCalledWith( expect.objectContaining({ - errorName: "UnknownError", + errorName: "TypeError", phase: "error", status: 500, }), diff --git a/tests/unit/oauth-token-route.test.ts b/tests/unit/oauth-token-route.test.ts index f23cedbcf..b8c99b36c 100644 --- a/tests/unit/oauth-token-route.test.ts +++ b/tests/unit/oauth-token-route.test.ts @@ -586,7 +586,7 @@ describe("OAuth 令牌路由", () => { "error", "oauth.token.grant-validation-failed", { - errorName: "UnknownError", + errorName: "Error", phase: "resolve-active-refresh-grant", }, ); diff --git a/tests/unit/page-inventory.test.ts b/tests/unit/page-inventory.test.ts new file mode 100644 index 000000000..9e952cb4d --- /dev/null +++ b/tests/unit/page-inventory.test.ts @@ -0,0 +1,189 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { workspaceTabIds } from "@/features/dashboard/lib/dashboard-nav"; +import { SETTINGS_TABS } from "@/features/settings/lib/settings-tabs"; +import { + INVENTORY_SETTINGS_TABS, + INVENTORY_WORKSPACE_TABS, + PAGE_INVENTORY, + type PageInventoryEntry, + routeIdFromPageFile, +} from "../e2e/src/app/_shared/page-inventory"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const routesRoot = path.join(repoRoot, "src/routes"); +const e2eRoot = path.join(repoRoot, "tests/e2e"); +const pageContractPath = path.join(e2eRoot, "src/app/_shared/page-contract.ts"); + +async function collectPageFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectPageFiles(fullPath))); + continue; + } + if (entry.name === "+page.svelte") { + files.push(fullPath); + } + } + return files; +} + +function isContractHandled( + contractPath: string, + contractSource: string, + cases: Set, +): boolean { + if (cases.has(contractPath)) { + return true; + } + if ( + contractPath === "/account/settings" || + contractPath.startsWith("/account/settings/") + ) { + return contractSource.includes( + 'routePath.startsWith("/account/settings/")', + ); + } + if ( + contractPath === "/workspace" || + contractPath === "/workspace/[tab]" || + contractPath.startsWith("/workspace/") + ) { + return ( + contractSource.includes('routePath.startsWith("/workspace/")') || + contractSource.includes('routePath === "/workspace"') + ); + } + return false; +} + +function resolveSpecPath(relative: string) { + return path.join(e2eRoot, relative); +} + +function entryHasContractCaller( + entry: PageInventoryEntry, + specSources: Map, +): boolean { + const candidates = [entry.e2eSpec, entry.coveredBy].filter( + (value): value is string => Boolean(value), + ); + if (candidates.length === 0) { + return false; + } + return candidates.some((relative) => { + const source = specSources.get(relative); + if (!source) { + return false; + } + return ( + source.includes("assertPageContract") && + (source.includes(entry.contractPath) || + source.includes(`routePath: "${entry.contractPath}"`) || + // Workspace/settings often pass concrete sample paths via variables. + entry.contractPath.startsWith("/workspace") || + entry.contractPath.startsWith("/account/settings") || + entry.contractPath.includes("[")) + ); + }); +} + +describe("page inventory gate", () => { + it("keeps settings and workspace tab ids aligned with product constants", () => { + expect([...INVENTORY_SETTINGS_TABS]).toEqual([...SETTINGS_TABS]); + expect([...INVENTORY_WORKSPACE_TABS]).toEqual([...workspaceTabIds]); + }); + + it("lists every src/routes +page.svelte route id exactly once", async () => { + const pageFiles = await collectPageFiles(routesRoot); + const routeIds = pageFiles + .map((file) => routeIdFromPageFile(path.relative(routesRoot, file))) + .sort(); + + const inventoryIds = PAGE_INVENTORY.map((entry) => entry.routeId).sort(); + expect(inventoryIds).toEqual(routeIds); + + const duplicates = inventoryIds.filter( + (id, index) => inventoryIds.indexOf(id) !== index, + ); + expect(duplicates).toEqual([]); + }); + + it("requires L1 contract wiring and L2 primary-action coverage", async () => { + const contractSource = await readFile(pageContractPath, "utf8"); + const cases = new Set( + [...contractSource.matchAll(/case\s+"([^"]+)":/g)].map( + (match) => match[1], + ), + ); + + const specSources = new Map(); + async function loadSpec(relative: string) { + const cached = specSources.get(relative); + if (cached !== undefined) { + return cached; + } + const absolute = resolveSpecPath(relative); + expect(existsSync(absolute), `missing e2e spec: ${relative}`).toBe(true); + const source = await readFile(absolute, "utf8"); + specSources.set(relative, source); + return source; + } + + const pageEntries = PAGE_INVENTORY.filter((entry) => entry.kind === "page"); + const redirectEntries = PAGE_INVENTORY.filter( + (entry) => entry.kind === "redirect", + ); + + for (const entry of [...pageEntries, ...redirectEntries]) { + const caller = entry.e2eSpec ?? entry.coveredBy; + expect(caller, `${entry.routeId} missing e2eSpec/coveredBy`).toBeTruthy(); + if (!caller) { + continue; + } + await loadSpec(caller); + + expect( + isContractHandled(entry.contractPath, contractSource, cases), + `${entry.routeId} contractPath ${entry.contractPath} is not handled by assertPageContract`, + ).toBe(true); + + if (entry.kind === "page") { + const callerSource = await loadSpec(caller); + expect( + entryHasContractCaller(entry, specSources) || + callerSource.includes("assertPageContract"), + `${entry.routeId} has no assertPageContract caller in ${caller}`, + ).toBe(true); + } + + for (const action of entry.primaryActions ?? []) { + if (action.exemption) { + expect( + action.exemption === "decorative" || + action.exemption === "live-oauth" || + action.exemption.startsWith("covered-by:"), + ).toBe(true); + continue; + } + expect( + action.e2eSpec, + `${entry.routeId} primaryAction ${action.id} needs e2eSpec or exemption`, + ).toBeTruthy(); + if (!action.e2eSpec) { + continue; + } + await loadSpec(action.e2eSpec); + } + } + }); +}); diff --git a/tests/unit/safe-error-name.test.ts b/tests/unit/safe-error-name.test.ts index 7f6fe1d13..44e818781 100644 --- a/tests/unit/safe-error-name.test.ts +++ b/tests/unit/safe-error-name.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from "vitest"; import { getSafeErrorName } from "@/lib/log/safe-error-name"; +/** Mirrors SvelteKit's non-Error HttpError (no `.name` property). */ +class HttpError { + status: number; + body: { message: string }; + + constructor(status: number, body: string) { + this.status = status; + this.body = { message: body }; + } +} + +/** Mirrors SvelteKitError: extends Error but `.name` stays `"Error"`. */ +class SvelteKitError extends Error { + status: number; + text: string; + + constructor(status: number, text: string, message: string) { + super(message); + this.status = status; + this.text = text; + } +} + +class OpaqueRouteFailure extends Error {} + describe("safe error names", () => { it("keeps allowlisted runtime error classes", () => { expect(getSafeErrorName(new TypeError("private detail"))).toBe("TypeError"); @@ -20,11 +45,55 @@ describe("safe error names", () => { ); }); - it("rejects arbitrary error names even when they look like identifiers", () => { + it("accepts allowlisted SvelteKit error names", () => { + expect(getSafeErrorName({ name: "HttpError" })).toBe("HttpError"); + expect(getSafeErrorName({ name: "Redirect" })).toBe("Redirect"); + expect(getSafeErrorName({ name: "SvelteKitError" })).toBe("SvelteKitError"); + }); + + it("uses HttpError constructor name when .name is absent", () => { + expect(getSafeErrorName(new HttpError(404, "secret path"))).toBe( + "HttpError", + ); + }); + + it("prefers SvelteKitError constructor over generic Error.name", () => { + const error = new SvelteKitError(404, "Not Found", "secret path"); + expect(error.name).toBe("Error"); + expect(getSafeErrorName(error)).toBe("SvelteKitError"); + }); + + it("does not leak arbitrary .name values; falls back to constructor", () => { const error = new Error("private detail"); error.name = "ApiKeyABC123"; - expect(getSafeErrorName(error)).toBe("UnknownError"); + expect(getSafeErrorName(error)).toBe("Error"); + }); + + it("uses custom constructor name when allowlist and cause miss", () => { + expect(getSafeErrorName(new OpaqueRouteFailure("secret"))).toBe( + "OpaqueRouteFailure", + ); + }); + + it("rejects constructor names that are not safe identifiers", () => { + class LocalFailure {} + Object.defineProperty(LocalFailure, "name", { + configurable: true, + value: "bad-name", + }); + + expect(getSafeErrorName(new LocalFailure())).toBe("UnknownError"); + }); + + it("rejects oversized constructor names", () => { + class LocalFailure {} + Object.defineProperty(LocalFailure, "name", { + configurable: true, + value: `E${"x".repeat(64)}`, + }); + + expect(getSafeErrorName(new LocalFailure())).toBe("UnknownError"); }); it("rejects non-errors", () => { @@ -40,4 +109,15 @@ describe("safe error names", () => { expect(getSafeErrorName(wrapper)).toBe("PrismaClientKnownRequestError"); }); + + it("walks cause to HttpError constructor when wrapper name is unknown", () => { + const wrapper = new Error("route failed"); + wrapper.name = "NotAllowlistedWrapper"; + (wrapper as Error & { cause: HttpError }).cause = new HttpError( + 403, + "hidden", + ); + + expect(getSafeErrorName(wrapper)).toBe("HttpError"); + }); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 6a178663a..723d37186 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=/dev/null --include-runtime=false` (hash: d0096d3db905f76097df32c19e7c8944) +// Generated by Wrangler by running `wrangler types --env-file=/dev/null --include-runtime=false` (hash: 3a7f42782d2c249ab96113b0f41e7b21) interface __BaseEnv_Env { CALENDAR_EXPORTS: KVNamespace; CATALOG_DETAIL_CORE: KVNamespace; @@ -7,6 +7,7 @@ interface __BaseEnv_Env { HYPERDRIVE: Hyperdrive; HYPERDRIVE_AUTH: Hyperdrive; ANALYTICS: AnalyticsEngineDataset; + CALENDAR_EXPORT_REBUILD: Queue; USER_WRITE_RATE_LIMITER: RateLimit; USER_BATCH_WRITE_RATE_LIMITER: RateLimit; ASSETS: Fetcher; diff --git a/wrangler.dev.jsonc b/wrangler.dev.jsonc index 0770a5d49..100a4ed9a 100644 --- a/wrangler.dev.jsonc +++ b/wrangler.dev.jsonc @@ -76,5 +76,21 @@ "binding": "R2_UPLOADS", "bucket_name": "life-ustc" } - ] + ], + "queues": { + "producers": [ + { + "binding": "CALENDAR_EXPORT_REBUILD", + "queue": "life-ustc-calendar-export-rebuild" + } + ], + "consumers": [ + { + "queue": "life-ustc-calendar-export-rebuild", + "max_batch_size": 5, + "max_retries": 3, + "dead_letter_queue": "life-ustc-calendar-export-rebuild-dlq" + } + ] + } } diff --git a/wrangler.e2e.jsonc b/wrangler.e2e.jsonc index 92a927a2f..744f33a29 100644 --- a/wrangler.e2e.jsonc +++ b/wrangler.e2e.jsonc @@ -93,5 +93,21 @@ "binding": "R2_UPLOADS", "bucket_name": "life-ustc" } - ] + ], + "queues": { + "producers": [ + { + "binding": "CALENDAR_EXPORT_REBUILD", + "queue": "life-ustc-calendar-export-rebuild" + } + ], + "consumers": [ + { + "queue": "life-ustc-calendar-export-rebuild", + "max_batch_size": 5, + "max_retries": 3, + "dead_letter_queue": "life-ustc-calendar-export-rebuild-dlq" + } + ] + } } diff --git a/wrangler.jsonc b/wrangler.jsonc index 9d3191178..1126bb056 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -54,7 +54,7 @@ "mode": "smart" }, "limits": { - "cpu_ms": 1000 + "cpu_ms": 30000 }, "alias": { "life-ustc-sveltekit-worker": "./.svelte-kit/cloudflare/_sveltekit.js", @@ -114,5 +114,21 @@ "binding": "R2_UPLOADS", "bucket_name": "life-ustc" } - ] + ], + "queues": { + "producers": [ + { + "binding": "CALENDAR_EXPORT_REBUILD", + "queue": "life-ustc-calendar-export-rebuild" + } + ], + "consumers": [ + { + "queue": "life-ustc-calendar-export-rebuild", + "max_batch_size": 5, + "max_retries": 3, + "dead_letter_queue": "life-ustc-calendar-export-rebuild-dlq" + } + ] + } }