Files
zpan/e2e/image-host.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

231 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E: Image Host gallery page
*
* Golden path: enable feature → upload (mocked S3 PUT) → view in grid →
* switch to table → copy Markdown URL → delete with Undo → delete permanently.
*
* S3 PUT is intercepted via page.route() because the test environment uses a
* fake storage endpoint. All other API calls go through the real server.
*/
import { type APIResponse, expect, test } from '@playwright/test'
import { expandSignUpForm } from './helpers'
const EMAIL = () => `ihost-${Date.now()}@example.com`
const USERNAME = () => `ihost${Date.now()}`
const PASSWORD = 'password123456'
async function expectApiOk(response: APIResponse, label: string) {
if (response.ok()) return
const body = await response.text().catch(() => '')
expect(response.ok(), `${label} failed with ${response.status()}: ${body}`).toBe(true)
}
async function openImageRowActions(page: import('@playwright/test').Page, fileName: string) {
const row = page.getByRole('row', { name: new RegExp(fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) })
await expect(row).toBeVisible({ timeout: 10000 })
await row.getByRole('button').last().click()
}
async function signUpAndGoToImageHost(page: import('@playwright/test').Page) {
await page.goto('/sign-up')
await expandSignUpForm(page)
await page.getByLabel('Email').fill(EMAIL())
await page.getByLabel('Username').fill(USERNAME())
await page.getByLabel('Password').fill(PASSWORD)
const [resp] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/auth/sign-up')),
page.getByRole('button', { name: 'Sign up' }).click(),
])
expect(resp.status()).toBe(200)
await expect(page).toHaveURL(/files/, { timeout: 10000 })
}
async function enableImageHostFromSettings(page: import('@playwright/test').Page) {
await page.goto('/settings/ihost')
await expect(page).toHaveURL(/settings\/ihost/, { timeout: 10000 })
const enableBtn = page.getByRole('button', { name: /enable|activate/i })
await expect(enableBtn).toBeVisible({ timeout: 10000 })
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/image-hosting/config'), { timeout: 10000 }),
enableBtn.click(),
])
expect(response.ok()).toBe(true)
}
// ---------------------------------------------------------------------------
// Image Host feature gate
// ---------------------------------------------------------------------------
test.describe('Image Host @all', () => {
test('shows enable-feature prompt in settings before activation', async ({ page }) => {
await signUpAndGoToImageHost(page)
await page.goto('/settings/ihost')
// Settings page shows an enable/activate button
const enableBtn = page.getByRole('button', { name: /enable|activate/i })
await expect(enableBtn).toBeVisible({ timeout: 10000 })
})
test('gallery is accessible after enabling the feature', async ({ page }) => {
await signUpAndGoToImageHost(page)
await enableImageHostFromSettings(page)
// Navigate to the image host page
await page.goto('/image-host')
await expect(page).toHaveURL(/image-host/, { timeout: 10000 })
// After enable, the gallery / empty state should be visible
await expect(page.getByText(/drag and drop|no image/i)).toBeVisible({ timeout: 10000 })
})
})
// ---------------------------------------------------------------------------
// Image Host gallery — full golden path
// ---------------------------------------------------------------------------
test.describe('Image Host gallery golden path @all', () => {
// Sign up, enable image hosting, and return to the page
async function setupImageHost(page: import('@playwright/test').Page) {
await signUpAndGoToImageHost(page)
await enableImageHostFromSettings(page)
await page.goto('/image-host')
await expect(page).toHaveURL(/image-host/, { timeout: 10000 })
// Wait for gallery to load
await page.waitForTimeout(500)
}
test('upload → view in grid → switch to table', async ({ page }) => {
await setupImageHost(page)
// Intercept S3 PUT so upload completes without a real S3
await page.route(/presigned-upload|s3\.amazonaws\.com|localhost:9000/, async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({ status: 200, body: '' })
} else {
await route.continue()
}
})
// Create a tiny PNG file buffer (1×1 transparent PNG)
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
)
await page.locator('input[type="file"]').first().setInputFiles({
name: 'test-image.png',
mimeType: 'image/png',
buffer: pngBytes,
})
// Wait for the presign + confirm API calls to complete
const uploadResp = await page.waitForResponse(
(r) => r.url().includes('/api/image-hosting/images') && r.request().method() === 'POST',
{ timeout: 10000 },
)
await expectApiOk(uploadResp, 'Image upload')
// Grid view should be the default
// Switch to table view
const tableViewBtn = page.getByRole('button', { name: /table|list/i })
if (await tableViewBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await tableViewBtn.click()
// Table header or rows should appear
await expect(page.getByRole('table').or(page.getByRole('row')).first()).toBeVisible({ timeout: 5000 })
}
// Switch back to grid
const gridViewBtn = page.getByRole('button', { name: /grid|card/i })
if (await gridViewBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await gridViewBtn.click()
}
})
test('copy URL with Markdown format via row actions', async ({ page, context }) => {
await setupImageHost(page)
// Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
// Seed an image via API so we have something to interact with
const presignResp = await page.request.post('/api/image-hosting/images/presign', {
headers: { 'Content-Type': 'application/json' },
data: { path: 'e2e-copy-test.png', mime: 'image/png', size: 100 },
})
await expectApiOk(presignResp, 'Seed image presign')
const { id: draftId } = await presignResp.json()
// Confirm the draft (simulate successful S3 upload)
const confirmResp = await page.request.put(`/api/image-hosting/images/${draftId}/status`)
await expectApiOk(confirmResp, 'Confirm seeded image')
// Reload to see the seeded image
await page.reload()
await openImageRowActions(page, 'e2e-copy-test.png')
await page.getByRole('menuitem', { name: /copy url/i }).hover()
await page.getByRole('menuitem', { name: /markdown/i }).click()
const clipText = await page.evaluate(() => navigator.clipboard.readText())
expect(clipText).toMatch(/!\[\]\(/)
})
test('delete with Undo → cancel → item restored', async ({ page }) => {
await setupImageHost(page)
// Seed an image
const presignResp = await page.request.post('/api/image-hosting/images/presign', {
headers: { 'Content-Type': 'application/json' },
data: { path: 'e2e-delete-undo.png', mime: 'image/png', size: 100 },
})
await expectApiOk(presignResp, 'Seed image presign')
const { id: draftId } = await presignResp.json()
const confirmResp = await page.request.put(`/api/image-hosting/images/${draftId}/status`)
await expectApiOk(confirmResp, 'Confirm seeded image')
await page.reload()
await openImageRowActions(page, 'e2e-delete-undo.png')
const deleteMenuItem = page.getByRole('menuitem', { name: /delete/i }).first()
await expect(deleteMenuItem).toBeVisible({ timeout: 3000 })
await deleteMenuItem.click()
// Undo toast should appear
const undoBtn = page.getByRole('button', { name: 'Undo', exact: true })
await expect(undoBtn).toBeVisible({ timeout: 5000 })
// Click Undo to cancel the deletion
await undoBtn.click()
// Toast should be dismissed — item remains in the gallery
await expect(undoBtn).not.toBeVisible({ timeout: 3000 })
})
test('delete permanently (let timer expire)', async ({ page }) => {
await setupImageHost(page)
// Seed an image
const presignResp = await page.request.post('/api/image-hosting/images/presign', {
headers: { 'Content-Type': 'application/json' },
data: { path: 'e2e-delete-perm.png', mime: 'image/png', size: 100 },
})
await expectApiOk(presignResp, 'Seed image presign')
const { id: draftId } = await presignResp.json()
const confirmResp = await page.request.put(`/api/image-hosting/images/${draftId}/status`)
await expectApiOk(confirmResp, 'Confirm seeded image')
await page.reload()
await openImageRowActions(page, 'e2e-delete-perm.png')
const deleteMenuItem = page.getByRole('menuitem', { name: /delete/i }).first()
await expect(deleteMenuItem).toBeVisible({ timeout: 3000 })
await deleteMenuItem.click()
// Undo toast appears — wait for the 5s timer, then the DELETE API call fires
const [deleteResp] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/image-hosting/images/') && r.request().method() === 'DELETE', {
timeout: 10000,
}),
page.waitForTimeout(5500), // wait past the 5s undo window
])
expect(deleteResp.ok()).toBe(true)
})
})