1mod helpers;
2mod jobs;
3mod loaders;
4mod mutations;
5mod queries;
6mod server;
7mod subscriptions;
8mod types;
9
10use std::ops::Deref;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::time::Duration;
14
15use async_graphql::dataloader::DataLoader;
16use async_graphql::{Context, Schema};
17use crossbeam_channel::Sender;
18use koan_core::audio::viz::VizSnapshot;
19use koan_core::db::connection::Database;
20use koan_core::db::pool::Pool;
21use koan_core::db::queries::{UidKind, is_uid};
22use koan_core::player::commands::PlayerCommand;
23use koan_core::player::state::{QueueItemId, SharedPlayerState};
24use uuid::Uuid;
25
26use koan_core::auth::Role;
27use loaders::DbLoader;
28use mutations::MutationRoot;
29use queries::QueryRoot;
30pub use server::{
31 ApiServerOpts, cmd_serve, cmd_serve_daemon, execute_in_process, start_api_background,
32};
33use subscriptions::SubscriptionRoot;
34
35use crate::auth::AuthUser;
36
37const BATCH_WINDOW: Duration = Duration::from_millis(10);
46
47const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(10);
51
52struct DbPool {
62 pool: Arc<Pool>,
63 permits_tx: Sender<()>,
66 permits_rx: crossbeam_channel::Receiver<()>,
67 batches: AtomicUsize,
70}
71
72impl DbPool {
73 fn new(pool: Arc<Pool>) -> Arc<Self> {
74 let max = std::thread::available_parallelism()
76 .map(|n| n.get())
77 .unwrap_or(4)
78 .clamp(4, 16);
79 let (permits_tx, permits_rx) = crossbeam_channel::bounded(max);
80 for _ in 0..max {
81 let _ = permits_tx.try_send(());
82 }
83 Arc::new(Self {
84 pool,
85 permits_tx,
86 permits_rx,
87 batches: AtomicUsize::new(0),
88 })
89 }
90
91 fn acquire(self: &Arc<Self>) -> async_graphql::Result<PooledDb> {
92 self.permits_rx
93 .recv_timeout(ACQUIRE_TIMEOUT)
94 .map_err(|_| async_graphql::Error::new("database busy"))?;
95 match self.pool.take() {
96 Ok(db) => Ok(PooledDb {
97 db: Some(db),
98 pool: self.clone(),
99 }),
100 Err(e) => {
101 let _ = self.permits_tx.try_send(());
102 Err(internal_error("db open", e))
103 }
104 }
105 }
106}
107
108struct PooledDb {
110 db: Option<Database>,
111 pool: Arc<DbPool>,
112}
113
114impl Deref for PooledDb {
115 type Target = Database;
116
117 fn deref(&self) -> &Database {
118 self.db.as_ref().expect("connection taken before drop")
119 }
120}
121
122impl Drop for PooledDb {
123 fn drop(&mut self) {
124 if let Some(db) = self.db.take() {
125 self.pool.pool.put_back(db);
126 }
127 let _ = self.pool.permits_tx.try_send(());
128 }
129}
130
131#[derive(Clone)]
136struct DbHandle {
137 pool: Arc<DbPool>,
138}
139
140impl DbHandle {
141 fn new(pool: Arc<Pool>) -> Self {
142 Self {
143 pool: DbPool::new(pool),
144 }
145 }
146
147 fn acquire(&self) -> async_graphql::Result<PooledDb> {
148 self.pool.acquire()
149 }
150
151 fn path(&self) -> std::path::PathBuf {
153 self.pool.pool.path().to_owned()
154 }
155
156 fn open_detached(&self) -> Result<Database, koan_core::db::connection::DbError> {
159 self.pool.pool.take()
160 }
161
162 fn note_batch(&self) {
163 self.pool.batches.fetch_add(1, Ordering::Relaxed);
164 }
165
166 #[cfg_attr(not(test), allow(dead_code))]
168 fn open_count(&self) -> usize {
169 self.pool.pool.opened()
170 }
171
172 #[cfg_attr(not(test), allow(dead_code))]
174 fn batch_count(&self) -> usize {
175 self.pool.batches.load(Ordering::Relaxed)
176 }
177}
178
179async fn with_db<T, F>(ctx: &Context<'_>, f: F) -> async_graphql::Result<T>
185where
186 F: FnOnce(&Database) -> async_graphql::Result<T> + Send + 'static,
187 T: Send + 'static,
188{
189 let handle = ctx.data::<DbHandle>()?.clone();
190 blocking(move || {
191 let db = handle.acquire()?;
192 f(&db)
193 })
194 .await
195}
196
197async fn uid(
200 ctx: &Context<'_>,
201 kind: UidKind,
202 id: i64,
203) -> async_graphql::Result<async_graphql::ID> {
204 Ok(async_graphql::ID(
205 id_loader(ctx)?
206 .load_one(loaders::UidOf(kind, id))
207 .await?
208 .unwrap_or_else(|| id.to_string()),
209 ))
210}
211
212async fn uids(
213 ctx: &Context<'_>,
214 kind: UidKind,
215 ids: &[i64],
216) -> async_graphql::Result<Vec<async_graphql::ID>> {
217 let found = id_loader(ctx)?
218 .load_many(ids.iter().map(|&id| loaders::UidOf(kind, id)))
219 .await?;
220 Ok(ids
221 .iter()
222 .map(|&id| {
223 async_graphql::ID(
224 found
225 .get(&loaders::UidOf(kind, id))
226 .cloned()
227 .unwrap_or_else(|| id.to_string()),
228 )
229 })
230 .collect())
231}
232
233async fn opt_uid(
234 ctx: &Context<'_>,
235 kind: UidKind,
236 id: Option<i64>,
237) -> async_graphql::Result<Option<async_graphql::ID>> {
238 match id {
239 Some(id) => uid(ctx, kind, id).await.map(Some),
240 None => Ok(None),
241 }
242}
243
244async fn row_id(ctx: &Context<'_>, kind: UidKind, id: &str) -> async_graphql::Result<i64> {
247 Ok(row_ids(ctx, kind, &[id]).await?[0])
248}
249
250async fn row_ids<S: AsRef<str> + Sync>(
252 ctx: &Context<'_>,
253 kind: UidKind,
254 ids: &[S],
255) -> async_graphql::Result<Vec<i64>> {
256 let loader = id_loader(ctx)?;
257 let uids = ids
258 .iter()
259 .map(AsRef::as_ref)
260 .filter(|id| id.parse::<i64>().is_err())
261 .map(|id| {
262 if is_uid(id) {
263 Ok(loaders::RowOf(kind, id.to_string()))
264 } else {
265 Err(async_graphql::Error::new(format!("not an id: {id}")))
266 }
267 })
268 .collect::<async_graphql::Result<Vec<_>>>()?;
269 let found = loader.load_many(uids).await?;
270 ids.iter()
271 .map(AsRef::as_ref)
272 .map(|id| match id.parse::<i64>() {
273 Ok(row) => Ok(row),
274 Err(_) => found
275 .get(&loaders::RowOf(kind, id.to_string()))
276 .copied()
277 .ok_or_else(|| {
278 async_graphql::Error::new(format!("no {kind:?} with id {id}").to_lowercase())
279 }),
280 })
281 .collect()
282}
283
284async fn opt_row_id(
285 ctx: &Context<'_>,
286 kind: UidKind,
287 id: Option<&async_graphql::ID>,
288) -> async_graphql::Result<Option<i64>> {
289 match id {
290 Some(id) => row_id(ctx, kind, id).await.map(Some),
291 None => Ok(None),
292 }
293}
294
295async fn opt_row_ids(
296 ctx: &Context<'_>,
297 kind: UidKind,
298 ids: Option<&[async_graphql::ID]>,
299) -> async_graphql::Result<Option<Vec<i64>>> {
300 match ids {
301 Some(ids) => row_ids(ctx, kind, ids).await.map(Some),
302 None => Ok(None),
303 }
304}
305
306fn id_loader<'a>(ctx: &'a Context<'_>) -> async_graphql::Result<&'a DataLoader<DbLoader>> {
307 ctx.data::<DataLoader<DbLoader>>()
308}
309
310async fn blocking<T, F>(f: F) -> async_graphql::Result<T>
313where
314 F: FnOnce() -> async_graphql::Result<T> + Send + 'static,
315 T: Send + 'static,
316{
317 tokio::task::spawn_blocking(f)
318 .await
319 .map_err(|e| internal_error("blocking task", e))?
320}
321
322pub(super) fn internal_error(context: &str, e: impl std::fmt::Display) -> async_graphql::Error {
327 log::error!("graphql {}: {}", context, e);
328 async_graphql::Error::new("internal error")
329}
330
331pub type KoanSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
336
337pub fn build_schema(
338 state: Arc<SharedPlayerState>,
339 cmd_tx: Sender<PlayerCommand>,
340 pool: Arc<Pool>,
341 viz: Option<Arc<VizSnapshot>>,
342) -> KoanSchema {
343 build_schema_with(DbHandle::new(pool), state, cmd_tx, viz)
344}
345
346pub(crate) fn build_schema_extended(
348 state: Arc<SharedPlayerState>,
349 cmd_tx: Sender<PlayerCommand>,
350 pool: Arc<Pool>,
351 extension: impl async_graphql::extensions::ExtensionFactory,
352) -> KoanSchema {
353 builder_with(DbHandle::new(pool), state, cmd_tx, None)
354 .extension(extension)
355 .finish()
356}
357
358fn build_schema_with(
359 handle: DbHandle,
360 state: Arc<SharedPlayerState>,
361 cmd_tx: Sender<PlayerCommand>,
362 viz: Option<Arc<VizSnapshot>>,
363) -> KoanSchema {
364 builder_with(handle, state, cmd_tx, viz).finish()
365}
366
367fn builder_with(
368 handle: DbHandle,
369 state: Arc<SharedPlayerState>,
370 cmd_tx: Sender<PlayerCommand>,
371 viz: Option<Arc<VizSnapshot>>,
372) -> async_graphql::SchemaBuilder<QueryRoot, MutationRoot, SubscriptionRoot> {
373 let loader = DataLoader::new(DbLoader::new(handle.clone()), tokio::spawn).delay(BATCH_WINDOW);
376 loader.enable_all_cache(false);
377
378 let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
379 .data(handle)
380 .data(loader)
381 .data(jobs::JobRegistry::default())
382 .data(Arc::new(queries::Fuzzy::default()))
383 .data(state)
384 .data(cmd_tx);
385 if let Some(viz) = viz {
386 builder = builder.data(viz);
387 }
388 builder.limit_depth(12).limit_complexity(2000)
391}
392
393fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
398 Uuid::parse_str(s)
399 .map(QueueItemId)
400 .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
401}
402
403const CMD_SEND_TIMEOUT: Duration = Duration::from_millis(250);
410
411fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
412 let tx = ctx.data::<Sender<PlayerCommand>>()?;
413 send_cmd_via(tx, cmd)
414}
415
416fn send_cmd_via(tx: &Sender<PlayerCommand>, cmd: PlayerCommand) -> async_graphql::Result<()> {
417 tx.send_timeout(cmd, CMD_SEND_TIMEOUT)
418 .map_err(|_| async_graphql::Error::new("player busy — command not accepted"))
419}
420
421#[derive(Clone)]
423pub(crate) struct RequestOrigin(pub String);
424
425fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
428 ctx.data::<AuthUser>()
429 .cloned()
430 .unwrap_or_else(|_| AuthUser::anonymous_admin())
431}
432
433fn user_id(ctx: &Context<'_>) -> i64 {
435 get_auth_user(ctx).user_id
436}
437
438fn share_owner(ctx: &Context<'_>) -> Option<i64> {
441 let user = get_auth_user(ctx);
442 (user.role != Role::Admin).then_some(user.user_id)
443}
444
445fn readable_playlist(
448 db: &Database,
449 user: i64,
450 id: i64,
451) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
452 use koan_core::db::queries;
453 let me = queries::auth::resolve_user(&db.conn, user).map_err(|e| internal_error("db", e))?;
454 queries::get_playlist(&db.conn, id)
455 .map_err(|e| internal_error("db", e))?
456 .filter(|p| p.readable_by(me))
457 .ok_or_else(|| async_graphql::Error::new(format!("playlist {id} not found")))
458}
459
460fn editable_playlist(
462 db: &Database,
463 user: i64,
464 id: i64,
465) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
466 let list = readable_playlist(db, user, id)?;
467 let me = koan_core::db::queries::auth::resolve_user(&db.conn, user)
468 .map_err(|e| internal_error("db", e))?;
469 if list.editable_by(me) {
470 Ok(list)
471 } else {
472 Err(async_graphql::Error::new(format!(
473 "playlist {id} belongs to someone else"
474 )))
475 }
476}
477
478fn fillable_playlist(
481 db: &Database,
482 user: i64,
483 id: i64,
484) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
485 let list = editable_playlist(db, user, id)?;
486 if list.readonly {
487 return Err(async_graphql::Error::new(format!(
488 "playlist {id} is read-only: its rules (setPlaylistRules changes \
489 them), its file in the library or its server decide what it holds"
490 )));
491 }
492 Ok(list)
493}
494
495fn renamable_playlist(
498 db: &Database,
499 user: i64,
500 id: i64,
501) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
502 let list = editable_playlist(db, user, id)?;
503 if let Some(path) = &list.source_path {
504 return Err(async_graphql::Error::new(format!(
505 "playlist {id} is read from {path}: edit the file instead"
506 )));
507 }
508 Ok(list)
509}
510
511fn refresh_smart(db: &Database, user: i64) {
514 match koan_core::db::queries::smart::refresh_due(&db.conn, user) {
515 Ok(changed) if !changed.is_empty() => crate::clients::changed(),
516 Ok(_) => {}
517 Err(e) => log::warn!("smart playlists not refreshed: {e}"),
518 }
519}
520
521fn client_scope(ctx: &Context<'_>) -> Option<String> {
524 Some(get_auth_user(ctx).username)
525}
526
527fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
530 let user = get_auth_user(ctx);
531 if user.role.has_permission(required) {
532 Ok(())
533 } else {
534 Err(async_graphql::Error::new(format!(
535 "forbidden: requires {} role, you have {}",
536 required, user.role
537 )))
538 }
539}
540
541#[cfg(test)]
546mod tests {
547 use super::*;
548 use koan_core::db::connection::Database;
549 use koan_core::db::pool::Pool;
550 use koan_core::db::queries;
551 use koan_core::player::commands::CommandChannel;
552 use tempfile::TempDir;
553
554 fn isolate_config() {
561 static DIR: std::sync::OnceLock<TempDir> = std::sync::OnceLock::new();
562 koan_core::config::set_config_dir(DIR.get_or_init(|| TempDir::new().unwrap()).path());
563 }
564
565 fn test_schema() -> (
566 KoanSchema,
567 crossbeam_channel::Receiver<PlayerCommand>,
568 TempDir,
569 ) {
570 isolate_config();
571 let tmp = TempDir::new().unwrap();
572 let db_path = tmp.path().join("test.db");
573 let db = Database::open(&db_path).unwrap();
574 koan_core::db::schema::create_tables(&db.conn).unwrap();
575
576 let state = SharedPlayerState::new();
577 let ch = CommandChannel::new();
578 let tx = ch.tx.clone();
579 let rx = ch.rx.clone();
580
581 let schema = build_schema(state, tx, Arc::new(Pool::new(db_path.clone())), None);
582 (schema, rx, tmp)
583 }
584
585 fn instrumented_schema() -> (
587 KoanSchema,
588 DbHandle,
589 crossbeam_channel::Receiver<PlayerCommand>,
590 TempDir,
591 ) {
592 let tmp = TempDir::new().unwrap();
593 let db_path = tmp.path().join("test.db");
594 let db = Database::open(&db_path).unwrap();
595 koan_core::db::schema::create_tables(&db.conn).unwrap();
596
597 let state = SharedPlayerState::new();
598 let ch = CommandChannel::new();
599 let handle = DbHandle::new(Arc::new(Pool::new(db_path)));
600 let schema = build_schema_with(handle.clone(), state, ch.tx.clone(), None);
601 (schema, handle, ch.rx.clone(), tmp)
602 }
603
604 fn seed_library(db_path: &std::path::Path, artists: usize, albums: usize, tracks: usize) {
606 let db = Database::open(db_path).unwrap();
607 for a in 0..artists {
608 for al in 0..albums {
609 for t in 0..tracks {
610 let meta = queries::TrackMeta {
611 title: format!("Track {:03}-{}-{:03}", a, al, t),
612 artist: format!("Artist {:03}", a),
613 album_artist: Some(format!("Artist {:03}", a)),
614 album: format!("Album {:03}-{}", a, al),
615 track_number: Some(t as i32),
616 disc: Some(1),
617 date: Some("2024".into()),
618 genre: Some("Electronic".into()),
619 duration_ms: Some(240_000),
620 path: Some(format!("/tmp/koan-test/{}/{}/{}.flac", a, al, t)),
621 codec: Some("FLAC".into()),
622 sample_rate: Some(44100),
623 bit_depth: Some(16),
624 channels: Some(2),
625 bitrate: Some(1411),
626 size_bytes: Some(42_000_000),
627 mtime: Some(1700000000),
628 source: "local".into(),
629 remote_id: None,
630 remote_url: None,
631 album_remote_id: None,
632 artist_remote_id: None,
633 mbid: None,
634 album_mbid: None,
635 album_added_at: None,
636 label: None,
637 };
638 queries::upsert_track(&db.conn, &meta).unwrap();
639 }
640 }
641 }
642 }
643
644 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
645 let db = Database::open(db_path).unwrap();
646 let meta = queries::TrackMeta {
647 title: title.to_string(),
648 artist: artist.to_string(),
649 album_artist: Some(artist.to_string()),
650 album: album.to_string(),
651 track_number: Some(1),
652 disc: Some(1),
653 date: Some("2024".into()),
654 genre: Some("Electronic".into()),
655 duration_ms: Some(240000),
656 path: Some(format!(
657 "/tmp/test/{}.flac",
658 title.to_lowercase().replace(' ', "_")
659 )),
660 codec: Some("FLAC".into()),
661 sample_rate: Some(44100),
662 bit_depth: Some(16),
663 channels: Some(2),
664 bitrate: Some(1411),
665 size_bytes: Some(42_000_000),
666 mtime: Some(1700000000),
667 source: "local".into(),
668 remote_id: None,
669 remote_url: None,
670 album_remote_id: None,
671 artist_remote_id: None,
672 mbid: None,
673 album_mbid: None,
674 album_added_at: None,
675 label: None,
676 };
677 queries::upsert_track(&db.conn, &meta).unwrap()
678 }
679
680 #[test]
681 fn schema_builds() {
682 let (_schema, _rx, _tmp) = test_schema();
683 }
684
685 #[tokio::test]
686 async fn library_stats_query() {
687 let (schema, _rx, tmp) = test_schema();
688 let db_path = tmp.path().join("test.db");
689 insert_test_track(&db_path, "Track1", "Artist1", "Album1");
690
691 let resp = schema
692 .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
693 .await;
694 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
695 let data = resp.data.into_json().unwrap();
696 assert_eq!(data["libraryStats"]["totalTracks"], 1);
697 assert_eq!(data["libraryStats"]["totalAlbums"], 1);
698 assert_eq!(data["libraryStats"]["totalArtists"], 1);
699 }
700
701 #[tokio::test]
702 async fn artists_query() {
703 let (schema, _rx, tmp) = test_schema();
704 let db_path = tmp.path().join("test.db");
705 insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
706 insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
707
708 let resp = schema
709 .execute("{ artists { edges { node { id name } } } }")
710 .await;
711 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
712 let data = resp.data.into_json().unwrap();
713 let edges = data["artists"]["edges"].as_array().unwrap();
714 assert_eq!(edges.len(), 2);
715 }
716
717 #[tokio::test]
718 async fn tracks_search() {
719 let (schema, _rx, tmp) = test_schema();
720 let db_path = tmp.path().join("test.db");
721 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
722 insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
723
724 let resp = schema
725 .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
726 .await;
727 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
728 let data = resp.data.into_json().unwrap();
729 let edges = data["tracks"]["edges"].as_array().unwrap();
730 assert_eq!(edges.len(), 1);
731 assert_eq!(edges[0]["node"]["title"], "Windowlicker");
732 }
733
734 #[tokio::test]
735 async fn now_playing_stopped() {
736 let (schema, _rx, _tmp) = test_schema();
737 let resp = schema
738 .execute("{ nowPlaying { state positionMs track { title } } }")
739 .await;
740 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
741 let data = resp.data.into_json().unwrap();
742 assert_eq!(data["nowPlaying"]["state"], "STOPPED");
743 }
744
745 #[tokio::test]
746 async fn pause_mutation() {
747 let (schema, rx, _tmp) = test_schema();
748 let resp = schema.execute("mutation { pause { ok message } }").await;
749 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
750 let data = resp.data.into_json().unwrap();
751 assert_eq!(data["pause"]["ok"], true);
752 let cmd = rx.try_recv().unwrap();
753 assert!(matches!(cmd, PlayerCommand::Pause));
754 }
755
756 #[tokio::test]
757 async fn nested_artist_albums_tracks() {
758 let (schema, _rx, tmp) = test_schema();
759 let db_path = tmp.path().join("test.db");
760 insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
761 insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
762
763 let resp = schema
764 .execute(
765 r#"{ artists(search: "Aphex") {
766 edges { node {
767 name
768 albums { edges { node {
769 title
770 tracks { edges { node { title } } }
771 } } }
772 } }
773 } }"#,
774 )
775 .await;
776 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
777 let data = resp.data.into_json().unwrap();
778 let artist = &data["artists"]["edges"][0]["node"];
779 assert_eq!(artist["name"], "Aphex Twin");
780 let album = &artist["albums"]["edges"][0]["node"];
781 assert_eq!(album["title"], "Drukqs");
782 let tracks = album["tracks"]["edges"].as_array().unwrap();
783 assert_eq!(tracks.len(), 2);
784 }
785
786 #[tokio::test]
787 async fn pagination_has_next() {
788 let (schema, _rx, tmp) = test_schema();
789 let db_path = tmp.path().join("test.db");
790 for i in 0..5 {
791 insert_test_track(
792 &db_path,
793 &format!("Track{}", i),
794 "Artist",
795 &format!("Album{}", i),
796 );
797 }
798
799 let resp = schema
800 .execute(
801 r#"{ artists(first: 1) {
802 edges { node { name } cursor }
803 pageInfo { hasNextPage endCursor }
804 } }"#,
805 )
806 .await;
807 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
808 let data = resp.data.into_json().unwrap();
809 assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
812 }
813
814 #[tokio::test]
815 async fn clear_queue_mutation() {
816 let (schema, rx, _tmp) = test_schema();
817 let resp = schema
818 .execute("mutation { clearQueue { ok message } }")
819 .await;
820 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
821 let cmd = rx.try_recv().unwrap();
822 assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
823 }
824
825 #[tokio::test]
826 async fn enqueue_mutation_adds_to_queue() {
827 let (schema, rx, tmp) = test_schema();
828 let db_path = tmp.path().join("test.db");
829
830 let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
832
833 let query = format!(
835 "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
836 track_id
837 );
838 let resp = schema.execute(&query).await;
839 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
840
841 let data = resp.data.into_json().unwrap();
842 assert_eq!(data["addToQueue"]["ok"], true);
843 assert_eq!(data["addToQueue"]["addedCount"], 1);
844
845 let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
846 assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
847
848 let cmd = rx.try_recv().unwrap();
851 match cmd {
852 PlayerCommand::AddToPlaylist(items) => {
853 assert_eq!(items.len(), 1);
854 assert_eq!(items[0].title, "Windowlicker");
855 assert_eq!(items[0].artist, "Aphex Twin");
856 assert_eq!(items[0].album, "Windowlicker EP");
857 }
858 other => panic!("expected AddToPlaylist, got {:?}", other),
859 }
860
861 let play_cmd = rx.try_recv().unwrap();
863 assert!(
864 matches!(play_cmd, PlayerCommand::Play(_)),
865 "expected Play command for auto-play, got {:?}",
866 play_cmd
867 );
868 }
869
870 #[tokio::test]
871 async fn replace_queue_mutation_clears_and_enqueues() {
872 let (schema, rx, tmp) = test_schema();
873 let db_path = tmp.path().join("test.db");
874
875 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
876 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
877
878 let query = format!(
879 "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
880 id1, id2
881 );
882 let resp = schema.execute(&query).await;
883 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
884
885 let data = resp.data.into_json().unwrap();
886 assert_eq!(data["replaceQueue"]["addedCount"], 2);
887
888 match rx.try_recv().unwrap() {
892 PlayerCommand::ReplacePlaylist { items, start, .. } => {
893 assert_eq!(items.len(), 2);
894 assert_eq!(start, 0, "defaults to the first track");
895 }
896 other => panic!("expected ReplacePlaylist, got {:?}", other),
897 }
898 assert!(rx.try_recv().is_err(), "no follow-up commands");
899 }
900
901 #[tokio::test]
902 async fn replace_queue_starts_where_it_was_asked_to() {
903 let (schema, rx, tmp) = test_schema();
904 let db_path = tmp.path().join("test.db");
905
906 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
907 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
908
909 let resp = schema
910 .execute(&format!(
911 "mutation {{ replaceQueue(trackIds: [{}, {}], startAt: 1) {{ ok }} }}",
912 id1, id2
913 ))
914 .await;
915 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
916
917 match rx.try_recv().unwrap() {
918 PlayerCommand::ReplacePlaylist { start, .. } => assert_eq!(start, 1),
919 other => panic!("expected ReplacePlaylist, got {:?}", other),
920 }
921 }
922
923 #[tokio::test]
928 async fn ids_are_uids_and_arguments_take_either() {
929 let (schema, _rx, tmp) = test_schema();
930 let row = insert_test_track(&tmp.path().join("test.db"), "Archangel", "Burial", "Untrue");
931
932 let resp = schema
933 .execute("{ tracks { edges { node { id albumId artistId } } } }")
934 .await;
935 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
936 let data = resp.data.into_json().unwrap();
937 let node = &data["tracks"]["edges"][0]["node"];
938 let [id, album, artist] = ["id", "albumId", "artistId"].map(|k| {
939 let v = node[k].as_str().unwrap().to_string();
940 assert!(is_uid(&v), "{k}: {v}");
941 v
942 });
943 assert!(id != album && album != artist);
944
945 for arg in [format!("\"{id}\""), row.to_string()] {
946 let resp = schema
947 .execute(format!("{{ track(id: {arg}) {{ id }} }}"))
948 .await;
949 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
950 assert_eq!(resp.data.into_json().unwrap()["track"]["id"], id);
951 }
952
953 let resp = schema
954 .execute(format!("{{ track(id: \"{album}\") {{ id }} }}"))
955 .await;
956 assert!(!resp.errors.is_empty(), "an album's uid named a track");
957 }
958
959 #[tokio::test]
962 async fn smart_playlists_over_the_api() {
963 let (schema, _rx, tmp) = test_schema();
964 let db_path = tmp.path().join("test.db");
965 let a = insert_test_track(&db_path, "Archangel", "Burial", "Untrue");
966 insert_test_track(&db_path, "Teardrop", "Massive Attack", "Mezzanine");
967
968 let resp = schema
969 .execute(
970 r#"mutation { createSmartPlaylist(name: "Burial", rules: {
971 rules: [{ field: "artist", op: "is", value: "burial" }]
972 }) { id trackCount readonly rules } }"#,
973 )
974 .await;
975 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
976 let data = resp.data.into_json().unwrap();
977 let made = &data["createSmartPlaylist"];
978 assert_eq!(made["trackCount"], 1);
979 assert_eq!(made["readonly"], true);
980 assert_eq!(made["rules"]["rules"][0]["field"], "artist");
981 let id = made["id"].to_string();
982
983 let resp = schema
984 .execute(format!(
985 "mutation {{ addToPlaylist(id: {id}, trackIds: [{a}]) {{ ok }} }}"
986 ))
987 .await;
988 assert!(
989 !resp.errors.is_empty(),
990 "an edit to a smart playlist's contents"
991 );
992
993 let resp = schema
994 .execute(
995 r#"mutation { createSmartPlaylist(name: "Bad", rules: {
996 rules: [{ field: "plays", op: "gt", value: 1 }]
997 }) { id } }"#,
998 )
999 .await;
1000 let message = &resp.errors[0].message;
1001 assert!(message.contains("unknown field 'plays'"), "{message}");
1002
1003 let resp = schema
1004 .execute(format!(
1005 "mutation {{ setPlaylistRules(id: {id}, rules: null) {{ readonly rules trackCount }} }}"
1006 ))
1007 .await;
1008 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1009 let data = resp.data.into_json().unwrap();
1010 assert_eq!(data["setPlaylistRules"]["readonly"], false);
1011 assert_eq!(
1012 data["setPlaylistRules"]["trackCount"], 1,
1013 "keeps what it held"
1014 );
1015 }
1016
1017 #[tokio::test]
1020 async fn playlists_round_trip_over_the_api() {
1021 let (schema, rx, tmp) = test_schema();
1022 let db_path = tmp.path().join("test.db");
1023
1024 let a = insert_test_track(&db_path, "Track A", "Artist", "Album");
1025 let b = insert_test_track(&db_path, "Track B", "Artist", "Album");
1026 let c = insert_test_track(&db_path, "Track C", "Artist", "Album");
1027
1028 let resp = schema
1029 .execute(&format!(
1030 "mutation {{ createPlaylist(name: \"Evening\", trackIds: [{a}, {b}]) \
1031 {{ id name trackCount }} }}"
1032 ))
1033 .await;
1034 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1035 let data = resp.data.into_json().unwrap();
1036 assert_eq!(data["createPlaylist"]["name"], "Evening");
1037 assert_eq!(data["createPlaylist"]["trackCount"], 2);
1038 assert!(is_uid(data["createPlaylist"]["id"].as_str().unwrap()));
1039 let id = data["createPlaylist"]["id"].to_string();
1042
1043 let resp = schema
1044 .execute(&format!(
1045 "mutation {{ addToPlaylist(id: {id}, trackIds: [{c}]) {{ ok }} }}"
1046 ))
1047 .await;
1048 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1049
1050 let resp = schema
1051 .execute(&format!("{{ playlistTracks(id: {id}) {{ title }} }}"))
1052 .await;
1053 let data = resp.data.into_json().unwrap();
1054 let titles: Vec<&str> = data["playlistTracks"]
1055 .as_array()
1056 .unwrap()
1057 .iter()
1058 .map(|t| t["title"].as_str().unwrap())
1059 .collect();
1060 assert_eq!(
1061 titles,
1062 ["Track A", "Track B", "Track C"],
1063 "playlist order is kept"
1064 );
1065
1066 let resp = schema
1069 .execute(&format!(
1070 "mutation {{ setPlaylistTracks(id: {id}, trackIds: [{c}, {a}]) {{ ok }} }}"
1071 ))
1072 .await;
1073 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1074
1075 let resp = schema
1076 .execute(&format!(
1077 "mutation {{ renamePlaylist(id: {id}, name: \"Late\") {{ ok }} }}"
1078 ))
1079 .await;
1080 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1081
1082 let resp = schema.execute("{ playlists { id name trackCount } }").await;
1083 let data = resp.data.into_json().unwrap();
1084 assert_eq!(data["playlists"][0]["name"], "Late");
1085 assert_eq!(data["playlists"][0]["trackCount"], 2);
1086
1087 let resp = schema
1089 .execute(&format!("mutation {{ playPlaylist(id: {id}) {{ ok }} }}"))
1090 .await;
1091 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1092 match rx.try_recv().unwrap() {
1093 PlayerCommand::ReplacePlaylist { items, start, .. } => {
1094 assert_eq!(items.len(), 2);
1095 assert_eq!(start, 0);
1096 assert!(items.iter().all(|i| i.playlist_entry_id.is_some()));
1097 }
1098 other => panic!("expected ReplacePlaylist, got {:?}", other),
1099 }
1100 assert!(rx.try_recv().is_err());
1101
1102 let resp = schema
1103 .execute(&format!("mutation {{ deletePlaylist(id: {id}) {{ ok }} }}"))
1104 .await;
1105 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1106 let resp = schema.execute("{ playlists { id } }").await;
1107 let data = resp.data.into_json().unwrap();
1108 assert!(data["playlists"].as_array().unwrap().is_empty());
1109 }
1110
1111 #[tokio::test]
1114 async fn saving_the_queue_keeps_only_what_the_library_knows() {
1115 use koan_core::player::state::{ItemState, PlaylistItem};
1116
1117 let tmp = TempDir::new().unwrap();
1118 let db_path = tmp.path().join("test.db");
1119 let db = Database::open(&db_path).unwrap();
1120 koan_core::db::schema::create_tables(&db.conn).unwrap();
1121 drop(db);
1122 let known = insert_test_track(&db_path, "Known", "Artist", "Album");
1123
1124 let state = SharedPlayerState::new();
1125 let ch = CommandChannel::new();
1126 let schema = build_schema(
1127 state.clone(),
1128 ch.tx.clone(),
1129 Arc::new(Pool::new(db_path.clone())),
1130 None,
1131 );
1132
1133 let item = |title: &str, path: &str, db_id: Option<i64>| PlaylistItem {
1134 playlist_entry_id: None,
1135 id: QueueItemId::new(),
1136 db_id,
1137 path: std::path::PathBuf::from(path),
1138 title: title.to_string(),
1139 artist: "Artist".to_string(),
1140 album_artist: "Artist".to_string(),
1141 album: "Album".to_string(),
1142 year: None,
1143 codec: None,
1144 track_number: None,
1145 disc: None,
1146 duration_ms: None,
1147 state: ItemState::Ready,
1148 played: false,
1149 };
1150 state.add_items(vec![
1151 item("Known", "/music/known.flac", Some(known)),
1152 item("Stranger", "/elsewhere/stranger.flac", None),
1155 ]);
1156
1157 let resp = schema
1158 .execute("mutation { saveQueueAsPlaylist(name: \"Tonight\") { id trackCount } }")
1159 .await;
1160 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1161 let data = resp.data.into_json().unwrap();
1162 assert_eq!(data["saveQueueAsPlaylist"]["trackCount"], 1);
1163 }
1164
1165 #[tokio::test]
1168 async fn queue_snapshot_has_version_and_status() {
1169 let (schema, _rx, _tmp) = test_schema();
1170
1171 let resp = schema
1172 .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
1173 .await;
1174 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1175 let data = resp.data.into_json().unwrap();
1176 assert_eq!(data["queue"]["version"], 0);
1178 assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
1179 assert_eq!(data["queue"]["hasPlaying"], false);
1180 assert_eq!(data["queue"]["queueCount"], 0);
1181 }
1182
1183 #[tokio::test]
1184 async fn queue_entries_have_status_and_download_progress() {
1185 use koan_core::player::state::{ItemState, PlaylistItem};
1186
1187 let tmp = TempDir::new().unwrap();
1189 let db_path = tmp.path().join("test.db");
1190 let db = Database::open(&db_path).unwrap();
1191 koan_core::db::schema::create_tables(&db.conn).unwrap();
1192
1193 let state = SharedPlayerState::new();
1194 let ch = CommandChannel::new();
1195 let schema = build_schema(
1196 state.clone(),
1197 ch.tx.clone(),
1198 Arc::new(Pool::new(db_path.clone())),
1199 None,
1200 );
1201
1202 let item = PlaylistItem {
1204 playlist_entry_id: None,
1205 id: QueueItemId::new(),
1206 db_id: None,
1207 path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
1208 title: "Windowlicker".to_string(),
1209 artist: "Aphex Twin".to_string(),
1210 album_artist: "Aphex Twin".to_string(),
1211 album: "Windowlicker EP".to_string(),
1212 year: None,
1213 codec: Some("FLAC".to_string()),
1214 track_number: Some(1),
1215 disc: Some(1),
1216 duration_ms: Some(240000),
1217 state: ItemState::Ready,
1218 played: false,
1219 };
1220 state.add_items(vec![item]);
1221
1222 let resp = schema
1224 .execute(
1225 "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
1226 )
1227 .await;
1228 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1229 let data = resp.data.into_json().unwrap();
1230 let entries = data["queue"]["entries"].as_array().unwrap();
1231 assert_eq!(entries.len(), 1);
1232 assert_eq!(entries[0]["title"], "Windowlicker");
1233 assert_eq!(entries[0]["status"], "QUEUED");
1235 assert_eq!(entries[0]["isCurrent"], false);
1236 assert!(entries[0]["downloadProgress"].is_null());
1238 }
1239
1240 #[tokio::test]
1241 async fn viz_frame_returns_none_without_viz() {
1242 let (schema, _rx, _tmp) = test_schema();
1243
1244 let resp = schema
1245 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
1246 .await;
1247 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1248 let data = resp.data.into_json().unwrap();
1249 assert!(data["vizFrame"].is_null());
1250 }
1251
1252 #[tokio::test]
1253 async fn viz_frame_returns_data_with_viz() {
1254 let tmp = TempDir::new().unwrap();
1256 let db_path = tmp.path().join("test.db");
1257 let db = Database::open(&db_path).unwrap();
1258 koan_core::db::schema::create_tables(&db.conn).unwrap();
1259
1260 let state = SharedPlayerState::new();
1261 let ch = CommandChannel::new();
1262 let viz = koan_core::audio::viz::VizSnapshot::new();
1263
1264 let mut spectrum = [0.0f32; 48];
1266 spectrum[0] = 0.75;
1267 viz.write(koan_core::audio::viz::VizFrame {
1268 spectrum,
1269 peaks: [0.0; 48],
1270 vu_levels: [0.42, 0.38],
1271 beat_energy: 0.6,
1272 timestamp: std::time::Instant::now(),
1273 waveform: Vec::new(),
1274 });
1275
1276 let schema = build_schema(
1277 state,
1278 ch.tx.clone(),
1279 Arc::new(Pool::new(db_path.clone())),
1280 Some(viz),
1281 );
1282
1283 let resp = schema
1284 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
1285 .await;
1286 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1287 let data = resp.data.into_json().unwrap();
1288 let frame = &data["vizFrame"];
1289 assert!(!frame.is_null());
1290 let spectrum = frame["spectrum"].as_array().unwrap();
1291 assert_eq!(spectrum.len(), 48);
1292 assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
1293 let vu = frame["vuLevels"].as_array().unwrap();
1294 assert_eq!(vu.len(), 2);
1295 assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
1296 assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
1297 let waveform = frame["waveform"].as_array().unwrap();
1299 assert!(waveform.is_empty());
1300 }
1301
1302 #[tokio::test]
1303 async fn config_query() {
1304 let (schema, _rx, _tmp) = test_schema();
1305
1306 let resp = schema
1307 .execute(
1308 "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
1309 )
1310 .await;
1311 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1312 let data = resp.data.into_json().unwrap();
1313 let cfg = &data["config"];
1314 assert!(cfg["libraryFolders"].is_array());
1316 assert!(cfg["targetFps"].as_i64().unwrap() > 0);
1317 assert!(cfg["artSize"].as_i64().unwrap() > 0);
1318 }
1319
1320 #[tokio::test]
1321 async fn playlist_version_query() {
1322 let (schema, _rx, _tmp) = test_schema();
1323
1324 let resp = schema.execute("{ playlistVersion }").await;
1325 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1326 let data = resp.data.into_json().unwrap();
1327 assert_eq!(data["playlistVersion"], 0);
1328 }
1329
1330 #[tokio::test]
1331 async fn subscription_types_in_schema() {
1332 let (schema, _rx, _tmp) = test_schema();
1334
1335 let resp = schema
1336 .execute("{ __schema { subscriptionType { fields { name } } } }")
1337 .await;
1338 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1339 let data = resp.data.into_json().unwrap();
1340 let fields = data["__schema"]["subscriptionType"]["fields"]
1341 .as_array()
1342 .unwrap();
1343 let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
1344 assert!(
1345 names.contains(&"nowPlaying"),
1346 "missing nowPlaying subscription"
1347 );
1348 assert!(
1349 names.contains(&"queueUpdated"),
1350 "missing queueUpdated subscription"
1351 );
1352 assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
1353 }
1354 #[tokio::test]
1357 async fn nested_fan_out_opens_a_bounded_number_of_connections() {
1358 let (schema, handle, _rx, tmp) = instrumented_schema();
1359 seed_library(&tmp.path().join("test.db"), 10, 3, 4);
1360
1361 let before = handle.open_count();
1362 let resp = schema
1363 .execute(
1364 "{ artists(first: 10) { edges { node { name albumCount trackCount \
1365 albums(first: 10) { edges { node { title trackCount totalDurationMs \
1366 tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
1367 )
1368 .await;
1369 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1370
1371 let data = resp.data.into_json().unwrap();
1372 let artists = data["artists"]["edges"].as_array().unwrap();
1373 assert_eq!(artists.len(), 10, "query did not actually fan out");
1374 assert_eq!(artists[0]["node"]["albumCount"], 3);
1375 assert_eq!(artists[0]["node"]["trackCount"], 12);
1376
1377 let opened = handle.open_count() - before;
1380 assert!(opened <= 16, "opened {} connections", opened);
1381 }
1382
1383 #[tokio::test]
1384 async fn is_favourite_batches_into_one_query() {
1385 let (schema, handle, _rx, tmp) = instrumented_schema();
1386 seed_library(&tmp.path().join("test.db"), 1, 1, 100);
1387
1388 let before = handle.batch_count();
1389 let resp = schema
1390 .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
1391 .await;
1392 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1393 let data = resp.data.into_json().unwrap();
1394 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
1395
1396 let batches = handle.batch_count() - before;
1401 assert!(
1402 batches < 10,
1403 "{batches} batches for 100 tracks — expected a handful, not one per row"
1404 );
1405 }
1406
1407 #[tokio::test]
1408 async fn tracks_without_first_returns_the_default_page() {
1409 let (schema, _handle, _rx, tmp) = instrumented_schema();
1410 seed_library(
1411 &tmp.path().join("test.db"),
1412 1,
1413 1,
1414 helpers::DEFAULT_PAGE + 20,
1415 );
1416
1417 let resp = schema
1418 .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
1419 .await;
1420 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1421 let data = resp.data.into_json().unwrap();
1422 assert_eq!(
1423 data["tracks"]["edges"].as_array().unwrap().len(),
1424 helpers::DEFAULT_PAGE
1425 );
1426 assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
1427 }
1428
1429 #[tokio::test]
1430 async fn first_is_clamped_to_the_maximum_page() {
1431 let (schema, _handle, _rx, tmp) = instrumented_schema();
1432 seed_library(&tmp.path().join("test.db"), 1, 1, 10);
1433
1434 let resp = schema
1435 .execute("{ tracks(first: 100000) { edges { node { title } } } }")
1436 .await;
1437 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1438 let data = resp.data.into_json().unwrap();
1439 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
1440 }
1441
1442 #[tokio::test]
1443 async fn negative_first_is_empty_not_a_panic() {
1444 let (schema, _handle, _rx, tmp) = instrumented_schema();
1445 seed_library(&tmp.path().join("test.db"), 2, 1, 3);
1446
1447 for query in [
1448 "{ tracks(first: -1) { edges { node { title } } } }",
1449 "{ artists(first: -1) { edges { node { name } } } }",
1450 "{ albums(first: -1) { edges { node { title } } } }",
1451 ] {
1452 let resp = schema.execute(query).await;
1453 assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn sort_arguments_are_honoured() {
1459 let (schema, _handle, _rx, tmp) = instrumented_schema();
1460 seed_library(&tmp.path().join("test.db"), 3, 1, 2);
1461
1462 let resp = schema
1463 .execute(
1464 "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
1465 )
1466 .await;
1467 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1468 let data = resp.data.into_json().unwrap();
1469 let titles: Vec<String> = data["tracks"]["edges"]
1470 .as_array()
1471 .unwrap()
1472 .iter()
1473 .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1474 .collect();
1475 let mut sorted = titles.clone();
1476 sorted.sort();
1477 sorted.reverse();
1478 assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1479 }
1480
1481 #[test]
1485 fn a_slow_query_does_not_stall_other_tasks() {
1486 use std::sync::atomic::{AtomicUsize, Ordering};
1487
1488 let rt = tokio::runtime::Builder::new_current_thread()
1489 .enable_time()
1490 .build()
1491 .unwrap();
1492 let (schema, _handle, _rx, tmp) = instrumented_schema();
1493 seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1494
1495 rt.block_on(async move {
1496 let ticks = Arc::new(AtomicUsize::new(0));
1497 let counter = ticks.clone();
1498 let ticker = tokio::spawn(async move {
1499 loop {
1500 tokio::time::sleep(Duration::from_millis(1)).await;
1501 counter.fetch_add(1, Ordering::Relaxed);
1502 }
1503 });
1504
1505 let mut running = Vec::new();
1507 for _ in 0..4 {
1508 let schema = schema.clone();
1509 running.push(tokio::spawn(async move {
1510 schema
1511 .execute("{ fuzzySearch(query: \"track\") { id } }")
1512 .await
1513 }));
1514 }
1515 for handle in running {
1516 let resp = handle.await.unwrap();
1517 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1518 }
1519
1520 ticker.abort();
1521 assert!(
1522 ticks.load(Ordering::Relaxed) >= 2,
1523 "no other task ran while the queries were in flight"
1524 );
1525 });
1526 }
1527
1528 #[tokio::test]
1529 async fn accounts_are_made_and_invited_through_graphql() {
1530 let (schema, _rx, _tmp) = test_schema();
1531 let run = |q: &str| {
1532 schema.execute(
1533 async_graphql::Request::new(q)
1534 .data(RequestOrigin("https://music.example.com".into())),
1535 )
1536 };
1537 let r = run(r#"mutation { createUser(username: "sarita", role: READONLY) { username link password emailText } }"#).await;
1538 assert!(r.errors.is_empty(), "{:?}", r.errors);
1539 let v = r.data.into_json().unwrap();
1540 let link = v["createUser"]["link"].as_str().unwrap();
1541 let invite = koan_core::invite::Invite::parse(link).unwrap();
1542 assert_eq!(invite.server, "https://music.example.com");
1543 assert!(invite.token.is_some() && invite.password.is_none());
1544 let password = v["createUser"]["password"].as_str().unwrap();
1545 assert!(
1546 v["createUser"]["emailText"]
1547 .as_str()
1548 .unwrap()
1549 .contains(password)
1550 );
1551
1552 let r = run(r#"mutation { inviteUser(username: "sarita", server: "https://other.example.com") { server password } }"#).await;
1553 let v = r.data.into_json().unwrap();
1554 assert_eq!(v["inviteUser"]["server"], "https://other.example.com");
1555 assert!(v["inviteUser"]["password"].is_null());
1556
1557 let r = run(
1558 r#"mutation { setUserPassword(username: "sarita", password: "correct horse") { ok } }"#,
1559 )
1560 .await;
1561 assert!(r.errors.is_empty(), "{:?}", r.errors);
1562
1563 let r = run(r#"mutation { setUserRole(username: "sarita", role: USER) { ok } }"#).await;
1564 assert!(r.errors.is_empty(), "{:?}", r.errors);
1565 let r = run("{ users { username role } }").await;
1566 let v = r.data.into_json().unwrap();
1567 assert_eq!(v["users"][0]["role"], "USER");
1568
1569 let r = run(r#"mutation { deleteUser(username: "sarita") { ok } }"#).await;
1570 assert!(r.errors.is_empty(), "{:?}", r.errors);
1571 let r = run("{ users { username } }").await;
1572 assert_eq!(
1573 r.data.into_json().unwrap()["users"]
1574 .as_array()
1575 .unwrap()
1576 .len(),
1577 0
1578 );
1579 }
1580}