Skip to content

Commit 5ed27e8

Browse files
Merge branch 'main' into feat/notification-service-122
2 parents 37b240f + 2691efc commit 5ed27e8

105 files changed

Lines changed: 14191 additions & 1109 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,26 @@ jobs:
1414
- name: Checkout Repository
1515
uses: actions/checkout@v4
1616

17+
- name: Setup pnpm
18+
uses: pnpm/action-setup@v4
19+
with:
20+
version: 10.32.1
21+
1722
- name: Setup Node.js
1823
uses: actions/setup-node@v4
1924
with:
2025
node-version: '20'
21-
cache: 'npm'
26+
cache: 'pnpm'
27+
cache-dependency-path: pnpm-lock.yaml
2228

2329
- name: Install Dependencies
24-
run: npm ci
30+
run: pnpm install --frozen-lockfile
2531

2632
- name: Run Lint
27-
run: npm run lint
33+
run: pnpm lint
2834

2935
- name: Build Project
30-
run: npm run build
36+
run: pnpm build
3137
env:
3238
# Provide a placeholder so Next.js module evaluation doesn't throw
3339
# during the build. No real DB connection is made at build time.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ yarn-error.log*
2626
.codex/
2727
.codex
2828

29+
# file uploads (milestone deliverables)
30+
/uploads
31+
2932
# typescript
3033
*.tsbuildinfo
3134
next-env.d.ts

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ Future work may introduce an alternative CI/CD pipeline (e.g., a different provi
163163

164164
See the Soroban escrow deployment guide for build and deploy steps, example CLI calls, and integration notes: [docs/soroban-escrow-deployment.md](docs/soroban-escrow-deployment.md)
165165

166+
---
167+
168+
## 📡 API Documentation
169+
170+
- [Public Freelancer Profile API](docs/public-freelancer-profile-api.md) — unauthenticated endpoints for freelancer profiles, completed contracts, reviews, and reputation scores
171+
- [Reputation API](docs/reputation-api.md) — raw delivery metrics and how they are aggregated
172+
166173

167174
## 📄 License
168175

__tests__/api/deliverables.test.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { NextRequest } from 'next/server'
3+
4+
import { POST, GET } from '@/app/api/milestones/[id]/deliverables/route'
5+
import { GET as getDeliverable, DELETE } from '@/app/api/milestones/[id]/deliverables/[deliverableId]/route'
6+
7+
vi.mock('@/lib/db', () => ({
8+
sql: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/security/fileEncryption', () => ({
12+
computeFileHash: vi.fn().mockReturnValue('dummy-hash'),
13+
storeEncryptedFile: vi.fn().mockResolvedValue({ iv: 'dummy-iv', filePath: '/tmp/dummy-path' }),
14+
readEncryptedFile: vi.fn().mockResolvedValue(Buffer.from('file-content')),
15+
removeEncryptedFile: vi.fn().mockResolvedValue(undefined),
16+
}))
17+
18+
import { sql } from '@/lib/db'
19+
20+
type SqlMock = ReturnType<typeof vi.fn>
21+
22+
function queueSql(responses: unknown[]) {
23+
const mock = sql as unknown as SqlMock
24+
for (const response of responses) {
25+
mock.mockResolvedValueOnce(response)
26+
}
27+
}
28+
29+
function queueSqlReject(error: unknown) {
30+
const mock = sql as unknown as SqlMock
31+
mock.mockRejectedValueOnce(error)
32+
}
33+
34+
function makePostRequest(url: string, files: File[]): NextRequest {
35+
const formData = new FormData()
36+
for (const file of files) {
37+
formData.append('files', file)
38+
}
39+
return new NextRequest(new Request(url, { method: 'POST', body: formData }))
40+
}
41+
42+
function makeGetRequest(url: string): NextRequest {
43+
return new NextRequest(new Request(url))
44+
}
45+
46+
function makeDeleteRequest(url: string): NextRequest {
47+
return new NextRequest(new Request(url, { method: 'DELETE' }))
48+
}
49+
50+
beforeEach(() => {
51+
vi.clearAllMocks()
52+
})
53+
54+
describe('POST /api/milestones/[id]/deliverables', () => {
55+
const milestoneId = '00000000-0000-0000-0000-000000000001'
56+
57+
it('returns 400 when no files are provided', async () => {
58+
const request = makePostRequest(
59+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
60+
[],
61+
)
62+
const response = await POST(request)
63+
expect(response.status).toBe(400)
64+
const body = await response.json()
65+
expect(body.code).toBe('NO_FILES')
66+
})
67+
68+
it('returns 422 when too many files are uploaded', async () => {
69+
const files = Array.from({ length: 11 }, (_, i) =>
70+
new File(['content'], `file${i}.pdf`, { type: 'application/pdf' }),
71+
)
72+
const request = makePostRequest(
73+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
74+
files,
75+
)
76+
const response = await POST(request)
77+
expect(response.status).toBe(422)
78+
const body = await response.json()
79+
expect(body.code).toBe('TOO_MANY_FILES')
80+
})
81+
82+
it('returns 422 when a file has an invalid type', async () => {
83+
const file = new File(['<script>'], 'bad.html', { type: 'text/html' })
84+
const request = makePostRequest(
85+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
86+
[file],
87+
)
88+
const response = await POST(request)
89+
expect(response.status).toBe(422)
90+
const body = await response.json()
91+
expect(body.code).toBe('VALIDATION_ERRORS')
92+
expect(body.details[0].filename).toBe('bad.html')
93+
})
94+
95+
it('returns 422 when a file exceeds the size limit', async () => {
96+
const oversized = new ArrayBuffer(60 * 1024 * 1024)
97+
const file = new File([oversized], 'huge.pdf', { type: 'application/pdf' })
98+
const request = makePostRequest(
99+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
100+
[file],
101+
)
102+
const response = await POST(request)
103+
expect(response.status).toBe(422)
104+
const body = await response.json()
105+
expect(body.code).toBe('VALIDATION_ERRORS')
106+
})
107+
108+
it('returns 404 when user is not found', async () => {
109+
queueSql([[]]) // no user
110+
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
111+
const request = makePostRequest(
112+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
113+
[file],
114+
)
115+
const response = await POST(request)
116+
expect(response.status).toBe(404)
117+
const body = await response.json()
118+
expect(body.code).toBe('USER_NOT_FOUND')
119+
})
120+
121+
it('returns 404 when milestone is not found', async () => {
122+
queueSql([
123+
[{ id: 'user-1' }], // user found
124+
[], // milestone not found
125+
])
126+
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
127+
const request = makePostRequest(
128+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
129+
[file],
130+
)
131+
const response = await POST(request)
132+
expect(response.status).toBe(404)
133+
const body = await response.json()
134+
expect(body.code).toBe('MILESTONE_NOT_FOUND')
135+
})
136+
137+
it('returns 403 when user is not the assigned freelancer', async () => {
138+
queueSql([
139+
[{ id: 'user-1' }], // user found
140+
[{ freelancer_id: 'user-2' }], // milestone found, different freelancer
141+
])
142+
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
143+
const request = makePostRequest(
144+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
145+
[file],
146+
)
147+
const response = await POST(request)
148+
expect(response.status).toBe(403)
149+
const body = await response.json()
150+
expect(body.code).toBe('FORBIDDEN')
151+
})
152+
153+
it('returns 201 and stores deliverable metadata on success', async () => {
154+
queueSql([
155+
[{ id: 'user-1' }], // user found
156+
[{ freelancer_id: 'user-1', status: 'in_progress' }], // milestone found
157+
[{ id: 'del-1', original_filename: 'doc.pdf', mime_type: 'application/pdf',
158+
file_size: 7, file_hash: 'dummy-hash', created_at: '2026-01-01T00:00:00Z' }], // insert result
159+
])
160+
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
161+
const request = makePostRequest(
162+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
163+
[file],
164+
)
165+
const response = await POST(request)
166+
expect(response.status).toBe(201)
167+
const body = await response.json()
168+
expect(body.deliverables).toHaveLength(1)
169+
expect(body.deliverables[0].original_filename).toBe('doc.pdf')
170+
})
171+
})
172+
173+
describe('GET /api/milestones/[id]/deliverables', () => {
174+
const milestoneId = '00000000-0000-0000-0000-000000000001'
175+
176+
it('returns 404 when user is not found', async () => {
177+
queueSql([[]])
178+
const request = makeGetRequest(
179+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
180+
)
181+
const response = await GET(request)
182+
expect(response.status).toBe(404)
183+
})
184+
185+
it('returns 404 when milestone is not found', async () => {
186+
queueSql([
187+
[{ id: 'user-1' }],
188+
[],
189+
])
190+
const request = makeGetRequest(
191+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
192+
)
193+
const response = await GET(request)
194+
expect(response.status).toBe(404)
195+
})
196+
197+
it('returns 403 when user has no access', async () => {
198+
queueSql([
199+
[{ id: 'user-3' }],
200+
[{ client_id: 'user-1', freelancer_id: 'user-2' }],
201+
])
202+
const request = makeGetRequest(
203+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
204+
)
205+
const response = await GET(request)
206+
expect(response.status).toBe(403)
207+
})
208+
209+
it('returns deliverables list for authorized user', async () => {
210+
queueSql([
211+
[{ id: 'user-1' }],
212+
[{ client_id: 'user-1', freelancer_id: 'user-2' }],
213+
[{ id: 'del-1', original_filename: 'report.pdf', mime_type: 'application/pdf',
214+
file_size: 100, file_hash: 'abc', created_at: '2026-01-01T00:00:00Z' }],
215+
])
216+
const request = makeGetRequest(
217+
`http://localhost/api/milestones/${milestoneId}/deliverables`,
218+
)
219+
const response = await GET(request)
220+
expect(response.status).toBe(200)
221+
const body = await response.json()
222+
expect(body.deliverables).toHaveLength(1)
223+
expect(body.deliverables[0].original_filename).toBe('report.pdf')
224+
})
225+
})
226+
227+
describe('GET /api/milestones/[id]/deliverables/[deliverableId]', () => {
228+
const milestoneId = '00000000-0000-0000-0000-000000000001'
229+
const deliverableId = '00000000-0000-0000-0000-0000000000dd'
230+
231+
it('returns 404 when deliverable is not found', async () => {
232+
queueSql([
233+
[{ id: 'user-1' }],
234+
[],
235+
])
236+
const request = makeGetRequest(
237+
`http://localhost/api/milestones/${milestoneId}/deliverables/${deliverableId}`,
238+
)
239+
const response = await getDeliverable(request)
240+
expect(response.status).toBe(404)
241+
const body = await response.json()
242+
expect(body.code).toBe('DELIVERABLE_NOT_FOUND')
243+
})
244+
245+
it('returns 403 when user is not authorized', async () => {
246+
queueSql([
247+
[{ id: 'user-3' }],
248+
[{ file_path: '/tmp/f', encryption_iv: 'iv', mime_type: 'application/pdf',
249+
original_filename: 'doc.pdf', client_id: 'user-1', freelancer_id: 'user-2' }],
250+
])
251+
const request = makeGetRequest(
252+
`http://localhost/api/milestones/${milestoneId}/deliverables/${deliverableId}`,
253+
)
254+
const response = await getDeliverable(request)
255+
expect(response.status).toBe(403)
256+
})
257+
258+
it('returns the file content with correct headers for authorized user', async () => {
259+
queueSql([
260+
[{ id: 'user-1' }],
261+
[{ file_path: '/tmp/f', encryption_iv: 'iv', mime_type: 'application/pdf',
262+
original_filename: 'doc.pdf', client_id: 'user-1', freelancer_id: 'user-2' }],
263+
])
264+
const request = makeGetRequest(
265+
`http://localhost/api/milestones/${milestoneId}/deliverables/${deliverableId}`,
266+
)
267+
const response = await getDeliverable(request)
268+
expect(response.status).toBe(200)
269+
expect(response.headers.get('Content-Type')).toBe('application/pdf')
270+
expect(response.headers.get('Content-Disposition')).toBe('attachment; filename="doc.pdf"')
271+
})
272+
})
273+
274+
describe('DELETE /api/milestones/[id]/deliverables/[deliverableId]', () => {
275+
const milestoneId = '00000000-0000-0000-0000-000000000001'
276+
const deliverableId = '00000000-0000-0000-0000-0000000000dd'
277+
278+
it('returns 403 when user is not the freelancer', async () => {
279+
queueSql([
280+
[{ id: 'user-1' }],
281+
[{ file_path: '/tmp/f', encryption_iv: 'iv', freelancer_id: 'user-2' }],
282+
])
283+
const request = makeDeleteRequest(
284+
`http://localhost/api/milestones/${milestoneId}/deliverables/${deliverableId}`,
285+
)
286+
const response = await DELETE(request)
287+
expect(response.status).toBe(403)
288+
})
289+
290+
it('returns success when deletion is allowed', async () => {
291+
queueSql([
292+
[{ id: 'user-1' }],
293+
[{ file_path: '/tmp/f', encryption_iv: 'iv', freelancer_id: 'user-1' }],
294+
[{ status: 'in_progress' }],
295+
[], // update result
296+
])
297+
const request = makeDeleteRequest(
298+
`http://localhost/api/milestones/${milestoneId}/deliverables/${deliverableId}`,
299+
)
300+
const response = await DELETE(request)
301+
expect(response.status).toBe(200)
302+
const body = await response.json()
303+
expect(body.success).toBe(true)
304+
})
305+
})

0 commit comments

Comments
 (0)