@freqhole/api-client 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/auth-test.html +651 -0
  2. package/package.json +42 -0
  3. package/src/AdminClient.ts +154 -0
  4. package/src/CharnelLocalTransport.ts +598 -0
  5. package/src/CharnelTransport.ts +616 -0
  6. package/src/FreqholeClient.ts +261 -0
  7. package/src/WasmTransport.ts +1077 -0
  8. package/src/automerge/IrohNetworkAdapter.ts +727 -0
  9. package/src/automerge/index.ts +14 -0
  10. package/src/codegen/admin_commands.ts +61 -0
  11. package/src/codegen/routes.ts +213 -0
  12. package/src/codegen/schema.ts +6721 -0
  13. package/src/domains/admin.ts +97 -0
  14. package/src/domains/app.ts +98 -0
  15. package/src/domains/auth.ts +191 -0
  16. package/src/domains/favorites.types.ts +52 -0
  17. package/src/domains/jobs.ts +48 -0
  18. package/src/domains/music.ts +1935 -0
  19. package/src/domains/types.ts +19 -0
  20. package/src/domains/upload.ts +214 -0
  21. package/src/index.ts +48 -0
  22. package/src/permissions.ts +468 -0
  23. package/src/playlistz/convert.ts +267 -0
  24. package/src/playlistz/index.ts +6 -0
  25. package/src/playlistz/mutations.ts +181 -0
  26. package/src/playlistz/protocol.ts +222 -0
  27. package/src/playlistz/schema.ts +138 -0
  28. package/src/playlistz/shareLink.ts +76 -0
  29. package/src/storage/blobs.ts +272 -0
  30. package/src/storage/identity.ts +190 -0
  31. package/src/storage/index.ts +5 -0
  32. package/src/storage/webLocks.ts +92 -0
  33. package/src/test/automerge.ts +796 -0
  34. package/src/test/coverage.ts +105 -0
  35. package/src/test/fixtures.ts +460 -0
  36. package/src/test/integration.ts +320 -0
  37. package/src/test/playlistz.ts +631 -0
  38. package/src/test/stateful.ts +561 -0
  39. package/src/test/storage.ts +443 -0
  40. package/src/test.ts +75 -0
  41. package/src/transport.ts +391 -0
  42. package/src/utils.ts +79 -0
  43. package/src/webauthn.ts +228 -0
  44. package/tsconfig.json +22 -0
  45. package/vite.config.js +9 -0
@@ -0,0 +1,561 @@
1
+ // stateful integration tests - end-to-end workflow validation
2
+ //
3
+ // purpose: test complete workflows with real data on a live server
4
+ //
5
+ // what this tests:
6
+ // - entity creation (playlists, artists)
7
+ // - entity modification (update playlist, add songs)
8
+ // - entity queries with real IDs
9
+ // - favorites and ratings workflows
10
+ // - analytics endpoints with real play data
11
+ // - proper cleanup of created entities
12
+ //
13
+ // what this does NOT test:
14
+ // - auth rejection (see integration.ts)
15
+ // - routes without auth (see integration.ts)
16
+ //
17
+ // requires:
18
+ // - running server with data (songs, albums, genres must exist)
19
+ // - valid API_KEY environment variable
20
+ // - fail-fast approach: if setup fails, tests don't proceed
21
+ import { createHttpClient } from "../FreqholeClient.js";
22
+ import { queryParams } from "./fixtures.js";
23
+
24
+ const baseUrl = process.env.API_URL || "http://localhost:8080";
25
+ const apiKey = process.env.API_KEY;
26
+
27
+ // shared state for created entities
28
+ const testState = {
29
+ createdPlaylistId: null as string | null,
30
+ createdArtistId: null as string | null,
31
+ createdTagId: null as string | null,
32
+ createdJobId: null as string | null,
33
+ existingSongId: null as string | null,
34
+ existingAlbumId: null as string | null,
35
+ existingGenreId: null as string | null,
36
+ existingMediaBlobId: null as string | null,
37
+ userId: null as string | null,
38
+ };
39
+
40
+ export async function runStatefulTests() {
41
+ if (!apiKey) {
42
+ console.log("⚠ skipping stateful tests (no API_KEY env var)\n");
43
+ return { passed: 0, failed: 0 };
44
+ }
45
+
46
+ // create authenticated client
47
+ const client = createHttpClient(baseUrl, apiKey);
48
+
49
+ let passed = 0;
50
+ let failed = 0;
51
+
52
+ async function test(name: string, fn: () => void | Promise<void>) {
53
+ try {
54
+ await fn();
55
+ console.log(`✓ ${name}`);
56
+ passed++;
57
+ } catch (err) {
58
+ console.log(
59
+ `✗ ${name}: ${err instanceof Error ? err.message : "unknown error"}`,
60
+ );
61
+ failed++;
62
+ }
63
+ }
64
+
65
+ console.log("running stateful integration tests...\n");
66
+
67
+ // setup - discover existing entities (fail if not found)
68
+ console.log("setup: discovering existing entities...\n");
69
+
70
+ await test("get current user", async () => {
71
+ const result = await client.auth.whoami();
72
+ if (!result.success) {
73
+ throw new Error(`failed to get current user: ${result.error.message}`);
74
+ }
75
+ testState.userId = result.data.user_id;
76
+ if (!testState.userId) {
77
+ throw new Error("user_id not returned from whoami");
78
+ }
79
+ });
80
+
81
+ await test("discover existing song", async () => {
82
+ const result = await client.music.querySongs(queryParams);
83
+ if (!result.success) {
84
+ throw new Error(`failed to query songs: ${result.error.message}`);
85
+ }
86
+ if (result.data.items.length === 0) {
87
+ throw new Error("no songs found in database - cannot run tests");
88
+ }
89
+ testState.existingSongId = result.data.items[0].song.id;
90
+ testState.existingMediaBlobId = result.data.items[0].song.media_blob_id;
91
+ });
92
+
93
+ await test("discover existing album", async () => {
94
+ const result = await client.music.queryAlbums(queryParams);
95
+ if (!result.success) {
96
+ throw new Error(`failed to query albums: ${result.error.message}`);
97
+ }
98
+ if (result.data.items.length === 0) {
99
+ throw new Error("no albums found in database - cannot run tests");
100
+ }
101
+ testState.existingAlbumId = result.data.items[0].album.id;
102
+ });
103
+
104
+ await test("discover existing genre", async () => {
105
+ const result = await client.music.queryTaxons({
106
+ kind_slug: "genre",
107
+ limit: 1,
108
+ offset: 0,
109
+ });
110
+ if (!result.success) {
111
+ throw new Error(`failed to query taxons: ${result.error.message}`);
112
+ }
113
+ if (result.data.items.length === 0) {
114
+ throw new Error("no genre taxons found in database - cannot run tests");
115
+ }
116
+ testState.existingGenreId = result.data.items[0].id;
117
+ });
118
+
119
+ console.log("");
120
+
121
+ // abort if setup failed
122
+ if (
123
+ !testState.existingSongId ||
124
+ !testState.existingAlbumId ||
125
+ !testState.existingGenreId
126
+ ) {
127
+ console.log("✗ setup failed - aborting stateful tests\n");
128
+ return { passed, failed };
129
+ }
130
+
131
+ // create entities
132
+ console.log("creating test entities...\n");
133
+
134
+ await test("create playlist", async () => {
135
+ const result = await client.music.createPlaylist({
136
+ id: null,
137
+ title: `Test Playlist ${Date.now()}`,
138
+ description: "Created by integration tests",
139
+ is_public: false,
140
+ created_by_id: null,
141
+ });
142
+ if (!result.success) {
143
+ throw new Error(`failed to create playlist: ${result.error.message}`);
144
+ }
145
+ testState.createdPlaylistId = result.data.id;
146
+ if (!testState.createdPlaylistId) {
147
+ throw new Error("playlist id not returned");
148
+ }
149
+ });
150
+
151
+ await test("create artist", async () => {
152
+ const result = await client.music.createArtist({
153
+ name: `Test Artist ${Date.now()}`,
154
+ created_by: null,
155
+ });
156
+ if (!result.success) {
157
+ throw new Error(`failed to create artist: ${result.error.message}`);
158
+ }
159
+ testState.createdArtistId = result.data.id;
160
+ if (!testState.createdArtistId) {
161
+ throw new Error("artist id not returned");
162
+ }
163
+ });
164
+
165
+ console.log("");
166
+
167
+ // use created entities
168
+ console.log("testing with created entities...\n");
169
+
170
+ await test("get created playlist", async () => {
171
+ const result = await client.music.getPlaylistById({
172
+ id: testState.createdPlaylistId!,
173
+ });
174
+ if (!result.success) {
175
+ throw new Error(`failed to get playlist: ${result.error.message}`);
176
+ }
177
+ if (result.data.id !== testState.createdPlaylistId) {
178
+ throw new Error("playlist id mismatch");
179
+ }
180
+ });
181
+
182
+ await test("update created playlist", async () => {
183
+ const result = await client.music.updatePlaylist({
184
+ playlist_id: testState.createdPlaylistId!,
185
+ title: "Updated Test Playlist",
186
+ description: "Updated by integration tests",
187
+ is_public: null,
188
+ entity_urls: null,
189
+ updated_by: null,
190
+ });
191
+ if (!result.success) {
192
+ throw new Error(`failed to update playlist: ${result.error.message}`);
193
+ }
194
+ if (result.data.title !== "Updated Test Playlist") {
195
+ throw new Error("playlist title not updated");
196
+ }
197
+ });
198
+
199
+ await test("add song to playlist", async () => {
200
+ const result = await client.music.addSongsToPlaylist({
201
+ playlist_id: testState.createdPlaylistId!,
202
+ song_ids: [testState.existingSongId!],
203
+ });
204
+ if (!result.success) {
205
+ throw new Error(`failed to add songs: ${result.error.message}`);
206
+ }
207
+ });
208
+
209
+ await test("query playlist songs", async () => {
210
+ const result = await client.music.queryPlaylistSongs({
211
+ playlist_id: testState.createdPlaylistId!,
212
+ q: null,
213
+ sort_by: null,
214
+ sort_direction: null,
215
+ limit: 10,
216
+ offset: 0,
217
+ });
218
+ if (!result.success) {
219
+ throw new Error(
220
+ `failed to query playlist songs: ${result.error.message}`,
221
+ );
222
+ }
223
+ if (!result.data.items || result.data.items.length === 0) {
224
+ throw new Error("expected at least one song in playlist");
225
+ }
226
+ });
227
+
228
+ await test("remove song from playlist", async () => {
229
+ const result = await client.music.removeSongsFromPlaylist({
230
+ playlist_id: testState.createdPlaylistId!,
231
+ song_ids: [testState.existingSongId!],
232
+ });
233
+ if (!result.success) {
234
+ throw new Error(`failed to remove songs: ${result.error.message}`);
235
+ }
236
+ });
237
+
238
+ await test("get created artist", async () => {
239
+ const result = await client.music.getArtist({
240
+ id: testState.createdArtistId!,
241
+ });
242
+ if (!result.success) {
243
+ throw new Error(`failed to get artist: ${result.error.message}`);
244
+ }
245
+ if (result.data.id !== testState.createdArtistId) {
246
+ throw new Error("artist id mismatch");
247
+ }
248
+ });
249
+
250
+ await test("set favorite on song", async () => {
251
+ const result = await client.music.setFavorite({
252
+ user_id: testState.userId!,
253
+ target_type: "song",
254
+ target_id: testState.existingSongId!,
255
+ is_favorite: true,
256
+ });
257
+ if (!result.success) {
258
+ throw new Error(`failed to set favorite: ${result.error.message}`);
259
+ }
260
+ });
261
+
262
+ await test("list favorites", async () => {
263
+ const result = await client.music.listFavorites({
264
+ user_id: testState.userId!,
265
+ target_type: "song",
266
+ limit: 10,
267
+ offset: 0,
268
+ });
269
+ if (!result.success) {
270
+ throw new Error(`failed to list favorites: ${result.error.message}`);
271
+ }
272
+ });
273
+
274
+ await test("set rating on song", async () => {
275
+ const result = await client.music.setRating({
276
+ user_id: testState.userId!,
277
+ target_type: "song",
278
+ target_id: testState.existingSongId!,
279
+ rating: 5,
280
+ });
281
+ if (!result.success) {
282
+ throw new Error(`failed to set rating: ${result.error.message}`);
283
+ }
284
+ });
285
+
286
+ await test("get rating stats", async () => {
287
+ const result = await client.music.getRatingStats({
288
+ target_type: "song",
289
+ target_id: testState.existingSongId!,
290
+ });
291
+ if (!result.success) {
292
+ throw new Error(`failed to get rating stats: ${result.error.message}`);
293
+ }
294
+ });
295
+
296
+ await test("record play event", async () => {
297
+ const result = await client.music.recordPlay({
298
+ media_blob_id: testState.existingMediaBlobId || "unknown-blob-id",
299
+ song_id: testState.existingSongId!,
300
+ session_id: null,
301
+ event_data: null,
302
+ });
303
+ if (!result.success) {
304
+ throw new Error(`failed to record play: ${result.error.message}`);
305
+ }
306
+ });
307
+
308
+ await test("get song analytics", async () => {
309
+ const result = await client.music.songAnalytics({
310
+ song_id: testState.existingSongId!,
311
+ });
312
+ if (!result.success) {
313
+ throw new Error(`failed to get analytics: ${result.error.message}`);
314
+ }
315
+ });
316
+
317
+ await test("get listening history", async () => {
318
+ const result = await client.music.listeningHistory({
319
+ user_id: null,
320
+ limit: 10,
321
+ offset: 0,
322
+ });
323
+ if (!result.success) {
324
+ throw new Error(`failed to get history: ${result.error.message}`);
325
+ }
326
+ });
327
+
328
+ await test("get top songs", async () => {
329
+ const result = await client.music.topSongs({ limit: 10, days: 7 });
330
+ if (!result.success) {
331
+ throw new Error(`failed to get top songs: ${result.error.message}`);
332
+ }
333
+ });
334
+
335
+ await test("list jobs", async () => {
336
+ const result = await client.music.listJobs({
337
+ session_id: null,
338
+ status: null,
339
+ limit: 10,
340
+ offset: 0,
341
+ });
342
+ if (!result.success) {
343
+ throw new Error(`failed to list jobs: ${result.error.message}`);
344
+ }
345
+ });
346
+
347
+ // search tests
348
+ console.log("\nsearch functionality...\n");
349
+
350
+ await test("suggestions - all fields", async () => {
351
+ const result = await client.music.suggestions({
352
+ field: "all",
353
+ partial: "test",
354
+ page: 1,
355
+ page_size: 10,
356
+ context: null,
357
+ });
358
+ if (!result.success) {
359
+ throw new Error(`failed to get suggestions: ${result.error.message}`);
360
+ }
361
+ if (!result.data.suggestions) {
362
+ throw new Error("suggestions array missing");
363
+ }
364
+ if (typeof result.data.query_time_ms !== "number") {
365
+ throw new Error("query_time_ms not returned");
366
+ }
367
+ });
368
+
369
+ await test("suggestions - songs only", async () => {
370
+ const result = await client.music.suggestions({
371
+ field: "songs",
372
+ partial: "a",
373
+ page: 1,
374
+ page_size: 5,
375
+ context: null,
376
+ });
377
+ if (!result.success) {
378
+ throw new Error(
379
+ `failed to get song suggestions: ${result.error.message}`,
380
+ );
381
+ }
382
+ // verify all suggestions are songs
383
+ for (const suggestion of result.data.suggestions) {
384
+ if (suggestion.suggestion_type !== "song") {
385
+ throw new Error(
386
+ `expected song suggestions, got ${suggestion.suggestion_type}`,
387
+ );
388
+ }
389
+ }
390
+ });
391
+
392
+ await test("search - all fields", async () => {
393
+ const result = await client.music.search({
394
+ query: "test",
395
+ field: null,
396
+ page: 1,
397
+ page_size: 10,
398
+ context: null,
399
+ });
400
+ if (!result.success) {
401
+ throw new Error(`failed to search: ${result.error.message}`);
402
+ }
403
+ if (!result.data.songs) {
404
+ throw new Error("songs array missing");
405
+ }
406
+ if (typeof result.data.total_count !== "number") {
407
+ throw new Error("total_count not returned");
408
+ }
409
+ if (typeof result.data.query_time_ms !== "number") {
410
+ throw new Error("query_time_ms not returned");
411
+ }
412
+ });
413
+
414
+ await test("search - songs only", async () => {
415
+ const result = await client.music.search({
416
+ query: "a",
417
+ field: "songs",
418
+ page: 1,
419
+ page_size: 20,
420
+ context: null,
421
+ });
422
+ if (!result.success) {
423
+ throw new Error(`failed to search songs: ${result.error.message}`);
424
+ }
425
+ if (result.data.artists !== null) {
426
+ throw new Error("artists should be null when field is songs");
427
+ }
428
+ if (result.data.albums !== null) {
429
+ throw new Error("albums should be null when field is songs");
430
+ }
431
+ });
432
+
433
+ await test("search - with tag include filter", async () => {
434
+ const result = await client.music.search({
435
+ query: "a",
436
+ field: "songs",
437
+ page: 1,
438
+ page_size: 50,
439
+ context: {
440
+ tags: {
441
+ include: ["tag001"],
442
+ exclude: [],
443
+ },
444
+ sort_field: null,
445
+ sort_direction: null,
446
+ search_query: null,
447
+ },
448
+ });
449
+ if (!result.success) {
450
+ throw new Error(
451
+ `failed to search with tag filter: ${result.error.message}`,
452
+ );
453
+ }
454
+ // verify context was applied (may or may not return results depending on data)
455
+ if (!result.data.applied_filters) {
456
+ throw new Error("applied_filters not returned");
457
+ }
458
+ if (typeof result.data.total_count !== "number") {
459
+ throw new Error("total_count not returned");
460
+ }
461
+ });
462
+
463
+ await test("search - with tag exclude filter", async () => {
464
+ const result = await client.music.search({
465
+ query: "a",
466
+ field: "songs",
467
+ page: 1,
468
+ page_size: 50,
469
+ context: {
470
+ tags: {
471
+ include: [],
472
+ exclude: ["tag001"],
473
+ },
474
+ sort_field: null,
475
+ sort_direction: null,
476
+ search_query: null,
477
+ },
478
+ });
479
+ if (!result.success) {
480
+ throw new Error(
481
+ `failed to search with exclude filter: ${result.error.message}`,
482
+ );
483
+ }
484
+ // verify exclude filter was applied
485
+ if (typeof result.data.total_count !== "number") {
486
+ throw new Error("total_count not returned");
487
+ }
488
+ if (!result.data.applied_filters) {
489
+ throw new Error("applied_filters not returned");
490
+ }
491
+ });
492
+
493
+ await test("search - tag filtering across all entity types", async () => {
494
+ const result = await client.music.search({
495
+ query: "death",
496
+ field: "all",
497
+ page: 1,
498
+ page_size: 20,
499
+ context: {
500
+ tags: {
501
+ include: ["tag002"],
502
+ exclude: [],
503
+ },
504
+ sort_field: null,
505
+ sort_direction: null,
506
+ search_query: null,
507
+ },
508
+ });
509
+ if (!result.success) {
510
+ throw new Error(
511
+ `failed to search all types with tags: ${result.error.message}`,
512
+ );
513
+ }
514
+ // verify response structure (results depend on data with tag002)
515
+ if (!result.data.songs) {
516
+ throw new Error("songs array missing");
517
+ }
518
+ if (typeof result.data.query_time_ms !== "number") {
519
+ throw new Error("query_time_ms not returned");
520
+ }
521
+ if (!result.data.applied_filters) {
522
+ throw new Error("applied_filters not returned");
523
+ }
524
+ });
525
+
526
+ console.log("");
527
+
528
+ // cleanup
529
+ console.log("cleaning up created entities...\n");
530
+
531
+ if (testState.createdPlaylistId) {
532
+ await test("delete created playlist", async () => {
533
+ const result = await client.music.deletePlaylist({
534
+ playlist_id: testState.createdPlaylistId!,
535
+ });
536
+ if (!result.success) {
537
+ throw new Error(`failed to delete playlist: ${result.error.message}`);
538
+ }
539
+ });
540
+ } else {
541
+ console.log("⚠ skipping playlist cleanup (not created)");
542
+ }
543
+
544
+ if (testState.createdArtistId) {
545
+ await test("delete created artist", async () => {
546
+ const result = await client.music.deleteArtist({
547
+ id: testState.createdArtistId!,
548
+ user_id: testState.userId!,
549
+ });
550
+ if (!result.success) {
551
+ throw new Error(`failed to delete artist: ${result.error.message}`);
552
+ }
553
+ });
554
+ } else {
555
+ console.log("⚠ skipping artist cleanup (not created)");
556
+ }
557
+
558
+ console.log(`\n${passed} passed, ${failed} failed\n`);
559
+
560
+ return { passed, failed };
561
+ }