@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/package.json +7 -5
- package/src/CharnelLocalTransport.ts +70 -35
- package/src/CharnelTransport.ts +391 -106
- package/src/FreqholeClient.ts +46 -83
- package/src/WasmTransport.ts +265 -219
- package/src/blobCachePolicy.test.ts +199 -0
- package/src/codegen/routes.ts +75 -13
- package/src/codegen/schema.ts +1436 -225
- package/src/domains/entities.ts +304 -0
- package/src/domains/favorites.types.ts +13 -3
- package/src/domains/music.ts +74 -126
- package/src/domains/upload.ts +54 -53
- package/src/domains/video.ts +463 -0
- package/src/errors.ts +142 -0
- package/src/index.ts +47 -10
- package/src/permissions.ts +18 -36
- package/src/tauriRuntime.ts +22 -0
- package/src/test/fixtures.ts +13 -23
- package/src/test/integration.ts +15 -28
- package/src/test/stateful.ts +11 -29
- package/src/transport.ts +72 -39
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 {
|
|
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 {
|
|
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 {
|
|
11
|
-
export {
|
|
12
|
-
|
|
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";
|
package/src/permissions.ts
CHANGED
|
@@ -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
|
|
167
|
-
*
|
|
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
|
|
157
|
+
export function canDeletePlaybackSession(
|
|
170
158
|
userId: string,
|
|
171
159
|
sessionOwnerId: string | null,
|
|
172
160
|
userRole: UserRoleName,
|
|
173
161
|
): boolean {
|
|
174
|
-
//
|
|
175
|
-
const auth = routes.music.
|
|
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
|
|
184
|
-
*
|
|
171
|
+
* check if user can update a playback session.
|
|
172
|
+
* playback sessions use Owner - strictly owner only.
|
|
185
173
|
*/
|
|
186
|
-
export function
|
|
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.
|
|
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.
|
|
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
|
|
319
|
-
*
|
|
303
|
+
* check if user can create playback sessions.
|
|
304
|
+
* playback sessions require Member role.
|
|
320
305
|
*/
|
|
321
|
-
export function
|
|
322
|
-
|
|
323
|
-
|
|
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
|
+
}
|
package/src/test/fixtures.ts
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
package/src/test/integration.ts
CHANGED
|
@@ -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: "
|
|
171
|
-
fn: () => client.
|
|
162
|
+
name: "entities.setFavorite",
|
|
163
|
+
fn: () => client.entities.setFavorite(fixtures.setFavorite),
|
|
172
164
|
},
|
|
173
165
|
|
|
174
166
|
// ratings
|
|
175
167
|
{
|
|
176
|
-
name: "
|
|
177
|
-
fn: () => client.
|
|
168
|
+
name: "entities.setRating",
|
|
169
|
+
fn: () => client.entities.setRating(fixtures.setRating),
|
|
178
170
|
},
|
|
179
171
|
{
|
|
180
|
-
name: "
|
|
181
|
-
fn: () => client.
|
|
172
|
+
name: "entities.removeRating",
|
|
173
|
+
fn: () => client.entities.removeRating(fixtures.removeRating),
|
|
182
174
|
},
|
|
183
175
|
{
|
|
184
|
-
name: "
|
|
185
|
-
fn: () => client.
|
|
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
|
-
|
|
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
|
|
package/src/test/stateful.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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) {
|