Skip to main content

max / makenotwork

21.7 KB · 653 lines History Blame Raw
1 //! DB-layer contract tests for `db::items::media`, the writeback layer every
2 //! upload confirm lands on.
3 //!
4 //! This is the compare-and-swap that decides whether storage may be credited,
5 //! the cover triple-write (url + key + size), and the ownership-filtered
6 //! size/metadata writebacks. These pin what each one actually promises: which
7 //! outcome a stale expectation produces, that a losing write leaves every
8 //! column untouched, that each file type writes its own column pair and no
9 //! other, and that a non-owner never lands a byte.
10 //!
11 //! The pending-uploads table that used to share this file has its own
12 //! module, db_pending_uploads_layer.
13 //!
14 //! Delete this file and two classes of loss stop being observable: a dropped
15 //! CAS predicate (double-credited storage and clobbered live objects), and a
16 //! size or metadata writeback that ignores its ownership filter.
17
18 use crate::harness::db::TestDb;
19 use crate::harness::{seed_project, seed_user};
20 use makenotwork::db::items::{FileConfirmOutcome, update_item_file_cas};
21 use makenotwork::db::{ItemId, ProjectId, UserId, items};
22 use makenotwork::error::AppError;
23 use makenotwork::storage::FileType;
24
25 // ── helpers ──────────────────────────────────────────────────────────────────
26
27 async fn seed_item(pool: &sqlx::PgPool, project: ProjectId, slug: &str) -> ItemId {
28 sqlx::query_scalar::<_, ItemId>(
29 "INSERT INTO items (project_id, title, item_type, price_cents, slug)
30 VALUES ($1, $2, 'digital', 1000, $3) RETURNING id",
31 )
32 .bind(project)
33 .bind(format!("Item {slug}"))
34 .bind(slug)
35 .fetch_one(pool)
36 .await
37 .expect("seed item")
38 }
39
40 /// A user, a project and one item, the minimum an item-media writeback needs.
41 async fn owner_and_item(db: &TestDb, tag: &str) -> (UserId, ItemId) {
42 let user = seed_user(&db.pool, &format!("media_{tag}")).await;
43 let project = seed_project(&db.pool, user, &format!("media-{tag}")).await;
44 let item = seed_item(&db.pool, project, &format!("i-{tag}")).await;
45 (user, item)
46 }
47
48 /// The audio pair as stored.
49 async fn audio_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) {
50 sqlx::query_as("SELECT audio_s3_key, audio_file_size_bytes FROM items WHERE id = $1")
51 .bind(item)
52 .fetch_one(pool)
53 .await
54 .expect("read audio columns")
55 }
56
57 /// The video pair as stored.
58 async fn video_cols(pool: &sqlx::PgPool, item: ItemId) -> (Option<String>, Option<i64>) {
59 sqlx::query_as("SELECT video_s3_key, video_file_size_bytes FROM items WHERE id = $1")
60 .bind(item)
61 .fetch_one(pool)
62 .await
63 .expect("read video columns")
64 }
65
66 /// The cover triple as stored.
67 #[allow(clippy::type_complexity)]
68 async fn cover_cols(
69 pool: &sqlx::PgPool,
70 item: ItemId,
71 ) -> (Option<String>, Option<String>, Option<i64>) {
72 sqlx::query_as(
73 "SELECT cover_image_url, cover_s3_key, cover_file_size_bytes FROM items WHERE id = $1",
74 )
75 .bind(item)
76 .fetch_one(pool)
77 .await
78 .expect("read cover columns")
79 }
80
81 // ── update_item_file_cas: the guarded write behind every storage credit ───────
82
83 /// The redelivery case. A confirm that is delivered twice observes the same
84 /// pre-state twice, so the second call carries the same `expected_old_key`. It
85 /// must report `LostRace` and leave the row exactly as the winner wrote it,
86 /// because the caller credits storage on `Committed` and would otherwise be
87 /// charged twice for one object.
88 #[tokio::test]
89 async fn a_replayed_file_confirm_reports_a_lost_race_and_credits_nothing_twice() {
90 let db = TestDb::new().await;
91 let (owner, item) = owner_and_item(&db, "replay").await;
92
93 let first = update_item_file_cas(
94 &db.pool,
95 item,
96 owner,
97 FileType::Audio,
98 None,
99 "staging/first.mp3",
100 7_340_032,
101 )
102 .await
103 .expect("first confirm runs");
104 assert_eq!(
105 first,
106 FileConfirmOutcome::Committed,
107 "the first confirm against a NULL column must commit"
108 );
109
110 // The exact byte count matters: the caller credits this number against the
111 // creator's quota, so an off-by-anything is a billing error.
112 let after_first = audio_cols(&db.pool, item).await;
113 assert_eq!(
114 after_first,
115 (Some("staging/first.mp3".to_string()), Some(7_340_032)),
116 "committed confirm must store the exact key and size, got {after_first:?}"
117 );
118
119 // Same event delivered again: the column no longer holds NULL.
120 let replay = update_item_file_cas(
121 &db.pool,
122 item,
123 owner,
124 FileType::Audio,
125 None,
126 "staging/first.mp3",
127 7_340_032,
128 )
129 .await
130 .expect("replayed confirm runs");
131 assert_eq!(
132 replay,
133 FileConfirmOutcome::LostRace,
134 "a redelivered confirm must lose the CAS, not commit a second time"
135 );
136
137 let after_replay = audio_cols(&db.pool, item).await;
138 assert_eq!(
139 after_replay, after_first,
140 "the replay must leave the row byte-identical, got {after_replay:?}"
141 );
142
143 // A genuine replace observes the current key and swaps it. The new size
144 // REPLACES the old one: 2_097_152, not 9_437_184 (a summing bug) and not
145 // 7_340_032 (a write that never landed).
146 let replace = update_item_file_cas(
147 &db.pool,
148 item,
149 owner,
150 FileType::Audio,
151 Some("staging/first.mp3"),
152 "staging/second.mp3",
153 2_097_152,
154 )
155 .await
156 .expect("replace confirm runs");
157 assert_eq!(
158 replace,
159 FileConfirmOutcome::Committed,
160 "a confirm carrying the current key must commit the swap"
161 );
162 let after_replace = audio_cols(&db.pool, item).await;
163 assert_eq!(
164 after_replace,
165 (Some("staging/second.mp3".to_string()), Some(2_097_152)),
166 "the replace must overwrite both columns, got {after_replace:?}"
167 );
168 }
169
170 /// The ownership filter is part of the same predicate as the CAS, so a stranger
171 /// holding the correct expected key still writes nothing. Asserted separately
172 /// from the CAS because a regression could drop either half alone.
173 #[tokio::test]
174 async fn a_non_owner_file_confirm_loses_the_race_and_writes_nothing() {
175 let db = TestDb::new().await;
176 let (owner, item) = owner_and_item(&db, "owner").await;
177 let stranger = seed_user(&db.pool, "media_stranger").await;
178
179 update_item_file_cas(
180 &db.pool,
181 item,
182 owner,
183 FileType::Video,
184 None,
185 "staging/owned.mp4",
186 4_500_000,
187 )
188 .await
189 .expect("owner confirm runs");
190
191 let outcome = update_item_file_cas(
192 &db.pool,
193 item,
194 stranger,
195 FileType::Video,
196 Some("staging/owned.mp4"),
197 "staging/stolen.mp4",
198 99,
199 )
200 .await
201 .expect("stranger confirm runs");
202 assert_eq!(
203 outcome,
204 FileConfirmOutcome::LostRace,
205 "a non-owner must not be able to swap another creator's file"
206 );
207
208 let cols = video_cols(&db.pool, item).await;
209 assert_eq!(
210 cols,
211 (Some("staging/owned.mp4".to_string()), Some(4_500_000)),
212 "the non-owner write must not land, got {cols:?}"
213 );
214 }
215
216 /// Each file type owns exactly one column pair. A mapping that crossed audio and
217 /// video would still "work" for a single-file item, so both are written on one
218 /// item with distinct keys and distinct sizes and each pair is read back.
219 #[tokio::test]
220 async fn each_file_type_writes_only_its_own_column_pair() {
221 let db = TestDb::new().await;
222 let (owner, item) = owner_and_item(&db, "cols").await;
223
224 update_item_file_cas(
225 &db.pool,
226 item,
227 owner,
228 FileType::Audio,
229 None,
230 "staging/a.mp3",
231 5_000_000,
232 )
233 .await
234 .expect("audio confirm runs");
235 update_item_file_cas(
236 &db.pool,
237 item,
238 owner,
239 FileType::Video,
240 None,
241 "staging/v.mp4",
242 3_000_000,
243 )
244 .await
245 .expect("video confirm runs");
246
247 let audio = audio_cols(&db.pool, item).await;
248 let video = video_cols(&db.pool, item).await;
249 assert_eq!(
250 audio,
251 (Some("staging/a.mp3".to_string()), Some(5_000_000)),
252 "audio columns hold the audio confirm, got {audio:?}"
253 );
254 assert_eq!(
255 video,
256 (Some("staging/v.mp4".to_string()), Some(3_000_000)),
257 "video columns hold the video confirm, got {video:?}"
258 );
259 // Neither generic confirm touches the cover triple.
260 let cover = cover_cols(&db.pool, item).await;
261 assert_eq!(
262 cover,
263 (None, None, None),
264 "audio/video confirms must leave the cover columns alone, got {cover:?}"
265 );
266 }
267
268 /// Types that need a third column (or another table) are refused rather than
269 /// half-written. The error names the route the caller should have used, which is
270 /// what makes the rejection actionable.
271 #[tokio::test]
272 async fn a_file_type_with_a_dedicated_route_is_refused_before_any_write() {
273 let db = TestDb::new().await;
274 let (owner, item) = owner_and_item(&db, "route").await;
275
276 let err = update_item_file_cas(
277 &db.pool,
278 item,
279 owner,
280 FileType::Cover,
281 None,
282 "staging/cover.png",
283 640_000,
284 )
285 .await
286 .expect_err("a cover must not be confirmable through the generic writer");
287 let message = err.to_string();
288 match err {
289 AppError::Internal(inner) => {
290 let detail = inner.to_string();
291 assert!(
292 detail.contains("/api/items/image/confirm"),
293 "the refusal must name the dedicated route, got {detail}"
294 );
295 assert!(
296 detail.contains("cover"),
297 "the refusal must name the offending file type, got {detail}"
298 );
299 }
300 other => panic!("expected AppError::Internal, got {other:?} ({message})"),
301 }
302
303 let cover = cover_cols(&db.pool, item).await;
304 assert_eq!(
305 cover,
306 (None, None, None),
307 "a refused confirm must not half-write the row, got {cover:?}"
308 );
309 }
310
311 /// The function takes any executor so the confirm and the storage credit share
312 /// one transaction. That is only worth anything if a rollback takes the file
313 /// writeback with it.
314 #[tokio::test]
315 async fn a_file_confirm_rolled_back_with_its_transaction_leaves_no_write() {
316 let db = TestDb::new().await;
317 let (owner, item) = owner_and_item(&db, "tx").await;
318
319 let mut tx = db.pool.begin().await.expect("begin");
320 let outcome = update_item_file_cas(
321 &mut *tx,
322 item,
323 owner,
324 FileType::Audio,
325 None,
326 "staging/rolled-back.mp3",
327 8_800_000,
328 )
329 .await
330 .expect("in-transaction confirm runs");
331 assert_eq!(
332 outcome,
333 FileConfirmOutcome::Committed,
334 "inside the transaction the CAS matches"
335 );
336 tx.rollback().await.expect("rollback");
337
338 let cols = audio_cols(&db.pool, item).await;
339 assert_eq!(
340 cols,
341 (None, None),
342 "rolling back the storage credit must undo the file writeback too, got {cols:?}"
343 );
344 }
345
346 // ── update_item_cover: the three-column write ────────────────────────────────
347
348 /// The cover write is the one that must move three columns together, and it
349 /// carries the same CAS as the audio/video path. A stale expectation must leave
350 /// all three as the winner left them: a partial write here shows a cover whose
351 /// url, key and size disagree.
352 #[tokio::test]
353 async fn a_cover_write_moves_url_key_and_size_together_and_guards_a_stale_key() {
354 let db = TestDb::new().await;
355 let (owner, item) = owner_and_item(&db, "cover").await;
356
357 let first = items::update_item_cover(
358 &db.pool,
359 item,
360 owner,
361 None,
362 "https://cdn.test/cover-one.png",
363 "covers/one.png",
364 640_000,
365 )
366 .await
367 .expect("first cover write runs");
368 assert!(first, "the first cover write against NULL must land");
369 let after_first = cover_cols(&db.pool, item).await;
370 assert_eq!(
371 after_first,
372 (
373 Some("https://cdn.test/cover-one.png".to_string()),
374 Some("covers/one.png".to_string()),
375 Some(640_000)
376 ),
377 "all three cover columns must be written, got {after_first:?}"
378 );
379
380 // A second confirm that still believes the cover is unset loses.
381 let stale = items::update_item_cover(
382 &db.pool,
383 item,
384 owner,
385 None,
386 "https://cdn.test/cover-two.png",
387 "covers/two.png",
388 250_000,
389 )
390 .await
391 .expect("stale cover write runs");
392 assert!(
393 !stale,
394 "a cover confirm carrying a stale expected key must report no rows updated"
395 );
396 let after_stale = cover_cols(&db.pool, item).await;
397 assert_eq!(
398 after_stale, after_first,
399 "the loser must not overwrite any of the three columns, got {after_stale:?}"
400 );
401
402 // The correct expectation replaces all three. 250_000 replaces 640_000; a
403 // summing bug would read 890_000 and a dropped write 640_000.
404 let replace = items::update_item_cover(
405 &db.pool,
406 item,
407 owner,
408 Some("covers/one.png"),
409 "https://cdn.test/cover-two.png",
410 "covers/two.png",
411 250_000,
412 )
413 .await
414 .expect("cover replace runs");
415 assert!(
416 replace,
417 "a cover confirm carrying the current key must land"
418 );
419 let after_replace = cover_cols(&db.pool, item).await;
420 assert_eq!(
421 after_replace,
422 (
423 Some("https://cdn.test/cover-two.png".to_string()),
424 Some("covers/two.png".to_string()),
425 Some(250_000)
426 ),
427 "the replace must swap all three columns, got {after_replace:?}"
428 );
429
430 // Ownership is the other half of the same predicate.
431 let stranger = seed_user(&db.pool, "media_cover_stranger").await;
432 let by_stranger = items::update_item_cover(
433 &db.pool,
434 item,
435 stranger,
436 Some("covers/two.png"),
437 "https://cdn.test/stolen.png",
438 "covers/stolen.png",
439 11,
440 )
441 .await
442 .expect("stranger cover write runs");
443 assert!(
444 !by_stranger,
445 "a non-owner cover write must report no rows updated"
446 );
447 let after_stranger = cover_cols(&db.pool, item).await;
448 assert_eq!(
449 after_stranger, after_replace,
450 "the non-owner write must not land, got {after_stranger:?}"
451 );
452 }
453
454 // ── size and metadata writebacks ─────────────────────────────────────────────
455
456 /// `get_item_file_sizes` feeds the storage decrement on delete, so it must read
457 /// the three columns into the three fields without crossing them, and a missing
458 /// item must read as three `None`s rather than an error (the delete path calls
459 /// it after the row may already be gone).
460 #[tokio::test]
461 async fn file_sizes_read_back_per_column_and_a_missing_item_reads_as_none() {
462 let db = TestDb::new().await;
463 let (_owner, item) = owner_and_item(&db, "sizes").await;
464
465 // Three distinct values, so a column swap changes the answer.
466 sqlx::query(
467 "UPDATE items SET audio_file_size_bytes = 5000000,
468 cover_file_size_bytes = 250000,
469 video_file_size_bytes = 3000000
470 WHERE id = $1",
471 )
472 .bind(item)
473 .execute(&db.pool)
474 .await
475 .expect("seed the three sizes");
476
477 let sizes = items::get_item_file_sizes(&db.pool, item)
478 .await
479 .expect("read sizes");
480 assert_eq!(
481 sizes.audio_file_size_bytes,
482 Some(5_000_000),
483 "audio size read from the audio column"
484 );
485 assert_eq!(
486 sizes.cover_file_size_bytes,
487 Some(250_000),
488 "cover size read from the cover column"
489 );
490 assert_eq!(
491 sizes.video_file_size_bytes,
492 Some(3_000_000),
493 "video size read from the video column"
494 );
495
496 let missing = items::get_item_file_sizes(&db.pool, ItemId::new())
497 .await
498 .expect("a missing item is not an error here");
499 assert_eq!(
500 (
501 missing.audio_file_size_bytes,
502 missing.cover_file_size_bytes,
503 missing.video_file_size_bytes
504 ),
505 (None, None, None),
506 "a deleted item must decrement nothing, so it reads as three Nones"
507 );
508 }
509
510 /// Each size writeback is ownership-filtered and touches exactly one column.
511 /// Written as one test because the interesting assertion is the cross-check: the
512 /// other two columns are unchanged after each call.
513 #[tokio::test]
514 async fn size_writebacks_are_owner_scoped_and_touch_one_column_each() {
515 let db = TestDb::new().await;
516 let (owner, item) = owner_and_item(&db, "writeback").await;
517 let stranger = seed_user(&db.pool, "media_size_stranger").await;
518
519 items::update_item_audio_file_size(&db.pool, item, owner, 6_200_000)
520 .await
521 .expect("audio size write");
522 items::update_item_cover_file_size(&db.pool, item, owner, 480_000)
523 .await
524 .expect("cover size write");
525 items::update_item_video_file_size(&db.pool, item, owner, 9_100_000)
526 .await
527 .expect("video size write");
528
529 let sizes = items::get_item_file_sizes(&db.pool, item)
530 .await
531 .expect("read sizes");
532 assert_eq!(
533 (
534 sizes.audio_file_size_bytes,
535 sizes.cover_file_size_bytes,
536 sizes.video_file_size_bytes
537 ),
538 (Some(6_200_000), Some(480_000), Some(9_100_000)),
539 "each writeback lands in its own column"
540 );
541
542 // A stranger's writeback is a silent no-op: the functions return Ok either
543 // way, so the only observable contract is that nothing changed.
544 items::update_item_audio_file_size(&db.pool, item, stranger, 17)
545 .await
546 .expect("stranger audio size write");
547 items::update_item_cover_file_size(&db.pool, item, stranger, 19)
548 .await
549 .expect("stranger cover size write");
550 items::update_item_video_file_size(&db.pool, item, stranger, 23)
551 .await
552 .expect("stranger video size write");
553
554 let after = items::get_item_file_sizes(&db.pool, item)
555 .await
556 .expect("read sizes again");
557 assert_eq!(
558 (
559 after.audio_file_size_bytes,
560 after.cover_file_size_bytes,
561 after.video_file_size_bytes
562 ),
563 (Some(6_200_000), Some(480_000), Some(9_100_000)),
564 "a non-owner must not be able to rewrite another creator's quota numbers"
565 );
566 }
567
568 /// `update_item_video_s3_key` returns the updated row, and its ownership filter
569 /// is enforced by the `fetch_one`: a non-owner gets a row-not-found database
570 /// error rather than a silent success, and the stored key is untouched.
571 #[tokio::test]
572 async fn setting_a_video_key_returns_the_row_and_a_non_owner_gets_row_not_found() {
573 let db = TestDb::new().await;
574 let (owner, item) = owner_and_item(&db, "vkey").await;
575 let stranger = seed_user(&db.pool, "media_vkey_stranger").await;
576
577 let updated = items::update_item_video_s3_key(&db.pool, item, owner, "videos/take-one.mp4")
578 .await
579 .expect("owner video key write");
580 assert_eq!(updated.id, item, "the returned row is the item written");
581 assert_eq!(
582 updated.video_s3_key.as_deref(),
583 Some("videos/take-one.mp4"),
584 "the returned row carries the new key, got {:?}",
585 updated.video_s3_key
586 );
587
588 let err = items::update_item_video_s3_key(&db.pool, item, stranger, "videos/stolen.mp4")
589 .await
590 .expect_err("a non-owner must not set another creator's video key");
591 assert!(
592 matches!(err, AppError::Database(sqlx::Error::RowNotFound)),
593 "the ownership filter matches no row, so the error is RowNotFound, got {err:?}"
594 );
595
596 let cols = video_cols(&db.pool, item).await;
597 assert_eq!(
598 cols.0.as_deref(),
599 Some("videos/take-one.mp4"),
600 "the non-owner write must not land, got {cols:?}"
601 );
602 }
603
604 /// Video metadata is three independent fields written in one statement. The
605 /// values are deliberately asymmetric so a width/height transposition fails, and
606 /// the clear-to-null pass pins that `None` writes NULL rather than being skipped.
607 #[tokio::test]
608 async fn video_metadata_writes_all_three_fields_is_owner_scoped_and_can_clear_them() {
609 let db = TestDb::new().await;
610 let (owner, item) = owner_and_item(&db, "vmeta").await;
611 let stranger = seed_user(&db.pool, "media_vmeta_stranger").await;
612
613 items::update_item_video_metadata(&db.pool, item, owner, Some(754), Some(1920), Some(1080))
614 .await
615 .expect("owner metadata write");
616
617 let read = |pool: sqlx::PgPool| async move {
618 sqlx::query_as::<_, (Option<i32>, Option<i32>, Option<i32>)>(
619 "SELECT video_duration_seconds, video_width, video_height FROM items WHERE id = $1",
620 )
621 .bind(item)
622 .fetch_one(&pool)
623 .await
624 .expect("read video metadata")
625 };
626
627 let after_owner = read(db.pool.clone()).await;
628 assert_eq!(
629 after_owner,
630 (Some(754), Some(1920), Some(1080)),
631 "duration, width and height each land in their own column, got {after_owner:?}"
632 );
633
634 items::update_item_video_metadata(&db.pool, item, stranger, Some(11), Some(320), Some(240))
635 .await
636 .expect("stranger metadata write");
637 let after_stranger = read(db.pool.clone()).await;
638 assert_eq!(
639 after_stranger, after_owner,
640 "a non-owner must not rewrite the metadata, got {after_stranger:?}"
641 );
642
643 items::update_item_video_metadata(&db.pool, item, owner, None, None, None)
644 .await
645 .expect("owner metadata clear");
646 let cleared = read(db.pool.clone()).await;
647 assert_eq!(
648 cleared,
649 (None, None, None),
650 "writing None must clear the columns, not leave the old values, got {cleared:?}"
651 );
652 }
653