mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
3402a1e099
* 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>
229 lines
9.1 KiB
TypeScript
229 lines
9.1 KiB
TypeScript
import { eq } from 'drizzle-orm'
|
|
import { nanoid } from 'nanoid'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import * as authSchema from '../db/auth-schema.js'
|
|
import { notifications, systemOptions } from '../db/schema.js'
|
|
import { createTestApp } from '../test/setup.js'
|
|
import type { ShareNotificationRecipient, ShareNotificationShare } from './ports'
|
|
import { dispatchShareCreated } from './share.js'
|
|
|
|
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
|
|
type TestDb = TestCtx['db']
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) {
|
|
const id = overrides.id ?? nanoid()
|
|
const email = overrides.email ?? `${id}@example.com`
|
|
await db.insert(authSchema.user).values({
|
|
id,
|
|
name: 'Test User',
|
|
email,
|
|
emailVerified: false,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
return { id, email }
|
|
}
|
|
|
|
function makeShare(overrides: Partial<ShareNotificationShare> = {}): ShareNotificationShare {
|
|
return {
|
|
id: overrides.id ?? nanoid(),
|
|
token: overrides.token ?? nanoid(10),
|
|
kind: overrides.kind ?? 'landing',
|
|
expiresAt: overrides.expiresAt ?? null,
|
|
}
|
|
}
|
|
|
|
async function configureEmail(db: TestDb) {
|
|
await db.insert(systemOptions).values([
|
|
{ key: 'email_enabled', value: 'true', public: false },
|
|
{ key: 'email_provider', value: 'smtp', public: false },
|
|
{ key: 'email_from', value: 'no-reply@example.com', public: false },
|
|
{ key: 'email_smtp_host', value: 'smtp.example.com', public: false },
|
|
{ key: 'email_smtp_port', value: '587', public: false },
|
|
])
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('dispatchShareCreated', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('inserts a notification row when recipient has recipientUserId', async () => {
|
|
const ctx = await createTestApp()
|
|
const user = await insertUser(ctx.db)
|
|
const share = makeShare()
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientUserId: user.id }]
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Alice', 'secret.pdf')
|
|
|
|
const rows = await ctx.db.select().from(notifications).where(eq(notifications.userId, user.id))
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0].type).toBe('share_received')
|
|
expect(rows[0].title).toContain('Alice')
|
|
expect(rows[0].title).toContain('secret.pdf')
|
|
expect(rows[0].refType).toBe('share')
|
|
expect(rows[0].refId).toBe(share.id)
|
|
})
|
|
|
|
it('does not insert notification when recipient has only email (no userId)', async () => {
|
|
const ctx = await createTestApp()
|
|
const share = makeShare()
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'someone@example.com' }]
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Bob', 'file.txt')
|
|
|
|
const rows = await ctx.db.select().from(notifications)
|
|
expect(rows).toHaveLength(0)
|
|
})
|
|
|
|
it('does not send email and does not throw when email is not configured', async () => {
|
|
const ctx = await createTestApp()
|
|
const sendSpy = vi.spyOn(ctx.deps.email, 'send')
|
|
const share = makeShare()
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'test@example.com' }]
|
|
|
|
// No email config in DB
|
|
await expect(
|
|
dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Carol', 'report.docx'),
|
|
).resolves.toBeUndefined()
|
|
expect(sendSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends email when email is configured and recipient has email', async () => {
|
|
const ctx = await createTestApp()
|
|
const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockResolvedValue(undefined)
|
|
|
|
await configureEmail(ctx.db)
|
|
|
|
const share = makeShare()
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'dave@example.com' }]
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Eve', 'photo.jpg')
|
|
|
|
expect(sendSpy).toHaveBeenCalledOnce()
|
|
const callArgs = sendSpy.mock.calls[0]
|
|
// send(platform, message) — second arg is the message
|
|
expect(callArgs[1].to).toBe('dave@example.com')
|
|
expect(callArgs[1].subject).toContain('Eve')
|
|
expect(callArgs[1].subject).toContain('photo.jpg')
|
|
})
|
|
|
|
it('looks up email from user table when recipient has only recipientUserId and email is configured', async () => {
|
|
const ctx = await createTestApp()
|
|
const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockResolvedValue(undefined)
|
|
|
|
await configureEmail(ctx.db)
|
|
|
|
const user = await insertUser(ctx.db, { email: 'frank@example.com' })
|
|
const share = makeShare()
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientUserId: user.id }]
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Grace', 'budget.xlsx')
|
|
|
|
expect(sendSpy).toHaveBeenCalledOnce()
|
|
const callArgs = sendSpy.mock.calls[0]
|
|
expect(callArgs[1].to).toBe('frank@example.com')
|
|
})
|
|
|
|
it('does not throw when email send fails — logs and continues', async () => {
|
|
const ctx = await createTestApp()
|
|
const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockRejectedValue(new Error('SMTP down'))
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
await configureEmail(ctx.db)
|
|
|
|
const share = makeShare()
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'victim@example.com' }]
|
|
|
|
// Should NOT throw despite email failure
|
|
await expect(
|
|
dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Sender', 'file.txt'),
|
|
).resolves.toBeUndefined()
|
|
expect(sendSpy).toHaveBeenCalledOnce()
|
|
expect(consoleErrorSpy).toHaveBeenCalled()
|
|
})
|
|
|
|
it('inserts in-app notifications for all recipients that have recipientUserId', async () => {
|
|
const ctx = await createTestApp()
|
|
const user1 = await insertUser(ctx.db)
|
|
const user2 = await insertUser(ctx.db)
|
|
const share = makeShare()
|
|
|
|
const recipients: ShareNotificationRecipient[] = [
|
|
{ recipientUserId: user1.id },
|
|
{ recipientUserId: user2.id },
|
|
{ recipientEmail: 'no-account@example.com' },
|
|
]
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Hub', 'multi.zip')
|
|
|
|
const rows1 = await ctx.db.select().from(notifications).where(eq(notifications.userId, user1.id))
|
|
expect(rows1).toHaveLength(1)
|
|
|
|
const rows2 = await ctx.db.select().from(notifications).where(eq(notifications.userId, user2.id))
|
|
expect(rows2).toHaveLength(1)
|
|
|
|
// No notification for email-only recipient
|
|
const allRows = await ctx.db.select().from(notifications)
|
|
expect(allRows).toHaveLength(2)
|
|
})
|
|
|
|
it('uses /s/{token} URL for landing shares in notification metadata', async () => {
|
|
const ctx = await createTestApp()
|
|
const user = await insertUser(ctx.db)
|
|
const share = makeShare({ kind: 'landing', token: 'abc123token' })
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, [{ recipientUserId: user.id }], 'Ian', 'landing.pdf')
|
|
|
|
const rows = await ctx.db.select().from(notifications).where(eq(notifications.userId, user.id))
|
|
expect(rows).toHaveLength(1)
|
|
const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record<string, unknown>
|
|
expect(metadata.token).toBe('abc123token')
|
|
expect(metadata.kind).toBe('landing')
|
|
})
|
|
|
|
it('uses /r/{token} URL for direct shares in notification metadata', async () => {
|
|
const ctx = await createTestApp()
|
|
const user = await insertUser(ctx.db)
|
|
const share = makeShare({ kind: 'direct', token: 'directtoken1' })
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, [{ recipientUserId: user.id }], 'Jane', 'direct.mp4')
|
|
|
|
const rows = await ctx.db.select().from(notifications).where(eq(notifications.userId, user.id))
|
|
expect(rows).toHaveLength(1)
|
|
const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record<string, unknown>
|
|
expect(metadata.kind).toBe('direct')
|
|
})
|
|
|
|
it('includes expiresAt in email body when share has an expiry date', async () => {
|
|
const ctx = await createTestApp()
|
|
const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockResolvedValue(undefined)
|
|
|
|
await configureEmail(ctx.db)
|
|
|
|
const expiresAt = new Date('2026-12-31T00:00:00Z')
|
|
const share = makeShare({ expiresAt })
|
|
const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'reader@example.com' }]
|
|
|
|
await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Karl', 'expiring.pdf')
|
|
|
|
expect(sendSpy).toHaveBeenCalledOnce()
|
|
const emailHtml = sendSpy.mock.calls[0][1].html
|
|
expect(emailHtml).toContain('2026-12-31')
|
|
})
|
|
|
|
it('handles empty recipients array without errors', async () => {
|
|
const ctx = await createTestApp()
|
|
const share = makeShare()
|
|
|
|
await expect(dispatchShareCreated(ctx.deps, ctx.platform, share, [], 'Leo', 'empty.txt')).resolves.toBeUndefined()
|
|
|
|
const rows = await ctx.db.select().from(notifications)
|
|
expect(rows).toHaveLength(0)
|
|
})
|
|
})
|