@freqhole/api-client 0.1.31 → 0.1.32
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 +1 -2
- package/src/automerge/IrohNetworkAdapter.ts +37 -5
- package/src/domains/admin.ts +23 -0
- package/src/test.ts +0 -9
- package/src/test/playlistz.ts +0 -631
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freqhole/api-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.32",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.ts",
|
|
15
15
|
"./storage": "./src/storage/index.ts",
|
|
16
|
-
"./playlistz": "./src/playlistz/index.ts",
|
|
17
16
|
"./automerge": "./src/automerge/index.ts"
|
|
18
17
|
},
|
|
19
18
|
"scripts": {
|
|
@@ -77,6 +77,8 @@ export interface BiStreamLike {
|
|
|
77
77
|
*/
|
|
78
78
|
export interface MiddenStreamNode {
|
|
79
79
|
node_id(): string;
|
|
80
|
+
/** full serialized endpoint addr (node id + relay url) for deterministic dialing. */
|
|
81
|
+
node_addr?(): string;
|
|
80
82
|
open_bi(peer_addr: string, alpn: string): Promise<BiStreamLike>;
|
|
81
83
|
accept(): Promise<BiStreamLike | null>;
|
|
82
84
|
}
|
|
@@ -153,6 +155,14 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
153
155
|
{ attempt: number; timer: ReturnType<typeof setTimeout> | null }
|
|
154
156
|
>();
|
|
155
157
|
|
|
158
|
+
/**
|
|
159
|
+
* optional per-peer dial address hint keyed by node id. when present the
|
|
160
|
+
* adapter dials this full endpoint addr instead of the bare node id, which
|
|
161
|
+
* skips discovery (pkarr/dns) lookup. empty in normal operation; populated
|
|
162
|
+
* by callers that already know a peer's reachable addr.
|
|
163
|
+
*/
|
|
164
|
+
private peerDialAddr = new Map<string, string>();
|
|
165
|
+
|
|
156
166
|
/** listeners for connection state changes. */
|
|
157
167
|
private connectionStateListeners: Array<() => void> = [];
|
|
158
168
|
|
|
@@ -248,6 +258,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
248
258
|
this.readLoops.clear();
|
|
249
259
|
this.intendedPeers.clear();
|
|
250
260
|
this.failedPeers.clear();
|
|
261
|
+
this.peerDialAddr.clear();
|
|
251
262
|
this.alpnHandlers.clear();
|
|
252
263
|
|
|
253
264
|
// cancel all pending reconnection timers
|
|
@@ -278,7 +289,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
278
289
|
* ALPN, starts a read loop, and emits a peer-candidate event so
|
|
279
290
|
* automerge-repo begins syncing with this peer.
|
|
280
291
|
*/
|
|
281
|
-
async addPeer(nodeId: string): Promise<void> {
|
|
292
|
+
async addPeer(nodeId: string, dialAddr?: string): Promise<void> {
|
|
282
293
|
if (this._disconnected) {
|
|
283
294
|
throw new Error("adapter is disconnected");
|
|
284
295
|
}
|
|
@@ -286,6 +297,11 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
286
297
|
// track this peer as one we intend to stay connected to
|
|
287
298
|
this.intendedPeers.add(nodeId);
|
|
288
299
|
this.failedPeers.delete(nodeId);
|
|
300
|
+
// remember an explicit dial addr hint so dials (and reconnects) can skip
|
|
301
|
+
// discovery. keyed by node id; everything else stays keyed by node id too.
|
|
302
|
+
if (dialAddr) {
|
|
303
|
+
this.peerDialAddr.set(nodeId, dialAddr);
|
|
304
|
+
}
|
|
289
305
|
this.emitConnectionStateChange();
|
|
290
306
|
|
|
291
307
|
// clear any pending reconnection state - this is a fresh attempt
|
|
@@ -298,9 +314,22 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
const midden = await this.ensureMidden();
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
317
|
+
const target = this.peerDialAddr.get(nodeId) ?? nodeId;
|
|
318
|
+
try {
|
|
319
|
+
const stream = await midden.open_bi(target, this.syncAlpn);
|
|
320
|
+
this.registerStream(nodeId, stream);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
// the initial dial can fail when the peer is not yet discoverable. hand
|
|
323
|
+
// off to the background reconnect loop with backoff instead of forcing
|
|
324
|
+
// every caller to implement its own retry.
|
|
325
|
+
console.warn(
|
|
326
|
+
TAG,
|
|
327
|
+
"initial dial failed, scheduling reconnect:",
|
|
328
|
+
nodeId.slice(0, 16) + "...",
|
|
329
|
+
err
|
|
330
|
+
);
|
|
331
|
+
this.scheduleReconnect(nodeId);
|
|
332
|
+
}
|
|
304
333
|
}
|
|
305
334
|
|
|
306
335
|
/**
|
|
@@ -313,6 +342,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
313
342
|
// remove from intended set first so removePeer() won't schedule a reconnect
|
|
314
343
|
this.intendedPeers.delete(nodeId);
|
|
315
344
|
this.failedPeers.delete(nodeId);
|
|
345
|
+
this.peerDialAddr.delete(nodeId);
|
|
316
346
|
this.clearReconnectState(nodeId);
|
|
317
347
|
// delegate stream cleanup and peer-disconnected emission to removePeer
|
|
318
348
|
this.removePeer(nodeId);
|
|
@@ -643,6 +673,7 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
643
673
|
);
|
|
644
674
|
this.intendedPeers.delete(peerId);
|
|
645
675
|
this.failedPeers.add(peerId);
|
|
676
|
+
this.peerDialAddr.delete(peerId);
|
|
646
677
|
this.clearReconnectState(peerId);
|
|
647
678
|
this.emitConnectionStateChange();
|
|
648
679
|
return;
|
|
@@ -696,7 +727,8 @@ export class IrohNetworkAdapter extends NetworkAdapter {
|
|
|
696
727
|
|
|
697
728
|
try {
|
|
698
729
|
const midden = await this.ensureMidden();
|
|
699
|
-
const
|
|
730
|
+
const target = this.peerDialAddr.get(peerId) ?? peerId;
|
|
731
|
+
const stream = await midden.open_bi(target, this.syncAlpn);
|
|
700
732
|
console.log(TAG, "reconnected to peer:", peerId.slice(0, 16) + "...");
|
|
701
733
|
this.registerStream(peerId, stream);
|
|
702
734
|
// registerStream calls clearReconnectState, so no need to do it here
|
package/src/domains/admin.ts
CHANGED
|
@@ -93,5 +93,28 @@ export function createAdminMethods(call: CallFn) {
|
|
|
93
93
|
routes.admin.get_knock_status_public.path,
|
|
94
94
|
);
|
|
95
95
|
},
|
|
96
|
+
|
|
97
|
+
// internal service callbacks
|
|
98
|
+
deviceLinkedCallback: (params: s.DeviceLinkedCallbackRequest) => {
|
|
99
|
+
return call(
|
|
100
|
+
"admin", "device_linked_callback",
|
|
101
|
+
routes.admin.device_linked_callback.resp,
|
|
102
|
+
routes.admin.device_linked_callback.req,
|
|
103
|
+
routes.admin.device_linked_callback.method,
|
|
104
|
+
routes.admin.device_linked_callback.path,
|
|
105
|
+
params,
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
knockAcceptedCallback: (params: s.KnockAcceptedCallbackRequest) => {
|
|
110
|
+
return call(
|
|
111
|
+
"admin", "knock_accepted_callback",
|
|
112
|
+
routes.admin.knock_accepted_callback.resp,
|
|
113
|
+
routes.admin.knock_accepted_callback.req,
|
|
114
|
+
routes.admin.knock_accepted_callback.method,
|
|
115
|
+
routes.admin.knock_accepted_callback.path,
|
|
116
|
+
params,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
96
119
|
};
|
|
97
120
|
}
|
package/src/test.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { validateWrapperCoverage } from "./test/coverage.js";
|
|
3
3
|
import { runIntegrationTests } from "./test/integration.js";
|
|
4
4
|
import { runStatefulTests } from "./test/stateful.js";
|
|
5
|
-
import { runPlaylistzTests } from "./test/playlistz.js";
|
|
6
5
|
import { runAutomergeTests } from "./test/automerge.js";
|
|
7
6
|
import { runStorageTests } from "./test/storage.js";
|
|
8
7
|
|
|
@@ -22,14 +21,6 @@ async function main() {
|
|
|
22
21
|
console.log("=".repeat(50));
|
|
23
22
|
console.log("");
|
|
24
23
|
|
|
25
|
-
// always run playlistz domain tests (pure - no server needed)
|
|
26
|
-
const playlistzResults = await runPlaylistzTests();
|
|
27
|
-
totalPassed += playlistzResults.passed;
|
|
28
|
-
totalFailed += playlistzResults.failed;
|
|
29
|
-
|
|
30
|
-
console.log("=".repeat(50));
|
|
31
|
-
console.log("");
|
|
32
|
-
|
|
33
24
|
// automerge adapter unit tests (no server required)
|
|
34
25
|
const automergeResults = await runAutomergeTests();
|
|
35
26
|
totalPassed += automergeResults.passed;
|
package/src/test/playlistz.ts
DELETED
|
@@ -1,631 +0,0 @@
|
|
|
1
|
-
// unit tests for the playlistz domain package.
|
|
2
|
-
// pure - no server, no automerge, no browser APIs required.
|
|
3
|
-
import {
|
|
4
|
-
parsePlaylistDoc,
|
|
5
|
-
emptyPlaylistDoc,
|
|
6
|
-
type SongEntry,
|
|
7
|
-
type ImageRef,
|
|
8
|
-
} from "../playlistz/schema.js";
|
|
9
|
-
import {
|
|
10
|
-
upsertSong,
|
|
11
|
-
removeSong,
|
|
12
|
-
reorderSongs,
|
|
13
|
-
setMetadata,
|
|
14
|
-
addImage,
|
|
15
|
-
setPrimaryImage,
|
|
16
|
-
addPeer,
|
|
17
|
-
stampLastSeen,
|
|
18
|
-
setAclRole,
|
|
19
|
-
tombstone,
|
|
20
|
-
} from "../playlistz/mutations.js";
|
|
21
|
-
import {
|
|
22
|
-
freqholePlaylistToDoc,
|
|
23
|
-
spumePlaylistToDoc,
|
|
24
|
-
docToFreqholePlaylist,
|
|
25
|
-
} from "../playlistz/convert.js";
|
|
26
|
-
import {
|
|
27
|
-
encodeShareToken,
|
|
28
|
-
decodeShareToken,
|
|
29
|
-
shareFragment,
|
|
30
|
-
} from "../playlistz/shareLink.js";
|
|
31
|
-
import {
|
|
32
|
-
encodeMessage,
|
|
33
|
-
decodeMessage,
|
|
34
|
-
ProtocolError,
|
|
35
|
-
PLAYLISTZ_ALPN,
|
|
36
|
-
AUTOMERGE_ALPN,
|
|
37
|
-
type Message,
|
|
38
|
-
} from "../playlistz/protocol.js";
|
|
39
|
-
|
|
40
|
-
// ---- minimal assertion helpers ----
|
|
41
|
-
|
|
42
|
-
function assert(condition: boolean, message: string): void {
|
|
43
|
-
if (!condition) throw new Error(message);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function assertEqual<T>(a: T, b: T, label: string): void {
|
|
47
|
-
if (a !== b)
|
|
48
|
-
throw new Error(`${label}: expected ${JSON.stringify(a)} got ${JSON.stringify(b)}`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function assertDeepEqual(a: unknown, b: unknown, label: string): void {
|
|
52
|
-
const as = JSON.stringify(a);
|
|
53
|
-
const bs = JSON.stringify(b);
|
|
54
|
-
if (as !== bs)
|
|
55
|
-
throw new Error(`${label}: expected ${bs} got ${as}`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ---- test runner ----
|
|
59
|
-
|
|
60
|
-
export async function runPlaylistzTests(): Promise<{
|
|
61
|
-
passed: number;
|
|
62
|
-
failed: number;
|
|
63
|
-
}> {
|
|
64
|
-
let passed = 0;
|
|
65
|
-
let failed = 0;
|
|
66
|
-
|
|
67
|
-
async function test(name: string, fn: () => void | Promise<void>) {
|
|
68
|
-
try {
|
|
69
|
-
await fn();
|
|
70
|
-
console.log(`✓ ${name}`);
|
|
71
|
-
passed++;
|
|
72
|
-
} catch (err) {
|
|
73
|
-
console.log(
|
|
74
|
-
`✗ ${name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
75
|
-
);
|
|
76
|
-
failed++;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
console.log("playlistz domain tests\n");
|
|
81
|
-
|
|
82
|
-
// ---- schema defaults ----
|
|
83
|
-
|
|
84
|
-
await test("emptyPlaylistDoc returns valid doc with defaults", () => {
|
|
85
|
-
const doc = emptyPlaylistDoc();
|
|
86
|
-
assertEqual(doc.version, 1, "version");
|
|
87
|
-
assertEqual(doc.title, "", "title");
|
|
88
|
-
assertEqual(doc.description, "", "description");
|
|
89
|
-
assert(doc.createdAt.length > 0, "createdAt non-empty");
|
|
90
|
-
assertDeepEqual(doc.songs, {}, "songs");
|
|
91
|
-
assertDeepEqual(doc.order, [], "order");
|
|
92
|
-
assertDeepEqual(doc.peers, {}, "peers");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
await test("emptyPlaylistDoc respects init overrides", () => {
|
|
96
|
-
const doc = emptyPlaylistDoc({ title: "my list", description: "desc" });
|
|
97
|
-
assertEqual(doc.title, "my list", "title");
|
|
98
|
-
assertEqual(doc.description, "desc", "description");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
await test("parsePlaylistDoc passes through valid data", () => {
|
|
102
|
-
const now = new Date().toISOString();
|
|
103
|
-
const raw = {
|
|
104
|
-
version: 1 as const,
|
|
105
|
-
title: "test",
|
|
106
|
-
description: "",
|
|
107
|
-
createdAt: now,
|
|
108
|
-
lastModified: now,
|
|
109
|
-
lastModifiedBy: "",
|
|
110
|
-
images: [],
|
|
111
|
-
urls: [],
|
|
112
|
-
songs: {},
|
|
113
|
-
order: [],
|
|
114
|
-
peers: {},
|
|
115
|
-
};
|
|
116
|
-
const doc = parsePlaylistDoc(raw);
|
|
117
|
-
assertEqual(doc.title, "test", "title");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
await test("parsePlaylistDoc degrades gracefully on invalid data", () => {
|
|
121
|
-
// invalid data: version wrong type, should fall back to defaults
|
|
122
|
-
const doc = parsePlaylistDoc({ version: "bad", title: 999 });
|
|
123
|
-
assertEqual(doc.version, 1, "version defaults to 1");
|
|
124
|
-
assertEqual(doc.title, "", "title defaults to empty string");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
await test("parsePlaylistDoc degrades on null", () => {
|
|
128
|
-
const doc = parsePlaylistDoc(null);
|
|
129
|
-
assertEqual(doc.version, 1, "version");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
// ---- mutations ----
|
|
133
|
-
|
|
134
|
-
function makeSong(id: string): SongEntry {
|
|
135
|
-
return {
|
|
136
|
-
id,
|
|
137
|
-
title: `song ${id}`,
|
|
138
|
-
artist: "artist",
|
|
139
|
-
album: "album",
|
|
140
|
-
duration: 180,
|
|
141
|
-
mimeType: "audio/mpeg",
|
|
142
|
-
fileSize: 1024,
|
|
143
|
-
sha256: `sha256-${id}`,
|
|
144
|
-
images: [],
|
|
145
|
-
urls: [],
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
await test("upsertSong adds song and appends to order", () => {
|
|
150
|
-
const doc = emptyPlaylistDoc();
|
|
151
|
-
upsertSong(doc, makeSong("a"));
|
|
152
|
-
assert("a" in doc.songs, "song a in songs");
|
|
153
|
-
assertEqual(doc.order.length, 1, "order length");
|
|
154
|
-
assertEqual(doc.order[0], "a", "order[0]");
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
await test("upsertSong does not duplicate order entry", () => {
|
|
158
|
-
const doc = emptyPlaylistDoc();
|
|
159
|
-
upsertSong(doc, makeSong("a"));
|
|
160
|
-
upsertSong(doc, makeSong("a"));
|
|
161
|
-
assertEqual(doc.order.length, 1, "order length stays 1");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
await test("upsertSong bumps lastModified", () => {
|
|
165
|
-
const doc = emptyPlaylistDoc();
|
|
166
|
-
// set a known old timestamp so any call to nowIso() will be strictly after it
|
|
167
|
-
doc.lastModified = "2000-01-01T00:00:00.000Z";
|
|
168
|
-
upsertSong(doc, makeSong("a"), "node1");
|
|
169
|
-
assert(doc.lastModified > "2000-01-01T00:00:00.000Z", "lastModified bumped past sentinel");
|
|
170
|
-
assertEqual(doc.lastModifiedBy, "node1", "lastModifiedBy set");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
await test("removeSong deletes from songs and order", () => {
|
|
174
|
-
const doc = emptyPlaylistDoc();
|
|
175
|
-
upsertSong(doc, makeSong("a"));
|
|
176
|
-
upsertSong(doc, makeSong("b"));
|
|
177
|
-
removeSong(doc, "a");
|
|
178
|
-
assert(!("a" in doc.songs), "a removed from songs");
|
|
179
|
-
assert(!doc.order.includes("a"), "a removed from order");
|
|
180
|
-
assertEqual(doc.order.length, 1, "order length");
|
|
181
|
-
assertEqual(doc.order[0], "b", "b remains");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
await test("removeSong on missing id is a no-op", () => {
|
|
185
|
-
const doc = emptyPlaylistDoc();
|
|
186
|
-
removeSong(doc, "nonexistent"); // should not throw
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
await test("reorderSongs moves song to target index", () => {
|
|
190
|
-
const doc = emptyPlaylistDoc();
|
|
191
|
-
upsertSong(doc, makeSong("a"));
|
|
192
|
-
upsertSong(doc, makeSong("b"));
|
|
193
|
-
upsertSong(doc, makeSong("c"));
|
|
194
|
-
// move "c" (index 2) to index 0
|
|
195
|
-
reorderSongs(doc, "c", 0);
|
|
196
|
-
assertEqual(doc.order[0], "c", "c is first");
|
|
197
|
-
assertEqual(doc.order[1], "a", "a is second");
|
|
198
|
-
assertEqual(doc.order[2], "b", "b is third");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
await test("reorderSongs clamps to valid range", () => {
|
|
202
|
-
const doc = emptyPlaylistDoc();
|
|
203
|
-
upsertSong(doc, makeSong("a"));
|
|
204
|
-
upsertSong(doc, makeSong("b"));
|
|
205
|
-
// move "a" to index 99 (should clamp to end)
|
|
206
|
-
reorderSongs(doc, "a", 99);
|
|
207
|
-
assertEqual(doc.order[doc.order.length - 1], "a", "a is last");
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
await test("reorderSongs ignores missing songId", () => {
|
|
211
|
-
const doc = emptyPlaylistDoc();
|
|
212
|
-
reorderSongs(doc, "ghost", 0); // should not throw
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
await test("setMetadata updates title and description", () => {
|
|
216
|
-
const doc = emptyPlaylistDoc();
|
|
217
|
-
setMetadata(doc, { title: "new title", description: "new desc" });
|
|
218
|
-
assertEqual(doc.title, "new title", "title");
|
|
219
|
-
assertEqual(doc.description, "new desc", "description");
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
await test("setMetadata partial update only changes supplied fields", () => {
|
|
223
|
-
const doc = emptyPlaylistDoc({ title: "original" });
|
|
224
|
-
setMetadata(doc, { description: "only desc" });
|
|
225
|
-
assertEqual(doc.title, "original", "title unchanged");
|
|
226
|
-
assertEqual(doc.description, "only desc", "description updated");
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
await test("addImage appends to playlist images", () => {
|
|
230
|
-
const doc = emptyPlaylistDoc();
|
|
231
|
-
const ref: ImageRef = { blobId: "blob1", isPrimary: false, blobType: "thumbnail" };
|
|
232
|
-
addImage(doc, ref);
|
|
233
|
-
assertEqual(doc.images.length, 1, "images length");
|
|
234
|
-
assertEqual(doc.images[0].blobId, "blob1", "blobId");
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
await test("addImage with isPrimary clears siblings", () => {
|
|
238
|
-
const doc = emptyPlaylistDoc();
|
|
239
|
-
addImage(doc, { blobId: "blob1", isPrimary: true, blobType: "original" });
|
|
240
|
-
addImage(doc, { blobId: "blob2", isPrimary: true, blobType: "thumbnail" });
|
|
241
|
-
// blob1 should no longer be primary
|
|
242
|
-
assert(!doc.images[0].isPrimary, "blob1 isPrimary cleared");
|
|
243
|
-
assert(doc.images[1].isPrimary, "blob2 is primary");
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
await test("addImage to song images", () => {
|
|
247
|
-
const doc = emptyPlaylistDoc();
|
|
248
|
-
upsertSong(doc, makeSong("s1"));
|
|
249
|
-
const ref: ImageRef = { blobId: "songblob", isPrimary: false, blobType: "thumbnail" };
|
|
250
|
-
addImage(doc, ref, { songId: "s1" });
|
|
251
|
-
assertEqual(doc.songs["s1"].images.length, 1, "song images length");
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
await test("addImage to missing song is a no-op", () => {
|
|
255
|
-
const doc = emptyPlaylistDoc();
|
|
256
|
-
addImage(doc, { blobId: "x", isPrimary: false, blobType: "original" }, { songId: "ghost" });
|
|
257
|
-
// no throw
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
await test("setPrimaryImage sets exactly one primary", () => {
|
|
261
|
-
const doc = emptyPlaylistDoc();
|
|
262
|
-
addImage(doc, { blobId: "a", isPrimary: true, blobType: "original" });
|
|
263
|
-
addImage(doc, { blobId: "b", isPrimary: false, blobType: "thumbnail" });
|
|
264
|
-
setPrimaryImage(doc, "b");
|
|
265
|
-
assert(!doc.images[0].isPrimary, "a no longer primary");
|
|
266
|
-
assert(doc.images[1].isPrimary, "b is primary");
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
await test("addPeer is idempotent", () => {
|
|
270
|
-
const doc = emptyPlaylistDoc();
|
|
271
|
-
addPeer(doc, "node1");
|
|
272
|
-
const firstJoinedAt = doc.peers["node1"].joinedAt;
|
|
273
|
-
addPeer(doc, "node1"); // second call should not overwrite
|
|
274
|
-
assertEqual(doc.peers["node1"].joinedAt, firstJoinedAt, "joinedAt unchanged");
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
await test("addPeer does not bump lastModified", () => {
|
|
278
|
-
const doc = emptyPlaylistDoc();
|
|
279
|
-
const before = doc.lastModified;
|
|
280
|
-
addPeer(doc, "node1");
|
|
281
|
-
assertEqual(doc.lastModified, before, "lastModified not bumped");
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
await test("stampLastSeen updates peer lastSeenAt", () => {
|
|
285
|
-
const doc = emptyPlaylistDoc();
|
|
286
|
-
addPeer(doc, "node1");
|
|
287
|
-
stampLastSeen(doc, "node1");
|
|
288
|
-
assert(doc.peers["node1"].lastSeenAt != null, "lastSeenAt set");
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
await test("stampLastSeen on unknown peer is a no-op", () => {
|
|
292
|
-
const doc = emptyPlaylistDoc();
|
|
293
|
-
stampLastSeen(doc, "ghost"); // should not throw
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
await test("setAclRole creates acl and sets role", () => {
|
|
297
|
-
const doc = emptyPlaylistDoc();
|
|
298
|
-
setAclRole(doc, "node1", "editor");
|
|
299
|
-
assert(doc.acl != null, "acl created");
|
|
300
|
-
assertEqual(doc.acl!["node1"].role, "editor", "role");
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
await test("tombstone sets deleted flag", () => {
|
|
304
|
-
const doc = emptyPlaylistDoc();
|
|
305
|
-
tombstone(doc);
|
|
306
|
-
assert(doc.deleted === true, "deleted is true");
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
// ---- converters ----
|
|
310
|
-
|
|
311
|
-
await test("freqholePlaylistToDoc maps snake_case + is_primary number", () => {
|
|
312
|
-
const doc = freqholePlaylistToDoc(
|
|
313
|
-
{
|
|
314
|
-
id: "fpl1",
|
|
315
|
-
title: "FH Playlist",
|
|
316
|
-
description: "desc",
|
|
317
|
-
is_public: 1,
|
|
318
|
-
images: [
|
|
319
|
-
{ blob_id: "img1", is_primary: 1, blob_type: "original" },
|
|
320
|
-
{ blob_id: "img2", is_primary: 0, blob_type: "thumbnail" },
|
|
321
|
-
],
|
|
322
|
-
urls: [{ id: "u1", name: "site", url: "https://example.com" }],
|
|
323
|
-
created_by_id: null,
|
|
324
|
-
created_at: 1000,
|
|
325
|
-
updated_at: 2000,
|
|
326
|
-
deleted_at: null,
|
|
327
|
-
deleted_by: null,
|
|
328
|
-
created_by: null,
|
|
329
|
-
updated_by: null,
|
|
330
|
-
song_count: 1,
|
|
331
|
-
},
|
|
332
|
-
[
|
|
333
|
-
{
|
|
334
|
-
id: "fs1",
|
|
335
|
-
media_blob_id: "mediablob1",
|
|
336
|
-
title: "FH Song",
|
|
337
|
-
track_number: 1,
|
|
338
|
-
disc_number: 1,
|
|
339
|
-
duration: 120,
|
|
340
|
-
images: [],
|
|
341
|
-
urls: [],
|
|
342
|
-
bpm: null,
|
|
343
|
-
track_artist: "The Artist",
|
|
344
|
-
metadata: null,
|
|
345
|
-
lyrics: "la la la",
|
|
346
|
-
created_at: 1000,
|
|
347
|
-
updated_at: 2000,
|
|
348
|
-
deleted_at: null,
|
|
349
|
-
deleted_by: null,
|
|
350
|
-
created_by: null,
|
|
351
|
-
updated_by: null,
|
|
352
|
-
created_by_username: null,
|
|
353
|
-
updated_by_username: null,
|
|
354
|
-
play_count: null,
|
|
355
|
-
},
|
|
356
|
-
],
|
|
357
|
-
);
|
|
358
|
-
assertEqual(doc.title, "FH Playlist", "title");
|
|
359
|
-
assert(doc.images[0].isPrimary, "first image is primary");
|
|
360
|
-
assert(!doc.images[1].isPrimary, "second image not primary");
|
|
361
|
-
assertEqual(doc.songs["fs1"].sha256, "mediablob1", "sha256 from media_blob_id");
|
|
362
|
-
assertEqual(doc.songs["fs1"].artist, "The Artist", "artist from track_artist");
|
|
363
|
-
assertEqual(doc.songs["fs1"].lyrics, "la la la", "lyrics");
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
await test("spumePlaylistToDoc maps spume field names", () => {
|
|
367
|
-
const doc = spumePlaylistToDoc(
|
|
368
|
-
{
|
|
369
|
-
playlist_id: "spl1",
|
|
370
|
-
title: "Spume Playlist",
|
|
371
|
-
description: "spume desc",
|
|
372
|
-
created_at: 1000,
|
|
373
|
-
updated_at: 2000,
|
|
374
|
-
images: [
|
|
375
|
-
{ local_blob_id: "lb1", is_primary: true, blob_type: "original" },
|
|
376
|
-
{ remote_blob_id: "rb1", is_primary: false, blob_type: "thumbnail" },
|
|
377
|
-
],
|
|
378
|
-
},
|
|
379
|
-
[
|
|
380
|
-
{
|
|
381
|
-
id: "ss1",
|
|
382
|
-
sha256: "spumehash1",
|
|
383
|
-
title: "Spume Song",
|
|
384
|
-
artist_name: "Spume Artist",
|
|
385
|
-
album_title: "Spume Album",
|
|
386
|
-
duration_seconds: 240,
|
|
387
|
-
mime_type: "audio/ogg",
|
|
388
|
-
file_size: 4096,
|
|
389
|
-
blake3: "blake3hash",
|
|
390
|
-
lyrics: "words",
|
|
391
|
-
},
|
|
392
|
-
],
|
|
393
|
-
);
|
|
394
|
-
assertEqual(doc.title, "Spume Playlist", "title");
|
|
395
|
-
assertEqual(doc.images[0].blobId, "lb1", "local_blob_id mapped");
|
|
396
|
-
assertEqual(doc.images[1].blobId, "rb1", "remote_blob_id mapped");
|
|
397
|
-
assertEqual(doc.songs["ss1"].sha256, "spumehash1", "sha256");
|
|
398
|
-
assertEqual(doc.songs["ss1"].artist, "Spume Artist", "artist_name mapped");
|
|
399
|
-
assertEqual(doc.songs["ss1"].album, "Spume Album", "album_title mapped");
|
|
400
|
-
assertEqual(doc.songs["ss1"].duration, 240, "duration_seconds mapped");
|
|
401
|
-
assertEqual(doc.songs["ss1"].blake3, "blake3hash", "blake3");
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
await test("spumePlaylistToDoc skips images with no blob id", () => {
|
|
405
|
-
const doc = spumePlaylistToDoc(
|
|
406
|
-
{
|
|
407
|
-
playlist_id: "pl",
|
|
408
|
-
title: "T",
|
|
409
|
-
images: [{ is_primary: false, blob_type: "original" }],
|
|
410
|
-
},
|
|
411
|
-
[],
|
|
412
|
-
);
|
|
413
|
-
assertEqual(doc.images.length, 0, "no images (no blob id)");
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
await test("docToFreqholePlaylist round-trip preserves core fields", () => {
|
|
417
|
-
const original = freqholePlaylistToDoc(
|
|
418
|
-
{
|
|
419
|
-
id: "x",
|
|
420
|
-
title: "RT Playlist",
|
|
421
|
-
description: "rt desc",
|
|
422
|
-
is_public: 0,
|
|
423
|
-
images: [],
|
|
424
|
-
urls: [],
|
|
425
|
-
created_by_id: null,
|
|
426
|
-
created_at: 1000,
|
|
427
|
-
updated_at: 2000,
|
|
428
|
-
deleted_at: null,
|
|
429
|
-
deleted_by: null,
|
|
430
|
-
created_by: null,
|
|
431
|
-
updated_by: null,
|
|
432
|
-
song_count: 0,
|
|
433
|
-
},
|
|
434
|
-
[],
|
|
435
|
-
);
|
|
436
|
-
const { playlist } = docToFreqholePlaylist(original);
|
|
437
|
-
assertEqual(playlist.title, "RT Playlist", "title round-trips");
|
|
438
|
-
assertEqual(playlist.description, "rt desc", "description round-trips");
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
await test("docToFreqholePlaylist songs preserve title and artist", () => {
|
|
442
|
-
const doc = emptyPlaylistDoc({ title: "pl" });
|
|
443
|
-
upsertSong(doc, {
|
|
444
|
-
id: "s1",
|
|
445
|
-
title: "Song Title",
|
|
446
|
-
artist: "Artist",
|
|
447
|
-
album: "Album",
|
|
448
|
-
duration: 100,
|
|
449
|
-
mimeType: "audio/mpeg",
|
|
450
|
-
fileSize: 512,
|
|
451
|
-
sha256: "hash1",
|
|
452
|
-
images: [],
|
|
453
|
-
urls: [],
|
|
454
|
-
});
|
|
455
|
-
const { songs } = docToFreqholePlaylist(doc);
|
|
456
|
-
assertEqual(songs.length, 1, "one song");
|
|
457
|
-
assertEqual(songs[0].title, "Song Title", "title");
|
|
458
|
-
assertEqual(songs[0].track_artist, "Artist", "track_artist from artist");
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
// ---- share token ----
|
|
462
|
-
|
|
463
|
-
await test("encodeShareToken + decodeShareToken round-trip", () => {
|
|
464
|
-
const payload = { v: 1 as const, n: "a".repeat(64), d: "docid123" };
|
|
465
|
-
const token = encodeShareToken(payload);
|
|
466
|
-
const decoded = decodeShareToken(token);
|
|
467
|
-
assert(decoded !== null, "decoded is not null");
|
|
468
|
-
assertEqual(decoded!.n, payload.n, "nodeId");
|
|
469
|
-
assertEqual(decoded!.d, payload.d, "docId");
|
|
470
|
-
assertEqual(decoded!.v, 1, "version");
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
await test("shareFragment builds correct fragment", () => {
|
|
474
|
-
const payload = { v: 1 as const, n: "n", d: "d", t: "title" };
|
|
475
|
-
const frag = shareFragment(payload);
|
|
476
|
-
assert(frag.startsWith("#share/"), "starts with #share/");
|
|
477
|
-
const decoded = decodeShareToken(frag);
|
|
478
|
-
assert(decoded !== null, "fragment decodes");
|
|
479
|
-
assertEqual(decoded!.t, "title", "title hint preserved");
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
await test("decodeShareToken strips #share/ prefix", () => {
|
|
483
|
-
const payload = { v: 1 as const, n: "nn", d: "dd" };
|
|
484
|
-
const frag = shareFragment(payload);
|
|
485
|
-
const decoded = decodeShareToken(frag);
|
|
486
|
-
assert(decoded !== null, "decoded");
|
|
487
|
-
assertEqual(decoded!.d, "dd", "docId");
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
await test("decodeShareToken strips share/ prefix", () => {
|
|
491
|
-
const payload = { v: 1 as const, n: "nn", d: "dd" };
|
|
492
|
-
const token = encodeShareToken(payload);
|
|
493
|
-
const decoded = decodeShareToken("share/" + token);
|
|
494
|
-
assert(decoded !== null, "decoded");
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
await test("decodeShareToken strips full url with #share/", () => {
|
|
498
|
-
const payload = { v: 1 as const, n: "nn", d: "dd" };
|
|
499
|
-
const token = encodeShareToken(payload);
|
|
500
|
-
const url = "https://example.com/app#share/" + token;
|
|
501
|
-
const decoded = decodeShareToken(url);
|
|
502
|
-
assert(decoded !== null, "decoded from full url");
|
|
503
|
-
assertEqual(decoded!.n, "nn", "nodeId");
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
await test("decodeShareToken accepts base64url encoding", () => {
|
|
507
|
-
// base64url uses - and _ instead of + and /; also no padding
|
|
508
|
-
const payload = { v: 1 as const, n: "nn", d: "dd" };
|
|
509
|
-
const token = encodeShareToken(payload);
|
|
510
|
-
// tokens from encodeShareToken are already base64url - decoding should work
|
|
511
|
-
assert(!token.includes("+"), "no + in token");
|
|
512
|
-
assert(!token.includes("/"), "no / in token");
|
|
513
|
-
assert(!token.includes("="), "no padding");
|
|
514
|
-
const decoded = decodeShareToken(token);
|
|
515
|
-
assert(decoded !== null, "decoded");
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
await test("decodeShareToken trims whitespace", () => {
|
|
519
|
-
const payload = { v: 1 as const, n: "n1", d: "d1" };
|
|
520
|
-
const token = encodeShareToken(payload);
|
|
521
|
-
const decoded = decodeShareToken(" " + token + "\n");
|
|
522
|
-
assert(decoded !== null, "decoded with whitespace");
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
await test("decodeShareToken returns null on garbage", () => {
|
|
526
|
-
const result = decodeShareToken("not-a-valid-token!!!");
|
|
527
|
-
assert(result === null, "null on garbage");
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
await test("decodeShareToken returns null on empty string", () => {
|
|
531
|
-
assert(decodeShareToken("") === null, "null on empty");
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
await test("decodeShareToken returns null on missing required fields", () => {
|
|
535
|
-
// payload missing 'd'
|
|
536
|
-
const partial = btoa(JSON.stringify({ v: 1, n: "nodeonly" }));
|
|
537
|
-
assert(decodeShareToken(partial) === null, "null on missing d");
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
// ---- protocol ----
|
|
541
|
-
|
|
542
|
-
await test("ALPN constants are correct", () => {
|
|
543
|
-
assertEqual(PLAYLISTZ_ALPN, "freqhole-playlistz/1", "playlistz ALPN");
|
|
544
|
-
assertEqual(AUTOMERGE_ALPN, "iroh/automerge-repo/1", "automerge ALPN");
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
await test("encodeMessage + decodeMessage round-trip for hello", () => {
|
|
548
|
-
const msg: Message = { v: 1, type: "hello", nodeId: "abc123" };
|
|
549
|
-
const bytes = encodeMessage(msg);
|
|
550
|
-
const decoded = decodeMessage(bytes);
|
|
551
|
-
assertDeepEqual(decoded, msg, "hello round-trip");
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
await test("encodeMessage + decodeMessage for playlists response", () => {
|
|
555
|
-
const msg: Message = {
|
|
556
|
-
v: 1,
|
|
557
|
-
type: "playlists",
|
|
558
|
-
items: [{ docId: "doc1", title: "My List", songCount: 5 }],
|
|
559
|
-
};
|
|
560
|
-
const decoded = decodeMessage(encodeMessage(msg));
|
|
561
|
-
assertDeepEqual(decoded, msg, "playlists round-trip");
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
await test("decodeMessage throws ProtocolError on non-JSON bytes", () => {
|
|
565
|
-
const bytes = new TextEncoder().encode("not json {{{{");
|
|
566
|
-
try {
|
|
567
|
-
decodeMessage(bytes);
|
|
568
|
-
throw new Error("should have thrown");
|
|
569
|
-
} catch (err) {
|
|
570
|
-
assert(err instanceof ProtocolError, "ProtocolError thrown");
|
|
571
|
-
assertEqual((err as ProtocolError).code, "parse_error", "code");
|
|
572
|
-
}
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
await test("decodeMessage throws ProtocolError on unknown type", () => {
|
|
576
|
-
const bytes = new TextEncoder().encode(
|
|
577
|
-
JSON.stringify({ v: 1, type: "unknown_type_xyz" }),
|
|
578
|
-
);
|
|
579
|
-
try {
|
|
580
|
-
decodeMessage(bytes);
|
|
581
|
-
throw new Error("should have thrown");
|
|
582
|
-
} catch (err) {
|
|
583
|
-
assert(err instanceof ProtocolError, "ProtocolError thrown");
|
|
584
|
-
assertEqual((err as ProtocolError).code, "schema_error", "code");
|
|
585
|
-
}
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
await test("decodeMessage throws ProtocolError on missing required fields", () => {
|
|
589
|
-
const bytes = new TextEncoder().encode(JSON.stringify({ v: 1, type: "hello" }));
|
|
590
|
-
// hello requires nodeId
|
|
591
|
-
try {
|
|
592
|
-
decodeMessage(bytes);
|
|
593
|
-
throw new Error("should have thrown");
|
|
594
|
-
} catch (err) {
|
|
595
|
-
assert(err instanceof ProtocolError, "ProtocolError thrown");
|
|
596
|
-
}
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
await test("all message types encode/decode", () => {
|
|
600
|
-
const messages: Message[] = [
|
|
601
|
-
{ v: 1, type: "hello", nodeId: "n1", name: "Alice" },
|
|
602
|
-
{ v: 1, type: "hello_ok", nodeId: "n2", public: true },
|
|
603
|
-
{ v: 1, type: "list_playlists" },
|
|
604
|
-
{ v: 1, type: "playlists", items: [] },
|
|
605
|
-
{ v: 1, type: "knock", nodeId: "n3", message: "hi" },
|
|
606
|
-
{
|
|
607
|
-
v: 1,
|
|
608
|
-
type: "knock_status",
|
|
609
|
-
status: "accepted",
|
|
610
|
-
grantedDocIds: ["doc1"],
|
|
611
|
-
},
|
|
612
|
-
{ v: 1, type: "blob_request", sha256: "a".repeat(64) },
|
|
613
|
-
{
|
|
614
|
-
v: 1,
|
|
615
|
-
type: "blob_ready",
|
|
616
|
-
sha256: "a".repeat(64),
|
|
617
|
-
blake3: "b".repeat(64),
|
|
618
|
-
size: 12345,
|
|
619
|
-
},
|
|
620
|
-
{ v: 1, type: "error", code: "not_found", message: "not found" },
|
|
621
|
-
];
|
|
622
|
-
for (const msg of messages) {
|
|
623
|
-
const decoded = decodeMessage(encodeMessage(msg));
|
|
624
|
-
assertDeepEqual(decoded, msg, `round-trip for type=${msg.type}`);
|
|
625
|
-
}
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
// summary
|
|
629
|
-
console.log(`\nplaylistz: ${passed} passed, ${failed} failed\n`);
|
|
630
|
-
return { passed, failed };
|
|
631
|
-
}
|