@roster-lock/match-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE.md +11 -0
  2. package/dist/authentication.js +53 -0
  3. package/dist/authentication.js.map +1 -0
  4. package/dist/config/constants.js +12 -0
  5. package/dist/config/constants.js.map +1 -0
  6. package/dist/config/findFile.js +32 -0
  7. package/dist/config/findFile.js.map +1 -0
  8. package/dist/config/index.js +29 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/manage.js +41 -0
  11. package/dist/config/manage.js.map +1 -0
  12. package/dist/cors.js +20 -0
  13. package/dist/cors.js.map +1 -0
  14. package/dist/editor-support/index.js +247 -0
  15. package/dist/editor-support/index.js.map +1 -0
  16. package/dist/handle-room/version-1/file-routes.js +53 -0
  17. package/dist/handle-room/version-1/file-routes.js.map +1 -0
  18. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js +76 -0
  19. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.js.map +1 -0
  20. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js +218 -0
  21. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.js.map +1 -0
  22. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js +18 -0
  23. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.js.map +1 -0
  24. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js +16 -0
  25. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.js.map +1 -0
  26. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js +370 -0
  27. package/dist/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.js.map +1 -0
  28. package/dist/handle-room/version-1/globals/FolderDB/index.js +18 -0
  29. package/dist/handle-room/version-1/globals/FolderDB/index.js.map +1 -0
  30. package/dist/handle-room/version-1/globals/FolderDB/types.js +3 -0
  31. package/dist/handle-room/version-1/globals/FolderDB/types.js.map +1 -0
  32. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js +95 -0
  33. package/dist/handle-room/version-1/globals/Room/WebsocketRoom.js.map +1 -0
  34. package/dist/handle-room/version-1/globals/Room/index.js +18 -0
  35. package/dist/handle-room/version-1/globals/Room/index.js.map +1 -0
  36. package/dist/handle-room/version-1/globals/Room/room.js +3 -0
  37. package/dist/handle-room/version-1/globals/Room/room.js.map +1 -0
  38. package/dist/handle-room/version-1/globals/index.js +20 -0
  39. package/dist/handle-room/version-1/globals/index.js.map +1 -0
  40. package/dist/handle-room/version-1/globals/types.js +3 -0
  41. package/dist/handle-room/version-1/globals/types.js.map +1 -0
  42. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js +83 -0
  43. package/dist/handle-room/version-1/handleDownloads/DownloadTracker.js.map +1 -0
  44. package/dist/handle-room/version-1/handleDownloads/index.js +35 -0
  45. package/dist/handle-room/version-1/handleDownloads/index.js.map +1 -0
  46. package/dist/handle-room/version-1/handleDownloads/types.js +3 -0
  47. package/dist/handle-room/version-1/handleDownloads/types.js.map +1 -0
  48. package/dist/handle-room/version-1/handleRoomSelections/encryption.js +21 -0
  49. package/dist/handle-room/version-1/handleRoomSelections/encryption.js.map +1 -0
  50. package/dist/handle-room/version-1/handleRoomSelections/random.js +12 -0
  51. package/dist/handle-room/version-1/handleRoomSelections/random.js.map +1 -0
  52. package/dist/handle-room/version-1/index.js +28 -0
  53. package/dist/handle-room/version-1/index.js.map +1 -0
  54. package/dist/handle-room/version-1/piece-sort.js +71 -0
  55. package/dist/handle-room/version-1/piece-sort.js.map +1 -0
  56. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js +89 -0
  57. package/dist/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.js.map +1 -0
  58. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js +19 -0
  59. package/dist/handle-room/version-1/room-handler-bridge/http-handler.js.map +1 -0
  60. package/dist/handle-room/version-1/room-handler-bridge/steps.js +170 -0
  61. package/dist/handle-room/version-1/room-handler-bridge/steps.js.map +1 -0
  62. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js +28 -0
  63. package/dist/handle-room/version-1/room-handler-bridge/ws-handler.js.map +1 -0
  64. package/dist/handle-room/version-1/schema/count.js +16 -0
  65. package/dist/handle-room/version-1/schema/count.js.map +1 -0
  66. package/dist/handle-room/version-1/schema/lock.js +46 -0
  67. package/dist/handle-room/version-1/schema/lock.js.map +1 -0
  68. package/dist/handle-room/version-1/schema/selected.js +13 -0
  69. package/dist/handle-room/version-1/schema/selected.js.map +1 -0
  70. package/dist/handle-room/version-1/select.js +94 -0
  71. package/dist/handle-room/version-1/select.js.map +1 -0
  72. package/dist/index.js +180 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/server.js +88 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/utils/fs.js +32 -0
  77. package/dist/utils/fs.js.map +1 -0
  78. package/dist/utils/http-router.js +49 -0
  79. package/dist/utils/http-router.js.map +1 -0
  80. package/dist/utils/pagination.js +23 -0
  81. package/dist/utils/pagination.js.map +1 -0
  82. package/dist/utils/websocket-router.js +15 -0
  83. package/dist/utils/websocket-router.js.map +1 -0
  84. package/package.json +66 -0
  85. package/src/authentication.ts +48 -0
  86. package/src/config/constants.ts +7 -0
  87. package/src/config/findFile.ts +34 -0
  88. package/src/config/index.ts +17 -0
  89. package/src/config/manage.ts +48 -0
  90. package/src/cors.ts +23 -0
  91. package/src/editor-support/index.ts +289 -0
  92. package/src/handle-room/version-0/piece-download/availableGamePiece/index.ts +109 -0
  93. package/src/handle-room/version-0/piece-download/availableGamePiece/isUpToDate.ts +78 -0
  94. package/src/handle-room/version-0/piece-download/downloadPieceAssets/downloadSingleAsset.ts +129 -0
  95. package/src/handle-room/version-0/piece-download/downloadPieceAssets/example.ts +116 -0
  96. package/src/handle-room/version-0/piece-download/downloadPieceAssets/index.ts +144 -0
  97. package/src/handle-room/version-0/piece-download/downloadPieceAssets/types.ts +32 -0
  98. package/src/handle-room/version-0/piece-download/downloadPieceAssets/validateAssetFile.ts +54 -0
  99. package/src/handle-room/version-0/piece-download/index.ts +5 -0
  100. package/src/handle-room/version-0/piece-selection/constants.ts +39 -0
  101. package/src/handle-room/version-0/piece-selection/handleRoomSelections.ts +202 -0
  102. package/src/handle-room/version-0/piece-selection/index.ts +31 -0
  103. package/src/handle-room/version-0/piece-selection/roomErrors.ts +25 -0
  104. package/src/handle-room/version-0/piece-selection/steps/encrypton.ts +70 -0
  105. package/src/handle-room/version-0/piece-selection/steps/final-selection.ts +72 -0
  106. package/src/handle-room/version-0/piece-selection/steps/player-selection.ts +100 -0
  107. package/src/handle-room/version-1/constants.ts +44 -0
  108. package/src/handle-room/version-1/file-routes.ts +68 -0
  109. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/MultiAbort.ts +83 -0
  110. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/ensurePieceExists.ts +317 -0
  111. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions.ts +31 -0
  112. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/index.ts +13 -0
  113. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema.ts +547 -0
  114. package/src/handle-room/version-1/globals/FolderDB/SQLFolderDB/types.ts +6 -0
  115. package/src/handle-room/version-1/globals/FolderDB/index.ts +3 -0
  116. package/src/handle-room/version-1/globals/FolderDB/types.ts +72 -0
  117. package/src/handle-room/version-1/globals/Room/WebsocketRoom.ts +118 -0
  118. package/src/handle-room/version-1/globals/Room/index.ts +3 -0
  119. package/src/handle-room/version-1/globals/Room/room.ts +8 -0
  120. package/src/handle-room/version-1/globals/index.ts +4 -0
  121. package/src/handle-room/version-1/globals/types.ts +7 -0
  122. package/src/handle-room/version-1/handleDownloads/DownloadTracker.ts +98 -0
  123. package/src/handle-room/version-1/handleDownloads/index.ts +41 -0
  124. package/src/handle-room/version-1/handleDownloads/types.ts +9 -0
  125. package/src/handle-room/version-1/handleRoomSelections/encryption.ts +29 -0
  126. package/src/handle-room/version-1/handleRoomSelections/random.ts +9 -0
  127. package/src/handle-room/version-1/index.ts +33 -0
  128. package/src/handle-room/version-1/piece-sort.ts +96 -0
  129. package/src/handle-room/version-1/room-handler-bridge/exchange-and-download-selections.ts +125 -0
  130. package/src/handle-room/version-1/room-handler-bridge/http-handler.ts +16 -0
  131. package/src/handle-room/version-1/room-handler-bridge/server.pseudo.ts +85 -0
  132. package/src/handle-room/version-1/room-handler-bridge/steps.ts +255 -0
  133. package/src/handle-room/version-1/room-handler-bridge/ws-handler.ts +30 -0
  134. package/src/handle-room/version-1/room-handler-state/RoomState.ts +132 -0
  135. package/src/handle-room/version-1/room-handler-state/bind-to-websocket.ts +93 -0
  136. package/src/handle-room/version-1/room-handler-state/handleRoomSelections.ts +71 -0
  137. package/src/handle-room/version-1/room-handler-state/handleRoomWithErrors.ts +55 -0
  138. package/src/handle-room/version-1/room-handler-state/roomErrors.ts +35 -0
  139. package/src/handle-room/version-1/room-handler-state/user-selection.ts +98 -0
  140. package/src/handle-room/version-1/schema/count.ts +13 -0
  141. package/src/handle-room/version-1/schema/lock.ts +47 -0
  142. package/src/handle-room/version-1/schema/selected.ts +14 -0
  143. package/src/handle-room/version-1/select.ts +130 -0
  144. package/src/handle-room/version-1/validateUnknown.ts +11 -0
  145. package/src/index.old.ts +71 -0
  146. package/src/index.ts +231 -0
  147. package/src/server.ts +85 -0
  148. package/src/utils/fs.ts +43 -0
  149. package/src/utils/http-router.ts +55 -0
  150. package/src/utils/pagination.ts +17 -0
  151. package/src/utils/websocket-router.ts +23 -0
  152. package/tests/integration/editor-pieces-search.test.ts +147 -0
  153. package/tests/integration/game-complete.test.ts +72 -0
  154. package/tests/integration/handle-full-selection.test.ts +143 -0
  155. package/tests/integration/helpers/bridge.ts +22 -0
  156. package/tests/integration/helpers/fakeFolderDB.ts +37 -0
  157. package/tests/integration/helpers/fakePluginRuntime.ts +46 -0
  158. package/tests/integration/helpers/lockConfig.ts +71 -0
  159. package/tests/integration/helpers/make-tar-gz.ts +41 -0
  160. package/tests/integration/helpers/piece.ts +98 -0
  161. package/tests/integration/helpers/plugin-dir.ts +26 -0
  162. package/tests/integration/helpers/room.ts +42 -0
  163. package/tests/integration/helpers/server.ts +51 -0
  164. package/tests/integration/helpers/validLockConfig.ts +80 -0
  165. package/tests/integration/piece-download-and-list.test.ts +194 -0
  166. package/tests/integration/piece-ensure-ws.test.ts +256 -0
  167. package/tests/integration/piece-files.test.ts +139 -0
  168. package/tests/integration/piece-sort-list.test.ts +113 -0
  169. package/tests/integration/protocol.test.ts +129 -0
  170. package/tests/integration/root-and-auth.test.ts +100 -0
  171. package/tests/integration/sync-dl.test.ts +62 -0
  172. package/tests/tsconfig.json +14 -0
  173. package/tsconfig.json +22 -0
  174. package/vitest.config.ts +10 -0
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { join as pathJoin } from "node:path";
3
+ import { startTestServer, TestServer, errorBody } from "./helpers/server";
4
+ import {
5
+ makeEngine, makePiece, makeTempFolder, seedCompletePiece, cleanupFolder, TEST_PIECE_TYPE,
6
+ } from "./helpers/piece";
7
+ import { prepareDatabase } from "../../src/handle-room/version-1/globals/FolderDB/SQLFolderDB/schema";
8
+ import { RosterLockPiece, RosterLockV1Config } from "@roster-lock/types";
9
+
10
+ type ListedPiece = Pick<RosterLockPiece, "version" | "pathVariables">;
11
+
12
+ describe("PUT /v1/piece", () => {
13
+ let server: TestServer;
14
+ let folder: string;
15
+
16
+ beforeEach(async () => {
17
+ folder = await makeTempFolder();
18
+ server = await startTestServer(folder);
19
+ });
20
+ afterEach(async () => {
21
+ await server.close();
22
+ await cleanupFolder(folder);
23
+ });
24
+
25
+ const engine = makeEngine();
26
+ const piece = makePiece();
27
+
28
+ function put(path: string, body: unknown) {
29
+ return fetch(`${server.httpUrl}/v1${path}`, {
30
+ method: "PUT",
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ Authorization: `Bearer ${server.authCode}`,
34
+ },
35
+ body: JSON.stringify(body),
36
+ });
37
+ }
38
+
39
+ it("400s on a malformed body", async () => {
40
+ const res = await put("/piece", {});
41
+ expect(res.status).toBe(400);
42
+ expect((await errorBody(res)).error).toBe("Bad Form");
43
+ });
44
+
45
+ // A real download requires a resolvable dl-protocol plugin under
46
+ // DEFAULT_PLUGIN_DIR (~/roster-lock/plugins), which isn't part of this
47
+ // package's test setup - only the request-shape validation is covered here.
48
+ it("400s when the piece fails the piece schema", async () => {
49
+ const res = await put("/piece", {
50
+ engine, pieceType: TEST_PIECE_TYPE, piece: { ...piece, id: undefined },
51
+ });
52
+ expect(res.status).toBe(400);
53
+ expect((await errorBody(res)).error).toBe("Bad Form");
54
+ });
55
+
56
+ // ensurePieceExists (SQLite3FolderDB) short-circuits to the existing folder
57
+ // when the DB already has the piece marked complete, without touching the
58
+ // download pipeline - so this is reachable without a real dl-protocol
59
+ // plugin, unlike the two tests above.
60
+ it("returns the existing folder when the piece is already downloaded", async () => {
61
+ const downloaded = makePiece({
62
+ id: "already-downloaded",
63
+ version: { logic: "already-downloaded-1.0.0", media: "1.0.0", docs: "1.0.0" },
64
+ });
65
+ const expectedFolder = await seedCompletePiece({
66
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece: downloaded, folderName: "already-downloaded-folder", files: {},
67
+ });
68
+
69
+ const res = await put("/piece", { engine, pieceType: TEST_PIECE_TYPE, piece: downloaded });
70
+ expect(res.status).toBe(200);
71
+ expect(await res.json()).toEqual({
72
+ pieceType: TEST_PIECE_TYPE,
73
+ pieceId: downloaded.id,
74
+ pieceVersions: { logic: downloaded.version.logic, media: downloaded.version.media },
75
+ folder: expectedFolder,
76
+ });
77
+ });
78
+ });
79
+
80
+ describe("QUERY /v1/piece/list-downloaded", () => {
81
+ let server: TestServer;
82
+ let folder: string;
83
+
84
+ beforeEach(async () => {
85
+ folder = await makeTempFolder();
86
+ server = await startTestServer(folder);
87
+ });
88
+ afterEach(async () => {
89
+ await server.close();
90
+ await cleanupFolder(folder);
91
+ });
92
+
93
+ const engine = makeEngine();
94
+
95
+ function query(path: string, body: unknown, params: Record<string, string> = {}) {
96
+ const url = new URL(`${server.httpUrl}/v1${path}`);
97
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
98
+ return fetch(url, {
99
+ method: "QUERY",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ Authorization: `Bearer ${server.authCode}`,
103
+ },
104
+ body: JSON.stringify(body),
105
+ });
106
+ }
107
+
108
+ // seedCompletePiece always stamps completed_at via unixepoch() (second
109
+ // resolution), which is too coarse to assert ordering across pieces
110
+ // seeded within the same test - so pagination/ordering tests set it
111
+ // directly on the row.
112
+ async function setCompletedAt(
113
+ engineConfig: RosterLockV1Config["engine"], pieceType: string, piece: RosterLockPiece, completedAt: number,
114
+ ) {
115
+ const db = prepareDatabase(pathJoin(folder, "rosterlock.sqlite3.db"));
116
+ try {
117
+ db.db.prepare(
118
+ `UPDATE pieces SET completed_at = @completedAt
119
+ WHERE engine_name = @engineName AND piece_type = @pieceType
120
+ AND logic_hash = @logic AND media_hash = @media`
121
+ ).run({
122
+ engineName: engineConfig.name,
123
+ pieceType,
124
+ logic: piece.version.logic,
125
+ media: piece.version.media,
126
+ completedAt,
127
+ });
128
+ } finally {
129
+ db.close();
130
+ }
131
+ }
132
+
133
+ describe("/piece/list-downloaded/direct", () => {
134
+ it("400s on a malformed body", async () => {
135
+ const res = await query("/piece/list-downloaded/direct", {});
136
+ expect(res.status).toBe(400);
137
+ expect((await errorBody(res)).error).toBe("Bad Form");
138
+ });
139
+
140
+ it("returns [] without touching the DB when logicIds is empty", async () => {
141
+ const res = await query("/piece/list-downloaded/direct", {
142
+ engineName: engine.name, pieceType: TEST_PIECE_TYPE, logicIds: [],
143
+ });
144
+ expect(res.status).toBe(200);
145
+ expect(await res.json()).toEqual([]);
146
+ });
147
+
148
+ it("only returns complete pieces whose logic hash was requested", async () => {
149
+ const matching = makePiece({ id: "match", version: { logic: "1.0.0", media: "1.0.0", docs: "1.0.0" } });
150
+ const unrequestedLogic = makePiece({ id: "unrequested", version: { logic: "9.9.9", media: "1.0.0", docs: "1.0.0" } });
151
+ const stillPending = makePiece({ id: "pending", version: { logic: "2.0.0", media: "1.0.0", docs: "1.0.0" } });
152
+
153
+ await seedCompletePiece({ folder, engine, pieceType: TEST_PIECE_TYPE, piece: matching, folderName: "a", files: {} });
154
+ await seedCompletePiece({ folder, engine, pieceType: TEST_PIECE_TYPE, piece: unrequestedLogic, folderName: "b", files: {} });
155
+ await seedCompletePiece({
156
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece: stillPending, folderName: "c", files: {}, complete: false,
157
+ });
158
+
159
+ const res = await query("/piece/list-downloaded/direct", {
160
+ engineName: engine.name, pieceType: TEST_PIECE_TYPE, logicIds: ["1.0.0", "2.0.0"],
161
+ });
162
+ expect(res.status).toBe(200);
163
+ const body = await res.json() as ListedPiece[];
164
+ expect(body).toHaveLength(1);
165
+ expect(body[0].version.logic).toBe("1.0.0");
166
+ });
167
+
168
+ it("paginates results ordered by completion time ascending", async () => {
169
+ const pieces = ["1.0.0", "2.0.0", "3.0.0"].map((logic, i) => (
170
+ makePiece({ id: `piece-${i}`, version: { logic, media: "1.0.0", docs: "1.0.0" } })
171
+ ));
172
+ for (const [i, p] of pieces.entries()) {
173
+ await seedCompletePiece({ folder, engine, pieceType: TEST_PIECE_TYPE, piece: p, folderName: `folder-${i}`, files: {} });
174
+ // Seed in reverse completion order so a correct ORDER BY is actually exercised.
175
+ await setCompletedAt(engine, TEST_PIECE_TYPE, p, 3000 - i);
176
+ }
177
+ const logicIds = pieces.map((p) => p.version.logic);
178
+
179
+ const page0 = await query(
180
+ "/piece/list-downloaded/direct",
181
+ { engineName: engine.name, pieceType: TEST_PIECE_TYPE, logicIds },
182
+ { page: "0", limit: "2" },
183
+ );
184
+ expect((await page0.json() as ListedPiece[]).map((p) => p.version.logic)).toEqual(["3.0.0", "2.0.0"]);
185
+
186
+ const page1 = await query(
187
+ "/piece/list-downloaded/direct",
188
+ { engineName: engine.name, pieceType: TEST_PIECE_TYPE, logicIds },
189
+ { page: "1", limit: "2" },
190
+ );
191
+ expect((await page1.json() as ListedPiece[]).map((p) => p.version.logic)).toEqual(["1.0.0"]);
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
2
+ import { once } from "node:events";
3
+ import { createServer, Server } from "node:http";
4
+ import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { AddressInfo } from "node:net";
8
+ import { WebSocket } from "ws";
9
+ import { MessageBridge } from "@roster-lock/utils";
10
+ import { startTestServer, TestServer } from "./helpers/server";
11
+ import {
12
+ makeEngine, makePiece, makeTempFolder, cleanupFolder, seedCompletePiece, TEST_PIECE_TYPE,
13
+ } from "./helpers/piece";
14
+ import { createFixturePluginDir } from "./helpers/plugin-dir";
15
+ import { makeTarGz } from "./helpers/make-tar-gz";
16
+ import { getDownloadSourceVersion } from "../../src/handle-room/version-1/globals/FolderDB/SQLFolderDB/getVersions";
17
+
18
+ describe("WS /v1/piece/ensure", () => {
19
+ let server: TestServer;
20
+ let folder: string;
21
+ beforeAll(async () => { folder = await makeTempFolder(); server = await startTestServer(folder); });
22
+ afterAll(async () => { await server.close(); await cleanupFolder(folder); });
23
+
24
+ const engine = makeEngine();
25
+ const piece = makePiece();
26
+
27
+ // Opens an authenticated connection and waits for "ready", mirroring the
28
+ // handshake every other test here needs before it can send a request.
29
+ async function connectReady() {
30
+ const ws = new WebSocket(`${server.wsUrl}/v1/piece/ensure`, {
31
+ headers: { Authorization: `Bearer ${server.authCode}` },
32
+ });
33
+ const bridge = new MessageBridge((message) => ws.send(JSON.stringify(message)));
34
+ const readyPromise = new Promise<void>((resolve) => bridge.onEvent("ready", () => resolve()));
35
+ ws.on("message", (data) => bridge.handleMessage(JSON.parse(data.toString())));
36
+
37
+ await once(ws, "open");
38
+ await readyPromise;
39
+ return { ws, bridge };
40
+ }
41
+
42
+ it("terminates the connection with no auth, without ever sending a ready event", async () => {
43
+ const ws = new WebSocket(`${server.wsUrl}/v1/piece/ensure`);
44
+ let receivedMessage = false;
45
+ ws.on("message", () => { receivedMessage = true; });
46
+ ws.on("error", () => {}); // abrupt terminate() can surface as ECONNRESET - not the point of this test
47
+
48
+ await once(ws, "close");
49
+ expect(receivedMessage).toBe(false);
50
+ });
51
+
52
+ it("sends a ready event once authenticated, and rejects an invalid ensure-piece request", async () => {
53
+ const { ws, bridge } = await connectReady();
54
+ try {
55
+ await expect(bridge.sendRequest("ensure-piece", { not: "a valid ensure-piece request" }))
56
+ .rejects.toMatch(/^Bad Form/);
57
+ } finally {
58
+ ws.close();
59
+ }
60
+ });
61
+
62
+ // A real download requires a resolvable dl-protocol plugin under
63
+ // DEFAULT_PLUGIN_DIR (~/roster-lock/plugins), which isn't part of this
64
+ // package's test setup - only the request-shape validation is covered here
65
+ // (mirrors the same limitation noted in piece-download-and-list.test.ts).
66
+ it("surfaces a download failure over the bridge rather than hanging", async () => {
67
+ const { ws, bridge } = await connectReady();
68
+ try {
69
+ await expect(bridge.sendRequest("ensure-piece", { engine, pieceType: TEST_PIECE_TYPE, piece }))
70
+ .rejects.toBeTruthy();
71
+ } finally {
72
+ ws.close();
73
+ }
74
+ });
75
+
76
+ // ensurePieceExists (SQLite3FolderDB) short-circuits to the existing folder
77
+ // when the DB already has the piece marked complete, without touching the
78
+ // download pipeline - so this is reachable without a real dl-protocol
79
+ // plugin, unlike the failure-path test above.
80
+ it("resolves with the existing folder and emits no progress when the piece is already downloaded", async () => {
81
+ const downloaded = makePiece({
82
+ id: "already-downloaded",
83
+ version: { logic: "already-downloaded-1.0.0", media: "1.0.0", docs: "1.0.0" },
84
+ });
85
+ const expectedFolder = await seedCompletePiece({
86
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece: downloaded, folderName: "already-downloaded-folder", files: {},
87
+ });
88
+
89
+ const { ws, bridge } = await connectReady();
90
+ try {
91
+ const progressEvents: unknown[] = [];
92
+ bridge.onEvent("download-progress", (update) => progressEvents.push(update));
93
+
94
+ const result = await bridge.sendRequest(
95
+ "ensure-piece", { engine, pieceType: TEST_PIECE_TYPE, piece: downloaded },
96
+ );
97
+
98
+ expect(result).toEqual({
99
+ pieceType: TEST_PIECE_TYPE,
100
+ pieceId: downloaded.id,
101
+ pieceVersions: { logic: downloaded.version.logic, media: downloaded.version.media },
102
+ folder: expectedFolder,
103
+ });
104
+ expect(progressEvents).toEqual([]);
105
+ } finally {
106
+ ws.close();
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("WS /v1/piece/ensure - first-time download", () => {
112
+ const cleanups: Array<() => Promise<void> | void> = [];
113
+ afterEach(async () => {
114
+ while (cleanups.length) await cleanups.pop()!();
115
+ });
116
+
117
+ const engine = makeEngine();
118
+ const spriteContent = "not actually a png, just fixture bytes\n";
119
+
120
+ async function connectReady(server: TestServer) {
121
+ const ws = new WebSocket(`${server.wsUrl}/v1/piece/ensure`, {
122
+ headers: { Authorization: `Bearer ${server.authCode}` },
123
+ });
124
+ const bridge = new MessageBridge((message) => ws.send(JSON.stringify(message)));
125
+ const readyPromise = new Promise<void>((resolve) => bridge.onEvent("ready", () => resolve()));
126
+ ws.on("message", (data) => bridge.handleMessage(JSON.parse(data.toString())));
127
+
128
+ await once(ws, "open");
129
+ await readyPromise;
130
+ return { ws, bridge };
131
+ }
132
+
133
+ function startArchiveServer(archive: Buffer): Promise<Server> {
134
+ return new Promise((resolve) => {
135
+ const server = createServer((_req, res) => {
136
+ res.writeHead(200, { "content-length": String(archive.length) });
137
+ res.end(archive);
138
+ });
139
+ server.listen(0, "localhost", () => resolve(server));
140
+ });
141
+ }
142
+
143
+ // Exercises the real download path end to end (http fetch -> gzip decompress
144
+ // -> tar extract -> hash validation) instead of the DB short-circuit the
145
+ // other tests in this file use, since that's the only way to see
146
+ // "download-progress" events actually stream and a first-time download
147
+ // land on disk. Requires a real plugin dir - see helpers/plugin-dir.ts.
148
+ it("streams download-progress events and resolves once the archive is extracted and validated", async () => {
149
+ const sourceDir = await mkdtemp(join(tmpdir(), "match-agent-source-"));
150
+ cleanups.push(() => rm(sourceDir, { recursive: true, force: true }));
151
+ await writeFile(join(sourceDir, "sprite.png"), spriteContent);
152
+ const version = await getDownloadSourceVersion(sourceDir, {}, engine.pieceDefinitions[TEST_PIECE_TYPE]);
153
+
154
+ const archive = makeTarGz([{ path: "sprite.png", content: spriteContent }]);
155
+ const archiveServer = await startArchiveServer(archive);
156
+ cleanups.push(() => new Promise((resolve) => archiveServer.close(() => resolve(undefined))));
157
+ const port = (archiveServer.address() as AddressInfo).port;
158
+
159
+ const fixture = await createFixturePluginDir([
160
+ "@roster-lock/dl-protocol-http", "@roster-lock/dl-compression-gzip", "@roster-lock/dl-archive-tar",
161
+ ]);
162
+ cleanups.push(fixture.cleanup);
163
+
164
+ const folder = await makeTempFolder();
165
+ cleanups.push(() => cleanupFolder(folder));
166
+ const server = await startTestServer(folder, undefined, fixture.pluginDir);
167
+ cleanups.push(() => server.close());
168
+
169
+ const piece = makePiece({
170
+ version,
171
+ downloadSources: [`http://localhost:${port}/archive.tar.gz`],
172
+ });
173
+
174
+ const { ws, bridge } = await connectReady(server);
175
+ try {
176
+ const progressEvents: Array<{ type: string }> = [];
177
+ bridge.onEvent("download-progress", (update) => progressEvents.push(update as { type: string }));
178
+
179
+ const result = await bridge.sendRequest("ensure-piece", { engine, pieceType: TEST_PIECE_TYPE, piece });
180
+
181
+ expect(result).toMatchObject({
182
+ pieceType: TEST_PIECE_TYPE,
183
+ pieceId: piece.id,
184
+ pieceVersions: { logic: version.logic, media: version.media },
185
+ });
186
+ expect(progressEvents.some((e) => e.type === "download-progress")).toBe(true);
187
+ expect(progressEvents.at(-1)).toMatchObject({ type: "download-validation" });
188
+
189
+ const downloadedFile = await readFile(join((result as { folder: string }).folder, "sprite.png"), "utf-8");
190
+ expect(downloadedFile).toBe(spriteContent);
191
+ } finally {
192
+ ws.close();
193
+ }
194
+ });
195
+
196
+ // Second ensure-piece call for the same piece should hit the DB
197
+ // short-circuit and emit no further progress or network traffic - this is
198
+ // the "second match reuses the cache" behavior already covered against a
199
+ // seeded DB in the test above this describe block; here it's proven
200
+ // end-to-end against a piece that was actually downloaded moments ago.
201
+ it("reuses the cached download on a second ensure-piece call, without re-downloading", async () => {
202
+ const sourceDir = await mkdtemp(join(tmpdir(), "match-agent-source-"));
203
+ cleanups.push(() => rm(sourceDir, { recursive: true, force: true }));
204
+ await writeFile(join(sourceDir, "sprite.png"), spriteContent);
205
+ const version = await getDownloadSourceVersion(sourceDir, {}, engine.pieceDefinitions[TEST_PIECE_TYPE]);
206
+
207
+ const archive = makeTarGz([{ path: "sprite.png", content: spriteContent }]);
208
+ let requestCount = 0;
209
+ const archiveServer = await new Promise<Server>((resolve) => {
210
+ const s = createServer((_req, res) => {
211
+ requestCount += 1;
212
+ res.writeHead(200, { "content-length": String(archive.length) });
213
+ res.end(archive);
214
+ });
215
+ s.listen(0, "localhost", () => resolve(s));
216
+ });
217
+ cleanups.push(() => new Promise((resolve) => archiveServer.close(() => resolve(undefined))));
218
+ const port = (archiveServer.address() as AddressInfo).port;
219
+
220
+ const fixture = await createFixturePluginDir([
221
+ "@roster-lock/dl-protocol-http", "@roster-lock/dl-compression-gzip", "@roster-lock/dl-archive-tar",
222
+ ]);
223
+ cleanups.push(fixture.cleanup);
224
+
225
+ const folder = await makeTempFolder();
226
+ cleanups.push(() => cleanupFolder(folder));
227
+ const server = await startTestServer(folder, undefined, fixture.pluginDir);
228
+ cleanups.push(() => server.close());
229
+
230
+ const piece = makePiece({
231
+ version,
232
+ downloadSources: [`http://localhost:${port}/archive.tar.gz`],
233
+ });
234
+
235
+ const first = await connectReady(server);
236
+ try {
237
+ await first.bridge.sendRequest("ensure-piece", { engine, pieceType: TEST_PIECE_TYPE, piece });
238
+ } finally {
239
+ first.ws.close();
240
+ }
241
+ expect(requestCount).toBe(1);
242
+
243
+ const second = await connectReady(server);
244
+ try {
245
+ const progressEvents: unknown[] = [];
246
+ second.bridge.onEvent("download-progress", (update) => progressEvents.push(update));
247
+ const result = await second.bridge.sendRequest("ensure-piece", { engine, pieceType: TEST_PIECE_TYPE, piece });
248
+
249
+ expect(result).toMatchObject({ pieceType: TEST_PIECE_TYPE, pieceId: piece.id });
250
+ expect(progressEvents).toEqual([]);
251
+ expect(requestCount).toBe(1);
252
+ } finally {
253
+ second.ws.close();
254
+ }
255
+ });
256
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { startTestServer, TestServer, errorBody } from "./helpers/server";
3
+ import {
4
+ makeEngine, makePiece, makeTempFolder, seedCompletePiece, cleanupFolder, TEST_PIECE_TYPE,
5
+ } from "./helpers/piece";
6
+
7
+ describe("/v1/piece/asset-files and /v1/piece/file-contents", () => {
8
+ let server: TestServer;
9
+ let folder: string;
10
+
11
+ beforeEach(async () => {
12
+ folder = await makeTempFolder();
13
+ server = await startTestServer(folder);
14
+ });
15
+ afterEach(async () => {
16
+ await server.close();
17
+ await cleanupFolder(folder);
18
+ });
19
+
20
+ const engine = makeEngine();
21
+ const piece = makePiece();
22
+
23
+ function post(path: string, body: unknown) {
24
+ return fetch(`${server.httpUrl}/v1${path}`, {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ Authorization: `Bearer ${server.authCode}`,
29
+ },
30
+ body: JSON.stringify(body),
31
+ });
32
+ }
33
+
34
+ describe("POST /v1/piece/asset-files", () => {
35
+ it("400s on a malformed body", async () => {
36
+ const res = await post("/piece/asset-files", {});
37
+ expect(res.status).toBe(400);
38
+ expect((await errorBody(res)).error).toBe("Bad Form");
39
+ });
40
+
41
+ it("404s when the piece has never been seen", async () => {
42
+ const res = await post("/piece/asset-files", {
43
+ engine, pieceType: TEST_PIECE_TYPE, piece, assetName: "sprite",
44
+ });
45
+ expect(res.status).toBe(404);
46
+ expect((await errorBody(res)).error).toBe("Piece Doesn't exist");
47
+ });
48
+
49
+ it("409s when the piece exists but hasn't finished downloading", async () => {
50
+ await seedCompletePiece({
51
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece,
52
+ folderName: "pending-folder", files: {}, complete: false,
53
+ });
54
+ const res = await post("/piece/asset-files", {
55
+ engine, pieceType: TEST_PIECE_TYPE, piece, assetName: "sprite",
56
+ });
57
+ expect(res.status).toBe(409);
58
+ expect((await errorBody(res)).error).toBe("Piece not finished");
59
+ });
60
+
61
+ it("200s with matching files for a completed piece", async () => {
62
+ await seedCompletePiece({
63
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece,
64
+ folderName: "complete-folder",
65
+ files: { "sprite.png": "fake-png-bytes", "unrelated.txt": "not an asset" },
66
+ });
67
+ const res = await post("/piece/asset-files", {
68
+ engine, pieceType: TEST_PIECE_TYPE, piece, assetName: "sprite",
69
+ });
70
+ expect(res.status).toBe(200);
71
+ expect(await res.json()).toEqual(["sprite.png"]);
72
+ });
73
+ });
74
+
75
+ describe("POST /v1/piece/file-contents", () => {
76
+ it("400s on a malformed body", async () => {
77
+ const res = await post("/piece/file-contents", {});
78
+ expect(res.status).toBe(400);
79
+ expect((await errorBody(res)).error).toBe("Bad Form");
80
+ });
81
+
82
+ it("404s when the piece has never been seen", async () => {
83
+ const res = await post("/piece/file-contents", {
84
+ engine, pieceType: TEST_PIECE_TYPE, piece, filePath: "sprite.png",
85
+ });
86
+ expect(res.status).toBe(404);
87
+ expect((await errorBody(res)).error).toBe("Piece doesn't exist");
88
+ });
89
+
90
+ it("409s when the piece exists but hasn't finished downloading", async () => {
91
+ await seedCompletePiece({
92
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece,
93
+ folderName: "pending-folder", files: {}, complete: false,
94
+ });
95
+ const res = await post("/piece/file-contents", {
96
+ engine, pieceType: TEST_PIECE_TYPE, piece, filePath: "sprite.png",
97
+ });
98
+ expect(res.status).toBe(409);
99
+ expect((await errorBody(res)).error).toBe("Piece not finished");
100
+ });
101
+
102
+ it("400s on a path-traversal attempt", async () => {
103
+ await seedCompletePiece({
104
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece,
105
+ folderName: "complete-folder", files: { "sprite.png": "fake-png-bytes" },
106
+ });
107
+ const res = await post("/piece/file-contents", {
108
+ engine, pieceType: TEST_PIECE_TYPE, piece, filePath: "../../etc/passwd",
109
+ });
110
+ expect(res.status).toBe(400);
111
+ expect((await errorBody(res)).error).toBe("Can't back out of folder");
112
+ });
113
+
114
+ it("404s when the requested file isn't on disk", async () => {
115
+ await seedCompletePiece({
116
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece,
117
+ folderName: "complete-folder", files: { "sprite.png": "fake-png-bytes" },
118
+ });
119
+ const res = await post("/piece/file-contents", {
120
+ engine, pieceType: TEST_PIECE_TYPE, piece, filePath: "missing.png",
121
+ });
122
+ expect(res.status).toBe(404);
123
+ expect((await errorBody(res)).error).toBe("File doesn't exist");
124
+ });
125
+
126
+ it("200s and streams the file contents with the right content type", async () => {
127
+ await seedCompletePiece({
128
+ folder, engine, pieceType: TEST_PIECE_TYPE, piece,
129
+ folderName: "complete-folder", files: { "sprite.png": "fake-png-bytes" },
130
+ });
131
+ const res = await post("/piece/file-contents", {
132
+ engine, pieceType: TEST_PIECE_TYPE, piece, filePath: "sprite.png",
133
+ });
134
+ expect(res.status).toBe(200);
135
+ expect(res.headers.get("content-type")).toBe("image/png");
136
+ await expect(res.text()).resolves.toBe("fake-png-bytes");
137
+ });
138
+ });
139
+ });