//! DB-layer contract tests for core `db::items` CRUD, the single-item side. //! //! Audit Run 18 Testing flagged the item core as asserted only through the HTTP //! surface. The batch mutations already have their own layer file //! (`db_items_bulk_layer.rs`); these pin the single-item contracts it leaves //! out: `create_item` + `get_item_by_id` round-trip (and the not-found `None`), //! the denormalized `sales_count` increment/decrement (with the floor-at-zero //! clamp), `update_item`'s publish toggle + ownership seal, the //! post-grace hide/unhide round-trip, and the admin-removal republish block. use crate::harness::TestHarness; use makenotwork::db::{AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId, items}; /// Create a logged-in creator with an empty-ish project and return /// `(user_id, project_id)`. (The helper also seeds one HTTP-created item, which /// these tests ignore, they add their own items via `items::create_item`.) async fn creator_project(h: &mut TestHarness, tag: &str) -> (UserId, ProjectId) { let setup = h .create_creator_with_item(&format!("itm_{tag}"), "digital", 1000) .await; let project_id: ProjectId = setup.project_id.parse().expect("project id parses"); (setup.user_id, project_id) } /// Seed a bare user directly (no HTTP-created item), for tests that assert on a /// per-user, project-spanning count and so need a contamination-free item set. async fn seed_user(h: &TestHarness, username: &str) -> UserId { let hash = makenotwork::auth::hash_password("password123").expect("hash"); sqlx::query_scalar::<_, UserId>( "INSERT INTO users (username, email, password_hash, email_verified) VALUES ($1, $2, $3, true) RETURNING id", ) .bind(username) .bind(format!("{username}@test.com")) .bind(&hash) .fetch_one(&h.db) .await .expect("seed user") } /// Seed a bare project directly for a user. async fn seed_project(h: &TestHarness, user: UserId, slug: &str) -> ProjectId { sqlx::query_scalar::<_, ProjectId>( "INSERT INTO projects (user_id, slug, title) VALUES ($1, $2, 'P') RETURNING id", ) .bind(user) .bind(slug) .fetch_one(&h.db) .await .expect("seed project") } /// Insert a fresh item into a project via the real `create_item` path. async fn make_item(h: &TestHarness, project: ProjectId, title: &str) -> ItemId { let item = items::create_item( &h.db, project, title, None, PriceCents::new(1000).expect("valid price"), ItemType::Digital, AiTier::Handmade, None, ) .await .expect("create_item"); item.id } async fn is_public(h: &TestHarness, item: ItemId) -> bool { sqlx::query_scalar::<_, bool>("SELECT is_public FROM items WHERE id = $1") .bind(item) .fetch_one(&h.db) .await .expect("read is_public") } async fn sales_count(h: &TestHarness, item: ItemId) -> i32 { sqlx::query_scalar::<_, i32>("SELECT sales_count FROM items WHERE id = $1") .bind(item) .fetch_one(&h.db) .await .expect("read sales_count") } // ── create_item + get_item_by_id: round-trip and not-found ──────────────────── #[tokio::test] async fn create_item_round_trips_through_get_by_id_and_missing_reads_none() { let mut h = TestHarness::new().await; let (_owner, project) = creator_project(&mut h, "get").await; let created = items::create_item( &h.db, project, "My First Track", Some("a description"), PriceCents::new(2500).expect("valid price"), ItemType::Audio, AiTier::Handmade, None, ) .await .expect("create_item"); // create_item auto-generates a URL-safe slug from the title. assert_eq!( created.slug, "my-first-track", "slug is derived from the title" ); let fetched = items::get_item_by_id(&h.db, created.id) .await .expect("get_item_by_id ok") .expect("the created item is found by id"); assert_eq!(fetched.id, created.id); assert_eq!(fetched.title, "My First Track"); assert_eq!( fetched.price_cents, 2500, "stored price is the created 2500 cents" ); // A random id that was never inserted reads as None, not an error. let missing = items::get_item_by_id(&h.db, ItemId::new()) .await .expect("get_item_by_id ok for a missing id"); assert!(missing.is_none(), "an unknown item id returns None"); } // ── increment/decrement_sales_count: the denormalized counter ───────────────── #[tokio::test] async fn sales_count_increments_decrements_and_floors_at_zero() { let mut h = TestHarness::new().await; let (_owner, project) = creator_project(&mut h, "sales").await; let item = make_item(&h, project, "Counter Item").await; assert_eq!( sales_count(&h, item).await, 0, "a new item starts at zero sales" ); items::increment_sales_count(&h.db, item) .await .expect("increment #1"); items::increment_sales_count(&h.db, item) .await .expect("increment #2"); assert_eq!( sales_count(&h, item).await, 2, "two increments bump the counter to 2" ); items::decrement_sales_count(&h.db, item) .await .expect("decrement"); assert_eq!( sales_count(&h, item).await, 1, "a decrement (refund) walks it back" ); // The clamp: decrementing past zero can't go negative (GREATEST(.., 0)). items::decrement_sales_count(&h.db, item) .await .expect("decrement to zero"); items::decrement_sales_count(&h.db, item) .await .expect("decrement below zero is clamped"); assert_eq!( sales_count(&h, item).await, 0, "sales_count never goes negative" ); } // ── update_item: publish toggle + ownership seal ────────────────────────────── #[tokio::test] async fn update_item_toggles_publish_and_is_owner_scoped() { let mut h = TestHarness::new().await; let (owner, project) = creator_project(&mut h, "upd").await; let attacker = h .signup( "itm_upd_attacker", "itm_upd_attacker@test.com", "password123", ) .await; let item = make_item(&h, project, "Toggle Me").await; // Start unpublished, then the owner publishes via update_item. items::update_item( &h.db, item, owner, None, None, None, None, Some(false), None, None, None, None, None, None, ) .await .expect("owner unpublish"); assert!(!is_public(&h, item).await); // A non-owner update matches zero rows: the ownership subquery excludes them, // so `fetch_one` finds nothing and the call errors, nothing changes. let hijack = items::update_item( &h.db, item, attacker, None, None, None, None, Some(true), None, None, None, None, None, None, ) .await; assert!( hijack.is_err(), "a non-owner update_item must not touch the row" ); assert!( !is_public(&h, item).await, "the item stays unpublished after the hijack attempt" ); // The owner flips it public. let published = items::update_item( &h.db, item, owner, None, None, None, None, Some(true), None, None, None, None, None, None, ) .await .expect("owner publish"); assert!(published.is_public, "the owner can publish their own item"); } // ── hide/unhide_all_items_for_user: the post-grace round-trip ───────────────── #[tokio::test] async fn hide_then_unhide_all_items_for_user_round_trips() { let h = TestHarness::new().await; // Seed a contamination-free creator: `hide_all_items_for_users` spans every // project the user owns, so the count must not include a stray HTTP-seeded item. let owner = seed_user(&h, "itm_hide_owner").await; let project = seed_project(&h, owner, "itm-hide-proj").await; // create_item leaves is_public at its column default (true), so both start public. let a = make_item(&h, project, "Public A").await; let b = make_item(&h, project, "Public B").await; assert!( is_public(&h, a).await && is_public(&h, b).await, "items start public" ); // The scheduler sweep hides every public item for the creator in one shot. let hidden = items::hide_all_items_for_users(&h.db, &[owner]) .await .expect("hide"); assert_eq!(hidden, 2, "both public items are hidden"); assert!(!is_public(&h, a).await && !is_public(&h, b).await); // An empty slice is a no-op, not a full-table update. assert_eq!( items::hide_all_items_for_users(&h.db, &[]) .await .expect("empty hide"), 0 ); // Re-subscribing unhides them again and reports the count restored. let unhidden = items::unhide_all_items_for_user(&h.db, owner) .await .expect("unhide"); assert_eq!(unhidden, 2, "both items are unhidden on re-subscribe"); assert!(is_public(&h, a).await && is_public(&h, b).await); } // ── admin_remove_item: hides + blocks creator republish ─────────────────────── #[tokio::test] async fn admin_removal_hides_the_item_and_blocks_creator_republish() { let mut h = TestHarness::new().await; let (owner, project) = creator_project(&mut h, "adm").await; let item = make_item(&h, project, "Flagged Item").await; assert!(is_public(&h, item).await, "item starts public"); let removed = items::admin_remove_item(&h.db, item, "violates policy") .await .expect("admin_remove_item"); assert!( removed.removed_by_admin, "the item is flagged removed_by_admin" ); assert!(!removed.is_public, "admin removal unpublishes the item"); assert_eq!(removed.removal_reason.as_deref(), Some("violates policy")); // The creator cannot republish an admin-removed item: update_item's CASE // forces is_public back to false while removed_by_admin is set. let attempt = items::update_item( &h.db, item, owner, None, None, None, None, Some(true), None, None, None, None, None, None, ) .await .expect("owner update runs (row is theirs)"); assert!( !attempt.is_public, "a removed item cannot be republished by its creator" ); // Admin restore clears the flags (but leaves it unpublished for manual republish). let restored = items::admin_restore_item(&h.db, item) .await .expect("admin_restore_item"); assert!( !restored.removed_by_admin, "restore clears the admin-removed flag" ); assert!(restored.removal_reason.is_none()); }