max / makenotwork
5 files changed,
+357 insertions,
-10 deletions
| @@ -167,7 +167,31 @@ async fn main() { | |||
| 167 | 167 | // See `makenotwork::seed` and `_private/docs/mnw/testnot-example-seed.md`. | |
| 168 | 168 | if std::env::args().any(|a| a == "--seed-examples") { | |
| 169 | 169 | let opts = makenotwork::seed::SeedOptions::from_env(&config.host_url); | |
| 170 | - | match makenotwork::seed::run(&db, &opts).await { | |
| 170 | + | ||
| 171 | + | // Build the storage handles the media phase needs (main + public/CDN | |
| 172 | + | // buckets). Both are `None` when S3 is unconfigured — the media phase then | |
| 173 | + | // no-ops and items stay hidden. Done here, inside the one-shot seed block, | |
| 174 | + | // so normal boot (which builds these later) is untouched. | |
| 175 | + | async fn seed_client( | |
| 176 | + | cfg: Option<&makenotwork::config::StorageConfig>, | |
| 177 | + | host_url: &str, | |
| 178 | + | ) -> Option<std::sync::Arc<dyn makenotwork::storage::StorageBackend>> { | |
| 179 | + | let cfg = cfg?; | |
| 180 | + | match S3Client::new(cfg, host_url).await { | |
| 181 | + | Ok(client) => Some(std::sync::Arc::new(client) as _), | |
| 182 | + | Err(e) => { | |
| 183 | + | tracing::warn!(error = ?e, "seed: failed to init S3 storage; media skipped"); | |
| 184 | + | None | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | let media = makenotwork::seed::SeedMedia { | |
| 189 | + | s3: seed_client(config.storage.as_ref(), &config.host_url).await, | |
| 190 | + | public_s3: seed_client(config.public_storage.as_ref(), &config.host_url).await, | |
| 191 | + | cdn_base_url: config.cdn_base_url.clone(), | |
| 192 | + | }; | |
| 193 | + | ||
| 194 | + | match makenotwork::seed::run(&db, &opts, &media).await { | |
| 171 | 195 | Ok(()) => { | |
| 172 | 196 | tracing::info!("example seed complete"); | |
| 173 | 197 | std::process::exit(0); |
| @@ -1,2 +1,220 @@ | |||
| 1 | - | //! Fetch and upload public-domain / CC0 media (audio, images, docs) through the | |
| 2 | - | //! storage layer, per `media-manifest.toml`. Filled in Phase 3. | |
| 1 | + | //! Attach media to seeded items and flip them visible. | |
| 2 | + | //! | |
| 3 | + | //! Phase 2 left every item `scan_status='pending'` (hidden). This phase uploads a | |
| 4 | + | //! file per item through the storage layer and promotes the item to `'clean'`, so | |
| 5 | + | //! previews/downloads resolve and the catalog surfaces in discover. | |
| 6 | + | //! | |
| 7 | + | //! This pass uploads **generated placeholders** (a silent WAV, a tiny PNG cover, a | |
| 8 | + | //! short byte blob), not real assets — enough to make the pipeline functional and | |
| 9 | + | //! reproducible with no external URLs. Real public-domain / CC0 media swaps in | |
| 10 | + | //! later via a `media-manifest.toml` + fetch step. When storage is unconfigured | |
| 11 | + | //! (testnot's stubbed default until MinIO is stood up), the whole phase is skipped | |
| 12 | + | //! and items stay hidden. | |
| 13 | + | ||
| 14 | + | use super::projects::SeededProject; | |
| 15 | + | use super::{SeedError, SeedMedia}; | |
| 16 | + | use crate::db::scan_jobs::ScanTargetKind; | |
| 17 | + | use crate::db::{self, FileScanStatus, ItemType}; | |
| 18 | + | use crate::storage::{FileType, S3Client, StorageBackend}; | |
| 19 | + | ||
| 20 | + | /// A minimal valid 16x16 grayscale PNG, used as a placeholder cover. | |
| 21 | + | static PLACEHOLDER_PNG: &[u8] = include_bytes!("assets/placeholder.png"); | |
| 22 | + | ||
| 23 | + | /// Attach placeholder media to every seeded item and promote it to visible. | |
| 24 | + | /// | |
| 25 | + | /// No-ops (leaving items hidden) when the main storage bucket is unconfigured. | |
| 26 | + | pub async fn seed_media( | |
| 27 | + | pool: &sqlx::PgPool, | |
| 28 | + | media: &SeedMedia, | |
| 29 | + | projects: &[SeededProject], | |
| 30 | + | ) -> Result<(), SeedError> { | |
| 31 | + | let Some(s3) = media.s3.as_deref() else { | |
| 32 | + | tracing::warn!("example seed: storage not configured; media skipped, items stay hidden"); | |
| 33 | + | return Ok(()); | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | for project in projects { | |
| 37 | + | // Project cover (best-effort; needs the public/CDN bucket). | |
| 38 | + | attach_project_cover(pool, media, project).await?; | |
| 39 | + | ||
| 40 | + | let items = db::items::get_items_by_project(pool, project.project.id).await?; | |
| 41 | + | for item in &items { | |
| 42 | + | attach_item_media(pool, s3, project, item).await?; | |
| 43 | + | attach_item_cover(pool, media, project, item).await?; | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | Ok(()) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Upload the item's primary file (by type) and mark the item `clean`. | |
| 50 | + | async fn attach_item_media( | |
| 51 | + | pool: &sqlx::PgPool, | |
| 52 | + | s3: &dyn StorageBackend, | |
| 53 | + | project: &SeededProject, | |
| 54 | + | item: &db::DbItem, | |
| 55 | + | ) -> Result<(), SeedError> { | |
| 56 | + | let user = project.user_id; | |
| 57 | + | match item.item_type { | |
| 58 | + | ItemType::Audio => { | |
| 59 | + | let key = S3Client::generate_key(user, item.id, FileType::Audio, "placeholder.wav"); | |
| 60 | + | s3.upload_object(&key, "audio/wav", silent_wav(), None).await?; | |
| 61 | + | db::scanning::promote_gated( | |
| 62 | + | pool, | |
| 63 | + | ScanTargetKind::Item, | |
| 64 | + | FileType::Audio, | |
| 65 | + | *item.id.as_uuid(), | |
| 66 | + | key.as_str(), | |
| 67 | + | ) | |
| 68 | + | .await?; | |
| 69 | + | } | |
| 70 | + | ItemType::Video => { | |
| 71 | + | let key = S3Client::generate_key(user, item.id, FileType::Video, "placeholder.mp4"); | |
| 72 | + | // Placeholder bytes — not a playable video; real media swaps in later. | |
| 73 | + | s3.upload_object(&key, "video/mp4", placeholder_blob("video"), None).await?; | |
| 74 | + | db::scanning::promote_gated( | |
| 75 | + | pool, | |
| 76 | + | ScanTargetKind::Item, | |
| 77 | + | FileType::Video, | |
| 78 | + | *item.id.as_uuid(), | |
| 79 | + | key.as_str(), | |
| 80 | + | ) | |
| 81 | + | .await?; | |
| 82 | + | } | |
| 83 | + | ItemType::Text => { | |
| 84 | + | // Body was set in Phase 2; a text item needs no file, only visibility. | |
| 85 | + | db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; | |
| 86 | + | } | |
| 87 | + | ItemType::Image => { | |
| 88 | + | // The cover (attached separately) is the media; just make it visible. | |
| 89 | + | db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; | |
| 90 | + | } | |
| 91 | + | // Everything else is served as a downloadable version. | |
| 92 | + | ItemType::Sample | |
| 93 | + | | ItemType::Plugin | |
| 94 | + | | ItemType::Preset | |
| 95 | + | | ItemType::Course | |
| 96 | + | | ItemType::Template | |
| 97 | + | | ItemType::Digital | |
| 98 | + | | ItemType::Bundle => { | |
| 99 | + | let blob = placeholder_blob(&item.title); | |
| 100 | + | let size = blob.len() as i64; | |
| 101 | + | let version = db::versions::create_version( | |
| 102 | + | pool, | |
| 103 | + | item.id, | |
| 104 | + | "1.0.0", | |
| 105 | + | Some("Initial placeholder release."), | |
| 106 | + | None, | |
| 107 | + | Some(size), | |
| 108 | + | Some("placeholder.txt"), | |
| 109 | + | None, | |
| 110 | + | ) | |
| 111 | + | .await?; | |
| 112 | + | let key = | |
| 113 | + | S3Client::generate_version_key(user, item.id, version.id, "placeholder.txt"); | |
| 114 | + | s3.upload_object(&key, "application/octet-stream", blob, None).await?; | |
| 115 | + | db::scanning::promote_gated( | |
| 116 | + | pool, | |
| 117 | + | ScanTargetKind::Version, | |
| 118 | + | FileType::Download, | |
| 119 | + | *version.id.as_uuid(), | |
| 120 | + | key.as_str(), | |
| 121 | + | ) | |
| 122 | + | .await?; | |
| 123 | + | // Version clean makes it downloadable; the item itself must be clean | |
| 124 | + | // too to surface in discover. | |
| 125 | + | db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?; | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | tracing::info!(title = %item.title, item_type = ?item.item_type, "example seed: attached media"); | |
| 129 | + | Ok(()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Attach a placeholder cover to an item. Best-effort: requires the public/CDN | |
| 133 | + | /// bucket + render base, else skipped. | |
| 134 | + | async fn attach_item_cover( | |
| 135 | + | pool: &sqlx::PgPool, | |
| 136 | + | media: &SeedMedia, | |
| 137 | + | project: &SeededProject, | |
| 138 | + | item: &db::DbItem, | |
| 139 | + | ) -> Result<(), SeedError> { | |
| 140 | + | let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref()) | |
| 141 | + | else { | |
| 142 | + | return Ok(()); | |
| 143 | + | }; | |
| 144 | + | let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, "cover.png"); | |
| 145 | + | public.upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None).await?; | |
| 146 | + | let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str()); | |
| 147 | + | sqlx::query( | |
| 148 | + | "UPDATE items SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \ | |
| 149 | + | updated_at = NOW() WHERE id = $3", | |
| 150 | + | ) | |
| 151 | + | .bind(key.as_str()) | |
| 152 | + | .bind(&url) | |
| 153 | + | .bind(item.id) | |
| 154 | + | .execute(pool) | |
| 155 | + | .await?; | |
| 156 | + | Ok(()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Attach a placeholder cover to a project. Best-effort (see [`attach_item_cover`]). | |
| 160 | + | async fn attach_project_cover( | |
| 161 | + | pool: &sqlx::PgPool, | |
| 162 | + | media: &SeedMedia, | |
| 163 | + | project: &SeededProject, | |
| 164 | + | ) -> Result<(), SeedError> { | |
| 165 | + | let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref()) | |
| 166 | + | else { | |
| 167 | + | return Ok(()); | |
| 168 | + | }; | |
| 169 | + | let key = S3Client::generate_project_image_key(project.project.id, "cover.png"); | |
| 170 | + | public.upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None).await?; | |
| 171 | + | let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str()); | |
| 172 | + | sqlx::query( | |
| 173 | + | "UPDATE projects SET cover_s3_key = $1, cover_image_url = $2, cover_scan_status = 'clean', \ | |
| 174 | + | updated_at = NOW() WHERE id = $3", | |
| 175 | + | ) | |
| 176 | + | .bind(key.as_str()) | |
| 177 | + | .bind(&url) | |
| 178 | + | .bind(project.project.id) | |
| 179 | + | .execute(pool) | |
| 180 | + | .await?; | |
| 181 | + | Ok(()) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s) — a valid, | |
| 185 | + | /// tiny audio file for the placeholder audio player. | |
| 186 | + | fn silent_wav() -> Vec<u8> { | |
| 187 | + | const SAMPLE_RATE: u32 = 8000; | |
| 188 | + | const BITS: u16 = 16; | |
| 189 | + | const CHANNELS: u16 = 1; | |
| 190 | + | const SAMPLES: u32 = SAMPLE_RATE / 2; // 0.5 s | |
| 191 | + | let data_len = SAMPLES * u32::from(BITS / 8) * u32::from(CHANNELS); | |
| 192 | + | let byte_rate = SAMPLE_RATE * u32::from(CHANNELS) * u32::from(BITS / 8); | |
| 193 | + | let block_align = CHANNELS * (BITS / 8); | |
| 194 | + | ||
| 195 | + | let mut w = Vec::with_capacity(44 + data_len as usize); | |
| 196 | + | w.extend_from_slice(b"RIFF"); | |
| 197 | + | w.extend_from_slice(&(36 + data_len).to_le_bytes()); | |
| 198 | + | w.extend_from_slice(b"WAVE"); | |
| 199 | + | w.extend_from_slice(b"fmt "); | |
| 200 | + | w.extend_from_slice(&16u32.to_le_bytes()); // PCM fmt chunk size | |
| 201 | + | w.extend_from_slice(&1u16.to_le_bytes()); // audio format = PCM | |
| 202 | + | w.extend_from_slice(&CHANNELS.to_le_bytes()); | |
| 203 | + | w.extend_from_slice(&SAMPLE_RATE.to_le_bytes()); | |
| 204 | + | w.extend_from_slice(&byte_rate.to_le_bytes()); | |
| 205 | + | w.extend_from_slice(&block_align.to_le_bytes()); | |
| 206 | + | w.extend_from_slice(&BITS.to_le_bytes()); | |
| 207 | + | w.extend_from_slice(b"data"); | |
| 208 | + | w.extend_from_slice(&data_len.to_le_bytes()); | |
| 209 | + | w.resize(44 + data_len as usize, 0); // silence | |
| 210 | + | w | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// A short UTF-8 placeholder blob for downloads / non-audio media. | |
| 214 | + | fn placeholder_blob(label: &str) -> Vec<u8> { | |
| 215 | + | format!( | |
| 216 | + | "{label}\n\nExample-seed placeholder file. Real public-domain media \ | |
| 217 | + | attaches in a later pass.\n" | |
| 218 | + | ) | |
| 219 | + | .into_bytes() | |
| 220 | + | } |
| @@ -37,8 +37,33 @@ pub mod media; | |||
| 37 | 37 | pub mod projects; | |
| 38 | 38 | pub mod social; | |
| 39 | 39 | ||
| 40 | + | use std::sync::Arc; | |
| 41 | + | ||
| 40 | 42 | use sqlx::PgPool; | |
| 41 | 43 | ||
| 44 | + | use crate::storage::StorageBackend; | |
| 45 | + | ||
| 46 | + | /// Storage handles the media phase needs, resolved from config at the seed call | |
| 47 | + | /// site. When `s3` is `None` (testnot's stubbed default until MinIO is stood up), | |
| 48 | + | /// the media phase is skipped and items stay hidden. Covers additionally require | |
| 49 | + | /// `public_s3` + `cdn_base_url` (the CDN render base). | |
| 50 | + | pub struct SeedMedia { | |
| 51 | + | /// Main (gated) bucket for audio/video/download files. | |
| 52 | + | pub s3: Option<Arc<dyn StorageBackend>>, | |
| 53 | + | /// Public/CDN bucket for cover images. | |
| 54 | + | pub public_s3: Option<Arc<dyn StorageBackend>>, | |
| 55 | + | /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`). | |
| 56 | + | pub cdn_base_url: Option<String>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl SeedMedia { | |
| 60 | + | /// No storage — the media phase is skipped and items remain hidden. Used on a | |
| 61 | + | /// stubbed-S3 box and in tests that only exercise the DB-only phases. | |
| 62 | + | pub fn none() -> Self { | |
| 63 | + | Self { s3: None, public_s3: None, cdn_base_url: None } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 42 | 67 | /// Reserved email domain for every seeded example account. It is the marker the | |
| 43 | 68 | /// prod-safety guard uses to tell fabricated seed data from real users: the seed | |
| 44 | 69 | /// refuses to run when any account with a different domain exists, and the reset | |
| @@ -100,7 +125,7 @@ impl SeedOptions { | |||
| 100 | 125 | /// Refuses (returns `Err`) unless every prod-safety guard passes. On success the | |
| 101 | 126 | /// database holds a fresh fabricated catalog. Callers should treat this as a | |
| 102 | 127 | /// one-shot: run it, then exit the process. | |
| 103 | - | pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> { | |
| 128 | + | pub async fn run(pool: &PgPool, opts: &SeedOptions, media: &SeedMedia) -> Result<(), SeedError> { | |
| 104 | 129 | // Guards 1 + 2: opt-in switch and approved host. Pure, no DB. | |
| 105 | 130 | check_static_guards(opts.allow_example_seed, &opts.host_url)?; | |
| 106 | 131 | ||
| @@ -125,7 +150,11 @@ pub async fn run(pool: &PgPool, opts: &SeedOptions) -> Result<(), SeedError> { | |||
| 125 | 150 | let creators = creators::seed_creators(pool).await?; | |
| 126 | 151 | let projects = projects::seed_projects(pool, &creators).await?; | |
| 127 | 152 | items::seed_items(pool, &projects).await?; | |
| 128 | - | tracing::warn!("example seed: media/blog/social pending (Phases 3-4)"); | |
| 153 | + | ||
| 154 | + | // Phase 3: attach placeholder media and flip items visible. Skipped (items | |
| 155 | + | // stay hidden) when storage is unconfigured. | |
| 156 | + | media::seed_media(pool, media, &projects).await?; | |
| 157 | + | tracing::warn!("example seed: blog/social pending (Phase 4)"); | |
| 129 | 158 | ||
| 130 | 159 | Ok(()) | |
| 131 | 160 | } |
| @@ -16,8 +16,12 @@ | |||
| 16 | 16 | //! counts stay fixed instead of doubling. | |
| 17 | 17 | //! 5. The prod-host guard still refuses even with the opt-in flag set. | |
| 18 | 18 | ||
| 19 | + | use std::sync::Arc; | |
| 20 | + | ||
| 19 | 21 | use crate::harness::db::TestDb; | |
| 20 | - | use makenotwork::seed::{self, SeedOptions}; | |
| 22 | + | use crate::harness::storage::InMemoryStorage; | |
| 23 | + | use makenotwork::seed::{self, SeedMedia, SeedOptions}; | |
| 24 | + | use makenotwork::storage::StorageBackend; | |
| 21 | 25 | ||
| 22 | 26 | /// The Phase-1 roster size (see `seed::creators::ROSTER`). | |
| 23 | 27 | const SEEDED_CREATORS: i64 = 5; | |
| @@ -113,7 +117,7 @@ async fn seeds_publicly_visible_creators_and_projects() { | |||
| 113 | 117 | .expect("count real users"); | |
| 114 | 118 | assert_eq!(real, 0, "a migrated DB should hold no non-example accounts"); | |
| 115 | 119 | ||
| 116 | - | seed::run(&db.pool, &testnot_opts()) | |
| 120 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()) | |
| 117 | 121 | .await | |
| 118 | 122 | .expect("seed should run on a clean migrated DB"); | |
| 119 | 123 | ||
| @@ -142,7 +146,7 @@ async fn seeds_publicly_visible_creators_and_projects() { | |||
| 142 | 146 | #[tokio::test] | |
| 143 | 147 | async fn seeds_items_across_types_and_pricing_but_hidden() { | |
| 144 | 148 | let db = TestDb::new().await; | |
| 145 | - | seed::run(&db.pool, &testnot_opts()).await.expect("seed"); | |
| 149 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()).await.expect("seed"); | |
| 146 | 150 | ||
| 147 | 151 | // One item per ItemType (11), and every type represented exactly once. | |
| 148 | 152 | assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS); | |
| @@ -214,8 +218,8 @@ async fn seeds_items_across_types_and_pricing_but_hidden() { | |||
| 214 | 218 | async fn seed_is_idempotent() { | |
| 215 | 219 | let db = TestDb::new().await; | |
| 216 | 220 | ||
| 217 | - | seed::run(&db.pool, &testnot_opts()).await.expect("first seed"); | |
| 218 | - | seed::run(&db.pool, &testnot_opts()).await.expect("second seed"); | |
| 221 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()).await.expect("first seed"); | |
| 222 | + | seed::run(&db.pool, &testnot_opts(), &SeedMedia::none()).await.expect("second seed"); | |
| 219 | 223 | ||
| 220 | 224 | // Re-running wipes prior example data first (cascading to projects + items), | |
| 221 | 225 | // so counts stay fixed instead of doubling. | |
| @@ -234,6 +238,7 @@ async fn refuses_on_prod_host() { | |||
| 234 | 238 | allow_example_seed: true, | |
| 235 | 239 | host_url: "https://makenot.work".to_string(), | |
| 236 | 240 | }, | |
| 241 | + | &SeedMedia::none(), | |
| 237 | 242 | ) | |
| 238 | 243 | .await | |
| 239 | 244 | .expect_err("prod host must be refused"); | |
| @@ -242,3 +247,74 @@ async fn refuses_on_prod_host() { | |||
| 242 | 247 | // Nothing was created, and the demo account is untouched. | |
| 243 | 248 | assert_eq!(count_example_creators(&db.pool).await, 0); | |
| 244 | 249 | } | |
| 250 | + | ||
| 251 | + | /// Guard-passing options bundled with an in-memory storage backend, so the media | |
| 252 | + | /// phase runs and flips items visible. | |
| 253 | + | fn media_ctx() -> SeedMedia { | |
| 254 | + | let s3: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new()); | |
| 255 | + | let public: Arc<dyn StorageBackend> = Arc::new(InMemoryStorage::new()); | |
| 256 | + | SeedMedia { | |
| 257 | + | s3: Some(s3), | |
| 258 | + | public_s3: Some(public), | |
| 259 | + | cdn_base_url: Some("https://cdn.example.test".to_string()), | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | #[tokio::test] | |
| 264 | + | async fn seeds_media_makes_items_visible() { | |
| 265 | + | let db = TestDb::new().await; | |
| 266 | + | seed::run(&db.pool, &testnot_opts(), &media_ctx()) | |
| 267 | + | .await | |
| 268 | + | .expect("seed with media"); | |
| 269 | + | ||
| 270 | + | // With media attached, every item is promoted 'clean' and now clears the | |
| 271 | + | // full item-discover gate (contrast the no-storage `..._but_hidden` test). | |
| 272 | + | assert_eq!( | |
| 273 | + | count_discover_visible_example_items(&db.pool).await, | |
| 274 | + | SEEDED_ITEMS, | |
| 275 | + | "all items should be visible once media is attached" | |
| 276 | + | ); | |
| 277 | + | ||
| 278 | + | // The audio item has its audio key set. | |
| 279 | + | let audio_keyed: i64 = sqlx::query_scalar( | |
| 280 | + | "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \ | |
| 281 | + | JOIN users u ON u.id = p.user_id \ | |
| 282 | + | WHERE lower(split_part(u.email,'@',2))='example.test' \ | |
| 283 | + | AND i.item_type = 'audio' AND i.audio_s3_key IS NOT NULL", | |
| 284 | + | ) | |
| 285 | + | .fetch_one(&db.pool) | |
| 286 | + | .await | |
| 287 | + | .expect("audio keyed"); | |
| 288 | + | assert!(audio_keyed >= 1, "audio item should have an audio_s3_key"); | |
| 289 | + | ||
| 290 | + | // Download-type items produced clean, keyed versions. | |
| 291 | + | let clean_versions: i64 = sqlx::query_scalar( | |
| 292 | + | "SELECT COUNT(*) FROM versions v JOIN items i ON i.id = v.item_id \ | |
| 293 | + | JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = p.user_id \ | |
| 294 | + | WHERE lower(split_part(u.email,'@',2))='example.test' \ | |
| 295 | + | AND v.s3_key IS NOT NULL AND v.scan_status = 'clean'", | |
| 296 | + | ) | |
| 297 | + | .fetch_one(&db.pool) | |
| 298 | + | .await | |
| 299 | + | .expect("clean versions"); | |
| 300 | + | assert!(clean_versions >= 1, "expected a clean, keyed download version"); | |
| 301 | + | ||
| 302 | + | // Covers were attached (public bucket + CDN base present). | |
| 303 | + | let covered: i64 = sqlx::query_scalar( | |
| 304 | + | "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id \ | |
| 305 | + | JOIN users u ON u.id = p.user_id \ | |
| 306 | + | WHERE lower(split_part(u.email,'@',2))='example.test' \ | |
| 307 | + | AND i.cover_s3_key IS NOT NULL AND i.cover_scan_status = 'clean'", | |
| 308 | + | ) | |
| 309 | + | .fetch_one(&db.pool) | |
| 310 | + | .await | |
| 311 | + | .expect("covered items"); | |
| 312 | + | assert!(covered >= 1, "expected item covers with cover_scan_status clean"); | |
| 313 | + | ||
| 314 | + | // Idempotent with media too: re-running stays at the roster size. | |
| 315 | + | seed::run(&db.pool, &testnot_opts(), &media_ctx()) | |
| 316 | + | .await | |
| 317 | + | .expect("re-seed with media"); | |
| 318 | + | assert_eq!(count_example_items(&db.pool).await, SEEDED_ITEMS); | |
| 319 | + | assert_eq!(count_discover_visible_example_items(&db.pool).await, SEEDED_ITEMS); | |
| 320 | + | } |