mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
ad0f21bb39
* fix!: unify pagination and realtime change delivery Replace offset paging on affected unbounded collections with signed keyset tokens and infinite loading. Persist scoped resource changes so one global SSE connection can resume and invalidate query caches safely. BREAKING CHANGE: migrated list APIs now accept pageToken and return nextPageToken instead of page and total fields. Refs #450 * fix: keep page tokens at the HTTP boundary Move signed page-token handling out of the pure domain layer so dependency-cruiser architecture checks pass without changing behavior. * fix: route background job stats through usecase Keep the HTTP boundary from reaching directly into repository ports and cover the new usecase wrapper. * fix: align clients and checks with cursor pagination * refactor: unify pagination boundaries and infinite loading
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { env } from 'cloudflare:workers'
|
|
import { sql } from 'drizzle-orm'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { S3Service } from '../adapters/gateways/s3'
|
|
import { createApp } from '../app'
|
|
import { createAuth } from '../auth'
|
|
import { createCloudflarePlatform } from '../platform/cloudflare'
|
|
|
|
async function buildApp() {
|
|
const platform = createCloudflarePlatform(env)
|
|
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
|
|
return createApp(platform, auth)
|
|
}
|
|
|
|
async function authedHeaders(app: ReturnType<typeof buildApp>) {
|
|
const email = `cf-obj-${Date.now()}@example.com`
|
|
const res = await app.request('/api/auth/sign-up/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Test', email, password: 'password123456' }),
|
|
})
|
|
const cookies = res.headers.getSetCookie()
|
|
return { Cookie: cookies.join('; ') }
|
|
}
|
|
|
|
describe('[CF] Objects API', () => {
|
|
it('returns 401 without auth', async () => {
|
|
const app = await buildApp()
|
|
const res = await app.request('/api/objects')
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('GET /api/objects returns empty list', async () => {
|
|
const app = await buildApp()
|
|
const headers = await authedHeaders(app)
|
|
const res = await app.request('/api/objects', { headers })
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
expect(body).toEqual({ items: [], nextPageToken: null })
|
|
})
|
|
|
|
it('POST /api/objects returns 400 for invalid input', async () => {
|
|
const app = await buildApp()
|
|
const headers = await authedHeaders(app)
|
|
const res = await app.request('/api/objects', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: '' }),
|
|
})
|
|
expect(res.status).toBe(400)
|
|
})
|
|
|
|
it('POST /api/objects returns 503 when no storage configured', async () => {
|
|
const app = await buildApp()
|
|
const headers = await authedHeaders(app)
|
|
const res = await app.request('/api/objects', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'test.txt', type: 'text/plain' }),
|
|
})
|
|
expect(res.status).toBe(503)
|
|
})
|
|
|
|
it('GET /api/objects/:id returns 404 for missing object', async () => {
|
|
const app = await buildApp()
|
|
const headers = await authedHeaders(app)
|
|
const res = await app.request('/api/objects/nonexistent', { headers })
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('PATCH /api/objects/:id returns 404 for missing object', async () => {
|
|
const app = await buildApp()
|
|
const headers = await authedHeaders(app)
|
|
const res = await app.request('/api/objects/nonexistent', {
|
|
method: 'PATCH',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Nope' }),
|
|
})
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('DELETE /api/objects/:id returns 404 for missing object', async () => {
|
|
const app = await buildApp()
|
|
const headers = await authedHeaders(app)
|
|
const res = await app.request('/api/objects/nonexistent', {
|
|
method: 'DELETE',
|
|
headers,
|
|
})
|
|
expect(res.status).toBe(404)
|
|
})
|
|
})
|
|
|
|
// ─── Upload + trash lifecycle against D1 ──────────────────────────────────────
|
|
// The D1 instance is shared across tests, so each test uses a fresh signed-up
|
|
// user (its own personal org) and a unique storage id to stay isolated.
|
|
|
|
async function buildAppWithDb() {
|
|
const platform = createCloudflarePlatform(env)
|
|
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET)
|
|
return { app: createApp(platform, auth), db: platform.db }
|
|
}
|
|
|
|
async function signUp(app: ReturnType<typeof createApp>, db: Awaited<ReturnType<typeof buildAppWithDb>>['db']) {
|
|
const email = `cf-obj-life-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`
|
|
const res = await app.request('/api/auth/sign-up/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'CF', email, password: 'password123456' }),
|
|
})
|
|
const headers = { Cookie: res.headers.getSetCookie().join('; ') }
|
|
const orgRows = await db.all<{ id: string }>(
|
|
sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' ORDER BY created_at DESC LIMIT 1`,
|
|
)
|
|
return { headers, orgId: orgRows[0].id }
|
|
}
|
|
|
|
async function insertStorage(db: Awaited<ReturnType<typeof buildAppWithDb>>['db'], id: string) {
|
|
const now = Date.now()
|
|
await db.run(sql`
|
|
INSERT OR IGNORE INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
|
VALUES (${id}, 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
|
`)
|
|
}
|
|
|
|
describe('[CF] Objects upload + trash lifecycle (D1)', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
vi.spyOn(S3Service.prototype, 'presignUpload').mockResolvedValue('https://cf-presigned-upload.example.com')
|
|
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
|
|
vi.spyOn(S3Service.prototype, 'deleteObjects').mockResolvedValue(undefined)
|
|
vi.spyOn(S3Service.prototype, 'headObject').mockResolvedValue({
|
|
size: 64,
|
|
contentType: 'text/plain',
|
|
etag: 'cf-etag',
|
|
})
|
|
})
|
|
|
|
it('lists only folders and reports whether they have child folders', async () => {
|
|
const { app, db } = await buildAppWithDb()
|
|
const { headers, orgId } = await signUp(app, db)
|
|
const storageId = `st-cf-tree-${Date.now()}`
|
|
await insertStorage(db, storageId)
|
|
const now = Date.now()
|
|
|
|
await db.run(sql`
|
|
INSERT INTO matters (
|
|
id, org_id, alias, name, type, size, dirtype, parent, object,
|
|
storage_id, status, created_at, updated_at
|
|
)
|
|
VALUES
|
|
('cf-parent', ${orgId}, ${`cf-parent-${now}`}, 'Parent', 'folder', 0, 1, '', '', ${storageId}, 'active', ${now}, ${now}),
|
|
('cf-leaf', ${orgId}, ${`cf-leaf-${now}`}, 'Leaf', 'folder', 0, 1, '', '', ${storageId}, 'active', ${now}, ${now}),
|
|
('cf-child', ${orgId}, ${`cf-child-${now}`}, 'Child', 'folder', 0, 1, 'Parent', '', ${storageId}, 'active', ${now}, ${now}),
|
|
('cf-file', ${orgId}, ${`cf-file-${now}`}, 'file.txt', 'text/plain', 1, 0, '', 'file.txt', ${storageId}, 'active', ${now}, ${now})
|
|
`)
|
|
|
|
const res = await app.request('/api/objects?type=folder&pageSize=100', { headers })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { items: Array<{ id: string; hasChildren: boolean }> }
|
|
expect(body.items).toHaveLength(2)
|
|
expect(body.items).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: 'cf-parent', hasChildren: true }),
|
|
expect.objectContaining({ id: 'cf-leaf', hasChildren: false }),
|
|
]),
|
|
)
|
|
})
|
|
|
|
it('creates a file draft, finalizes it via completions, then soft-deletes, restores, and purges', async () => {
|
|
const { app, db } = await buildAppWithDb()
|
|
const { headers } = await signUp(app, db)
|
|
await insertStorage(db, `st-cf-life-${Date.now()}`)
|
|
|
|
// Create draft → single-PUT upload instructions.
|
|
const createRes = await app.request('/api/objects', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'cf-upload.txt', type: 'text/plain', size: 64, parent: '', dirtype: 0 }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const created = (await createRes.json()) as {
|
|
id: string
|
|
status: string
|
|
upload: { sessionId: string; urls: string[] }
|
|
}
|
|
expect(created.status).toBe('draft')
|
|
expect(created.upload.urls).toEqual(['https://cf-presigned-upload.example.com'])
|
|
|
|
// Finalize via completions (HEAD etag matches).
|
|
const completeRes = await app.request(
|
|
`/api/objects/${created.id}/uploads/${created.upload.sessionId}/completions`,
|
|
{
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'cf-etag' }] }),
|
|
},
|
|
)
|
|
expect(completeRes.status).toBe(200)
|
|
expect(((await completeRes.json()) as { status: string }).status).toBe('active')
|
|
|
|
// Soft delete → 204, then it appears in the trash listing.
|
|
const trashRes = await app.request(`/api/objects/${created.id}`, { method: 'DELETE', headers })
|
|
expect(trashRes.status).toBe(204)
|
|
const trashList = await app.request('/api/trash/objects', { headers })
|
|
const trashBody = (await trashList.json()) as { items: Array<{ id: string }> }
|
|
expect(trashBody.items.some((m) => m.id === created.id)).toBe(true)
|
|
|
|
// Restore → live again.
|
|
const restoreRes = await app.request(`/api/trash/objects/${created.id}/restorations`, {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
})
|
|
expect(restoreRes.status).toBe(200)
|
|
expect(((await restoreRes.json()) as { trashedAt: number | null }).trashedAt).toBeNull()
|
|
|
|
// Soft delete again, then permanently purge → 204.
|
|
await app.request(`/api/objects/${created.id}`, { method: 'DELETE', headers })
|
|
const purgeRes = await app.request(`/api/trash/objects/${created.id}`, { method: 'DELETE', headers })
|
|
expect(purgeRes.status).toBe(204)
|
|
const gone = await app.request(`/api/objects/${created.id}`, { headers })
|
|
expect(gone.status).toBe(404)
|
|
})
|
|
|
|
it('aborts an in-progress upload via DELETE /uploads/:sid and discards the draft', async () => {
|
|
const { app, db } = await buildAppWithDb()
|
|
const { headers } = await signUp(app, db)
|
|
await insertStorage(db, `st-cf-abort-${Date.now()}`)
|
|
|
|
const createRes = await app.request('/api/objects', {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'cf-abort.txt', type: 'text/plain', size: 64, parent: '', dirtype: 0 }),
|
|
})
|
|
expect(createRes.status).toBe(201)
|
|
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
|
|
|
|
const abortRes = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}`, {
|
|
method: 'DELETE',
|
|
headers,
|
|
})
|
|
expect(abortRes.status).toBe(204)
|
|
const gone = await app.request(`/api/objects/${created.id}`, { headers })
|
|
expect(gone.status).toBe(404)
|
|
})
|
|
})
|