mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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, 'http://localhost')
|
|
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, 'http://localhost')
|
|
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; parts: Array<{ url: string }> }
|
|
}
|
|
expect(created.status).toBe('draft')
|
|
expect(created.upload.parts.map((part) => part.url)).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)
|
|
})
|
|
})
|