Files
zpan/e2e/cloud-store.spec.ts
T
Jasper VanandClaude Opus 4.8 3402a1e099 refactor(api): RESTful resource-oriented API — drop /admin, status sub-resources, merge audience-split routers (#437)
* refactor(api): RESTful resource-oriented API — drop /admin, status sub-resources, merge audience-split routers

Reorganize the entire HTTP surface around resource abstraction instead of
business/audience abstraction.

- Auth: authMiddleware is now soft + global for /api/*; gating is per-route
  (requireAuth/requireAdmin/requireTeamRole), so one resource path serves
  public, user, and admin callers (no security change — guards moved, not dropped).
- Drop /admin from URLs; merge audience-split routers into one resource each
  (announcements, auth-providers, users, teams, quotas, invite-codes,
  site-invitations, downloaders, branding, audit).
- State transitions -> PUT /:id/status: objects (confirm/trash/restore),
  download-tasks (pause/resume/cancel), background-jobs, image-hosting confirm.
- Verbs -> noun sub-resources: objects/:id/copies, download-tasks/:id/attempts,
  background-jobs/:id/retries, site-invitations/:id/deliveries,
  licensing/pairings + /pairings/:code + refresh-runs, teams/:id/invite-links.
- Config -> /api/site/* (branding, email, options, instance, changelog);
  ihost -> image-hosting; me + profiles + admin/users -> one /api/users
  (the :username slot also resolves the internal id, so the admin UI is unchanged).
- External downloader OpenAPI contract cut over in lockstep.

Frontend (rpc.ts + api.ts) and all integration/CF/unit tests updated to match.
Typecheck (server + src), lint:http, biome, and all 4394 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(downloader): regenerate Go client + sync spec for the new RESTful contract

The Go downloader agent (cmd/) and the BDD spec live in this repo, so they must
move with the API:

- Regenerate docs/openapi/downloader.json and cmd/internal/openapi/client.gen.go
  from the updated server OpenAPI.
- Update the hand-written Go client: heartbeat -> /downloaders/me/heartbeats,
  register -> /downloaders, object confirm -> PUT /objects/:id/status, upload
  complete -> PUT .../status, abort -> DELETE .../uploads/:sid. Drop the now-dead
  union helpers (jsonBody/decodeJSON) and the bytes import.
- spec: drop the obsolete teams invite-token-missing scenario (the route is now
  a path param) and add the auth-providers anon-public-list scenario (the merged
  GET serves the public list to anonymous callers).

gofmt clean, go test (121) pass, lint:spec passes (418 scenarios covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(api): cover users admin detail/entitlements + getUser wrapper

Close the patch-coverage gaps from the users-resource merge: add integration
tests for GET /api/users/:id (admin detail, success + 404) and
GET /api/users/:id/entitlements (success + 404), and a unit test for the
getUser() api.ts wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): update Playwright specs + global setup to the new RESTful paths

The e2e specs make direct API calls / response matchers that bypass the SPA, so
they need the new paths too: global-setup storage+options seeding
(/api/storages, /api/site/options), image-host (/api/image-hosting, confirm via
PUT /images/:id/status), object confirm in archive (PUT /objects/:id/status),
announcements and site-invitations (/api/announcements, /api/site-invitations,
/api/site/email). The cloud pairing action:'approve' is the external cloud API,
left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): fix cloud-store instance pairing path to /api/licensing/pairings

The cloud-store spec calls the INSTANCE pairing endpoint directly:
POST /api/licensing/pair -> /api/licensing/pairings and the poll
GET /api/licensing/pair/:code/poll -> GET /api/licensing/pairings/:code.
/api/licensing/status and /binding are unchanged; /api/pairings is the
external cloud API, left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): rename /api/site-invitations to /api/invitations

Avoids visual proximity with the /api/site/* config namespace. Top-level
/api/invitations is unambiguous — team invitations are nested under
/api/teams/:id/invitations and invite codes under /api/invite-codes. URL-only
change; the internal site-invitations naming stays (still the accurate concept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): group resources by functional domain (URLs)

Move non-core resources under functional-domain prefixes (not permission):
- /api/site/* absorbs storages, auth-providers, audit-events, licensing,
  invitations, invite-codes (joining branding, email, options, instance, changelog)
- /api/downloads/* = tasks + downloaders (regenerated OpenAPI + Go client)
Core resources stay top-level. Updates app.ts, rpc.ts, OpenAPI doc + Go agent
client, and all integration/CF/e2e tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): mirror functional-domain grouping in http/ and usecases/ dirs

Reorganize source files to match the functional URL domains established for
the routes, so the directory tree reflects the same grouping as the API:

- http/{site,downloads,image-hosting}/ and usecases/{site,downloads,image-hosting}/
- dissolve the permission-based console/ dir — admin resources are grouped by
  domain (site), not by audience
- console/user -> top-level (users is a core resource, not an admin-only one)

Co-located tests move with their sources; relative imports and vi.mock paths
updated for the new depths. Pure file/directory restructure, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): finish structural cleanup — merge split admin routers, drop rename leftovers

Three follow-ups from the directory-structure review, completing the
one-file-per-resource and domain-named-file conventions:

- Merge the last two audience-split router files into their resource file as a
  second export (matching branding/quotas/invite-codes/site-invitations):
  teams-admin.ts -> teams.ts (adminTeams), licensing-admin.ts -> licensing.ts
  (licensing + licensingAdmin).
- Drop pre-rename filename leftovers now that the dirs carry the domain:
  http/image-hosting/{ihost,ihost-config} -> {images,config};
  http/site/site-invitations -> invitations;
  usecases/site/{site-invitation,site-public-origin} -> {invitation,public-origin};
  usecases/image-hosting/{image-hosting,image-hosting-config} -> {images,config}.
- Group the loose store helpers under the store domain:
  http/{cloud-store-helpers,traffic-metering-utils} -> http/cloud-store/{helpers,traffic-metering}.

Routes and exports unchanged; pure file/structure move. tests + co-located
specs move with their sources. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(api): move announcements under /api/site, co-locate stray tests

Announcements is instance-level, admin-authored content (like branding) — a
site resource, not a top-level one. Move it under the site domain:
- /api/announcements -> /api/site/announcements (mount, RPC base path, api.test, e2e spec)
- http/announcements -> http/site/announcements; usecases/announcement -> usecases/site/announcement

Co-locate the tests that drifted from their sources during the dir reorg
(the 1:1-paired cf-test/unit tests belong next to what they exercise):
- http/storages.cf-test.ts -> http/site/ (next to storages.ts)
- usecases/{license-certificate,license-policy,license-refresh,licensing-admin}.test
  -> usecases/site/ (next to the licensing usecase; imports simplified to ./licensing)

No behavior change beyond the announcements path. Routes/exports otherwise stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(usecases): de-fragment the users and objects domains at the usecase layer

The HTTP layer already serves these as single resources; consolidate their
usecases to match, removing leftover files that mirrored the old split:

- Fold me.ts (avatar) + profile.ts (public lookup) into user.ts — one user
  usecase with self/public/admin sections; drop the stale /api/me/avatar and
  /api/profiles/:username doc comments. Their unit tests move into user.test.ts.
- Fold matter.ts (confirmUpload, draft→active) into object.ts — the objects
  domain is now under one "object" name (the Matter *type* stays in ports/).

Importers updated; no behavior change. server tsc + lint:http + lint:spec clean;
Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(usecases): fold sub-concern usecases into their resource (one file per resource)

Consolidate the usecase layer so each resource is a single source file:

- object.ts absorbs object-upload-session, purge, and save-to-drive (its
  upload-session / recursive-purge / save-to-drive sub-concerns)
- share.ts absorbs share-notification and share-ref

External importers re-pointed (trash, redirect, entry-node, workers/scheduled,
http/share-utils, and the surviving integration/cf tests). share.ts now pulls
copyMatterToOrg/saveShareToDrive from object. share.test.ts asserts the real
notification+email fan-out now that dispatchShareCreated is intra-module.

Shared domain services (storage-usage, cloud-traffic-metering, captcha) stay
separate — they're used by many resources. 5 files removed; no behavior change.
Node 4337 / CF 57 / libsql 6 green; tsc + lint:http + lint:spec clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(http): collapse concern-split integration tests into one per resource

Each resource now has a single Node integration test file; the scenario-split
files fold into their resource's main:

- objects-quota + object-multipart-live -> objects.integration.test.ts
- me + profile -> users.integration.test.ts
- quotas-listing -> quotas.integration.test.ts
- teams-admin -> teams.integration.test.ts
- share-public -> shares.integration.test.ts (share-public.cf-test stays — CF runtime)

Helpers de-duplicated or scoped per describe; all [spec:] breadcrumbs preserved
(lint:spec still 418). 7 files removed, all 4337 tests retained. The multipart-live
block now restoreAllMocks so it exercises the real S3 gateway (latent bug fixed).
Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: finish test-file reorg + convert cloud licensing to a real Playwright e2e

Directory grouping (finishing the reorg): auth tests -> http/auth/, cloud-store
test -> cloud-store/, captcha + signup-mode -> usecases/site/ (with import-depth
fixes the moves needed).

One file per resource at the test layer:
- save-to-drive.integration + purge.integration -> object.integration.test.ts
- save-to-drive.cf-test -> object.cf-test.ts
- share-notification.integration -> share.integration.test.ts
- webdav.e2e (a vitest integration test, not Playwright) -> merged into
  webdav.integration.test.ts

Cloud licensing e2e: e2e-cloud-integration.test.ts was a vitest file mostly
duplicating existing integration coverage (licensing-admin.integration +
licensing-cloud.test) and the pairing e2e already in cloud-store.spec.ts.
Replaced with a real Playwright e2e (e2e/licensing.spec.ts): pair+approve ->
assert a Pro gate opens -> unbind -> assert it closes. Shared pairing helpers
extracted to e2e/helpers.ts (cloud-store.spec now imports them). run-cloud-e2e
runs both cloud specs in one tunnel; CI grep-invert excludes the new title from
the no-cloud run.

tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): move the cloud-store domain under store/ (matches /api/store)

Following the dir move: http/cloud-store/* -> http/store/*, the cloud-store +
cloud-traffic-metering usecases -> usecases/store/, and the top-level
cloud-traffic-metering http integration test -> http/store/. The http/cloud-store.ts
barrel now re-exports from ./store/*. All importers + moved-file imports rewired.

tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): drop the cloud- prefix under store/ now that the dir carries it

- usecases/store/cloud-store -> store.ts; cloud-traffic-metering -> traffic-metering.ts
- http/store/cloud-store.integration -> store.integration; cloud-traffic-metering
  .integration -> traffic-metering.integration
- the http barrel http/cloud-store.ts -> http/store/index.ts (re-exports from
  ./storefront + ./webhooks); app.ts imports './http/store'

store/ is now uniformly named (storefront/webhooks/helpers/shared/traffic-metering
+ store + index). tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(e2e): licensing spec asserts the bind/unbind lifecycle, not a pro-only gate

The cloud E2E account is business-tier; its pairing certificate does not grant
open_registration (that's why the old vitest test seeded a local pro cert for
that assertion). Assert the edition-agnostic licensing lifecycle instead:
pairAndApprove (binds + waits active) -> unbind -> /status reports bound:false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:51:01 -04:00

346 lines
11 KiB
TypeScript

import {
type APIRequestContext,
type Browser,
expect,
type Page,
request as playwrightRequest,
test,
} from '@playwright/test'
import {
expectCloudOk,
getJson,
pairAndApprove,
postJson,
signInAsAdmin,
signUpAndGoToFiles,
unbindCurrentCloudBinding,
} from './helpers'
const LOCALHOST_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/
const CLOUD_BASE_ORIGIN = new URL(process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev').origin
type CloudProduct = {
id: string
name: string
prices: Array<{ id: string; currency: string; amount: number }>
}
type CloudGiftCard = {
code: string | null
codeLast4: string
}
type CloudOrder = {
id: string
paymentStatus: string
fulfillmentStatus: string
}
type CloudStore = {
id: string
type: string
}
type CloudBusinessContext = {
request: APIRequestContext
storeId: string
}
type ResponseLike = {
status(): number
text(): Promise<string>
}
test.describe
.serial('ZPan Cloud store integration', () => {
test.afterAll(async () => {
await unbindCurrentCloudBinding()
})
test('@desktop covers pairing, Cloud store setup, gift-card credit redemption, and checkout', async ({
page,
baseURL,
}) => {
test.setTimeout(420_000)
await signInAsAdmin(page)
const cloud = await ensureCloudBinding(page)
try {
const testId = Date.now()
const packageName = `E2E Cloud Plan ${testId}`
const creditPackageName = `E2E Credits ${testId}`
const storagePlan = await createStoragePlan(cloud, packageName)
const product = await createCreditPackage(cloud, creditPackageName)
const giftCard = await createGiftCard(cloud)
await expectCloudProductVisible(cloud, storagePlan.id, packageName)
await expectCloudGiftCardVisible(cloud, giftCard.code)
await page.goto('/storage')
await expect(page.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible({ timeout: 20_000 })
await expectStorefrontProductVisibleInApi(page, packageName)
const creditsBefore = await getCreditBalance(page)
await redeemGiftCard(page, giftCard.code)
await expect.poll(() => getCreditBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(creditsBefore + 200)
const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin))
if (!hasPublicCallbackUrl) {
test.info().annotations.push({
type: 'checkout-delivery-skipped',
description: 'Cloud staging cannot call back to a localhost ZPan instance.',
})
return
}
await postJson<{ orderId: string; url: string }>(page, '/api/store/checkouts', {
packageId: product.id,
priceId: product.prices[0].id,
})
await expectOrderCreated(page)
} finally {
await cloud.request.dispose()
}
})
test('@desktop lets a regular user list Cloud packages and redeem a gift card', async ({
page,
browser,
baseURL,
}) => {
test.setTimeout(300_000)
await signInAsAdmin(page)
const cloud = await ensureCloudBinding(page)
try {
const testId = Date.now()
const packageName = `E2E User Plan ${testId}`
await createStoragePlan(cloud, packageName)
const giftCard = await createGiftCard(cloud)
await expectStorefrontProductVisibleInApi(page, packageName)
const userContext = await newBrowserContext(browser, baseURL)
try {
const userPage = await userContext.newPage()
await signUpAndGoToFiles(userPage)
await userPage.goto('/storage')
await expect(userPage.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible({ timeout: 20_000 })
await expectStorefrontProductVisibleInApi(userPage, packageName)
const creditsBefore = await getCreditBalance(userPage)
await redeemGiftCard(userPage, giftCard.code)
await expect
.poll(() => getCreditBalance(userPage), { timeout: 20_000 })
.toBeGreaterThanOrEqual(creditsBefore + 200)
} finally {
await userContext.close()
}
} finally {
await cloud.request.dispose()
}
})
})
async function ensureCloudBinding(page: Page): Promise<CloudBusinessContext> {
const approved = await pairAndApprove(page)
return createCloudBusinessContext(CLOUD_BASE_ORIGIN, approved.cloud_store_id)
}
async function createCloudBusinessContext(baseURL: string, storeId?: string): Promise<CloudBusinessContext> {
const email = process.env.E2E_CLOUD_BUSINESS_EMAIL ?? process.env.E2E_CLOUD_PRO_EMAIL
const password = process.env.E2E_CLOUD_BUSINESS_PASSWORD ?? process.env.E2E_CLOUD_PRO_PASSWORD
if (!email || !password) {
throw new Error('E2E_CLOUD_BUSINESS_EMAIL and E2E_CLOUD_BUSINESS_PASSWORD are required')
}
const request = await playwrightRequest.newContext({ baseURL })
const signIn = await request.post('/api/auth/sign-in/email', {
data: { email, password },
})
await expectCloudOk(signIn, 'Cloud test account sign-in failed')
return { request, storeId: storeId ?? (await pollCloudBusinessStore(request)) }
}
async function pollCloudBusinessStore(request: APIRequestContext): Promise<string> {
let storeId: string | null = null
await expect
.poll(
async () => {
const response = await request.get('/api/accounts/me/stores')
await expectCloudOk(response, 'Cloud store list failed')
const body = (await response.json()) as { data?: { items?: CloudStore[] }; items?: CloudStore[] }
const stores = body.data?.items ?? body.items ?? []
storeId = stores.find((store) => store.type === 'instance')?.id ?? null
return storeId
},
{ timeout: 60_000 },
)
.not.toBeNull()
return storeId!
}
async function createStoragePlan(cloud: CloudBusinessContext, name: string) {
return cloudJson<CloudProduct>(cloud, 'POST', `/api/stores/${cloud.storeId}/products`, {
type: 'store_item',
name,
description: 'Playwright staging Cloud store plan',
metadata: {
deliverable: {
type: 'zpan.plan',
storageBytes: 1024 * 1024,
includedCredits: 200,
},
},
prices: [
{
currency: 'usd',
amount: 100,
recurring: { interval: 'month', intervalCount: 1 },
metadata: { creditGrantType: 'subscription_grant', creditAmount: '200' },
},
],
active: true,
sortOrder: -Date.now(),
})
}
async function createCreditPackage(cloud: CloudBusinessContext, name: string) {
return cloudJson<CloudProduct>(cloud, 'POST', `/api/stores/${cloud.storeId}/products`, {
type: 'store_item',
name,
description: 'Playwright staging Cloud store Credits package',
metadata: {
deliverable: {
type: 'zpan.credits',
includedCredits: 200,
},
},
prices: [{ currency: 'usd', amount: 100, metadata: { creditGrantType: 'top_up', creditAmount: '200' } }],
active: true,
sortOrder: -Date.now(),
})
}
async function createGiftCard(cloud: CloudBusinessContext) {
const cards = await cloudJson<CloudGiftCard[]>(cloud, 'POST', `/api/stores/${cloud.storeId}/gift-cards`, {
credits: 200,
count: 1,
})
expect(cards.length).toBe(1)
const card = cards[0]
if (card.code === null) throw new Error('Cloud gift card create response did not include code')
return { ...card, code: card.code }
}
async function expectCloudProductVisible(cloud: CloudBusinessContext, packageId: string, packageName: string) {
await expect
.poll(
async () => {
const product = await cloudJson<CloudProduct>(
cloud,
'GET',
`/api/stores/${cloud.storeId}/products/${packageId}`,
)
return product.name
},
{ timeout: 60_000 },
)
.toBe(packageName)
}
async function expectCloudGiftCardVisible(cloud: CloudBusinessContext, code: string) {
await expect
.poll(
async () => {
const giftCards = await cloudJson<{ items: CloudGiftCard[] }>(
cloud,
'GET',
`/api/stores/${cloud.storeId}/gift-cards`,
)
return giftCards.items.map((item) => item.codeLast4)
},
{ timeout: 60_000 },
)
.toContain(code.slice(-4))
}
async function redeemGiftCard(page: Page, code: string) {
await page.getByRole('button', { name: 'View credit activity' }).click()
const creditsDialog = page.getByRole('dialog', { name: 'Credits' })
await creditsDialog.getByRole('button', { name: 'Redeem gift card' }).click()
const redeemDialog = page.getByRole('dialog', { name: 'Redeem gift card' })
await redeemDialog.getByLabel('Gift card code').fill(code)
const redeemResponse = page.waitForResponse(
(response) => response.url().includes('/api/store/credits/redemptions') && response.request().method() === 'POST',
)
await redeemDialog.getByRole('button', { name: 'Redeem' }).click()
await expectResponseStatus(await redeemResponse, 200)
await expect(page.getByText(/Redeemed successfully/)).toBeVisible({ timeout: 20_000 })
await page.keyboard.press('Escape')
}
async function getCreditBalance(page: Page) {
const credits = await getJson<{ balance: number }>(page, '/api/store/credits')
return credits.balance
}
async function expectStorefrontProductVisibleInApi(page: Page, packageName: string) {
await expect
.poll(
async () => {
try {
const products = await getJson<{ items: CloudProduct[] }>(page, '/api/store/packages')
return products.items.map((item) => item.name)
} catch (error) {
if (isPlaywrightSkipError(error)) throw error
return [`API error: ${error instanceof Error ? error.message : String(error)}`]
}
},
{ timeout: 180_000 },
)
.toContain(packageName)
}
async function expectOrderCreated(page: Page) {
const orders = await getJson<{ items: CloudOrder[] }>(page, '/api/store/orders')
expect(orders.items[0]).toEqual(
expect.objectContaining({
paymentStatus: expect.stringMatching(/paid|pending|unpaid/),
}),
)
return orders
}
async function newBrowserContext(browser: Browser, baseURL: string | undefined) {
return browser.newContext({ baseURL, locale: 'en-US' })
}
async function cloudJson<T>(
cloud: CloudBusinessContext,
method: 'GET' | 'POST',
url: string,
data?: unknown,
): Promise<T> {
const response = await cloud.request.fetch(url, {
method,
data,
})
await expectCloudOk(response, `Cloud ${method} ${url} failed`)
const body = (await response.json()) as { data?: T } | T
return body && typeof body === 'object' && 'data' in body ? (body.data as T) : (body as T)
}
async function expectResponseStatus(response: ResponseLike, status: number) {
if (response.status() === status) return
const text = await response.text()
expect(response.status(), `expected ${status}, got ${response.status()}: ${text}`).toBe(status)
}
function isPlaywrightSkipError(error: unknown) {
return error instanceof Error && error.message.startsWith('Test is skipped:')
}