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) { 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, db: Awaited>['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>['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) }) })