1mod helpers;
2pub(crate) mod 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_sharing(
348 state: Arc<SharedPlayerState>,
349 cmd_tx: Sender<PlayerCommand>,
350 pool: Arc<Pool>,
351 viz: Option<Arc<VizSnapshot>>,
352 jobs: jobs::JobRegistry,
353) -> KoanSchema {
354 builder_with(DbHandle::new(pool), state, cmd_tx, viz, jobs).finish()
355}
356
357pub(crate) fn build_schema_extended(
359 state: Arc<SharedPlayerState>,
360 cmd_tx: Sender<PlayerCommand>,
361 pool: Arc<Pool>,
362 extension: impl async_graphql::extensions::ExtensionFactory,
363) -> KoanSchema {
364 builder_with(DbHandle::new(pool), state, cmd_tx, None, Default::default())
365 .extension(extension)
366 .finish()
367}
368
369fn build_schema_with(
370 handle: DbHandle,
371 state: Arc<SharedPlayerState>,
372 cmd_tx: Sender<PlayerCommand>,
373 viz: Option<Arc<VizSnapshot>>,
374) -> KoanSchema {
375 builder_with(handle, state, cmd_tx, viz, Default::default()).finish()
376}
377
378fn builder_with(
379 handle: DbHandle,
380 state: Arc<SharedPlayerState>,
381 cmd_tx: Sender<PlayerCommand>,
382 viz: Option<Arc<VizSnapshot>>,
383 jobs: jobs::JobRegistry,
384) -> async_graphql::SchemaBuilder<QueryRoot, MutationRoot, SubscriptionRoot> {
385 let loader = DataLoader::new(DbLoader::new(handle.clone()), tokio::spawn).delay(BATCH_WINDOW);
388 loader.enable_all_cache(false);
389
390 let mut builder = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
391 .data(handle)
392 .data(loader)
393 .data(jobs)
394 .data(Arc::new(queries::Fuzzy::default()))
395 .data(state)
396 .data(cmd_tx);
397 if let Some(viz) = viz {
398 builder = builder.data(viz);
399 }
400 builder.limit_depth(12).limit_complexity(2000)
403}
404
405fn parse_queue_item_id(s: &str) -> async_graphql::Result<QueueItemId> {
410 Uuid::parse_str(s)
411 .map(QueueItemId)
412 .map_err(|e| async_graphql::Error::new(format!("invalid queue item ID '{}': {}", s, e)))
413}
414
415const CMD_SEND_TIMEOUT: Duration = Duration::from_millis(250);
422
423fn send_cmd(ctx: &Context<'_>, cmd: PlayerCommand) -> async_graphql::Result<()> {
424 let tx = ctx.data::<Sender<PlayerCommand>>()?;
425 send_cmd_via(tx, cmd)
426}
427
428fn send_cmd_via(tx: &Sender<PlayerCommand>, cmd: PlayerCommand) -> async_graphql::Result<()> {
429 tx.send_timeout(cmd, CMD_SEND_TIMEOUT)
430 .map_err(|_| async_graphql::Error::new("player busy — command not accepted"))
431}
432
433fn get_auth_user(ctx: &Context<'_>) -> AuthUser {
436 ctx.data::<AuthUser>()
437 .cloned()
438 .unwrap_or_else(|_| AuthUser::anonymous_admin())
439}
440
441fn user_id(ctx: &Context<'_>) -> i64 {
443 get_auth_user(ctx).user_id
444}
445
446fn share_owner(ctx: &Context<'_>) -> Option<i64> {
449 let user = get_auth_user(ctx);
450 (user.role != Role::Admin).then_some(user.user_id)
451}
452
453fn readable_playlist(
456 db: &Database,
457 user: i64,
458 id: i64,
459) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
460 use koan_core::db::queries;
461 let me = queries::auth::resolve_user(&db.conn, user).map_err(|e| internal_error("db", e))?;
462 queries::get_playlist(&db.conn, id)
463 .map_err(|e| internal_error("db", e))?
464 .filter(|p| p.readable_by(me))
465 .ok_or_else(|| async_graphql::Error::new(format!("playlist {id} not found")))
466}
467
468fn editable_playlist(
470 db: &Database,
471 user: i64,
472 id: i64,
473) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
474 let list = readable_playlist(db, user, id)?;
475 let me = koan_core::db::queries::auth::resolve_user(&db.conn, user)
476 .map_err(|e| internal_error("db", e))?;
477 if list.editable_by(me) {
478 Ok(list)
479 } else {
480 Err(async_graphql::Error::new(format!(
481 "playlist {id} belongs to someone else"
482 )))
483 }
484}
485
486fn fillable_playlist(
489 db: &Database,
490 user: i64,
491 id: i64,
492) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
493 let list = editable_playlist(db, user, id)?;
494 if list.readonly {
495 return Err(async_graphql::Error::new(format!(
496 "playlist {id} is read-only: its rules (setPlaylistRules changes \
497 them), its file in the library or its server decide what it holds"
498 )));
499 }
500 Ok(list)
501}
502
503fn renamable_playlist(
506 db: &Database,
507 user: i64,
508 id: i64,
509) -> async_graphql::Result<koan_core::db::queries::PlaylistRow> {
510 let list = editable_playlist(db, user, id)?;
511 if let Some(path) = &list.source_path {
512 return Err(async_graphql::Error::new(format!(
513 "playlist {id} is read from {path}: edit the file instead"
514 )));
515 }
516 Ok(list)
517}
518
519fn refresh_smart(db: &Database, user: i64) {
522 match koan_core::db::queries::smart::refresh_due(&db.conn, user) {
523 Ok(changed) if !changed.is_empty() => crate::clients::changed(),
524 Ok(_) => {}
525 Err(e) => log::warn!("smart playlists not refreshed: {e}"),
526 }
527}
528
529fn client_scope(ctx: &Context<'_>) -> Option<String> {
532 Some(get_auth_user(ctx).username)
533}
534
535fn require_role(ctx: &Context<'_>, required: Role) -> async_graphql::Result<()> {
538 let user = get_auth_user(ctx);
539 if user.role.has_permission(required) {
540 Ok(())
541 } else {
542 Err(async_graphql::Error::new(format!(
543 "forbidden: requires {} role, you have {}",
544 required, user.role
545 )))
546 }
547}
548
549#[cfg(test)]
554mod tests {
555 use super::*;
556 use koan_core::db::connection::Database;
557 use koan_core::db::pool::Pool;
558 use koan_core::db::queries;
559 use koan_core::player::commands::CommandChannel;
560 use tempfile::TempDir;
561
562 fn isolate_config() {
569 static DIR: std::sync::OnceLock<TempDir> = std::sync::OnceLock::new();
570 koan_core::config::set_config_dir(DIR.get_or_init(|| TempDir::new().unwrap()).path());
571 }
572
573 fn test_schema() -> (
574 KoanSchema,
575 crossbeam_channel::Receiver<PlayerCommand>,
576 TempDir,
577 ) {
578 isolate_config();
579 let tmp = TempDir::new().unwrap();
580 let db_path = tmp.path().join("test.db");
581 let db = Database::open(&db_path).unwrap();
582 koan_core::db::schema::create_tables(&db.conn).unwrap();
583
584 let state = SharedPlayerState::new();
585 let ch = CommandChannel::new();
586 let tx = ch.tx.clone();
587 let rx = ch.rx.clone();
588
589 let schema = build_schema(state, tx, Arc::new(Pool::new(db_path.clone())), None);
590 (schema, rx, tmp)
591 }
592
593 fn instrumented_schema() -> (
595 KoanSchema,
596 DbHandle,
597 crossbeam_channel::Receiver<PlayerCommand>,
598 TempDir,
599 ) {
600 let tmp = TempDir::new().unwrap();
601 let db_path = tmp.path().join("test.db");
602 let db = Database::open(&db_path).unwrap();
603 koan_core::db::schema::create_tables(&db.conn).unwrap();
604
605 let state = SharedPlayerState::new();
606 let ch = CommandChannel::new();
607 let handle = DbHandle::new(Arc::new(Pool::new(db_path)));
608 let schema = build_schema_with(handle.clone(), state, ch.tx.clone(), None);
609 (schema, handle, ch.rx.clone(), tmp)
610 }
611
612 fn seed_library(db_path: &std::path::Path, artists: usize, albums: usize, tracks: usize) {
614 let db = Database::open(db_path).unwrap();
615 for a in 0..artists {
616 for al in 0..albums {
617 for t in 0..tracks {
618 let meta = queries::TrackMeta {
619 title: format!("Track {:03}-{}-{:03}", a, al, t),
620 artist: format!("Artist {:03}", a),
621 album_artist: Some(format!("Artist {:03}", a)),
622 album: format!("Album {:03}-{}", a, al),
623 track_number: Some(t as i32),
624 disc: Some(1),
625 disc_total: None,
626 date: Some("2024".into()),
627 genre: Some("Electronic".into()),
628 duration_ms: Some(240_000),
629 path: Some(format!("/tmp/koan-test/{}/{}/{}.flac", a, al, t)),
630 server_path: None,
631 codec: Some("FLAC".into()),
632 sample_rate: Some(44100),
633 bit_depth: Some(16),
634 channels: Some(2),
635 bitrate: Some(1411),
636 size_bytes: Some(42_000_000),
637 mtime: Some(1700000000),
638 source: "local".into(),
639 remote_id: None,
640 remote_url: None,
641 album_remote_id: None,
642 artist_remote_id: None,
643 mbid: None,
644 album_mbid: None,
645 album_added_at: None,
646 label: None,
647 credits: Default::default(),
648 };
649 queries::upsert_track(&db.conn, &meta).unwrap();
650 }
651 }
652 }
653 }
654
655 fn insert_test_track(db_path: &std::path::Path, title: &str, artist: &str, album: &str) -> i64 {
656 let db = Database::open(db_path).unwrap();
657 let meta = queries::TrackMeta {
658 title: title.to_string(),
659 artist: artist.to_string(),
660 album_artist: Some(artist.to_string()),
661 album: album.to_string(),
662 track_number: Some(1),
663 disc: Some(1),
664 disc_total: None,
665 date: Some("2024".into()),
666 genre: Some("Electronic".into()),
667 duration_ms: Some(240000),
668 path: Some(format!(
669 "/tmp/test/{}.flac",
670 title.to_lowercase().replace(' ', "_")
671 )),
672 server_path: None,
673 codec: Some("FLAC".into()),
674 sample_rate: Some(44100),
675 bit_depth: Some(16),
676 channels: Some(2),
677 bitrate: Some(1411),
678 size_bytes: Some(42_000_000),
679 mtime: Some(1700000000),
680 source: "local".into(),
681 remote_id: None,
682 remote_url: None,
683 album_remote_id: None,
684 artist_remote_id: None,
685 mbid: None,
686 album_mbid: None,
687 album_added_at: None,
688 label: None,
689 credits: Default::default(),
690 };
691 queries::upsert_track(&db.conn, &meta).unwrap()
692 }
693
694 #[test]
695 fn schema_builds() {
696 let (_schema, _rx, _tmp) = test_schema();
697 }
698
699 #[tokio::test]
700 async fn library_stats_query() {
701 let (schema, _rx, tmp) = test_schema();
702 let db_path = tmp.path().join("test.db");
703 insert_test_track(&db_path, "Track1", "Artist1", "Album1");
704
705 let resp = schema
706 .execute("{ libraryStats { totalTracks totalAlbums totalArtists } }")
707 .await;
708 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
709 let data = resp.data.into_json().unwrap();
710 assert_eq!(data["libraryStats"]["totalTracks"], 1);
711 assert_eq!(data["libraryStats"]["totalAlbums"], 1);
712 assert_eq!(data["libraryStats"]["totalArtists"], 1);
713 }
714
715 #[tokio::test]
716 async fn artists_query() {
717 let (schema, _rx, tmp) = test_schema();
718 let db_path = tmp.path().join("test.db");
719 insert_test_track(&db_path, "T1", "Aphex Twin", "Drukqs");
720 insert_test_track(&db_path, "T2", "Boards of Canada", "MHTRTC");
721
722 let resp = schema
723 .execute("{ artists { edges { node { id name } } } }")
724 .await;
725 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
726 let data = resp.data.into_json().unwrap();
727 let edges = data["artists"]["edges"].as_array().unwrap();
728 assert_eq!(edges.len(), 2);
729 }
730
731 #[tokio::test]
732 async fn tracks_search() {
733 let (schema, _rx, tmp) = test_schema();
734 let db_path = tmp.path().join("test.db");
735 insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
736 insert_test_track(&db_path, "Roygbiv", "Boards of Canada", "MHTRTC");
737
738 let resp = schema
739 .execute(r#"{ tracks(search: "Aphex") { edges { node { id title artist } } } }"#)
740 .await;
741 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
742 let data = resp.data.into_json().unwrap();
743 let edges = data["tracks"]["edges"].as_array().unwrap();
744 assert_eq!(edges.len(), 1);
745 assert_eq!(edges[0]["node"]["title"], "Windowlicker");
746 }
747
748 #[tokio::test]
749 async fn now_playing_stopped() {
750 let (schema, _rx, _tmp) = test_schema();
751 let resp = schema
752 .execute("{ nowPlaying { state positionMs track { title } } }")
753 .await;
754 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
755 let data = resp.data.into_json().unwrap();
756 assert_eq!(data["nowPlaying"]["state"], "STOPPED");
757 }
758
759 #[tokio::test]
760 async fn pause_mutation() {
761 let (schema, rx, _tmp) = test_schema();
762 let resp = schema.execute("mutation { pause { ok message } }").await;
763 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
764 let data = resp.data.into_json().unwrap();
765 assert_eq!(data["pause"]["ok"], true);
766 let cmd = rx.try_recv().unwrap();
767 assert!(matches!(cmd, PlayerCommand::Pause));
768 }
769
770 #[tokio::test]
771 async fn nested_artist_albums_tracks() {
772 let (schema, _rx, tmp) = test_schema();
773 let db_path = tmp.path().join("test.db");
774 insert_test_track(&db_path, "Vordhosbn", "Aphex Twin", "Drukqs");
775 insert_test_track(&db_path, "Avril 14th", "Aphex Twin", "Drukqs");
776
777 let resp = schema
778 .execute(
779 r#"{ artists(search: "Aphex") {
780 edges { node {
781 name
782 albums { edges { node {
783 title
784 tracks { edges { node { title } } }
785 } } }
786 } }
787 } }"#,
788 )
789 .await;
790 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
791 let data = resp.data.into_json().unwrap();
792 let artist = &data["artists"]["edges"][0]["node"];
793 assert_eq!(artist["name"], "Aphex Twin");
794 let album = &artist["albums"]["edges"][0]["node"];
795 assert_eq!(album["title"], "Drukqs");
796 let tracks = album["tracks"]["edges"].as_array().unwrap();
797 assert_eq!(tracks.len(), 2);
798 }
799
800 #[tokio::test]
801 async fn pagination_has_next() {
802 let (schema, _rx, tmp) = test_schema();
803 let db_path = tmp.path().join("test.db");
804 for i in 0..5 {
805 insert_test_track(
806 &db_path,
807 &format!("Track{}", i),
808 "Artist",
809 &format!("Album{}", i),
810 );
811 }
812
813 let resp = schema
814 .execute(
815 r#"{ artists(first: 1) {
816 edges { node { name } cursor }
817 pageInfo { hasNextPage endCursor }
818 } }"#,
819 )
820 .await;
821 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
822 let data = resp.data.into_json().unwrap();
823 assert_eq!(data["artists"]["edges"].as_array().unwrap().len(), 1);
826 }
827
828 #[tokio::test]
829 async fn clear_queue_mutation() {
830 let (schema, rx, _tmp) = test_schema();
831 let resp = schema
832 .execute("mutation { clearQueue { ok message } }")
833 .await;
834 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
835 let cmd = rx.try_recv().unwrap();
836 assert!(matches!(cmd, PlayerCommand::ClearPlaylist));
837 }
838
839 #[tokio::test]
840 async fn enqueue_mutation_adds_to_queue() {
841 let (schema, rx, tmp) = test_schema();
842 let db_path = tmp.path().join("test.db");
843
844 let track_id = insert_test_track(&db_path, "Windowlicker", "Aphex Twin", "Windowlicker EP");
846
847 let query = format!(
849 "mutation {{ addToQueue(trackIds: [{}]) {{ ok message addedCount queueItemIds }} }}",
850 track_id
851 );
852 let resp = schema.execute(&query).await;
853 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
854
855 let data = resp.data.into_json().unwrap();
856 assert_eq!(data["addToQueue"]["ok"], true);
857 assert_eq!(data["addToQueue"]["addedCount"], 1);
858
859 let queue_ids = data["addToQueue"]["queueItemIds"].as_array().unwrap();
860 assert_eq!(queue_ids.len(), 1, "should return one queue item ID");
861
862 let cmd = rx.try_recv().unwrap();
865 match cmd {
866 PlayerCommand::AddToPlaylist(items) => {
867 assert_eq!(items.len(), 1);
868 assert_eq!(items[0].title, "Windowlicker");
869 assert_eq!(items[0].artist, "Aphex Twin");
870 assert_eq!(items[0].album, "Windowlicker EP");
871 }
872 other => panic!("expected AddToPlaylist, got {:?}", other),
873 }
874
875 let play_cmd = rx.try_recv().unwrap();
877 assert!(
878 matches!(play_cmd, PlayerCommand::Play(_)),
879 "expected Play command for auto-play, got {:?}",
880 play_cmd
881 );
882 }
883
884 #[tokio::test]
885 async fn replace_queue_mutation_clears_and_enqueues() {
886 let (schema, rx, tmp) = test_schema();
887 let db_path = tmp.path().join("test.db");
888
889 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
890 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
891
892 let query = format!(
893 "mutation {{ replaceQueue(trackIds: [{}, {}]) {{ ok addedCount queueItemIds }} }}",
894 id1, id2
895 );
896 let resp = schema.execute(&query).await;
897 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
898
899 let data = resp.data.into_json().unwrap();
900 assert_eq!(data["replaceQueue"]["addedCount"], 2);
901
902 match rx.try_recv().unwrap() {
906 PlayerCommand::ReplacePlaylist { items, start, .. } => {
907 assert_eq!(items.len(), 2);
908 assert_eq!(start, 0, "defaults to the first track");
909 }
910 other => panic!("expected ReplacePlaylist, got {:?}", other),
911 }
912 assert!(rx.try_recv().is_err(), "no follow-up commands");
913 }
914
915 #[tokio::test]
916 async fn replace_queue_starts_where_it_was_asked_to() {
917 let (schema, rx, tmp) = test_schema();
918 let db_path = tmp.path().join("test.db");
919
920 let id1 = insert_test_track(&db_path, "Track A", "Artist", "Album");
921 let id2 = insert_test_track(&db_path, "Track B", "Artist", "Album");
922
923 let resp = schema
924 .execute(&format!(
925 "mutation {{ replaceQueue(trackIds: [{}, {}], startAt: 1) {{ ok }} }}",
926 id1, id2
927 ))
928 .await;
929 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
930
931 match rx.try_recv().unwrap() {
932 PlayerCommand::ReplacePlaylist { start, .. } => assert_eq!(start, 1),
933 other => panic!("expected ReplacePlaylist, got {:?}", other),
934 }
935 }
936
937 #[tokio::test]
942 async fn ids_are_uids_and_arguments_take_either() {
943 let (schema, _rx, tmp) = test_schema();
944 let row = insert_test_track(&tmp.path().join("test.db"), "Archangel", "Burial", "Untrue");
945
946 let resp = schema
947 .execute("{ tracks { edges { node { id albumId artistId } } } }")
948 .await;
949 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
950 let data = resp.data.into_json().unwrap();
951 let node = &data["tracks"]["edges"][0]["node"];
952 let [id, album, artist] = ["id", "albumId", "artistId"].map(|k| {
953 let v = node[k].as_str().unwrap().to_string();
954 assert!(is_uid(&v), "{k}: {v}");
955 v
956 });
957 assert!(id != album && album != artist);
958
959 for arg in [format!("\"{id}\""), row.to_string()] {
960 let resp = schema
961 .execute(format!("{{ track(id: {arg}) {{ id }} }}"))
962 .await;
963 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
964 assert_eq!(resp.data.into_json().unwrap()["track"]["id"], id);
965 }
966
967 let resp = schema
968 .execute(format!("{{ track(id: \"{album}\") {{ id }} }}"))
969 .await;
970 assert!(!resp.errors.is_empty(), "an album's uid named a track");
971 }
972
973 #[tokio::test]
976 async fn smart_playlists_over_the_api() {
977 let (schema, _rx, tmp) = test_schema();
978 let db_path = tmp.path().join("test.db");
979 let a = insert_test_track(&db_path, "Archangel", "Burial", "Untrue");
980 insert_test_track(&db_path, "Teardrop", "Massive Attack", "Mezzanine");
981
982 let resp = schema
983 .execute(
984 r#"mutation { createSmartPlaylist(name: "Burial", rules: {
985 rules: [{ field: "artist", op: "is", value: "burial" }]
986 }) { id trackCount readonly rules } }"#,
987 )
988 .await;
989 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
990 let data = resp.data.into_json().unwrap();
991 let made = &data["createSmartPlaylist"];
992 assert_eq!(made["trackCount"], 1);
993 assert_eq!(made["readonly"], true);
994 assert_eq!(made["rules"]["rules"][0]["field"], "artist");
995 let id = made["id"].to_string();
996
997 let resp = schema
998 .execute(format!(
999 "mutation {{ addToPlaylist(id: {id}, trackIds: [{a}]) {{ ok }} }}"
1000 ))
1001 .await;
1002 assert!(
1003 !resp.errors.is_empty(),
1004 "an edit to a smart playlist's contents"
1005 );
1006
1007 let resp = schema
1008 .execute(
1009 r#"mutation { createSmartPlaylist(name: "Bad", rules: {
1010 rules: [{ field: "plays", op: "gt", value: 1 }]
1011 }) { id } }"#,
1012 )
1013 .await;
1014 let message = &resp.errors[0].message;
1015 assert!(message.contains("unknown field 'plays'"), "{message}");
1016
1017 let resp = schema
1018 .execute(format!(
1019 "mutation {{ setPlaylistRules(id: {id}, rules: null) {{ readonly rules trackCount }} }}"
1020 ))
1021 .await;
1022 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1023 let data = resp.data.into_json().unwrap();
1024 assert_eq!(data["setPlaylistRules"]["readonly"], false);
1025 assert_eq!(
1026 data["setPlaylistRules"]["trackCount"], 1,
1027 "keeps what it held"
1028 );
1029 }
1030
1031 #[tokio::test]
1034 async fn playlists_round_trip_over_the_api() {
1035 let (schema, rx, tmp) = test_schema();
1036 let db_path = tmp.path().join("test.db");
1037
1038 let a = insert_test_track(&db_path, "Track A", "Artist", "Album");
1039 let b = insert_test_track(&db_path, "Track B", "Artist", "Album");
1040 let c = insert_test_track(&db_path, "Track C", "Artist", "Album");
1041
1042 let resp = schema
1043 .execute(&format!(
1044 "mutation {{ createPlaylist(name: \"Evening\", trackIds: [{a}, {b}]) \
1045 {{ id name trackCount }} }}"
1046 ))
1047 .await;
1048 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1049 let data = resp.data.into_json().unwrap();
1050 assert_eq!(data["createPlaylist"]["name"], "Evening");
1051 assert_eq!(data["createPlaylist"]["trackCount"], 2);
1052 assert!(is_uid(data["createPlaylist"]["id"].as_str().unwrap()));
1053 let id = data["createPlaylist"]["id"].to_string();
1056
1057 let resp = schema
1058 .execute(&format!(
1059 "mutation {{ addToPlaylist(id: {id}, trackIds: [{c}]) {{ ok }} }}"
1060 ))
1061 .await;
1062 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1063
1064 let resp = schema
1065 .execute(&format!("{{ playlistTracks(id: {id}) {{ title }} }}"))
1066 .await;
1067 let data = resp.data.into_json().unwrap();
1068 let titles: Vec<&str> = data["playlistTracks"]
1069 .as_array()
1070 .unwrap()
1071 .iter()
1072 .map(|t| t["title"].as_str().unwrap())
1073 .collect();
1074 assert_eq!(
1075 titles,
1076 ["Track A", "Track B", "Track C"],
1077 "playlist order is kept"
1078 );
1079
1080 let resp = schema
1083 .execute(&format!(
1084 "mutation {{ setPlaylistTracks(id: {id}, trackIds: [{c}, {a}]) {{ ok }} }}"
1085 ))
1086 .await;
1087 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1088
1089 let resp = schema
1090 .execute(&format!(
1091 "mutation {{ renamePlaylist(id: {id}, name: \"Late\") {{ ok }} }}"
1092 ))
1093 .await;
1094 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1095
1096 let resp = schema.execute("{ playlists { id name trackCount } }").await;
1097 let data = resp.data.into_json().unwrap();
1098 assert_eq!(data["playlists"][0]["name"], "Late");
1099 assert_eq!(data["playlists"][0]["trackCount"], 2);
1100
1101 let resp = schema
1103 .execute(&format!("mutation {{ playPlaylist(id: {id}) {{ ok }} }}"))
1104 .await;
1105 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1106 match rx.try_recv().unwrap() {
1107 PlayerCommand::ReplacePlaylist { items, start, .. } => {
1108 assert_eq!(items.len(), 2);
1109 assert_eq!(start, 0);
1110 assert!(items.iter().all(|i| i.playlist_entry_id.is_some()));
1111 }
1112 other => panic!("expected ReplacePlaylist, got {:?}", other),
1113 }
1114 assert!(rx.try_recv().is_err());
1115
1116 let resp = schema
1117 .execute(&format!("mutation {{ deletePlaylist(id: {id}) {{ ok }} }}"))
1118 .await;
1119 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1120 let resp = schema.execute("{ playlists { id } }").await;
1121 let data = resp.data.into_json().unwrap();
1122 assert!(data["playlists"].as_array().unwrap().is_empty());
1123 }
1124
1125 #[tokio::test]
1128 async fn saving_the_queue_keeps_only_what_the_library_knows() {
1129 use koan_core::player::state::{ItemState, PlaylistItem};
1130
1131 let tmp = TempDir::new().unwrap();
1132 let db_path = tmp.path().join("test.db");
1133 let db = Database::open(&db_path).unwrap();
1134 koan_core::db::schema::create_tables(&db.conn).unwrap();
1135 drop(db);
1136 let known = insert_test_track(&db_path, "Known", "Artist", "Album");
1137
1138 let state = SharedPlayerState::new();
1139 let ch = CommandChannel::new();
1140 let schema = build_schema(
1141 state.clone(),
1142 ch.tx.clone(),
1143 Arc::new(Pool::new(db_path.clone())),
1144 None,
1145 );
1146
1147 let item = |title: &str, path: &str, db_id: Option<i64>| PlaylistItem {
1148 uid: None,
1149 playlist_entry_id: None,
1150 id: QueueItemId::new(),
1151 db_id,
1152 path: std::path::PathBuf::from(path),
1153 title: title.to_string(),
1154 artist: "Artist".to_string(),
1155 album_artist: "Artist".to_string(),
1156 album: "Album".to_string(),
1157 year: None,
1158 codec: None,
1159 track_number: None,
1160 disc: None,
1161 duration_ms: None,
1162 state: ItemState::Ready,
1163 played: false,
1164 };
1165 state.add_items(vec![
1166 item("Known", "/music/known.flac", Some(known)),
1167 item("Stranger", "/elsewhere/stranger.flac", None),
1170 ]);
1171
1172 let resp = schema
1173 .execute("mutation { saveQueueAsPlaylist(name: \"Tonight\") { id trackCount } }")
1174 .await;
1175 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1176 let data = resp.data.into_json().unwrap();
1177 assert_eq!(data["saveQueueAsPlaylist"]["trackCount"], 1);
1178 }
1179
1180 #[tokio::test]
1183 async fn queue_snapshot_has_version_and_status() {
1184 let (schema, _rx, _tmp) = test_schema();
1185
1186 let resp = schema
1187 .execute("{ queue { version entries { queueItemId status } hasPlaying queueCount } }")
1188 .await;
1189 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1190 let data = resp.data.into_json().unwrap();
1191 assert_eq!(data["queue"]["version"], 0);
1193 assert_eq!(data["queue"]["entries"].as_array().unwrap().len(), 0);
1194 assert_eq!(data["queue"]["hasPlaying"], false);
1195 assert_eq!(data["queue"]["queueCount"], 0);
1196 }
1197
1198 #[tokio::test]
1199 async fn queue_entries_have_status_and_download_progress() {
1200 use koan_core::player::state::{ItemState, PlaylistItem};
1201
1202 let tmp = TempDir::new().unwrap();
1204 let db_path = tmp.path().join("test.db");
1205 let db = Database::open(&db_path).unwrap();
1206 koan_core::db::schema::create_tables(&db.conn).unwrap();
1207
1208 let state = SharedPlayerState::new();
1209 let ch = CommandChannel::new();
1210 let schema = build_schema(
1211 state.clone(),
1212 ch.tx.clone(),
1213 Arc::new(Pool::new(db_path.clone())),
1214 None,
1215 );
1216
1217 let item = PlaylistItem {
1219 uid: None,
1220 playlist_entry_id: None,
1221 id: QueueItemId::new(),
1222 db_id: None,
1223 path: std::path::PathBuf::from("/tmp/test/windowlicker.flac"),
1224 title: "Windowlicker".to_string(),
1225 artist: "Aphex Twin".to_string(),
1226 album_artist: "Aphex Twin".to_string(),
1227 album: "Windowlicker EP".to_string(),
1228 year: None,
1229 codec: Some("FLAC".to_string()),
1230 track_number: Some(1),
1231 disc: Some(1),
1232 duration_ms: Some(240000),
1233 state: ItemState::Ready,
1234 played: false,
1235 };
1236 state.add_items(vec![item]);
1237
1238 let resp = schema
1240 .execute(
1241 "{ queue { version entries { queueItemId title status downloadProgress { downloaded total } isCurrent } finishedCount } }",
1242 )
1243 .await;
1244 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1245 let data = resp.data.into_json().unwrap();
1246 let entries = data["queue"]["entries"].as_array().unwrap();
1247 assert_eq!(entries.len(), 1);
1248 assert_eq!(entries[0]["title"], "Windowlicker");
1249 assert_eq!(entries[0]["status"], "QUEUED");
1251 assert_eq!(entries[0]["isCurrent"], false);
1252 assert!(entries[0]["downloadProgress"].is_null());
1254 }
1255
1256 #[tokio::test]
1257 async fn viz_frame_returns_none_without_viz() {
1258 let (schema, _rx, _tmp) = test_schema();
1259
1260 let resp = schema
1261 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy } }")
1262 .await;
1263 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1264 let data = resp.data.into_json().unwrap();
1265 assert!(data["vizFrame"].is_null());
1266 }
1267
1268 #[tokio::test]
1269 async fn viz_frame_returns_data_with_viz() {
1270 let tmp = TempDir::new().unwrap();
1272 let db_path = tmp.path().join("test.db");
1273 let db = Database::open(&db_path).unwrap();
1274 koan_core::db::schema::create_tables(&db.conn).unwrap();
1275
1276 let state = SharedPlayerState::new();
1277 let ch = CommandChannel::new();
1278 let viz = koan_core::audio::viz::VizSnapshot::new();
1279
1280 let mut spectrum = [0.0f32; 48];
1282 spectrum[0] = 0.75;
1283 viz.write(koan_core::audio::viz::VizFrame {
1284 spectrum,
1285 peaks: [0.0; 48],
1286 vu_levels: [0.42, 0.38],
1287 beat_energy: 0.6,
1288 timestamp: std::time::Instant::now(),
1289 waveform: Vec::new(),
1290 loudness: Default::default(),
1291 });
1292
1293 let schema = build_schema(
1294 state,
1295 ch.tx.clone(),
1296 Arc::new(Pool::new(db_path.clone())),
1297 Some(viz),
1298 );
1299
1300 let resp = schema
1301 .execute("{ vizFrame { spectrum peaks vuLevels beatEnergy waveform } }")
1302 .await;
1303 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1304 let data = resp.data.into_json().unwrap();
1305 let frame = &data["vizFrame"];
1306 assert!(!frame.is_null());
1307 let spectrum = frame["spectrum"].as_array().unwrap();
1308 assert_eq!(spectrum.len(), 48);
1309 assert!((spectrum[0].as_f64().unwrap() - 0.75).abs() < 0.01);
1310 let vu = frame["vuLevels"].as_array().unwrap();
1311 assert_eq!(vu.len(), 2);
1312 assert!((vu[0].as_f64().unwrap() - 0.42).abs() < 0.01);
1313 assert!((frame["beatEnergy"].as_f64().unwrap() - 0.6).abs() < 0.01);
1314 let waveform = frame["waveform"].as_array().unwrap();
1316 assert!(waveform.is_empty());
1317 }
1318
1319 #[tokio::test]
1320 async fn config_query() {
1321 let (schema, _rx, _tmp) = test_schema();
1322
1323 let resp = schema
1324 .execute(
1325 "{ config { libraryFolders replaygainMode targetFps artSize remoteEnabled graphqlPort } }",
1326 )
1327 .await;
1328 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1329 let data = resp.data.into_json().unwrap();
1330 let cfg = &data["config"];
1331 assert!(cfg["libraryFolders"].is_array());
1333 assert!(cfg["targetFps"].as_i64().unwrap() > 0);
1334 assert!(cfg["artSize"].as_i64().unwrap() > 0);
1335 }
1336
1337 #[tokio::test]
1338 async fn playlist_version_query() {
1339 let (schema, _rx, _tmp) = test_schema();
1340
1341 let resp = schema.execute("{ playlistVersion }").await;
1342 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1343 let data = resp.data.into_json().unwrap();
1344 assert_eq!(data["playlistVersion"], 0);
1345 }
1346
1347 #[tokio::test]
1348 async fn subscription_types_in_schema() {
1349 let (schema, _rx, _tmp) = test_schema();
1351
1352 let resp = schema
1353 .execute("{ __schema { subscriptionType { fields { name } } } }")
1354 .await;
1355 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1356 let data = resp.data.into_json().unwrap();
1357 let fields = data["__schema"]["subscriptionType"]["fields"]
1358 .as_array()
1359 .unwrap();
1360 let names: Vec<&str> = fields.iter().filter_map(|f| f["name"].as_str()).collect();
1361 assert!(
1362 names.contains(&"nowPlaying"),
1363 "missing nowPlaying subscription"
1364 );
1365 assert!(
1366 names.contains(&"queueUpdated"),
1367 "missing queueUpdated subscription"
1368 );
1369 assert!(names.contains(&"vizFrame"), "missing vizFrame subscription");
1370 }
1371 #[tokio::test]
1374 async fn nested_fan_out_opens_a_bounded_number_of_connections() {
1375 let (schema, handle, _rx, tmp) = instrumented_schema();
1376 seed_library(&tmp.path().join("test.db"), 10, 3, 4);
1377
1378 let before = handle.open_count();
1379 let resp = schema
1380 .execute(
1381 "{ artists(first: 10) { edges { node { name albumCount trackCount \
1382 albums(first: 10) { edges { node { title trackCount totalDurationMs \
1383 tracks(first: 10) { edges { node { title isFavourite } } } } } } } } } }",
1384 )
1385 .await;
1386 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1387
1388 let data = resp.data.into_json().unwrap();
1389 let artists = data["artists"]["edges"].as_array().unwrap();
1390 assert_eq!(artists.len(), 10, "query did not actually fan out");
1391 assert_eq!(artists[0]["node"]["albumCount"], 3);
1392 assert_eq!(artists[0]["node"]["trackCount"], 12);
1393
1394 let opened = handle.open_count() - before;
1397 assert!(opened <= 16, "opened {} connections", opened);
1398 }
1399
1400 #[tokio::test]
1401 async fn is_favourite_batches_into_one_query() {
1402 let (schema, handle, _rx, tmp) = instrumented_schema();
1403 seed_library(&tmp.path().join("test.db"), 1, 1, 100);
1404
1405 let before = handle.batch_count();
1406 let resp = schema
1407 .execute("{ tracks(first: 100) { edges { node { title isFavourite } } } }")
1408 .await;
1409 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1410 let data = resp.data.into_json().unwrap();
1411 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 100);
1412
1413 let batches = handle.batch_count() - before;
1418 assert!(
1419 batches < 10,
1420 "{batches} batches for 100 tracks — expected a handful, not one per row"
1421 );
1422 }
1423
1424 #[tokio::test]
1425 async fn tracks_without_first_returns_the_default_page() {
1426 let (schema, _handle, _rx, tmp) = instrumented_schema();
1427 seed_library(
1428 &tmp.path().join("test.db"),
1429 1,
1430 1,
1431 helpers::DEFAULT_PAGE + 20,
1432 );
1433
1434 let resp = schema
1435 .execute("{ tracks { edges { node { title } } pageInfo { hasNextPage } } }")
1436 .await;
1437 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1438 let data = resp.data.into_json().unwrap();
1439 assert_eq!(
1440 data["tracks"]["edges"].as_array().unwrap().len(),
1441 helpers::DEFAULT_PAGE
1442 );
1443 assert_eq!(data["tracks"]["pageInfo"]["hasNextPage"], true);
1444 }
1445
1446 #[tokio::test]
1447 async fn first_is_clamped_to_the_maximum_page() {
1448 let (schema, _handle, _rx, tmp) = instrumented_schema();
1449 seed_library(&tmp.path().join("test.db"), 1, 1, 10);
1450
1451 let resp = schema
1452 .execute("{ tracks(first: 100000) { edges { node { title } } } }")
1453 .await;
1454 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1455 let data = resp.data.into_json().unwrap();
1456 assert_eq!(data["tracks"]["edges"].as_array().unwrap().len(), 10);
1457 }
1458
1459 #[tokio::test]
1460 async fn negative_first_is_empty_not_a_panic() {
1461 let (schema, _handle, _rx, tmp) = instrumented_schema();
1462 seed_library(&tmp.path().join("test.db"), 2, 1, 3);
1463
1464 for query in [
1465 "{ tracks(first: -1) { edges { node { title } } } }",
1466 "{ artists(first: -1) { edges { node { name } } } }",
1467 "{ albums(first: -1) { edges { node { title } } } }",
1468 ] {
1469 let resp = schema.execute(query).await;
1470 assert!(resp.errors.is_empty(), "{}: {:?}", query, resp.errors);
1471 }
1472 }
1473
1474 #[tokio::test]
1475 async fn sort_arguments_are_honoured() {
1476 let (schema, _handle, _rx, tmp) = instrumented_schema();
1477 seed_library(&tmp.path().join("test.db"), 3, 1, 2);
1478
1479 let resp = schema
1480 .execute(
1481 "{ tracks(first: 100, sortBy: TITLE, sortDir: DESC) { edges { node { title } } } }",
1482 )
1483 .await;
1484 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1485 let data = resp.data.into_json().unwrap();
1486 let titles: Vec<String> = data["tracks"]["edges"]
1487 .as_array()
1488 .unwrap()
1489 .iter()
1490 .map(|e| e["node"]["title"].as_str().unwrap().to_string())
1491 .collect();
1492 let mut sorted = titles.clone();
1493 sorted.sort();
1494 sorted.reverse();
1495 assert_eq!(titles, sorted, "sortBy/sortDir were ignored");
1496 }
1497
1498 #[test]
1502 fn a_slow_query_does_not_stall_other_tasks() {
1503 use std::sync::atomic::{AtomicUsize, Ordering};
1504
1505 let rt = tokio::runtime::Builder::new_current_thread()
1506 .enable_time()
1507 .build()
1508 .unwrap();
1509 let (schema, _handle, _rx, tmp) = instrumented_schema();
1510 seed_library(&tmp.path().join("test.db"), 20, 5, 8);
1511
1512 rt.block_on(async move {
1513 let ticks = Arc::new(AtomicUsize::new(0));
1514 let counter = ticks.clone();
1515 let ticker = tokio::spawn(async move {
1516 loop {
1517 tokio::time::sleep(Duration::from_millis(1)).await;
1518 counter.fetch_add(1, Ordering::Relaxed);
1519 }
1520 });
1521
1522 let mut running = Vec::new();
1524 for _ in 0..4 {
1525 let schema = schema.clone();
1526 running.push(tokio::spawn(async move {
1527 schema
1528 .execute("{ fuzzySearch(query: \"track\") { id } }")
1529 .await
1530 }));
1531 }
1532 for handle in running {
1533 let resp = handle.await.unwrap();
1534 assert!(resp.errors.is_empty(), "errors: {:?}", resp.errors);
1535 }
1536
1537 ticker.abort();
1538 assert!(
1539 ticks.load(Ordering::Relaxed) >= 2,
1540 "no other task ran while the queries were in flight"
1541 );
1542 });
1543 }
1544
1545 #[tokio::test]
1546 async fn accounts_are_made_and_invited_through_graphql() {
1547 let (schema, _rx, _tmp) = test_schema();
1548 let run = |q: &str| schema.execute(async_graphql::Request::new(q));
1549 let r = run(r#"mutation { createUser(username: "sarita", role: READONLY) { username link password emailText } }"#).await;
1550 assert!(
1551 r.errors[0].message.contains("server.url"),
1552 "no address to invite to: {:?}",
1553 r.errors
1554 );
1555 let r = run(r#"mutation { createUser(username: "sarita", role: READONLY, server: "https://music.example.com") { username link password emailText } }"#).await;
1556 assert!(r.errors.is_empty(), "{:?}", r.errors);
1557 let v = r.data.into_json().unwrap();
1558 let link = v["createUser"]["link"].as_str().unwrap();
1559 let invite = koan_core::invite::Invite::parse(link).unwrap();
1560 assert_eq!(invite.server, "https://music.example.com");
1561 assert!(invite.token.is_some() && invite.password.is_none());
1562 let password = v["createUser"]["password"].as_str().unwrap();
1563 assert!(
1564 v["createUser"]["emailText"]
1565 .as_str()
1566 .unwrap()
1567 .contains(password)
1568 );
1569
1570 let r = run(r#"mutation { inviteUser(username: "sarita", server: "https://other.example.com") { server password } }"#).await;
1571 let v = r.data.into_json().unwrap();
1572 assert_eq!(v["inviteUser"]["server"], "https://other.example.com");
1573 assert!(v["inviteUser"]["password"].is_null());
1574
1575 let r = run(
1576 r#"mutation { setUserPassword(username: "sarita", password: "correct horse") { ok } }"#,
1577 )
1578 .await;
1579 assert!(r.errors.is_empty(), "{:?}", r.errors);
1580
1581 let r = run(r#"mutation { setUserRole(username: "sarita", role: USER) { ok } }"#).await;
1582 assert!(r.errors.is_empty(), "{:?}", r.errors);
1583 let r = run("{ users { username role } }").await;
1584 let v = r.data.into_json().unwrap();
1585 assert_eq!(v["users"][0]["role"], "USER");
1586
1587 let r = run(r#"mutation { deleteUser(username: "sarita") { ok } }"#).await;
1588 assert!(r.errors.is_empty(), "{:?}", r.errors);
1589 let r = run("{ users { username } }").await;
1590 assert_eq!(
1591 r.data.into_json().unwrap()["users"]
1592 .as_array()
1593 .unwrap()
1594 .len(),
1595 0
1596 );
1597 }
1598}