mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
705aa67a2d
* refactor(server): enforce http→usecase boundary + extract storages usecase
Adds an AST-based lint (scripts/lint-http-boundary.ts, `pnpm lint:http`, wired
into CI) that forbids http handlers from reaching into deps ports directly
(`c.get('deps').<port>.<method>()`) — the runtime signal of business logic
leaking into the delivery layer, which dependency-cruiser's import-graph rules
cannot see. It ships with a migration ratchet of the 30 handlers that still
violate: CI fails on any new violation and on any ratcheted file that has become
clean, so the list only shrinks. When empty, the boundary is locked.
Converts storages as the first usecase-per-resource example:
- usecases/storage.ts owns all storage business rules (Community storage limit,
egress-credit-billing feature gate, activity logging)
- http/storages.ts is now thin: validate → call usecase → serialize
- usecases/storage.test.ts exhausts the branches with fake ports (14 cases)
- storages removed from the ratchet (29 remain)
Behavior preserved: storages.integration.test.ts (24) unchanged and green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract profile + notification resource usecases
Converts two owner/public single-port resources to the usecase-per-resource
convention (handlers now only validate → call usecase → serialize):
- usecases/profile.ts (getPublicProfile) + usecases/notification.ts
(list/unreadCount/markRead/markAllRead), each with fake-port unit tests
- http/profile.ts, http/notifications.ts no longer touch deps ports
- ratchet: 29 → 27
Behavior preserved: profile + notifications integration suites (23) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract audit + quota + announcement resource usecases
- usecases/audit.ts (listAuditEvents)
- usecases/quota.ts (listQuotaOverview with org-type parsing, getUserQuota with
personal-org fallback)
- usecases/announcement.ts (user/admin list + CRUD)
Handlers keep only pure input parsing (pagination clamp) + serialization; no
deps-port access. Each usecase has fake-port unit tests (12 cases).
ratchet: 27 → 24
Behavior preserved: audit/quotas/announcements integration suites (65) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract auth-provider/background-job/email-config/invite-code/site-invitation/user usecases
Wave of six independent resources converted to usecase-per-resource (parallel
subagents, centrally verified). Each: new usecases/<resource>.ts holding all
port access + business rules, a thinned handler (validate → call usecase →
serialize, no deps-port access), and fake-port unit tests.
- auth-provider.ts: provider config list/upsert/delete; OIDC validation +
social-login free-limit gate as outcome unions
- background-job.ts: list/get/cancel/create/retry; keeps port-thrown
BackgroundJobError mapping
- email-config.ts: masked get / save rows / send-test (send_failed outcome)
- invite-code.ts: list/validate/generate(expiry policy)/delete outcome union
- site-invitation.ts: create/resend/revoke/getByToken; email-before-write
ordering preserved
- user.ts: admin user status/delete + entitlement CRUD; repo-chosen failure
statuses threaded through unchanged
ratchet: 24 → 18
Verified: typecheck, lint:http, lint:arch, biome all clean; 90 new unit tests +
124 existing integration tests green (behavior preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract ihost/team/branding/me/trash/system resource usecases
Second parallel wave (centrally verified). Handlers thinned to validate → call
usecase → serialize; all port access + business rules moved into usecases.
- image-hosting.ts (extended) + image-hosting-config.ts: ihost upload/list/delete
+ config CRUD with CF custom-hostname lifecycle; quota→422 preserved
- team.ts: /api/teams + /api/admin/teams (invite links, join, activity feed,
org logo, admin quota entitlements); role checks + repo-failure threading
- branding.ts (extended): admin write orchestration (logo/favicon upload,
theme, single audit event) + reset; white_label gating stays in middleware
- me.ts: avatar upload/delete (gateway status passthrough, DB-first delete)
- trash.ts: empty-trash (reuses purge.ts; trash_empty audit only when >0)
- system.ts: instance info, changelog, system-options CRUD (signup/captcha/quota
validation ordering preserved)
Also removed dead code: the speculative team.ts createTeamGate (the team-create
limit is enforced in auth.ts via licensing.checkTeamLimit; nothing called it).
ratchet: 18 → 10
Verified: typecheck, lint:http, lint:arch, biome clean; 148 new unit tests +
228 integration tests green (behavior preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract cloud-store + licensing-admin + events usecases
Third parallel wave (centrally verified + test fixups).
- cloud-store.ts: storefront reads, checkout/orders, and webhook delivery
(cloud event token verification + idempotency); binding gate as outcome union
- licensing.ts (extended, admin section): initiatePairing / pollPairing (cert
verify + rollback) / triggerRefresh / unbindLicense
- events.ts: the multiplexed SSE stream as a (deps, params, signal, emit)
usecase; the handler owns the ReadableStream/Response and feeds ONE
AbortController from both teardown paths (request abort + body cancel)
Streaming fix: guard the stream controller so a consumer cancel() — which
already closes it before firing the abort listener — no longer double-closes
(ERR_INVALID_STATE), eliminating the unhandled errors in the events suite.
Test fixups (behavior was correct, verified by integration): cloud-store fake
rebuilt the bound client per request and reset its response queue (singleton
now); licensing-admin unit test forced onto the node env (paseto-ts needs a
real TextEncoder).
ratchet: 10 → 5
Verified: typecheck, lint:http, lint:arch, biome clean; 61 unit + 77 integration
tests green (behavior preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract download-traffic metering into a usecase
traffic-metering-utils.ts was an http helper that read deps off the request and
ran the quota+egress download meter inline. Moves the decision into
cloud-traffic-metering.ts as meterDownloadTraffic / reportDownloadEgress
(deps-first, returning a plain {ok|quota_exceeded|insufficient_credits}
outcome). The http helper stays as a thin Context adapter that resolves the
cloud base URL, calls the usecase (deps passed whole), and renders the 422/402
responses — so its four consumers (shares, objects, redirect, webdav) are
unchanged and the file is now boundary-clean.
ratchet: 5 → 4
Verified: typecheck, lint:http, lint:arch, biome clean; cloud-traffic-metering
unit (13, incl. 3 new download tests) + 104 consumer integration tests
(redirect/objects-quota/share-public/cloud-traffic-metering) green — download
metering behavior preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract redirect + share + object resource usecases
The download-flow consumers. Each download orchestration (resolve → access/
expiry/limit gates → atomic increment → meter → presign → audit) moved into its
resource usecase, which calls meterDownloadTraffic/reportDownloadEgress(deps, …)
directly; the handler computes cloudBaseUrl, manages cookies, and renders the
route-specific 302/JSON/410/422/402 responses from the returned outcome.
- usecases/redirect.ts: /r/:token (ds_ direct share + ih_ image hosting), with
refer-allowlist + presign-rollback
- usecases/share.ts: public + authed share routes; cookies become usecase
*decisions* the handler applies (view-dedup, password session); imports
save-to-drive + share-notification unchanged
- usecases/object.ts: upload sessions, confirm, list/move/trash/restore/delete,
copy/transfer, download; keeps ObjectUploadSessionError; consolidated two
identical write-access middlewares
- usecases/share-ref.ts: pure share-token helpers (HMAC ref codec, breadcrumb,
access gate, presign TTL) moved out of http/share-utils so usecases can import
them without reaching into http; share-utils re-exports them for handlers
ratchet: 4 → 1 (only webdav remains)
Verified: typecheck, lint:http, lint:arch, biome clean; 152 new unit + 206
integration tests (redirect/shares/share-public/objects/objects-quota) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract webdav resource usecase — http boundary fully locked
The last and largest handler (1273 lines, 63 violations). All WebDAV port
orchestration — auth resolution, path/lock/dead-property access, PROPPATCH, PUT
(streamed reservation + rollback), MKCOL, DELETE, MOVE, recursive COPY, and the
GET download metering — moves into usecases/webdav.ts. The handler keeps the
protocol machinery: XML multistatus rendering, status codes (207/201/204/423/
412/409/416), header parsing (Depth/Destination/Range/If/Lock-Token/Overwrite),
basic-auth/API-key parsing, and all streaming Response framing (FixedLengthStream,
single-range 206, multipart/byteranges). The GET path calls meterDownloadTraffic
directly; getWebDavObjectBody returns the S3 body for the handler to stream,
preserving the exact (storage, object[, range]) call shape and refund-on-failure.
ratchet: 1 → 0. `pnpm lint:http` now reports "http boundary fully locked".
Verified: typecheck, lint:http (LOCKED), lint:arch, biome clean; 43 unit + 41
integration tests (the 2027-line webdav spec) green — behavior preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(webdav): preserve api-key rate-limit message; deterministic object test dates
- resolveWebDavAuth now threads the original ApiKeyRateLimitError message
through its rate_limited outcome so the 429 body stays "Rate limit exceeded."
(the webdav auth refactor had hardcoded "Rate limited") — restores
api-keys-rate-limit.integration.test.ts.
- object.test.ts file() used argless new Date() in both the mock and the
expected value; a shared FIXED_DATE makes the deep-equal deterministic (it
flaked under full-suite load).
Full suite green: 4327 passed (184 files).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(e2e): isolate the e2e database — own throwaway DB, wiped each run
entry-node and e2e/global-setup both honor DATABASE_URL, but it defaulted to the
shared dev ./zpan.db and nothing wiped it — so a local `pnpm e2e` ran against
(and mutated) the dev database and wasn't clean between runs. playwright.config
now defaults DATABASE_URL to a throwaway .e2e/e2e.db (node runtime; CF uses D1)
and wipes it on every run, and sets reuseExistingServer:false so e2e never
silently reuses a running dev server. CI is unaffected (fresh box; reuse already
off). Opt out by setting DATABASE_URL yourself.
Verified: `pnpm e2e auth.spec.ts` (7 passed) ran on .e2e/e2e.db while ./zpan.db
stayed byte-for-byte unchanged (mtime+size identical).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
import { mkdirSync, rmSync } from 'node:fs'
|
|
import { defineConfig, devices } from '@playwright/test'
|
|
|
|
// e2e runs against its OWN throwaway SQLite database, wiped on every run, so it
|
|
// never reads or writes the dev ./zpan.db. entry-node and e2e/global-setup both
|
|
// honor DATABASE_URL, so setting it here (before the web servers spawn) isolates
|
|
// the whole stack. The CF runtime uses D1, not this file; set DATABASE_URL
|
|
// yourself to opt out.
|
|
if (process.env.E2E_RUNTIME !== 'cf' && !process.env.DATABASE_URL) {
|
|
process.env.DATABASE_URL = '.e2e/e2e.db'
|
|
rmSync('.e2e', { recursive: true, force: true })
|
|
mkdirSync('.e2e', { recursive: true })
|
|
}
|
|
|
|
const isCF = process.env.E2E_RUNTIME === 'cf'
|
|
const envFile = process.env.CI ? '' : '--env-file=.dev.vars'
|
|
const chromeHostResolverRules = process.env.E2E_CHROME_HOST_RESOLVER_RULES
|
|
const appPort = Number(process.env.E2E_APP_PORT ?? 5185)
|
|
const apiPort = Number(process.env.E2E_API_PORT ?? 8222)
|
|
const s3MockPort = Number(process.env.E2E_S3_MOCK_PORT ?? 9191)
|
|
const nodeCommand = JSON.stringify(process.execPath)
|
|
|
|
const s3MockServer = process.env.E2E_S3_MOCK
|
|
? [
|
|
{
|
|
command: `node scripts/s3-mock.mjs`,
|
|
port: s3MockPort,
|
|
reuseExistingServer: false,
|
|
},
|
|
]
|
|
: []
|
|
|
|
const nodeServers = [
|
|
...s3MockServer,
|
|
{
|
|
command: `PORT=${apiPort} ${nodeCommand} ${envFile} node_modules/tsx/dist/cli.mjs server/entry-node.ts`,
|
|
port: apiPort,
|
|
reuseExistingServer: false,
|
|
},
|
|
{
|
|
command: `${nodeCommand} ${envFile} node_modules/vite/bin/vite.js --mode node --host 127.0.0.1 --port ${appPort} --strictPort`,
|
|
port: appPort,
|
|
reuseExistingServer: false,
|
|
},
|
|
]
|
|
|
|
const cfServers = [
|
|
...s3MockServer,
|
|
{
|
|
command: `vite dev --host 127.0.0.1 --port ${appPort} --strictPort`,
|
|
port: appPort,
|
|
reuseExistingServer: false,
|
|
},
|
|
]
|
|
|
|
export default defineConfig({
|
|
testDir: './e2e',
|
|
timeout: process.env.CI ? 180000 : 30000,
|
|
// The suite shares one local dev server pair and its own (throwaway) SQLite
|
|
// database. Keep execution serial to avoid flaky connection resets and
|
|
// cross-test bleed.
|
|
workers: 1,
|
|
retries: process.env.CI ? 1 : 0,
|
|
reporter: process.env.CI ? 'github' : 'list',
|
|
use: {
|
|
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5185',
|
|
headless: true,
|
|
channel: process.env.CI ? 'chrome' : undefined,
|
|
launchOptions: chromeHostResolverRules
|
|
? { args: [`--host-resolver-rules=${chromeHostResolverRules}`] }
|
|
: undefined,
|
|
trace: 'on-first-retry',
|
|
},
|
|
projects: [
|
|
{
|
|
name: 'setup',
|
|
testMatch: /global-setup\.ts/,
|
|
},
|
|
{
|
|
name: 'desktop',
|
|
grep: /@desktop|@all/,
|
|
dependencies: ['setup'],
|
|
use: { ...devices['Desktop Chrome'] },
|
|
},
|
|
{
|
|
name: 'tablet',
|
|
grep: /@tablet|@all/,
|
|
dependencies: ['setup'],
|
|
use: {
|
|
...devices['Desktop Chrome'],
|
|
viewport: { width: 768, height: 1024 },
|
|
},
|
|
},
|
|
{
|
|
name: 'mobile',
|
|
grep: /@mobile|@all/,
|
|
dependencies: ['setup'],
|
|
use: {
|
|
...devices['Desktop Chrome'],
|
|
viewport: { width: 390, height: 844 },
|
|
isMobile: true,
|
|
},
|
|
},
|
|
],
|
|
webServer: isCF ? cfServers : nodeServers,
|
|
})
|