Skip to main content

max / mnw-cli

Add API endpoint test suite (61 tests, ffmpeg content generation) Shell-based integration tests for all internal API endpoints: projects, items, blog, promo, license keys, analytics, transactions, export, SSH keys, storage, upload pipeline, and error cases. Uses ffmpeg to generate test audio/zip content for upload tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 21:11 UTC
Commit: d814997bb542aceab07897ce491ff02b2ac2c18d
Parent: c5e6608
1 file changed, +500 insertions, -0 deletions
@@ -0,0 +1,581 @@
1 + #!/bin/bash
2 + # MNW CLI — Internal API endpoint test suite
3 + #
4 + # Tests all TUI-backing endpoints against a live MNW server.
5 + # Uses ffmpeg to generate test audio content for upload tests.
6 + #
7 + # Usage:
8 + # ./tests/api_endpoints.sh # Run against localhost:3000
9 + # MNW_URL=http://host:3000 ./tests/api_endpoints.sh # Custom server
10 + #
11 + # Prerequisites:
12 + # - MNW server running with CLI_SERVICE_TOKEN set
13 + # - ffmpeg (for generating test audio)
14 + # - curl, jq
15 + # - Seed data loaded (elena user + demo projects)
16 +
17 + set -euo pipefail
18 +
19 + # ── Configuration ──────────────────────────────────────────────────
20 + MNW_URL="${MNW_URL:-http://localhost:3000}"
21 + TOKEN="${MNW_SERVICE_TOKEN:-7f28a67c2e97a1b774aa482692c2a7d3e011da6de625a9a8b480d9156512e050}"
22 + ELENA_ID="11111111-1111-1111-1111-111111111111"
23 + PROJECT_NEWSLETTER="22222222-2222-2222-2222-222222222221"
24 + PROJECT_PODCAST="22222222-2222-2222-2222-222222222222"
25 + ITEM_ESSAY="33333333-3333-3333-3333-333333333331"
26 + ITEM_PODCAST="33333333-3333-3333-3333-333333333341"
27 +
28 + TMPDIR="$(mktemp -d)"
29 + trap 'rm -rf "$TMPDIR"' EXIT
30 +
31 + PASS=0
32 + FAIL=0
33 + SKIP=0
34 + ERRORS=""
35 +
36 + # ── Helpers ────────────────────────────────────────────────────────
37 + AUTH="Authorization: Bearer $TOKEN"
38 + CT="Content-Type: application/json"
39 +
40 + api() {
41 + local method="$1" path="$2"
42 + shift 2
43 + curl -sf -X "$method" -H "$AUTH" -H "$CT" "$@" "${MNW_URL}${path}"
44 + }
45 +
46 + api_status() {
47 + local method="$1" path="$2"
48 + shift 2
49 + curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "$AUTH" -H "$CT" "$@" "${MNW_URL}${path}"
50 + }
51 +
52 + pass() {
53 + PASS=$((PASS + 1))
54 + printf " [PASS] %s\n" "$1"
55 + }
56 +
57 + fail() {
58 + FAIL=$((FAIL + 1))
59 + printf " [FAIL] %s — %s\n" "$1" "$2"
60 + ERRORS="${ERRORS}\n - $1: $2"
61 + }
62 +
63 + skip() {
64 + SKIP=$((SKIP + 1))
65 + printf " [SKIP] %s — %s\n" "$1" "$2"
66 + }
67 +
68 + assert_status() {
69 + local name="$1" expected="$2" method="$3" path="$4"
70 + shift 4
71 + local status
72 + status=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "$AUTH" -H "$CT" "$@" "${MNW_URL}${path}")
73 + if [ "$status" = "$expected" ]; then
74 + pass "$name (HTTP $status)"
75 + else
76 + fail "$name" "expected $expected, got $status"
77 + fi
78 + }
79 +
80 + assert_json_field() {
81 + local name="$1" json="$2" field="$3"
82 + local val
83 + val=$(echo "$json" | jq -r "$field" 2>/dev/null)
84 + if [ -n "$val" ] && [ "$val" != "null" ]; then
85 + pass "$name ($field=$val)"
86 + else
87 + fail "$name" "missing field $field"
88 + fi
89 + }
90 +
91 + assert_json_eq() {
92 + local name="$1" json="$2" field="$3" expected="$4"
93 + local val
94 + val=$(echo "$json" | jq -r "$field" 2>/dev/null)
95 + if [ "$val" = "$expected" ]; then
96 + pass "$name"
97 + else
98 + fail "$name" "expected $field=$expected, got $val"
99 + fi
100 + }
101 +
102 + assert_json_count() {
103 + local name="$1" json="$2" min="$3"
104 + local count
105 + count=$(echo "$json" | jq 'length' 2>/dev/null)
106 + if [ "$count" -ge "$min" ]; then
107 + pass "$name (count=$count)"
108 + else
109 + fail "$name" "expected >= $min items, got $count"
110 + fi
111 + }
112 +
113 + # ── Check prerequisites ───────────────────────────────────────────
114 + echo "=== MNW CLI API Endpoint Tests ==="
115 + echo "Server: $MNW_URL"
116 + echo ""
117 +
118 + # Verify server is up
119 + if ! curl -sf "${MNW_URL}/api/health" > /dev/null 2>&1; then
120 + echo "ERROR: MNW server not reachable at $MNW_URL"
121 + exit 1
122 + fi
123 + echo "Server reachable."
124 +
125 + # Verify ffmpeg
126 + if ! command -v ffmpeg > /dev/null 2>&1; then
127 + echo "WARNING: ffmpeg not found — upload tests will be skipped"
128 + HAS_FFMPEG=false
129 + else
130 + HAS_FFMPEG=true
131 + fi
132 +
133 + # Verify auth works
134 + STATUS=$(api_status GET "/api/internal/creator/projects?user_id=$ELENA_ID")
135 + if [ "$STATUS" != "200" ]; then
136 + echo "ERROR: Auth failed (HTTP $STATUS). Check CLI_SERVICE_TOKEN."
137 + exit 1
138 + fi
139 + echo "Auth OK."
140 + echo ""
141 +
142 + # ── Generate test content with ffmpeg ──────────────────────────────
143 + if $HAS_FFMPEG; then
144 + echo "Generating test audio files..."
145 + # 5-second sine wave WAV (440 Hz)
146 + ffmpeg -y -f lavfi -i "sine=frequency=440:duration=5" \
147 + -ar 44100 -ac 2 "$TMPDIR/test_track.wav" 2>/dev/null
148 + echo " Created test_track.wav ($(du -h "$TMPDIR/test_track.wav" | cut -f1))"
149 +
150 + # 3-second MP3 (220 Hz)
151 + ffmpeg -y -f lavfi -i "sine=frequency=220:duration=3" \
152 + -ar 44100 -ac 1 -b:a 128k "$TMPDIR/test_audio.mp3" 2>/dev/null
153 + echo " Created test_audio.mp3 ($(du -h "$TMPDIR/test_audio.mp3" | cut -f1))"
154 +
155 + # Small ZIP file (placeholder download)
156 + echo "test content for zip" > "$TMPDIR/readme.txt"
157 + (cd "$TMPDIR" && zip -q test_download.zip readme.txt)
158 + echo " Created test_download.zip ($(du -h "$TMPDIR/test_download.zip" | cut -f1))"
159 + echo ""
160 + fi
161 +
162 + # ══════════════════════════════════════════════════════════════════
163 + # Section 1: Creator Projects & Stats
164 + # ══════════════════════════════════════════════════════════════════
165 + echo "── 1. Projects & Stats ──"
166 +
167 + # List projects
168 + PROJECTS=$(api GET "/api/internal/creator/projects?user_id=$ELENA_ID")
169 + assert_json_count "List projects" "$PROJECTS" 3
170 +
171 + # Check project fields
172 + FIRST=$(echo "$PROJECTS" | jq '.[0]')
173 + assert_json_field "Project has id" "$FIRST" ".id"
174 + assert_json_field "Project has slug" "$FIRST" ".slug"
175 + assert_json_field "Project has title" "$FIRST" ".title"
176 + assert_json_field "Project has project_type" "$FIRST" ".project_type"
177 +
178 + # List items for a project
179 + ITEMS=$(api GET "/api/internal/creator/projects/$PROJECT_NEWSLETTER/items?user_id=$ELENA_ID")
180 + assert_json_count "List newsletter items" "$ITEMS" 3
181 +
182 + # Stats
183 + STATS=$(api GET "/api/internal/creator/stats?user_id=$ELENA_ID&range=30d")
184 + assert_json_field "Stats has total_items" "$STATS" ".total_items"
185 + assert_json_field "Stats has total_projects" "$STATS" ".total_projects"
186 + assert_json_field "Stats has current_revenue_cents" "$STATS" ".current_revenue_cents"
187 +
188 + # Stats ranges
189 + for range in 7d 30d 90d all; do
190 + assert_status "Stats range=$range" 200 GET "/api/internal/creator/stats?user_id=$ELENA_ID&range=$range"
191 + done
192 +
193 + echo ""
194 +
195 + # ══════════════════════════════════════════════════════════════════
196 + # Section 2: Storage Info
197 + # ══════════════════════════════════════════════════════════════════
198 + echo "── 2. Storage ──"
199 +
200 + STORAGE=$(api GET "/api/internal/creator/storage?user_id=$ELENA_ID")
201 + assert_json_field "Storage has storage_used_bytes" "$STORAGE" ".storage_used_bytes"
202 + assert_json_field "Storage has max_storage_bytes" "$STORAGE" ".max_storage_bytes"
203 + assert_json_field "Storage has allows_file_uploads" "$STORAGE" ".allows_file_uploads"
204 +
205 + echo ""
206 +
207 + # ══════════════════════════════════════════════════════════════════
208 + # Section 3: Item Detail & Mutations
209 + # ══════════════════════════════════════════════════════════════════
210 + echo "── 3. Item Detail & Mutations ──"
211 +
212 + # Get item detail
213 + DETAIL=$(api GET "/api/internal/creator/items/$ITEM_ESSAY?user_id=$ELENA_ID")
214 + assert_json_eq "Item detail title" "$DETAIL" ".title" "The Art of Doing Less"
215 + assert_json_field "Item has price_cents" "$DETAIL" ".price_cents"
216 + assert_json_field "Item has item_type" "$DETAIL" ".item_type"
217 + assert_json_field "Item has is_public" "$DETAIL" ".is_public"
218 + assert_json_field "Item has created_at" "$DETAIL" ".created_at"
219 +
220 + # Get item versions
221 + VERSIONS=$(api GET "/api/internal/creator/items/$ITEM_ESSAY/versions?user_id=$ELENA_ID")
222 + # May be empty, just verify it returns an array
223 + VCOUNT=$(echo "$VERSIONS" | jq 'length' 2>/dev/null)
224 + if [ "$VCOUNT" -ge 0 ] 2>/dev/null; then
225 + pass "Item versions returns array (count=$VCOUNT)"
226 + else
227 + fail "Item versions" "did not return array"
228 + fi
229 +
230 + # Create a new item
231 + NEW_ITEM=$(api POST "/api/internal/creator/items" \
232 + -d "{\"user_id\":\"$ELENA_ID\",\"project_id\":\"$PROJECT_NEWSLETTER\",\"title\":\"Test Item $(date +%s)\",\"item_type\":\"text\",\"price_cents\":999}")
233 + NEW_ITEM_ID=$(echo "$NEW_ITEM" | jq -r '.item_id')
234 + if [ -n "$NEW_ITEM_ID" ] && [ "$NEW_ITEM_ID" != "null" ]; then
235 + pass "Create item (id=$NEW_ITEM_ID)"
236 + else
237 + fail "Create item" "no item_id in response"
238 + NEW_ITEM_ID=""
239 + fi
240 +
241 + # Update the item
242 + if [ -n "$NEW_ITEM_ID" ]; then
243 + UPDATED=$(api PUT "/api/internal/creator/items/$NEW_ITEM_ID" \
244 + -d "{\"user_id\":\"$ELENA_ID\",\"title\":\"Updated Title\",\"description\":\"A test description\",\"price_cents\":1299}")
245 + assert_json_eq "Update item title" "$UPDATED" ".title" "Updated Title"
246 + assert_json_eq "Update item price" "$UPDATED" ".price_cents" "1299"
247 +
248 + # Unpublish / publish toggle
249 + UNPUB=$(api POST "/api/internal/creator/items/$NEW_ITEM_ID/unpublish" \
250 + -d "{\"user_id\":\"$ELENA_ID\"}")
251 + assert_json_eq "Unpublish item" "$UNPUB" ".is_public" "false"
252 +
253 + PUB=$(api POST "/api/internal/creator/items/$NEW_ITEM_ID/publish" \
254 + -d "{\"user_id\":\"$ELENA_ID\"}")
255 + assert_json_eq "Publish item" "$PUB" ".is_public" "true"
256 + fi
257 +
258 + echo ""
259 +
260 + # ══════════════════════════════════════════════════════════════════
261 + # Section 4: Blog Posts
262 + # ══════════════════════════════════════════════════════════════════
263 + echo "── 4. Blog Posts ──"
264 +
265 + # List blog posts
266 + BLOG=$(api GET "/api/internal/creator/projects/$PROJECT_NEWSLETTER/blog?user_id=$ELENA_ID")
267 + BLOG_COUNT=$(echo "$BLOG" | jq 'length' 2>/dev/null)
268 + if [ "$BLOG_COUNT" -ge 0 ] 2>/dev/null; then
269 + pass "List blog posts (count=$BLOG_COUNT)"
270 + else
271 + fail "List blog posts" "did not return array"
272 + fi
273 +
274 + # Create blog post
275 + TS=$(date +%s)
276 + NEW_POST=$(api POST "/api/internal/creator/blog" \
277 + -d "{\"user_id\":\"$ELENA_ID\",\"project_id\":\"$PROJECT_NEWSLETTER\",\"title\":\"Test Post $TS\",\"body_markdown\":\"Hello from the test suite.\",\"publish\":false}")
278 + POST_ID=$(echo "$NEW_POST" | jq -r '.id')
279 + if [ -n "$POST_ID" ] && [ "$POST_ID" != "null" ]; then
280 + pass "Create blog post (id=$POST_ID)"
281 + assert_json_eq "Blog post title" "$NEW_POST" ".title" "Test Post $TS"
282 + assert_json_eq "Blog post unpublished" "$NEW_POST" ".is_published" "false"
283 + else
284 + fail "Create blog post" "no id in response"
285 + POST_ID=""
286 + fi
287 +
288 + # Delete blog post
289 + if [ -n "$POST_ID" ]; then
290 + assert_status "Delete blog post" 204 DELETE "/api/internal/creator/blog/$POST_ID?user_id=$ELENA_ID"
291 + fi
292 +
293 + echo ""
294 +
295 + # ══════════════════════════════════════════════════════════════════
296 + # Section 5: Promo Codes
297 + # ══════════════════════════════════════════════════════════════════
298 + echo "── 5. Promo Codes ──"
299 +
300 + # List promo codes
301 + PROMOS=$(api GET "/api/internal/creator/promo-codes?user_id=$ELENA_ID")
302 + PROMO_COUNT=$(echo "$PROMOS" | jq 'length' 2>/dev/null)
303 + if [ "$PROMO_COUNT" -ge 0 ] 2>/dev/null; then
304 + pass "List promo codes (count=$PROMO_COUNT)"
305 + else
306 + fail "List promo codes" "did not return array"
307 + fi
308 +
309 + # Create promo code
310 + PROMO_CODE="TEST$(date +%s)"
311 + NEW_PROMO=$(api POST "/api/internal/creator/promo-codes" \
312 + -d "{\"user_id\":\"$ELENA_ID\",\"code\":\"$PROMO_CODE\",\"discount_type\":\"percentage\",\"discount_value\":25}")
313 + PROMO_ID=$(echo "$NEW_PROMO" | jq -r '.id')
314 + if [ -n "$PROMO_ID" ] && [ "$PROMO_ID" != "null" ]; then
315 + pass "Create promo code (code=$PROMO_CODE)"
316 + assert_json_eq "Promo discount" "$NEW_PROMO" ".discount_value" "25"
317 + else
318 + fail "Create promo code" "no id in response"
319 + PROMO_ID=""
320 + fi
321 +
322 + # Delete promo code
323 + if [ -n "$PROMO_ID" ]; then
324 + assert_status "Delete promo code" 204 DELETE "/api/internal/creator/promo-codes/$PROMO_ID?user_id=$ELENA_ID"
325 + fi
326 +
327 + echo ""
328 +
329 + # ══════════════════════════════════════════════════════════════════
330 + # Section 6: License Keys
331 + # ══════════════════════════════════════════════════════════════════
332 + echo "── 6. License Keys ──"
333 +
334 + # List license keys for an item
335 + KEYS=$(api GET "/api/internal/creator/items/$ITEM_ESSAY/keys?user_id=$ELENA_ID")
336 + KEY_COUNT=$(echo "$KEYS" | jq 'length' 2>/dev/null)
337 + if [ "$KEY_COUNT" -ge 0 ] 2>/dev/null; then
338 + pass "List license keys (count=$KEY_COUNT)"
339 + else
340 + fail "List license keys" "did not return array"
341 + fi
342 +
343 + # Generate a license key
344 + NEW_KEY=$(api POST "/api/internal/creator/items/$ITEM_ESSAY/keys" \
345 + -d "{\"user_id\":\"$ELENA_ID\"}")
346 + KEY_ID=$(echo "$NEW_KEY" | jq -r '.id')
347 + KEY_CODE=$(echo "$NEW_KEY" | jq -r '.key_code')
348 + if [ -n "$KEY_ID" ] && [ "$KEY_ID" != "null" ]; then
349 + pass "Generate license key (code=$KEY_CODE)"
350 + else
351 + fail "Generate license key" "no id in response"
352 + KEY_ID=""
353 + fi
354 +
355 + # Revoke license key
356 + if [ -n "$KEY_ID" ]; then
357 + assert_status "Revoke license key" 204 POST "/api/internal/creator/keys/$KEY_ID/revoke" \
358 + -d "{\"user_id\":\"$ELENA_ID\"}"
359 + fi
360 +
361 + echo ""
362 +
363 + # ══════════════════════════════════════════════════════════════════
364 + # Section 7: Analytics & Transactions
365 + # ══════════════════════════════════════════════════════════════════
366 + echo "── 7. Analytics & Transactions ──"
367 +
368 + # Analytics
369 + for range in 7d 30d 90d all; do
370 + ANALYTICS=$(api GET "/api/internal/creator/analytics?user_id=$ELENA_ID&range=$range")
371 + assert_json_field "Analytics ($range) has buckets" "$ANALYTICS" ".buckets"
372 + assert_json_field "Analytics ($range) has current_revenue_cents" "$ANALYTICS" ".current_revenue_cents"
373 + done
374 +
375 + # Top projects in analytics
376 + ANALYTICS_30=$(api GET "/api/internal/creator/analytics?user_id=$ELENA_ID&range=30d")
377 + TOP=$(echo "$ANALYTICS_30" | jq '.top_projects' 2>/dev/null)
378 + if [ "$(echo "$TOP" | jq 'type')" = '"array"' ]; then
379 + pass "Analytics top_projects is array"
380 + else
381 + fail "Analytics top_projects" "not an array"
382 + fi
383 +
384 + # Transactions
385 + TXS=$(api GET "/api/internal/creator/transactions?user_id=$ELENA_ID")
386 + TX_COUNT=$(echo "$TXS" | jq 'length' 2>/dev/null)
387 + if [ "$TX_COUNT" -ge 0 ] 2>/dev/null; then
388 + pass "List transactions (count=$TX_COUNT)"
389 + else
390 + fail "List transactions" "did not return array"
391 + fi
392 +
393 + # Export sales CSV
394 + EXPORT=$(api GET "/api/internal/creator/export/sales?user_id=$ELENA_ID")
395 + assert_json_field "Export has csv" "$EXPORT" ".csv"
396 + ROW_COUNT=$(echo "$EXPORT" | jq -r '.row_count' 2>/dev/null)
397 + if [ "$ROW_COUNT" -ge 0 ] 2>/dev/null; then
398 + pass "Export row_count=$ROW_COUNT"
399 + else
400 + fail "Export sales" "missing row_count"
401 + fi
402 +
403 + echo ""
404 +
405 + # ══════════════════════════════════════════════════════════════════
406 + # Section 8: SSH Keys
407 + # ══════════════════════════════════════════════════════════════════
408 + echo "── 8. SSH Keys ──"
409 +
410 + SSH_KEYS=$(api GET "/api/internal/creator/ssh-keys?user_id=$ELENA_ID")
411 + SSH_COUNT=$(echo "$SSH_KEYS" | jq 'length' 2>/dev/null)
412 + if [ "$SSH_COUNT" -ge 0 ] 2>/dev/null; then
413 + pass "List SSH keys (count=$SSH_COUNT)"
414 + else
415 + fail "List SSH keys" "did not return array"
416 + fi
417 +
418 + echo ""
419 +
420 + # ══════════════════════════════════════════════════════════════════
421 + # Section 9: SSH Key Lookup
422 + # ══════════════════════════════════════════════════════════════════
423 + echo "── 9. SSH Key Lookup ──"
424 +
425 + # Test with a fake fingerprint (should 404)
426 + LOOKUP_STATUS=$(api_status GET "/api/internal/ssh-key-lookup?fingerprint=SHA256:fakefingerprint123")
427 + if [ "$LOOKUP_STATUS" = "404" ]; then
428 + pass "SSH key lookup 404 for unknown key"
429 + else
430 + fail "SSH key lookup unknown" "expected 404, got $LOOKUP_STATUS"
431 + fi
432 +
433 + # Insert a test SSH key and look it up
434 + TEST_FP="SHA256:test$(date +%s)"
435 + DB_NAME="${MNW_DB:-makenotwork_staging}"
436 + psql "$DB_NAME" -c "INSERT INTO ssh_keys (user_id, public_key, fingerprint, label) VALUES ('$ELENA_ID', 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAItest', '$TEST_FP', 'test-key') ON CONFLICT DO NOTHING;" 2>/dev/null
437 +
438 + LOOKUP=$(api GET "/api/internal/ssh-key-lookup?fingerprint=$TEST_FP" 2>/dev/null || echo '{}')
439 + LOOKUP_USER=$(echo "$LOOKUP" | jq -r '.username' 2>/dev/null)
440 + if [ "$LOOKUP_USER" = "elena" ]; then
441 + pass "SSH key lookup returns correct user"
442 + else
443 + fail "SSH key lookup" "expected elena, got $LOOKUP_USER"
444 + fi
445 +
446 + # Clean up test key
447 + psql "$DB_NAME" -c "DELETE FROM ssh_keys WHERE fingerprint = '$TEST_FP';" 2>/dev/null
448 +
449 + echo ""
450 +
451 + # ══════════════════════════════════════════════════════════════════
452 + # Section 10: Upload Pipeline (presign + confirm)
453 + # ══════════════════════════════════════════════════════════════════
454 + echo "── 10. Upload Pipeline ──"
455 +
456 + if [ -z "$NEW_ITEM_ID" ]; then
457 + skip "Upload presign" "no test item created"
458 + skip "Upload confirm" "no test item created"
459 + elif ! $HAS_FFMPEG; then
460 + skip "Upload presign" "no ffmpeg"
461 + skip "Upload confirm" "no ffmpeg"
462 + else
463 + # Presign for audio upload
464 + PRESIGN=$(api POST "/api/internal/upload/presign" \
465 + -d "{\"user_id\":\"$ELENA_ID\",\"item_id\":\"$NEW_ITEM_ID\",\"file_type\":\"audio\",\"file_name\":\"test_track.wav\",\"content_type\":\"audio/wav\"}" 2>/dev/null || echo '{}')
466 + UPLOAD_URL=$(echo "$PRESIGN" | jq -r '.upload_url' 2>/dev/null)
467 + S3_KEY=$(echo "$PRESIGN" | jq -r '.s3_key' 2>/dev/null)
468 +
469 + if [ -n "$UPLOAD_URL" ] && [ "$UPLOAD_URL" != "null" ]; then
470 + pass "Presign upload (s3_key=$S3_KEY)"
471 + assert_json_field "Presign has expires_in" "$PRESIGN" ".expires_in"
472 +
473 + # Upload to S3
474 + S3_STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
475 + -H "Content-Type: audio/wav" \
476 + --data-binary "@$TMPDIR/test_track.wav" \
477 + "$UPLOAD_URL")
478 + if [ "$S3_STATUS" = "200" ]; then
479 + pass "S3 upload (HTTP $S3_STATUS)"
480 +
481 + # Confirm upload
482 + CONFIRM=$(api POST "/api/internal/upload/confirm" \
483 + -d "{\"user_id\":\"$ELENA_ID\",\"item_id\":\"$NEW_ITEM_ID\",\"file_type\":\"audio\",\"s3_key\":\"$S3_KEY\"}" 2>/dev/null || echo '{}')
484 + CONFIRM_OK=$(echo "$CONFIRM" | jq -r '.success' 2>/dev/null)
485 + if [ "$CONFIRM_OK" = "true" ]; then
486 + pass "Confirm upload"
487 + else
488 + fail "Confirm upload" "success=$CONFIRM_OK (response: $CONFIRM)"
489 + fi
490 + else
491 + fail "S3 upload" "HTTP $S3_STATUS"
492 + skip "Confirm upload" "S3 upload failed"
493 + fi
494 + else
495 + # Presign may fail if no S3 configured on staging — that's expected
496 + skip "Presign upload" "no S3 configured (response: $(echo "$PRESIGN" | head -c 200))"
497 + skip "S3 upload" "no presign URL"
498 + skip "Confirm upload" "no presign URL"
499 + fi
500 + fi
Lines truncated