Implement file storage layer with local provider, upload/download API, tests

- StorageProvider interface with LocalProvider (S3 placeholder)
- File table with entity_type/entity_id references, content type, path
- POST /v1/files (multipart upload), GET /v1/files (list by entity),
  GET /v1/files/:id (metadata), GET /v1/files/serve/* (content),
  DELETE /v1/files/:id
- member_identifier drops base64 columns, uses file_id FKs
- File validation: type whitelist, size limits, per-entity max
- Fastify storage plugin injects provider into app
- 6 API tests for upload, list, get, delete, validation
- Test runner kills stale port before starting backend
This commit is contained in:
Ryan Moon
2026-03-28 15:29:06 -05:00
parent de4d2e0a32
commit 760e995ae3
19 changed files with 615 additions and 6 deletions

View File

@@ -3,6 +3,8 @@ import { type TestResult, printSuiteHeader, printTestResult } from './reporter.j
export interface TestContext {
api: ApiClient
token: string
baseUrl: string
test: (name: string, optsOrFn: { tags?: string[] } | (() => Promise<void>), maybeFn?: () => Promise<void>) => void
assert: {
status: (res: ApiResponse, expected: number) => void
@@ -117,6 +119,8 @@ export async function runSuite(
const ctx: TestContext = {
api,
token,
baseUrl,
assert: makeAssert(),
test(name, optsOrFn, maybeFn) {
const opts = typeof optsOrFn === 'function' ? {} : optsOrFn

View File

@@ -77,7 +77,16 @@ async function setupDatabase() {
}
// --- Start backend ---
async function killPort(port: number) {
try {
const { execSync } = await import('child_process')
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' })
await new Promise((r) => setTimeout(r, 1000))
} catch {}
}
async function startBackend(): Promise<Subprocess> {
await killPort(TEST_PORT)
const proc = spawn({
cmd: ['bun', 'run', 'src/main.ts'],
cwd: new URL('..', import.meta.url).pathname,
@@ -90,6 +99,7 @@ async function startBackend(): Promise<Subprocess> {
HOST: '0.0.0.0',
NODE_ENV: 'development',
LOG_LEVEL: 'error',
STORAGE_LOCAL_PATH: '/tmp/forte-test-files',
},
stdout: 'pipe',
stderr: 'pipe',

View File

@@ -0,0 +1,146 @@
import { suite } from '../lib/context.js'
// Helper: create a tiny 1x1 JPEG for testing uploads
const TINY_JPEG = Buffer.from(
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB' +
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEB' +
'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIA' +
'AhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEA' +
'AAAAAAAAAAAAAAAAAAAB/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJgA//9k=',
'base64',
)
suite('Files', { tags: ['files', 'storage'] }, (t) => {
t.test('uploads an image file', { tags: ['upload'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'File Test' })
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
firstName: 'File',
lastName: 'Test',
})
// Upload via multipart
const formData = new FormData()
formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'test.jpg')
formData.append('entityType', 'member_identifier')
formData.append('entityId', member.data.id)
formData.append('category', 'front')
const res = await fetch(`${t.baseUrl}/v1/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${t.token}` },
body: formData,
})
const data = await res.json()
t.assert.equal(res.status, 201)
t.assert.ok(data.id)
t.assert.equal(data.contentType, 'image/jpeg')
t.assert.equal(data.entityType, 'member_identifier')
t.assert.equal(data.category, 'front')
t.assert.ok(data.url)
})
t.test('lists files for an entity', { tags: ['read'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'File List Test' })
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
firstName: 'List',
lastName: 'Files',
})
// Upload a file first
const formData = new FormData()
formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'list-test.jpg')
formData.append('entityType', 'member_identifier')
formData.append('entityId', member.data.id)
formData.append('category', 'back')
await fetch(`${t.baseUrl}/v1/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${t.token}` },
body: formData,
})
const res = await t.api.get('/v1/files', {
entityType: 'member_identifier',
entityId: member.data.id,
})
t.assert.status(res, 200)
t.assert.greaterThan(res.data.data.length, 0)
t.assert.ok(res.data.data[0].url)
})
t.test('gets file metadata by id', { tags: ['read'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'File Get Test' })
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
firstName: 'Get',
lastName: 'File',
})
const formData = new FormData()
formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'get-test.jpg')
formData.append('entityType', 'member_identifier')
formData.append('entityId', member.data.id)
formData.append('category', 'front')
const uploadRes = await fetch(`${t.baseUrl}/v1/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${t.token}` },
body: formData,
})
const uploaded = await uploadRes.json()
const res = await t.api.get(`/v1/files/${uploaded.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.id, uploaded.id)
t.assert.equal(res.data.filename, 'get-test.jpg')
})
t.test('deletes a file', { tags: ['delete'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'File Delete Test' })
const member = await t.api.post(`/v1/accounts/${acct.data.id}/members`, {
firstName: 'Delete',
lastName: 'File',
})
const formData = new FormData()
formData.append('file', new Blob([TINY_JPEG], { type: 'image/jpeg' }), 'delete-test.jpg')
formData.append('entityType', 'member_identifier')
formData.append('entityId', member.data.id)
formData.append('category', 'front')
const uploadRes = await fetch(`${t.baseUrl}/v1/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${t.token}` },
body: formData,
})
const uploaded = await uploadRes.json()
const res = await t.api.del(`/v1/files/${uploaded.id}`)
t.assert.status(res, 200)
const check = await t.api.get(`/v1/files/${uploaded.id}`)
t.assert.status(check, 404)
})
t.test('rejects unsupported file types', { tags: ['validation'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'File Reject Test' })
const formData = new FormData()
formData.append('file', new Blob(['not an image'], { type: 'text/plain' }), 'test.txt')
formData.append('entityType', 'member_identifier')
formData.append('entityId', acct.data.id)
formData.append('category', 'front')
const res = await fetch(`${t.baseUrl}/v1/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${t.token}` },
body: formData,
})
t.assert.equal(res.status, 400)
})
t.test('returns 404 for missing file', { tags: ['read'] }, async () => {
const res = await t.api.get('/v1/files/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
})