@freqhole/api-client 0.2.14 → 0.3.1

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.
package/src/errors.ts ADDED
@@ -0,0 +1,142 @@
1
+ // shared "turn a raw failed-request body into a typed, human-readable
2
+ // error" logic, used by every transport/domain method that has to parse a
3
+ // non-2xx response (FreqholeClient's json route caller, domains/upload.ts's
4
+ // multipart uploads, etc). this is the ONE place that understands both
5
+ // server-side failure shapes:
6
+ //
7
+ // - the axum `ApiError` shape: `{ error, code, error_type? }` (see
8
+ // server/src/error.rs) - used for request-level failures (bad request,
9
+ // unauthorized, or a `GrimoireError` surfaced via `?`).
10
+ // - the `GrimoireResponse` failure shape: `{ success: false, message,
11
+ // errors: ErrorDetail[] }` (see grimoire/src/response.rs) - used by
12
+ // offal-dispatched routes that fail after starting a "success" response.
13
+ //
14
+ // both carry the same underlying idea: a stable snake_case `error_type`
15
+ // code plus a human `detail`/`message`. everything downstream (isAuthError,
16
+ // job-error checks, UI error banners) should key off `error_type`, never
17
+ // off substring-matching a message - see docs/error-handling.md.
18
+
19
+ import { z } from "zod";
20
+
21
+ /** sentinel path segment marking a 401/session-expired failure. */
22
+ export const AUTH_ERROR_PATH = "__auth_expired__";
23
+
24
+ export interface ParsedApiError {
25
+ /** best available human-readable message - never empty. */
26
+ message: string;
27
+ /** stable error_type code, when the server provided one. */
28
+ errorType?: string;
29
+ }
30
+
31
+ // curated overrides for error_type codes whose server-provided detail
32
+ // reads badly to an end user (too technical, or missing entirely for
33
+ // codes that only ever carry a generic `detail`). this is NOT meant to
34
+ // cover every error_type - anything absent here just falls back to the
35
+ // server's own `detail`/`message`, which is normally fine on its own.
36
+ // exported so callers with extra context (e.g. spume's humanizeJobError,
37
+ // which also handles un-tagged legacy message strings) can extend this
38
+ // same table instead of maintaining a separate, drifting copy.
39
+ export const ERROR_TYPE_MESSAGES: Record<string, string> = {
40
+ unauthorized: "your session has expired - please sign in again",
41
+ forbidden: "you don't have permission to do that",
42
+ route_not_found: "that server doesn't support this feature (server may need an update)",
43
+ duplicate_song: "this song already exists in the library",
44
+ duplicate_video: "this video already exists in the library",
45
+ duplicate_playlist_item: "this item is already in the playlist",
46
+ video_previously_deleted:
47
+ "this video was already deleted from the library - an admin needs to run a maintenance cleanup before it can be added back",
48
+ fetch_not_configured: "downloading from urls isn't set up on this server",
49
+ fetch_forbidden: "source blocked the request (403) - it may require login/cookies",
50
+ fetch_not_found: "source returned 404 - the video may have been deleted, or the url is wrong",
51
+ fetch_video_unavailable: "video is unavailable",
52
+ fetch_private_video: "video is private",
53
+ fetch_video_removed: "video is no longer available",
54
+ fetch_content_unavailable: "content isn't available (may be region-locked)",
55
+ fetch_login_required:
56
+ "site requires sign-in verification - can't fetch without cookies configured",
57
+ message_too_large:
58
+ "file is too large to send over this P2P connection - large file transfers need a blob-pull upload path, not a direct embedded message",
59
+ upload_too_large_for_transport:
60
+ "file is too large to upload over this connection - large video uploads aren't fully supported over P2P yet",
61
+ };
62
+
63
+ function bodyFallbackMessage(status: number): string {
64
+ return status === 0 ? "connection error" : `request failed (HTTP ${status})`;
65
+ }
66
+
67
+ /** parse a raw HTTP/IPC response body into a best-effort {@link ParsedApiError}. */
68
+ export function parseErrorResponseBody(body: string, status: number): ParsedApiError {
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(body);
72
+ } catch {
73
+ return { message: bodyFallbackMessage(status) };
74
+ }
75
+ if (typeof parsed !== "object" || parsed === null) {
76
+ return { message: bodyFallbackMessage(status) };
77
+ }
78
+ const obj = parsed as Record<string, unknown>;
79
+
80
+ // GrimoireResponse failure shape: {success:false, message, errors:[{error_type,title,detail}]}
81
+ if (obj.success === false) {
82
+ const firstError =
83
+ Array.isArray(obj.errors) && obj.errors.length > 0
84
+ ? (obj.errors[0] as Record<string, unknown>)
85
+ : undefined;
86
+ const errorType =
87
+ typeof firstError?.error_type === "string" ? firstError.error_type : undefined;
88
+ const message =
89
+ (typeof firstError?.detail === "string" && firstError.detail) ||
90
+ (typeof obj.message === "string" && obj.message) ||
91
+ bodyFallbackMessage(status);
92
+ return { message, errorType };
93
+ }
94
+
95
+ // axum ApiError shape: {error, code, error_type?}
96
+ if (
97
+ typeof obj.error === "string" ||
98
+ typeof obj.code === "string" ||
99
+ typeof obj.error_type === "string"
100
+ ) {
101
+ const errorType =
102
+ (typeof obj.error_type === "string" && obj.error_type) ||
103
+ (typeof obj.code === "string" && obj.code) ||
104
+ undefined;
105
+ const message =
106
+ (typeof obj.error === "string" && obj.error) ||
107
+ (typeof obj.message === "string" && obj.message) ||
108
+ bodyFallbackMessage(status);
109
+ return { message, errorType };
110
+ }
111
+
112
+ return { message: bodyFallbackMessage(status) };
113
+ }
114
+
115
+ /** apply the friendly-message registry on top of a {@link ParsedApiError}. */
116
+ export function friendlyMessage(parsed: ParsedApiError): string {
117
+ if (parsed.errorType && ERROR_TYPE_MESSAGES[parsed.errorType]) {
118
+ return ERROR_TYPE_MESSAGES[parsed.errorType];
119
+ }
120
+ return parsed.message;
121
+ }
122
+
123
+ /**
124
+ * build the zod issue used by every transport's error branch: `path`
125
+ * carries the error_type (and the auth sentinel on 401) so existing
126
+ * `isAuthError`/`errors?.some(e => path.includes(...))`-style checks keep
127
+ * working, `message` is the friendliest text available.
128
+ */
129
+ export function buildErrorIssue(
130
+ parsed: ParsedApiError,
131
+ status: number,
132
+ ): { code: "custom"; path: (string | number)[]; message: string } {
133
+ const path: (string | number)[] = [];
134
+ if (status === 401 || parsed.errorType === "unauthorized") path.push(AUTH_ERROR_PATH);
135
+ if (parsed.errorType) path.push(parsed.errorType);
136
+ return { code: "custom", path, message: friendlyMessage(parsed) };
137
+ }
138
+
139
+ /** parse + wrap a failed response body straight into a `z.ZodError`. */
140
+ export function toZodError(body: string, status: number): z.ZodError {
141
+ return new z.ZodError([buildErrorIssue(parseErrorResponseBody(body, status), status)]);
142
+ }
package/src/index.ts CHANGED
@@ -1,17 +1,58 @@
1
1
  // freqhole api client - main exports
2
2
 
3
3
  // instance-based client
4
- export { FreqholeClient, createClient, createHttpClient, isAuthError, isNetworkError } from "./FreqholeClient.js";
4
+ export {
5
+ FreqholeClient,
6
+ createClient,
7
+ createHttpClient,
8
+ isAuthError,
9
+ isNetworkError,
10
+ } from "./FreqholeClient.js";
5
11
  export type { SafeParseResult } from "./FreqholeClient.js";
6
12
 
7
13
  // transport abstraction
8
- export { HttpTransport, pollingJobEvents, snapshotJobEventsViaRequest, POLL_INTERVAL_FALLBACK_MS } from "./transport.js";
14
+ export {
15
+ HttpTransport,
16
+ pollingJobEvents,
17
+ snapshotJobEventsViaRequest,
18
+ POLL_INTERVAL_FALLBACK_MS,
19
+ } from "./transport.js";
9
20
  export { WasmTransport } from "./WasmTransport.js";
10
- export { CharnelTransport, createCharnelTransport, getCharnelTransport, getCharnelNodeId, isCharnelAvailable, isCharnelP2PAvailable } from "./CharnelTransport.js";
11
- export { CharnelLocalTransport, createCharnelLocalTransport, JobEventsStreamClosed } from "./CharnelLocalTransport.js";
12
- export type { MiddenNodeLike, BlobResultLike, BlobProgressCallback, RadioHandleLike } from "./WasmTransport.js";
21
+ export { isTauriRuntime } from "./tauriRuntime.js";
22
+ export {
23
+ CharnelTransport,
24
+ createCharnelTransport,
25
+ getCharnelTransport,
26
+ getCharnelNodeId,
27
+ isCharnelAvailable,
28
+ isCharnelP2PAvailable,
29
+ } from "./CharnelTransport.js";
30
+ export {
31
+ CharnelLocalTransport,
32
+ createCharnelLocalTransport,
33
+ JobEventsStreamClosed,
34
+ } from "./CharnelLocalTransport.js";
35
+ export type {
36
+ MiddenNodeLike,
37
+ BlobResultLike,
38
+ BlobProgressCallback,
39
+ RadioHandleLike,
40
+ BiStreamLike,
41
+ } from "./WasmTransport.js";
13
42
  export type { Transport, TransportResponse, BlobData } from "./transport.js";
14
43
 
44
+ // shared error parsing/human-message conventions - the canonical place to
45
+ // turn a server error_type into user-facing text (see docs/error-handling.md)
46
+ export {
47
+ AUTH_ERROR_PATH,
48
+ ERROR_TYPE_MESSAGES,
49
+ parseErrorResponseBody,
50
+ friendlyMessage,
51
+ buildErrorIssue,
52
+ toZodError,
53
+ } from "./errors.js";
54
+ export type { ParsedApiError } from "./errors.js";
55
+
15
56
  // export utilities (url helpers, uploads, etc)
16
57
  export * as utils from "./utils.js";
17
58
 
@@ -39,10 +80,6 @@ export {
39
80
  AdminRequestValidationError,
40
81
  AdminResponseValidationError,
41
82
  } from "./AdminClient.js";
42
- export type {
43
- AdminTransport,
44
- AdminResponse,
45
- AdminErrorDetail,
46
- } from "./AdminClient.js";
83
+ export type { AdminTransport, AdminResponse, AdminErrorDetail } from "./AdminClient.js";
47
84
  export { adminCommands } from "./codegen/admin_commands.js";
48
85
  export type { AdminCommandName, AdminAuth, AdminAuthType } from "./codegen/admin_commands.js";
@@ -7,12 +7,7 @@
7
7
  // the entity-specific route lookups below are tomb-specific.
8
8
 
9
9
  import { bindRoleHierarchy, type AccessRule } from "@freqhole/haruspex/permissions";
10
- import {
11
- roleHierarchy,
12
- routes,
13
- type RouteAuth,
14
- type UserRoleName,
15
- } from "./codegen/routes.js";
10
+ import { roleHierarchy, routes, type RouteAuth, type UserRoleName } from "./codegen/routes.js";
16
11
 
17
12
  const roleChecks = bindRoleHierarchy(roleHierarchy);
18
13
 
@@ -28,10 +23,7 @@ const roleChecks = bindRoleHierarchy(roleHierarchy);
28
23
  * @param requiredRole - the minimum required role
29
24
  * @returns true if user has sufficient privileges
30
25
  */
31
- export function canAccessRole(
32
- userRole: UserRoleName,
33
- requiredRole: UserRoleName,
34
- ): boolean {
26
+ export function canAccessRole(userRole: UserRoleName, requiredRole: UserRoleName): boolean {
35
27
  return roleChecks.hasAtLeastRole(userRole, requiredRole);
36
28
  }
37
29
 
@@ -62,10 +54,7 @@ export function canAccessOwnerOr(
62
54
  * @param ownerId - the resource owner's id
63
55
  * @returns true if user is the owner
64
56
  */
65
- export function canAccessOwner(
66
- userId: string,
67
- ownerId: string | null,
68
- ): boolean {
57
+ export function canAccessOwner(userId: string, ownerId: string | null): boolean {
69
58
  return roleChecks.isOwner(userId, ownerId);
70
59
  }
71
60
 
@@ -92,7 +81,6 @@ export function canAccessRoute(
92
81
  });
93
82
  }
94
83
 
95
-
96
84
  // ============================================================================
97
85
  // entity-specific permission helpers
98
86
  // ============================================================================
@@ -163,16 +151,16 @@ export function canRemoveSongsFromPlaylist(
163
151
  }
164
152
 
165
153
  /**
166
- * check if user can delete a listen session.
167
- * listen sessions use OwnerOr(Admin) - owner or admin can delete.
154
+ * check if user can delete a playback session.
155
+ * playback sessions use OwnerOr(Admin) - owner or admin can delete.
168
156
  */
169
- export function canDeleteListenSession(
157
+ export function canDeletePlaybackSession(
170
158
  userId: string,
171
159
  sessionOwnerId: string | null,
172
160
  userRole: UserRoleName,
173
161
  ): boolean {
174
- // listen sessions use OwnerOr(Admin) - owner or admin can delete
175
- const auth = routes.music.delete_listen_session.auth;
162
+ // playback sessions use OwnerOr(Admin) - owner or admin can delete
163
+ const auth = routes.music.delete_playback_session.auth;
176
164
  if (auth.type === "owner_or") {
177
165
  return canAccessOwnerOr(userId, sessionOwnerId, userRole, auth.role);
178
166
  }
@@ -180,13 +168,10 @@ export function canDeleteListenSession(
180
168
  }
181
169
 
182
170
  /**
183
- * check if user can update a listen session.
184
- * listen sessions use Owner - strictly owner only.
171
+ * check if user can update a playback session.
172
+ * playback sessions use Owner - strictly owner only.
185
173
  */
186
- export function canUpdateListenSession(
187
- userId: string,
188
- sessionOwnerId: string | null,
189
- ): boolean {
174
+ export function canUpdatePlaybackSession(userId: string, sessionOwnerId: string | null): boolean {
190
175
  return canAccessOwner(userId, sessionOwnerId);
191
176
  }
192
177
 
@@ -283,7 +268,7 @@ export function canCreatePlaylist(userRole: UserRoleName): boolean {
283
268
  * favorites require Member role.
284
269
  */
285
270
  export function canSetFavorite(userRole: UserRoleName): boolean {
286
- const auth = routes.music.set_favorite.auth;
271
+ const auth = routes.entities.set_favorite.auth;
287
272
  if (auth.type === "role") {
288
273
  return canAccessRole(userRole, auth.role);
289
274
  }
@@ -295,7 +280,7 @@ export function canSetFavorite(userRole: UserRoleName): boolean {
295
280
  * ratings require Member role.
296
281
  */
297
282
  export function canSetRating(userRole: UserRoleName): boolean {
298
- const auth = routes.music.set_rating.auth;
283
+ const auth = routes.entities.set_rating.auth;
299
284
  if (auth.type === "role") {
300
285
  return canAccessRole(userRole, auth.role);
301
286
  }
@@ -315,15 +300,12 @@ export function canUploadImage(userRole: UserRoleName): boolean {
315
300
  }
316
301
 
317
302
  /**
318
- * check if user can create listen sessions.
319
- * listen sessions require Member role.
303
+ * check if user can create playback sessions.
304
+ * playback sessions require Member role.
320
305
  */
321
- export function canCreateListenSession(userRole: UserRoleName): boolean {
322
- const auth = routes.music.create_listen_session.auth;
323
- if (auth.type === "role") {
324
- return canAccessRole(userRole, auth.role);
325
- }
326
- return false;
306
+ export function canCreatePlaybackSession(userRole: UserRoleName): boolean {
307
+ // route auth is "authenticated" (any logged-in role), not role-gated - use the generic check
308
+ return canAccessRoute(routes.music.create_playback_session.auth, userRole, null, null);
327
309
  }
328
310
 
329
311
  /**
@@ -0,0 +1,22 @@
1
+ // the one place that knows how to detect a tauri runtime.
2
+ //
3
+ // tauri v2 only injects `window.__TAURI__` when `app.withGlobalTauri` is
4
+ // enabled in tauri.conf.json - freqhole does NOT enable it, so any
5
+ // `"__TAURI__" in window` check is silently false in the real desktop app.
6
+ // `__TAURI_INTERNALS__` is always injected, and probing its `invoke` (rather
7
+ // than mere property existence) avoids false positives from browser
8
+ // extensions that define the namespace without a working runtime.
9
+ //
10
+ // every tauri-detection site in the codebase must route through here rather
11
+ // than sniffing globals directly, so a future tauri change is a one-line fix.
12
+
13
+ declare global {
14
+ interface Window {
15
+ __TAURI_INTERNALS__?: { invoke?: unknown };
16
+ }
17
+ }
18
+
19
+ /** true when running inside a tauri webview with a usable ipc bridge. */
20
+ export function isTauriRuntime(): boolean {
21
+ return typeof window !== "undefined" && typeof window.__TAURI_INTERNALS__?.invoke === "function";
22
+ }
@@ -151,11 +151,10 @@ export const addSongsToPlaylistRequest: s.AddSongsToPlaylistRequest = {
151
151
  song_ids: [PLACEHOLDER_ID],
152
152
  };
153
153
 
154
- export const removeSongsFromPlaylistRequest: s.RemoveSongsFromPlaylistRequest =
155
- {
156
- playlist_id: PLACEHOLDER_ID,
157
- song_ids: [PLACEHOLDER_ID],
158
- };
154
+ export const removeSongsFromPlaylistRequest: s.RemoveSongsFromPlaylistRequest = {
155
+ playlist_id: PLACEHOLDER_ID,
156
+ song_ids: [PLACEHOLDER_ID],
157
+ };
159
158
 
160
159
  export const reorderPlaylistSongsRequest: s.ReorderPlaylistSongsRequest = {
161
160
  playlist_id: PLACEHOLDER_ID,
@@ -163,12 +162,11 @@ export const reorderPlaylistSongsRequest: s.ReorderPlaylistSongsRequest = {
163
162
  new_position: 0,
164
163
  };
165
164
 
166
- export const removePlaylistThumbnailRequest: s.RemovePlaylistThumbnailRequest =
167
- {
168
- playlist_id: PLACEHOLDER_ID,
169
- cleanup_blob: false,
170
- deleted_by: null,
171
- };
165
+ export const removePlaylistThumbnailRequest: s.RemovePlaylistThumbnailRequest = {
166
+ playlist_id: PLACEHOLDER_ID,
167
+ cleanup_blob: false,
168
+ deleted_by: null,
169
+ };
172
170
 
173
171
  // Favorites
174
172
  export const listFavoritesRequest: s.ListFavoritesRequest = {
@@ -312,6 +310,7 @@ export const getJobsStatusRequest: s.GetJobsStatusRequest = {
312
310
  // Fetch
313
311
  export const fetchMediaParams: s.FetchMediaParams = {
314
312
  url: "https://example.com/video",
313
+ domain: "music",
315
314
  user_id: null,
316
315
  };
317
316
 
@@ -336,10 +335,7 @@ export const redeemInviteRequest: s.RedeemInviteRequest = {
336
335
  // ============================================================================
337
336
 
338
337
  // merge fixture with overrides, replacing PLACEHOLDER_ID with real IDs
339
- export function merge<T extends Record<string, any>>(
340
- fixture: T,
341
- overrides: Partial<T>,
342
- ): T {
338
+ export function merge<T extends Record<string, any>>(fixture: T, overrides: Partial<T>): T {
343
339
  const merged = { ...fixture, ...overrides };
344
340
 
345
341
  // recursively replace PLACEHOLDER_ID in the merged object
@@ -357,10 +353,7 @@ export function withId<T extends { id?: string }>(fixture: T, id: string): T {
357
353
  }
358
354
 
359
355
  // create a fixture with specific entity_id
360
- export function withEntityId<T extends { entity_id?: string }>(
361
- fixture: T,
362
- entityId: string,
363
- ): T {
356
+ export function withEntityId<T extends { entity_id?: string }>(fixture: T, entityId: string): T {
364
357
  return { ...fixture, entity_id: entityId };
365
358
  }
366
359
 
@@ -373,10 +366,7 @@ export function withPlaylistId<T extends { playlist_id?: string }>(
373
366
  }
374
367
 
375
368
  // create a fixture with specific album_id
376
- export function withAlbumId<T extends { album_id?: string }>(
377
- fixture: T,
378
- albumId: string,
379
- ): T {
369
+ export function withAlbumId<T extends { album_id?: string }>(fixture: T, albumId: string): T {
380
370
  return { ...fixture, album_id: albumId };
381
371
  }
382
372
 
@@ -13,11 +13,7 @@
13
13
  //
14
14
  // runs without API_KEY - uses placeholder data to trigger auth checks
15
15
  import { createHttpClient } from "../FreqholeClient.js";
16
- import {
17
- fixtures,
18
- PLACEHOLDER_ID,
19
- queryParams,
20
- } from "./fixtures.js";
16
+ import { fixtures, PLACEHOLDER_ID, queryParams } from "./fixtures.js";
21
17
 
22
18
  const baseUrl = process.env.API_URL || "http://localhost:8080";
23
19
 
@@ -34,9 +30,7 @@ export async function runIntegrationTests() {
34
30
  console.log(`✓ ${name}`);
35
31
  passed++;
36
32
  } catch (err) {
37
- console.log(
38
- `✗ ${name}: ${err instanceof Error ? err.message : "unknown error"}`,
39
- );
33
+ console.log(`✗ ${name}: ${err instanceof Error ? err.message : "unknown error"}`);
40
34
  failed++;
41
35
  }
42
36
  }
@@ -145,13 +139,11 @@ export async function runIntegrationTests() {
145
139
  },
146
140
  {
147
141
  name: "music.removeSongsFromPlaylist",
148
- fn: () =>
149
- client.music.removeSongsFromPlaylist(fixtures.removeSongsFromPlaylist),
142
+ fn: () => client.music.removeSongsFromPlaylist(fixtures.removeSongsFromPlaylist),
150
143
  },
151
144
  {
152
145
  name: "music.reorderPlaylistSongs",
153
- fn: () =>
154
- client.music.reorderPlaylistSongs(fixtures.reorderPlaylistSongs),
146
+ fn: () => client.music.reorderPlaylistSongs(fixtures.reorderPlaylistSongs),
155
147
  },
156
148
  {
157
149
  name: "music.deleteImage",
@@ -167,22 +159,22 @@ export async function runIntegrationTests() {
167
159
  fn: () => client.music.listFavorites(fixtures.listFavorites),
168
160
  },
169
161
  {
170
- name: "music.setFavorite",
171
- fn: () => client.music.setFavorite(fixtures.setFavorite),
162
+ name: "entities.setFavorite",
163
+ fn: () => client.entities.setFavorite(fixtures.setFavorite),
172
164
  },
173
165
 
174
166
  // ratings
175
167
  {
176
- name: "music.setRating",
177
- fn: () => client.music.setRating(fixtures.setRating),
168
+ name: "entities.setRating",
169
+ fn: () => client.entities.setRating(fixtures.setRating),
178
170
  },
179
171
  {
180
- name: "music.removeRating",
181
- fn: () => client.music.removeRating(fixtures.removeRating),
172
+ name: "entities.removeRating",
173
+ fn: () => client.entities.removeRating(fixtures.removeRating),
182
174
  },
183
175
  {
184
- name: "music.getRatingStats",
185
- fn: () => client.music.getRatingStats(fixtures.getRatingStats),
176
+ name: "entities.getRatingStats",
177
+ fn: () => client.entities.getRatingStats(fixtures.getRatingStats),
186
178
  },
187
179
 
188
180
  // tags
@@ -249,8 +241,7 @@ export async function runIntegrationTests() {
249
241
  // musicbrainz
250
242
  {
251
243
  name: "music.searchMusicbrainzReleases",
252
- fn: () =>
253
- client.music.searchMusicbrainzReleases(fixtures.searchReleases),
244
+ fn: () => client.music.searchMusicbrainzReleases(fixtures.searchReleases),
254
245
  },
255
246
  {
256
247
  name: "music.getMusicbrainzRelease",
@@ -307,12 +298,8 @@ export async function runIntegrationTests() {
307
298
  }
308
299
 
309
300
  console.log("");
310
- console.log(
311
- "✓ auth validation complete - all routes reject without credentials\n",
312
- );
313
- console.log(
314
- " (authenticated workflows tested in stateful tests - see stateful.ts)\n",
315
- );
301
+ console.log("✓ auth validation complete - all routes reject without credentials\n");
302
+ console.log(" (authenticated workflows tested in stateful tests - see stateful.ts)\n");
316
303
 
317
304
  console.log(`\n${passed} passed, ${failed} failed\n`);
318
305
 
@@ -55,9 +55,7 @@ export async function runStatefulTests() {
55
55
  console.log(`✓ ${name}`);
56
56
  passed++;
57
57
  } catch (err) {
58
- console.log(
59
- `✗ ${name}: ${err instanceof Error ? err.message : "unknown error"}`,
60
- );
58
+ console.log(`✗ ${name}: ${err instanceof Error ? err.message : "unknown error"}`);
61
59
  failed++;
62
60
  }
63
61
  }
@@ -119,11 +117,7 @@ export async function runStatefulTests() {
119
117
  console.log("");
120
118
 
121
119
  // abort if setup failed
122
- if (
123
- !testState.existingSongId ||
124
- !testState.existingAlbumId ||
125
- !testState.existingGenreId
126
- ) {
120
+ if (!testState.existingSongId || !testState.existingAlbumId || !testState.existingGenreId) {
127
121
  console.log("✗ setup failed - aborting stateful tests\n");
128
122
  return { passed, failed };
129
123
  }
@@ -216,9 +210,7 @@ export async function runStatefulTests() {
216
210
  offset: 0,
217
211
  });
218
212
  if (!result.success) {
219
- throw new Error(
220
- `failed to query playlist songs: ${result.error.message}`,
221
- );
213
+ throw new Error(`failed to query playlist songs: ${result.error.message}`);
222
214
  }
223
215
  if (!result.data.items || result.data.items.length === 0) {
224
216
  throw new Error("expected at least one song in playlist");
@@ -248,7 +240,7 @@ export async function runStatefulTests() {
248
240
  });
249
241
 
250
242
  await test("set favorite on song", async () => {
251
- const result = await client.music.setFavorite({
243
+ const result = await client.entities.setFavorite({
252
244
  user_id: testState.userId!,
253
245
  target_type: "song",
254
246
  target_id: testState.existingSongId!,
@@ -272,7 +264,7 @@ export async function runStatefulTests() {
272
264
  });
273
265
 
274
266
  await test("set rating on song", async () => {
275
- const result = await client.music.setRating({
267
+ const result = await client.entities.setRating({
276
268
  user_id: testState.userId!,
277
269
  target_type: "song",
278
270
  target_id: testState.existingSongId!,
@@ -284,7 +276,7 @@ export async function runStatefulTests() {
284
276
  });
285
277
 
286
278
  await test("get rating stats", async () => {
287
- const result = await client.music.getRatingStats({
279
+ const result = await client.entities.getRatingStats({
288
280
  target_type: "song",
289
281
  target_id: testState.existingSongId!,
290
282
  });
@@ -375,16 +367,12 @@ export async function runStatefulTests() {
375
367
  context: null,
376
368
  });
377
369
  if (!result.success) {
378
- throw new Error(
379
- `failed to get song suggestions: ${result.error.message}`,
380
- );
370
+ throw new Error(`failed to get song suggestions: ${result.error.message}`);
381
371
  }
382
372
  // verify all suggestions are songs
383
373
  for (const suggestion of result.data.suggestions) {
384
374
  if (suggestion.suggestion_type !== "song") {
385
- throw new Error(
386
- `expected song suggestions, got ${suggestion.suggestion_type}`,
387
- );
375
+ throw new Error(`expected song suggestions, got ${suggestion.suggestion_type}`);
388
376
  }
389
377
  }
390
378
  });
@@ -447,9 +435,7 @@ export async function runStatefulTests() {
447
435
  },
448
436
  });
449
437
  if (!result.success) {
450
- throw new Error(
451
- `failed to search with tag filter: ${result.error.message}`,
452
- );
438
+ throw new Error(`failed to search with tag filter: ${result.error.message}`);
453
439
  }
454
440
  // verify context was applied (may or may not return results depending on data)
455
441
  if (!result.data.applied_filters) {
@@ -477,9 +463,7 @@ export async function runStatefulTests() {
477
463
  },
478
464
  });
479
465
  if (!result.success) {
480
- throw new Error(
481
- `failed to search with exclude filter: ${result.error.message}`,
482
- );
466
+ throw new Error(`failed to search with exclude filter: ${result.error.message}`);
483
467
  }
484
468
  // verify exclude filter was applied
485
469
  if (typeof result.data.total_count !== "number") {
@@ -507,9 +491,7 @@ export async function runStatefulTests() {
507
491
  },
508
492
  });
509
493
  if (!result.success) {
510
- throw new Error(
511
- `failed to search all types with tags: ${result.error.message}`,
512
- );
494
+ throw new Error(`failed to search all types with tags: ${result.error.message}`);
513
495
  }
514
496
  // verify response structure (results depend on data with tag002)
515
497
  if (!result.data.songs) {