diff --git a/Cargo.lock b/Cargo.lock index 8c5d31a..ae4c798 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2931,7 +2931,7 @@ dependencies = [ [[package]] name = "waveflow-core" version = "1.4.0" -source = "git+https://github.com/InstaZDLL/WaveFlow?rev=062c5509752f0f816dca272454ac2f2d4e84bd79#062c5509752f0f816dca272454ac2f2d4e84bd79" +source = "git+https://github.com/InstaZDLL/WaveFlow?rev=25b9ada6c2d5404cd40343a8fda44a92bfb46968#25b9ada6c2d5404cd40343a8fda44a92bfb46968" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 69523e9..7b67777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ homepage = "https://waveflow.app" # build is reproducible — bump the rev in tree when picking up a new # core release. `default-features = false` because core's # default-feature set is empty; we only want the `postgres` feature. -waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "062c5509752f0f816dca272454ac2f2d4e84bd79", default-features = false, features = ["postgres"] } +waveflow-core = { git = "https://github.com/InstaZDLL/WaveFlow", rev = "25b9ada6c2d5404cd40343a8fda44a92bfb46968", default-features = false, features = ["postgres"] } # Database. `runtime-tokio` matches our `#[tokio::main]` runtime; # `postgres` is the driver; `macros` enables `query_as!`; `migrate` diff --git a/migrations/20260530000004_playlist.sql b/migrations/20260530000004_playlist.sql new file mode 100644 index 0000000..e350cc4 --- /dev/null +++ b/migrations/20260530000004_playlist.sql @@ -0,0 +1,70 @@ +-- Playlist table — multi-tenant counterpart of the desktop's `playlist` +-- row (see `src-tauri/migrations/profile/20260411120000_initial.sql` +-- in the WaveFlow repo). A playlist belongs directly to a profile — +-- different from `track`, which sits one tier deeper under `library`. +-- +-- 1.b.5c ships custom playlists only. Smart playlists (`is_smart = 1` +-- with `smart_rules` JSON) and the playlist_track join still live +-- exclusively on the desktop until later phases port the smart-playlist +-- engine and the tracks-in-playlist routes. The columns are present so +-- the wire shape stays in lockstep with the desktop's `Playlist` DTO; +-- the server-side repo just hardcodes `is_smart = 0`, `smart_rules = +-- NULL` on inserts. +-- +-- ON DELETE CASCADE on `profile_id` so a profile delete fan-outs to +-- its playlists. Every playlist must belong to a profile — an +-- orphaned playlist would violate the tenancy chain that +-- `PostgresPlaylistRepository` enforces in `waveflow-core`. + +CREATE TABLE playlist ( + id BIGSERIAL PRIMARY KEY, + profile_id BIGINT NOT NULL + REFERENCES profile(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + + -- Brand-defined design-system tokens; defaults mirror the desktop + -- (`DEFAULT 'violet'` / `DEFAULT 'music'`). The repo writes the + -- columns explicitly so the server stays authoritative when the + -- client omits them. + color_id TEXT NOT NULL DEFAULT 'violet', + icon_id TEXT NOT NULL DEFAULT 'music', + + -- Smart-playlist discriminant + rule payload. BIGINT (not + -- BOOLEAN / SMALLINT) so the column round-trips into + -- `Playlist.is_smart: i64` from waveflow-core without a + -- narrowing decode error — same lesson as `track.rating` in + -- 1.b.5b. Today the server only writes 0 / NULL; the columns + -- exist for forward parity with the desktop schema. + is_smart BIGINT NOT NULL DEFAULT 0, + smart_rules TEXT, + + -- Cover management. `cover_hash` references the shared + -- `metadata_artwork/.jpg` blob (the cache table itself + -- hasn't been ported to the server yet; the column is here for + -- forward parity). `cover_is_auto = 1` means the auto-regen + -- pipeline owns the slot — `0` is reserved for the case where + -- the user uploaded their own image and the pipeline should + -- leave the row alone, matching the desktop convention. Default + -- mirrors the desktop's `DEFAULT 1`. + cover_hash TEXT, + cover_is_auto BIGINT NOT NULL DEFAULT 1, + + -- Drag-and-drop sidebar order. `0` is fine as a default — the + -- desktop already lives with collisions on this column (it + -- orders by `position ASC, updated_at DESC` so ties resolve on + -- recency), and the server's `list_for_profile` follows the + -- same order. + position BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +-- The per-profile list query orders by `(position ASC, updated_at +-- DESC)` filtered on `profile_id`; the composite index keeps the +-- per-tenant lookup flat as the table grows across profiles. It also +-- serves the equality filter on its leading column for the ON DELETE +-- CASCADE fan-out from `profile`, so a `profile_id`-only index would +-- be pure write amplification. +CREATE INDEX playlist_profile_position_idx + ON playlist (profile_id, position ASC, updated_at DESC); diff --git a/src/api/mod.rs b/src/api/mod.rs index de5b04b..2aad659 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -5,9 +5,10 @@ //! readiness, `users.rs` mints dev-shim users, `profiles.rs` covers //! tenant-scoped profile CRUD, `libraries.rs` covers tenant-scoped //! library CRUD nested under a profile, `tracks.rs` covers -//! tenant-scoped track CRUD nested under a library. Future modules -//! will cover `playlists`, `auth`, `sync`, `stream` (per RFC-001 -//! §6 / §7). +//! tenant-scoped track CRUD nested under a library, `playlists.rs` +//! covers tenant-scoped playlist CRUD nested under a profile (same +//! depth as library). Future modules will cover `auth`, `sync`, +//! `stream` (per RFC-001 §6 / §7). //! //! Versioning policy: every resource module mounts under `/api/v1/` //! (except `/health` and `/ready`, which are unversioned by convention @@ -28,6 +29,7 @@ use crate::{middleware as auth_middleware, AppState, Config}; mod health; mod libraries; +mod playlists; mod profiles; mod ready; mod tracks; @@ -38,10 +40,11 @@ mod users; /// their `#[utoipa::path]` declarations to the merged OpenAPI spec. /// /// `/api/v1/users`, `/api/v1/profiles/*`, -/// `/api/v1/profiles/{profile_id}/libraries/*` and +/// `/api/v1/profiles/{profile_id}/libraries/*`, /// `/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/*` -/// ride behind [`reject_dev_auth_disabled`] when -/// `config.dev_auth_enabled` is false (the production default). +/// and `/api/v1/profiles/{profile_id}/playlists/*` ride behind +/// [`reject_dev_auth_disabled`] when `config.dev_auth_enabled` is +/// false (the production default). /// Without the gate a forged `X-User-Id` header on a publicly-exposed /// instance would walk straight into another tenant's data — Phase /// 1.d retires both the flag and the shim together when Better Auth @@ -72,6 +75,13 @@ pub fn router(state: AppState, config: &Config) -> OpenApiRouter { tracks::router(state.clone()).layer(middleware::from_fn(reject_dev_auth_disabled)) }; + let playlists_router = if config.dev_auth_enabled { + playlists::router(state.clone()) + .layer(middleware::from_fn(auth_middleware::require_user_id)) + } else { + playlists::router(state.clone()).layer(middleware::from_fn(reject_dev_auth_disabled)) + }; + OpenApiRouter::new() // Probes — no auth, no gate. .merge(health::router()) @@ -80,6 +90,7 @@ pub fn router(state: AppState, config: &Config) -> OpenApiRouter { .merge(profiles_router) .merge(libraries_router) .merge(tracks_router) + .merge(playlists_router) } /// Reject every request with **503 Service Unavailable**. Mounted on diff --git a/src/api/playlists.rs b/src/api/playlists.rs new file mode 100644 index 0000000..ffdd629 --- /dev/null +++ b/src/api/playlists.rs @@ -0,0 +1,368 @@ +//! `/api/v1/profiles/{profile_id}/playlists/*` — tenant-scoped CRUD +//! over the `playlist` table. +//! +//! A playlist belongs directly to a profile (not nested under +//! library), so the ownership chain is the shorter +//! `playlist → profile → user` — same depth as libraries, different +//! parent. Every handler reads [`UserId`] from the request extension, +//! threads the path's `profile_id` straight through, and calls a +//! `*_for_profile` method on [`PostgresPlaylistRepository`]. The +//! repository SQL validates the chain inline, so requests targeting +//! a foreign profile / non-owned playlist short-circuit at the +//! storage layer. +//! +//! 1.b.5c ships custom playlists only. Smart playlists +//! (`is_smart = 1`, `smart_rules` JSON) and the playlist_track join +//! table are scheduled for later phases; the wire shape keeps the +//! fields stubbed so the web client doesn't need to adapt when they +//! materialise. +//! +//! 404 (vs 403) on missing or non-owned rows is deliberate — same +//! no-existence-leak rationale as `libraries.rs` and `profiles.rs`. + +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; +use waveflow_core::{ + domain::playlist::Playlist, + repository::{ + playlist::{PlaylistDraft, PlaylistUpdate}, + postgres::PostgresPlaylistRepository, + }, +}; + +use crate::{middleware::UserId, AppState}; + +/// Brand defaults for `color_id` / `icon_id` mirror the desktop's +/// SQLite column defaults (`DEFAULT 'violet'` / `DEFAULT 'music'`). +const DEFAULT_COLOR_ID: &str = "violet"; +const DEFAULT_ICON_ID: &str = "music"; + +/// Wire-format playlist. Mirrors the desktop's `Playlist` DTO minus +/// the path-derived `profile_id` and the desktop-only `cover_path` +/// (resolved app-side from `cover_hash` against the per-profile +/// artwork dir, which the server doesn't own — same NULL projection +/// as the repo's SELECT). +#[derive(Debug, Serialize, ToSchema)] +pub struct PlaylistResponse { + pub id: i64, + pub name: String, + pub description: Option, + pub color_id: String, + pub icon_id: String, + /// `0` for user-curated playlists, `1` for smart-generated ones. + /// Always `0` today — server-side smart playlists land in a + /// later phase. + pub is_smart: i64, + /// BLAKE3 hash of the cover image in the shared metadata cache. + /// `None` until the artwork pipeline ships on the server. + pub cover_hash: Option, + /// `1` when the cover is managed by the auto-regen pipeline, + /// `0` when the user uploaded their own image and the pipeline + /// should leave it alone. Always `1` on freshly-created rows + /// here — matches the desktop convention. + pub cover_is_auto: i64, + pub position: i64, + pub created_at: i64, + pub updated_at: i64, + /// Denormalised count, stubbed at `0` until `playlist_track` + /// ships on the server. + pub track_count: i64, + /// Denormalised sum, stubbed at `0` until `playlist_track` + /// ships on the server. + pub total_duration_ms: i64, + /// Raw JSON payload from `playlist.smart_rules`. Always `None` + /// today (every server-side playlist is custom). + pub smart_rules: Option, +} + +impl From for PlaylistResponse { + fn from(p: Playlist) -> Self { + Self { + id: p.id, + name: p.name, + description: p.description, + color_id: p.color_id, + icon_id: p.icon_id, + is_smart: p.is_smart, + cover_hash: p.cover_hash, + cover_is_auto: p.cover_is_auto, + position: p.position, + created_at: p.created_at, + updated_at: p.updated_at, + track_count: p.track_count, + total_duration_ms: p.total_duration_ms, + smart_rules: p.smart_rules, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreatePlaylistRequest { + /// Display name shown in the sidebar. Trimmed and validated + /// server-side — empty / whitespace-only after trim is rejected + /// with 400. The trimmed form is what gets persisted. + pub name: String, + /// Optional free-form description. + pub description: Option, + /// Brand-defined design-system colour token. Falls back to + /// `"violet"` (the desktop default) when omitted. + pub color_id: Option, + /// Brand-defined design-system icon token. Falls back to + /// `"music"` (the desktop default) when omitted. + pub icon_id: Option, +} + +/// Partial update payload. Every field is optional; the repository's +/// `COALESCE` keeps the existing value when a field is omitted. `name`, +/// when present, is trimmed and validated server-side — `Some("")` / +/// `Some(" ")` is rejected with 400 before the storage round-trip, +/// same rule as `CreatePlaylistRequest`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdatePlaylistRequest { + pub name: Option, + pub description: Option, + pub color_id: Option, + pub icon_id: Option, +} + +pub fn router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list_playlists, create_playlist)) + .routes(routes!(get_playlist, update_playlist, delete_playlist)) + .with_state(state) +} + +/// List every playlist the calling user owns under `profile_id`, +/// ordered `(position ASC, updated_at DESC)` to match the desktop +/// sidebar. A foreign `profile_id` returns `[]` — no existence leak. +#[utoipa::path( + get, + path = "/api/v1/profiles/{profile_id}/playlists", + tag = "playlists", + params( + ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ), + responses( + (status = 200, description = "Playlists under the profile, in sidebar order", body = Vec), + (status = 401, description = "Missing or invalid X-User-Id"), + (status = 500, description = "Database or internal failure (body is a plain-text reason)"), + ), +)] +async fn list_playlists( + State(state): State, + Extension(UserId(user_id)): Extension, + Path(profile_id): Path, +) -> impl IntoResponse { + let repo = PostgresPlaylistRepository::new(state.db.clone()); + match repo.list_for_profile(profile_id, user_id).await { + Ok(playlists) => { + let body: Vec = playlists.into_iter().map(Into::into).collect(); + (StatusCode::OK, Json(body)).into_response() + } + Err(err) => { + tracing::error!(error = %err, user_id, profile_id, "list playlists failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "list failed").into_response() + } + } +} + +/// Create a custom playlist under `profile_id`. Smart playlists +/// aren't writable through this route — the repo hardcodes +/// `is_smart = 0`, `smart_rules = NULL`, `position = 0`, +/// `cover_hash = NULL`, `cover_is_auto = 1` (auto-managed slot, +/// matches the desktop default). The `INSERT … SELECT … WHERE … +/// AND p.user_id = $` clause guarantees atomicity — a non-owned +/// profile fails the same round-trip as the write. +#[utoipa::path( + post, + path = "/api/v1/profiles/{profile_id}/playlists", + tag = "playlists", + params( + ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ), + request_body = CreatePlaylistRequest, + responses( + (status = 201, description = "Playlist created", body = PlaylistResponse), + (status = 400, description = "Empty / whitespace-only `name` after trim"), + (status = 401, description = "Missing or invalid X-User-Id"), + (status = 404, description = "Profile not owned by the calling user"), + (status = 500, description = "Database or internal failure (body is a plain-text reason)"), + ), +)] +async fn create_playlist( + State(state): State, + Extension(UserId(user_id)): Extension, + Path(profile_id): Path, + Json(req): Json, +) -> impl IntoResponse { + let name = req.name.trim(); + if name.is_empty() { + return (StatusCode::BAD_REQUEST, "name is required").into_response(); + } + let now = Utc::now().timestamp_millis(); + let draft = PlaylistDraft { + name: name.to_string(), + description: req.description, + color_id: req.color_id.unwrap_or_else(|| DEFAULT_COLOR_ID.to_string()), + icon_id: req.icon_id.unwrap_or_else(|| DEFAULT_ICON_ID.to_string()), + now_ms: now, + }; + let repo = PostgresPlaylistRepository::new(state.db.clone()); + match repo.insert_for_profile(&draft, profile_id, user_id).await { + Ok(Some(playlist)) => { + (StatusCode::CREATED, Json(PlaylistResponse::from(playlist))).into_response() + } + Ok(None) => { + // Profile doesn't exist OR isn't owned by the caller — + // blur the two so the response doesn't leak existence + // of a foreign profile. + (StatusCode::NOT_FOUND, "profile not found").into_response() + } + Err(err) => { + tracing::error!(error = %err, user_id, profile_id, "create playlist failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "create failed").into_response() + } + } +} + +/// Fetch one playlist by id, scoped to both the profile and the +/// calling user. 404 covers "no such playlist", "playlist belongs to +/// a different profile", AND "profile belongs to a different user". +#[utoipa::path( + get, + path = "/api/v1/profiles/{profile_id}/playlists/{id}", + tag = "playlists", + params( + ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ("id" = i64, Path, description = "Playlist id"), + ), + responses( + (status = 200, description = "Playlist found", body = PlaylistResponse), + (status = 401, description = "Missing or invalid X-User-Id"), + (status = 404, description = "No playlist with that id under the profile owned by the calling user"), + (status = 500, description = "Database or internal failure (body is a plain-text reason)"), + ), +)] +async fn get_playlist( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_id, id)): Path<(i64, i64)>, +) -> impl IntoResponse { + let repo = PostgresPlaylistRepository::new(state.db.clone()); + match repo.get_for_profile(id, profile_id, user_id).await { + Ok(Some(playlist)) => { + (StatusCode::OK, Json(PlaylistResponse::from(playlist))).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!(error = %err, id, profile_id, user_id, "get playlist failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "get failed").into_response() + } + } +} + +/// Partial update via `UPDATE … RETURNING …`. Race-free against +/// concurrent delete; `name`, when supplied, must trim to non-empty. +#[utoipa::path( + patch, + path = "/api/v1/profiles/{profile_id}/playlists/{id}", + tag = "playlists", + params( + ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ("id" = i64, Path, description = "Playlist id"), + ), + request_body = UpdatePlaylistRequest, + responses( + (status = 200, description = "Playlist updated", body = PlaylistResponse), + (status = 400, description = "`name` was supplied but is empty / whitespace-only after trim"), + (status = 401, description = "Missing or invalid X-User-Id"), + (status = 404, description = "No playlist with that id under the profile owned by the calling user"), + (status = 500, description = "Database or internal failure (body is a plain-text reason)"), + ), +)] +async fn update_playlist( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_id, id)): Path<(i64, i64)>, + Json(req): Json, +) -> impl IntoResponse { + let name = match req.name { + Some(n) => { + let trimmed = n.trim(); + if trimmed.is_empty() { + return (StatusCode::BAD_REQUEST, "name must not be empty").into_response(); + } + Some(trimmed.to_string()) + } + None => None, + }; + let patch = PlaylistUpdate { + name, + description: req.description, + color_id: req.color_id, + icon_id: req.icon_id, + }; + let now = Utc::now().timestamp_millis(); + let repo = PostgresPlaylistRepository::new(state.db.clone()); + match repo + .update_for_profile(id, &patch, now, profile_id, user_id) + .await + { + Ok(Some(playlist)) => { + (StatusCode::OK, Json(PlaylistResponse::from(playlist))).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!(error = %err, id, profile_id, user_id, "update playlist failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "update failed").into_response() + } + } +} + +/// Delete a playlist. 204 on success, 404 when the row isn't owned +/// by the (profile_id, user_id) pair. The future `playlist_track` +/// table will carry `ON DELETE CASCADE` on `playlist_id` so the +/// dependent rows go away in one statement once that schema lands. +#[utoipa::path( + delete, + path = "/api/v1/profiles/{profile_id}/playlists/{id}", + tag = "playlists", + params( + ("x-user-id" = i64, Header, description = "Dev shim — owning user id (replaced by JWT in 1.d)"), + ("profile_id" = i64, Path, description = "Owning profile id"), + ("id" = i64, Path, description = "Playlist id"), + ), + responses( + (status = 204, description = "Playlist deleted"), + (status = 401, description = "Missing or invalid X-User-Id"), + (status = 404, description = "No playlist with that id under the profile owned by the calling user"), + (status = 500, description = "Database or internal failure (body is a plain-text reason)"), + ), +)] +async fn delete_playlist( + State(state): State, + Extension(UserId(user_id)): Extension, + Path((profile_id, id)): Path<(i64, i64)>, +) -> impl IntoResponse { + let repo = PostgresPlaylistRepository::new(state.db.clone()); + match repo.delete_for_profile(id, profile_id, user_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => (StatusCode::NOT_FOUND, "playlist not found").into_response(), + Err(err) => { + tracing::error!(error = %err, id, profile_id, user_id, "delete playlist failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "delete failed").into_response() + } + } +} diff --git a/tests/openapi.rs b/tests/openapi.rs index ae6b3d1..5d690d0 100644 --- a/tests/openapi.rs +++ b/tests/openapi.rs @@ -54,6 +54,14 @@ async fn openapi_doc_lists_every_handler(pool: PgPool) { paths.contains_key("/api/v1/profiles/{profile_id}/libraries/{library_id}/tracks/{id}"), "missing tracks item path in spec" ); + assert!( + paths.contains_key("/api/v1/profiles/{profile_id}/playlists"), + "missing playlists collection path in spec" + ); + assert!( + paths.contains_key("/api/v1/profiles/{profile_id}/playlists/{id}"), + "missing playlists item path in spec" + ); // The /ready operation must declare both the 200 and the 503 // shape — the readiness contract is a 503-as-data API and the diff --git a/tests/playlists.rs b/tests/playlists.rs new file mode 100644 index 0000000..6d6a32a --- /dev/null +++ b/tests/playlists.rs @@ -0,0 +1,627 @@ +//! End-to-end tests for `/api/v1/profiles/{profile_id}/playlists`. +//! +//! Same harness pattern as `tests/libraries.rs` — a playlist sits at +//! the same depth as a library (direct child of a profile), so the +//! tenant-isolation battery is essentially the library suite +//! re-applied: 401 gate, default color / icon fall-back, foreign +//! profile 404 on POST, the proxy-attack battery covering all +//! `(proxy_profile, target_playlist)` combinations, partial PATCH +//! preservation, CASCADE from profile, prod-gate 503. Plus the +//! 1.b.5c-specific assertions on `is_smart=0` and `cover_is_auto=1` +//! defaults — those are sticky-flag invariants whose drift would +//! quietly break a future server-side smart-playlist or auto-cover +//! pipeline (cf. CR finding on waveflow#188 that flipped +//! `cover_is_auto` from 0 → 1). + +mod support; + +use reqwest::StatusCode; +use serde_json::{json, Value}; +use sqlx::PgPool; +use support::spawn_app; + +async fn mint_user(base: &str) -> i64 { + let body: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/users")) + .send() + .await + .expect("user create failed") + .error_for_status() + .expect("non-2xx on user create") + .json() + .await + .expect("user create body"); + body["id"].as_i64().expect("user id missing from response") +} + +async fn mint_profile(base: &str, user_id: i64, name: &str) -> i64 { + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": name, "color_id": "emerald" })) + .send() + .await + .expect("profile create failed") + .error_for_status() + .expect("non-2xx on profile create") + .json() + .await + .expect("profile create body"); + created["id"].as_i64().expect("profile id missing") +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn playlists_require_x_user_id(pool: PgPool) { + let base = spawn_app(pool).await; + + for (method, path) in [ + ("GET", "/api/v1/profiles/1/playlists"), + ("POST", "/api/v1/profiles/1/playlists"), + ("GET", "/api/v1/profiles/1/playlists/1"), + ("PATCH", "/api/v1/profiles/1/playlists/1"), + ("DELETE", "/api/v1/profiles/1/playlists/1"), + ] { + let req = match method { + "GET" => reqwest::Client::new().get(format!("{base}{path}")), + "POST" => reqwest::Client::new() + .post(format!("{base}{path}")) + .json(&json!({ "name": "x" })), + "PATCH" => reqwest::Client::new() + .patch(format!("{base}{path}")) + .json(&json!({ "name": "x" })), + "DELETE" => reqwest::Client::new().delete(format!("{base}{path}")), + _ => unreachable!(), + }; + let resp = req.send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{method} {path}"); + } +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn create_then_list_then_get_under_profile(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + // Empty list initially. + let list: Vec = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(list.is_empty()); + + // Create — minimal body (color/icon fall back to defaults). + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": "Soirée" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let id = created["id"].as_i64().unwrap(); + assert_eq!(created["name"], "Soirée"); + assert_eq!(created["color_id"], "violet", "default color_id"); + assert_eq!(created["icon_id"], "music", "default icon_id"); + + // Sticky-flag invariants — drift here would break a future + // smart-playlist or auto-cover pipeline silently. + assert_eq!( + created["is_smart"].as_i64().unwrap(), + 0, + "freshly created playlist must be custom (is_smart=0)" + ); + assert!( + created["smart_rules"].is_null(), + "custom playlist must have smart_rules=NULL" + ); + assert_eq!( + created["cover_is_auto"].as_i64().unwrap(), + 1, + "no-manual-cover playlist must be auto-managed (cover_is_auto=1, cf. waveflow#188 CR)" + ); + assert!( + created["cover_hash"].is_null(), + "fresh playlist must have no cover_hash yet" + ); + assert_eq!( + created["track_count"].as_i64().unwrap(), + 0, + "track_count stubbed at 0 until playlist_track ships" + ); + assert_eq!( + created["total_duration_ms"].as_i64().unwrap(), + 0, + "total_duration_ms stubbed at 0 until playlist_track ships" + ); + + // List sees it. + let list: Vec = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["id"].as_i64().unwrap(), id); + + // Get round-trips. + let one: Value = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(one["id"].as_i64().unwrap(), id); + assert_eq!(one["name"], "Soirée"); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn create_with_explicit_color_and_icon(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ + "name": "Focus", + "description": "Lo-fi pour bosser", + "color_id": "ocean", + "icon_id": "headphones", + })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(created["color_id"], "ocean"); + assert_eq!(created["icon_id"], "headphones"); + assert_eq!(created["description"], "Lo-fi pour bosser"); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn create_rejects_empty_name(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + for blank in ["", " ", "\t\n "] { + let resp = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": blank })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "name = {blank:?} should 400" + ); + } + + let list: Vec = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(list.is_empty(), "blank-name request leaked a row"); +} + +/// Foreign profile id under the calling user must 404. +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn create_under_foreign_profile_returns_404(pool: PgPool) { + let base = spawn_app(pool).await; + let user_a = mint_user(&base).await; + let user_b = mint_user(&base).await; + let profile_a = mint_profile(&base, user_a, "A's profile").await; + + let resp = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_a}/playlists")) + .header("x-user-id", user_b.to_string()) + .json(&json!({ "name": "stolen" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let list: Vec = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/{profile_a}/playlists")) + .header("x-user-id", user_a.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(list.is_empty(), "foreign POST leaked into user A"); +} + +/// Full tenant isolation battery: user B must NOT see, get, update, +/// or delete user A's playlist — neither through their own profile +/// nor through user A's profile. +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn tenants_are_isolated(pool: PgPool) { + let base = spawn_app(pool).await; + let user_a = mint_user(&base).await; + let user_b = mint_user(&base).await; + let profile_a = mint_profile(&base, user_a, "A").await; + let profile_b = mint_profile(&base, user_b, "B").await; + + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_a}/playlists")) + .header("x-user-id", user_a.to_string()) + .json(&json!({ "name": "A's playlist" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let playlist_id = created["id"].as_i64().unwrap(); + + // User B's list under their own profile is empty. + let list_b: Vec = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/{profile_b}/playlists")) + .header("x-user-id", user_b.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(list_b.is_empty()); + + // User B can't list user A's playlists via user A's profile id. + let list_b_proxy: Vec = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/{profile_a}/playlists")) + .header("x-user-id", user_b.to_string()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + list_b_proxy.is_empty(), + "user B saw user A's playlists via user A's profile id" + ); + + // User B can't GET A's playlist via either proxy. + for proxy_profile in [profile_a, profile_b] { + let resp = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{proxy_profile}/playlists/{playlist_id}" + )) + .header("x-user-id", user_b.to_string()) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "user B GET'd A's playlist via profile {proxy_profile}" + ); + } + + // User B can't PATCH A's playlist. + let resp = reqwest::Client::new() + .patch(format!( + "{base}/api/v1/profiles/{profile_a}/playlists/{playlist_id}" + )) + .header("x-user-id", user_b.to_string()) + .json(&json!({ "name": "hijacked" })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // User B can't DELETE A's playlist. + let resp = reqwest::Client::new() + .delete(format!( + "{base}/api/v1/profiles/{profile_a}/playlists/{playlist_id}" + )) + .header("x-user-id", user_b.to_string()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // User A's playlist is still there, unmodified. + let one: Value = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{profile_a}/playlists/{playlist_id}" + )) + .header("x-user-id", user_a.to_string()) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(one["name"], "A's playlist"); +} + +/// PATCH round-trips and the response carries the new value. +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn update_renames_in_place(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + let id = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": "Old name" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_i64() + .unwrap(); + + let renamed: Value = reqwest::Client::new() + .patch(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": "New name", "color_id": "crimson" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(renamed["name"], "New name"); + assert_eq!(renamed["color_id"], "crimson"); + assert_eq!(renamed["id"].as_i64().unwrap(), id); +} + +/// Partial PATCH preserves omitted fields (the `COALESCE` path). +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn update_preserves_omitted_fields(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + let created: Value = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ + "name": "Keep me", + "color_id": "ocean", + "icon_id": "headphones", + })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let id = created["id"].as_i64().unwrap(); + + let patched: Value = reqwest::Client::new() + .patch(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "color_id": "sunset" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(patched["name"], "Keep me"); + assert_eq!(patched["color_id"], "sunset"); + assert_eq!(patched["icon_id"], "headphones"); +} + +/// PATCH blank name must 400 — same boundary check as create. +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn update_rejects_empty_name(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + let id = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": "Keep me" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_i64() + .unwrap(); + + for blank in ["", " ", "\t\n "] { + let resp = reqwest::Client::new() + .patch(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": blank })) + .send() + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "PATCH name = {blank:?} should 400" + ); + } + + // Original name is untouched. + let one: Value = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(one["name"], "Keep me"); +} + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn delete_returns_204_then_404(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + let profile_id = mint_profile(&base, user_id, "Alice").await; + + let id = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{profile_id}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": "doomed" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_i64() + .unwrap(); + + let resp = reqwest::Client::new() + .delete(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{profile_id}/playlists/{id}" + )) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +/// Deleting a profile must cascade through to its playlists. +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn profile_delete_cascades_to_playlists(pool: PgPool) { + let base = spawn_app(pool).await; + let user_id = mint_user(&base).await; + + // Two profiles so the delete-last-profile guard doesn't block us. + let p1 = mint_profile(&base, user_id, "to delete").await; + let p2 = mint_profile(&base, user_id, "to keep").await; + + let playlist_id = reqwest::Client::new() + .post(format!("{base}/api/v1/profiles/{p1}/playlists")) + .header("x-user-id", user_id.to_string()) + .json(&json!({ "name": "doomed playlist" })) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_i64() + .unwrap(); + + let resp = reqwest::Client::new() + .delete(format!("{base}/api/v1/profiles/{p1}")) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Via p1 (deleted): trivially 404. + let resp = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{p1}/playlists/{playlist_id}" + )) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // Via p2 (still owned): real cascade canary. If CASCADE didn't + // fire the row would still exist with `profile_id = p1`. + let resp = reqwest::Client::new() + .get(format!( + "{base}/api/v1/profiles/{p2}/playlists/{playlist_id}" + )) + .header("x-user-id", user_id.to_string()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +/// Production-gate sanity. +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn dev_auth_gate_returns_503_for_playlists_when_disabled(pool: PgPool) { + let base = support::spawn_app_prod_gate(pool).await; + + let resp = reqwest::Client::new() + .get(format!("{base}/api/v1/profiles/1/playlists")) + .header("x-user-id", "42") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} diff --git a/tests/ready.rs b/tests/ready.rs index 96d4600..34081e9 100644 --- a/tests/ready.rs +++ b/tests/ready.rs @@ -86,3 +86,21 @@ async fn migration_creates_track_table(pool: PgPool) { assert!(exists, "track table missing after migrations"); } + +#[sqlx::test(migrator = "waveflow_server::db::MIGRATOR")] +async fn migration_creates_playlist_table(pool: PgPool) { + // Same canary as the other tables: a renamed / dropped + // playlist.sql would pass `sqlx::test` provisioning but every + // 1.b.5c CRUD test would explode on the first INSERT. + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'playlist' + )", + ) + .fetch_one(&pool) + .await + .expect("query failed"); + + assert!(exists, "playlist table missing after migrations"); +}