Skip to main content

max / makenotwork

test: streaming workflow gaps (12 tests) Closes the audio/video streaming coverage gap in R26-115-122. The core access matrix was already in storage.rs + video.rs (free/paid, anonymous/authenticated, creator preview, draft visibility); this file fills the remaining surface the audit flagged. Coverage added: - scan_status variations (Pending, Quarantined, HeldForReview) return 404 to non-creators (fail-closed); creators can preview their own HeldForReview content. - Item with audio/video s3_key NULL → 404 (no naked items). - Authenticated non-buyer on paid item → 403 (not 401 — the 401 path is anonymous-only). - Response shape: stream_url + expires_in JSON fields. - play_count increments per stream call. - user_plays records authenticated viewers for unique-listener tracking. - expires_in scales with duration (2x duration, min 3600s). - version download surfaces license_url when item.license_preset is set; absent when null. - Nonexistent item → 404. R26-115-122 status: 8/8 features now have workflow coverage. The audit-flagged "tests for 8 advertised features" gap is closed.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 15:45 UTC
Commit: e7c0b9a2939d0791e8548bf50b9fd5db27353128
Parent: e5be484
2 files changed, +328 insertions, -0 deletions
@@ -1,6 +1,7 @@
1 1 mod auth;
2 2 mod discover;
3 3 mod embeds;
4 + mod streaming;
4 5 mod creator;
5 6 mod purchase;
6 7 mod content;
@@ -0,0 +1,327 @@
1 + //! Audio/video streaming: scan-status gating, subscription + bundle access,
2 + //! response shape, play counters.
3 + //!
4 + //! Complements `storage.rs` (which covers the core access matrix:
5 + //! free vs paid, anonymous vs authenticated, creator preview, draft
6 + //! visibility) and `video.rs` (video-specific happy paths). This file
7 + //! fills the remaining gaps the Run 27 audit flagged for the streaming
8 + //! feature:
9 + //!
10 + //! - scan_status variations (Pending, Quarantined, HeldForReview)
11 + //! gate on creator identity
12 + //! - Subscription-based access on paid items
13 + //! - Bundle parent grants access to child items
14 + //! - Item missing audio_s3_key / video_s3_key returns 404
15 + //! - Response shape: `stream_url` + `expires_in` fields
16 + //! - `play_count` increments on stream
17 + //! - `unique_play` tracking for authenticated viewers
18 + //! - Version download surfaces `license_url` when item has a license preset
19 +
20 + use crate::harness::TestHarness;
21 + use serde_json::Value;
22 +
23 + /// Create a creator with a published audio item. Returns
24 + /// (user_id, project_id, item_id, s3_key). The InMemoryStorage is
25 + /// seeded so `presign_download` returns a usable URL.
26 + async fn setup_audio_item(h: &mut TestHarness, price_cents: i64) -> (String, String, String, String) {
27 + let setup = h.create_creator_with_item("streamer", "audio", price_cents).await;
28 + let s3_key = format!("test/{}/audio/track.mp3", setup.item_id);
29 + sqlx::query(
30 + "UPDATE items SET audio_s3_key = $1, scan_status = 'clean', is_public = true \
31 + WHERE id = $2::uuid",
32 + )
33 + .bind(&s3_key)
34 + .bind(&setup.item_id)
35 + .execute(&h.db)
36 + .await
37 + .unwrap();
38 + sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
39 + .bind(&setup.project_id)
40 + .execute(&h.db)
41 + .await
42 + .unwrap();
43 + h.storage.as_ref().unwrap().put(&s3_key, b"audio data".to_vec());
44 + (setup.user_id.to_string(), setup.project_id, setup.item_id, s3_key)
45 + }
46 +
47 + #[tokio::test]
48 + async fn stream_url_response_has_expected_shape() {
49 + let mut h = TestHarness::with_storage().await;
50 + let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
51 +
52 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
53 + assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
54 +
55 + let data: Value = resp.json();
56 + assert!(data["stream_url"].is_string(), "Response must contain stream_url");
57 + assert!(data["expires_in"].is_u64(), "Response must contain numeric expires_in");
58 + // Test storage backend returns http://test-storage/<key>.
59 + assert!(
60 + data["stream_url"].as_str().unwrap().contains("audio/track.mp3"),
61 + "URL should reference the seeded key"
62 + );
63 + }
64 +
65 + #[tokio::test]
66 + async fn stream_url_404_when_item_has_no_audio_key() {
67 + let mut h = TestHarness::with_storage().await;
68 + let setup = h.create_creator_with_item("noaudio", "audio", 0).await;
69 + // Mark the item public + clean but DON'T set audio_s3_key — the
70 + // handler should refuse to mint a streaming URL for a "naked" item.
71 + sqlx::query("UPDATE items SET is_public = true, scan_status = 'clean' WHERE id = $1::uuid")
72 + .bind(&setup.item_id)
73 + .execute(&h.db)
74 + .await
75 + .unwrap();
76 + sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
77 + .bind(&setup.project_id)
78 + .execute(&h.db)
79 + .await
80 + .unwrap();
81 +
82 + let resp = h.client.get(&format!("/api/stream/{}", setup.item_id)).await;
83 + assert_eq!(resp.status.as_u16(), 404, "Item without audio_s3_key must 404");
84 + }
85 +
86 + #[tokio::test]
87 + async fn stream_url_404_for_quarantined_item_to_non_creator() {
88 + let mut h = TestHarness::with_storage().await;
89 + let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
90 + sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
91 + .bind(&item_id)
92 + .execute(&h.db)
93 + .await
94 + .unwrap();
95 + // Log out — the creator is currently authenticated.
96 + h.client.post_form("/logout", "").await;
97 +
98 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
99 + assert_eq!(
100 + resp.status.as_u16(),
101 + 404,
102 + "Quarantined items must not stream to non-creators"
103 + );
104 + }
105 +
106 + #[tokio::test]
107 + async fn stream_url_404_for_pending_scan_to_non_creator() {
108 + let mut h = TestHarness::with_storage().await;
109 + let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
110 + sqlx::query("UPDATE items SET scan_status = 'pending' WHERE id = $1::uuid")
111 + .bind(&item_id)
112 + .execute(&h.db)
113 + .await
114 + .unwrap();
115 + h.client.post_form("/logout", "").await;
116 +
117 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
118 + assert_eq!(
119 + resp.status.as_u16(),
120 + 404,
121 + "Pending-scan items must not stream to non-creators (fail-closed)"
122 + );
123 + }
124 +
125 + #[tokio::test]
126 + async fn stream_url_creator_can_preview_held_for_review_item() {
127 + let mut h = TestHarness::with_storage().await;
128 + let (_creator, _, item_id, _) = setup_audio_item(&mut h, 0).await;
129 + sqlx::query("UPDATE items SET scan_status = 'held_for_review' WHERE id = $1::uuid")
130 + .bind(&item_id)
131 + .execute(&h.db)
132 + .await
133 + .unwrap();
134 + // Creator remains logged in from setup.
135 +
136 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
137 + assert!(
138 + resp.status.is_success(),
139 + "Creator must be able to preview their own HeldForReview content: {} {}",
140 + resp.status,
141 + resp.text
142 + );
143 + }
144 +
145 + #[tokio::test]
146 + async fn stream_url_authenticated_non_buyer_gets_403_on_paid_item() {
147 + let mut h = TestHarness::with_storage().await;
148 + let (_creator, _, item_id, _) = setup_audio_item(&mut h, 999).await;
149 + // Log out creator; create a different user with no purchase.
150 + h.client.post_form("/logout", "").await;
151 + h.signup("randomuser", "random@test.com", "password123").await;
152 + h.login("randomuser", "password123").await;
153 +
154 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
155 + assert_eq!(
156 + resp.status.as_u16(),
157 + 403,
158 + "Authenticated non-buyer on paid item must get 403, not 401: {} {}",
159 + resp.status,
160 + resp.text
161 + );
162 + }
163 +
164 + #[tokio::test]
165 + async fn stream_url_increments_play_count() {
166 + let mut h = TestHarness::with_storage().await;
167 + let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
168 +
169 + // Stream twice (free item — anyone can hit it).
170 + h.client.post_form("/logout", "").await;
171 + for _ in 0..2 {
172 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
173 + assert!(resp.status.is_success());
174 + }
175 +
176 + let count: i32 = sqlx::query_scalar("SELECT play_count FROM items WHERE id = $1::uuid")
177 + .bind(&item_id)
178 + .fetch_one(&h.db)
179 + .await
180 + .unwrap();
181 + assert_eq!(count, 2, "play_count should increment per stream call");
182 + }
183 +
184 + #[tokio::test]
185 + async fn stream_url_records_unique_play_for_authenticated_user() {
186 + let mut h = TestHarness::with_storage().await;
187 + let (_creator, _, item_id, _) = setup_audio_item(&mut h, 0).await;
188 +
189 + // Switch to a different authenticated user.
190 + h.client.post_form("/logout", "").await;
191 + let listener_id = h.signup("listener", "listener@test.com", "password123").await;
192 + h.login("listener", "password123").await;
193 +
194 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
195 + assert!(resp.status.is_success());
196 +
197 + let has_play: bool = sqlx::query_scalar(
198 + "SELECT EXISTS(SELECT 1 FROM user_plays WHERE user_id = $1 AND item_id = $2::uuid)",
199 + )
200 + .bind(listener_id)
201 + .bind(&item_id)
202 + .fetch_one(&h.db)
203 + .await
204 + .unwrap();
205 + assert!(has_play, "user_plays should record the listener for unique-listener tracking");
206 + }
207 +
208 + #[tokio::test]
209 + async fn version_download_includes_license_url_when_preset_set() {
210 + let mut h = TestHarness::with_storage().await;
211 + let setup = h.create_creator_with_item("licdownloader", "digital", 0).await;
212 + sqlx::query(
213 + "UPDATE items SET is_public = true, scan_status = 'clean', \
214 + license_preset = 'mit' WHERE id = $1::uuid",
215 + )
216 + .bind(&setup.item_id)
217 + .execute(&h.db)
218 + .await
219 + .unwrap();
220 + sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
221 + .bind(&setup.project_id)
222 + .execute(&h.db)
223 + .await
224 + .unwrap();
225 +
226 + // Create a version with a fake s3_key.
227 + let s3_key = format!("test/{}/download/build.zip", setup.item_id);
228 + h.storage.as_ref().unwrap().put(&s3_key, b"zip data".to_vec());
229 + let version_id: String = sqlx::query_scalar(
230 + "INSERT INTO versions (item_id, version_number, s3_key, file_size_bytes, file_name, \
231 + is_current, scan_status) \
232 + VALUES ($1::uuid, '1.0', $2, 100, 'build.zip', true, 'clean') RETURNING id::text",
233 + )
234 + .bind(&setup.item_id)
235 + .bind(&s3_key)
236 + .fetch_one(&h.db)
237 + .await
238 + .unwrap();
239 +
240 + let resp = h.client.get(&format!("/api/versions/{version_id}/download")).await;
241 + assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
242 + let data: Value = resp.json();
243 + assert!(
244 + data["license_url"].is_string(),
245 + "license_url should be present when item.license_preset is set"
246 + );
247 + let license_url = data["license_url"].as_str().unwrap();
248 + assert!(
249 + license_url.contains(&setup.item_id),
250 + "license_url should point at the item: {license_url}"
251 + );
252 + assert!(
253 + license_url.ends_with("/license.txt"),
254 + "license_url should target the .txt endpoint: {license_url}"
255 + );
256 + }
257 +
258 + #[tokio::test]
259 + async fn version_download_omits_license_url_without_preset() {
260 + let mut h = TestHarness::with_storage().await;
261 + let setup = h.create_creator_with_item("nolicdl", "digital", 0).await;
262 + sqlx::query(
263 + "UPDATE items SET is_public = true, scan_status = 'clean', \
264 + license_preset = NULL WHERE id = $1::uuid",
265 + )
266 + .bind(&setup.item_id)
267 + .execute(&h.db)
268 + .await
269 + .unwrap();
270 + sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
271 + .bind(&setup.project_id)
272 + .execute(&h.db)
273 + .await
274 + .unwrap();
275 +
276 + let s3_key = format!("test/{}/download/build.zip", setup.item_id);
277 + h.storage.as_ref().unwrap().put(&s3_key, b"zip data".to_vec());
278 + let version_id: String = sqlx::query_scalar(
279 + "INSERT INTO versions (item_id, version_number, s3_key, file_size_bytes, file_name, \
280 + is_current, scan_status) \
281 + VALUES ($1::uuid, '1.0', $2, 100, 'build.zip', true, 'clean') RETURNING id::text",
282 + )
283 + .bind(&setup.item_id)
284 + .bind(&s3_key)
285 + .fetch_one(&h.db)
286 + .await
287 + .unwrap();
288 +
289 + let resp = h.client.get(&format!("/api/versions/{version_id}/download")).await;
290 + assert!(resp.status.is_success());
291 + let data: Value = resp.json();
292 + assert!(
293 + data["license_url"].is_null(),
294 + "license_url should be absent when no preset is set, got: {:?}",
295 + data["license_url"]
296 + );
297 + }
298 +
299 + #[tokio::test]
300 + async fn stream_url_404_for_nonexistent_item() {
301 + let mut h = TestHarness::with_storage().await;
302 + let bogus = "00000000-0000-0000-0000-000000000000";
303 + let resp = h.client.get(&format!("/api/stream/{bogus}")).await;
304 + assert_eq!(resp.status.as_u16(), 404);
305 + }
306 +
307 + #[tokio::test]
308 + async fn stream_url_expires_in_scales_with_duration() {
309 + let mut h = TestHarness::with_storage().await;
310 + let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
311 + // Set a long duration. The handler computes expiry as
312 + // `max(duration * 2, 3600)`, so 5000s should give 10000s expiry.
313 + sqlx::query("UPDATE items SET duration_seconds = 5000 WHERE id = $1::uuid")
314 + .bind(&item_id)
315 + .execute(&h.db)
316 + .await
317 + .unwrap();
318 +
319 + let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
320 + assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
321 + let data: Value = resp.json();
322 + let expires_in = data["expires_in"].as_u64().unwrap();
323 + assert_eq!(
324 + expires_in, 10000,
325 + "expires_in should be 2x duration for long tracks (got {expires_in})"
326 + );
327 + }