@digitalpaws/starr 0.0.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.
Files changed (290) hide show
  1. package/README.rst +75 -0
  2. package/data/.nfs.811bbb0d.43c34.4 +0 -0
  3. package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
  4. package/data/.nfs.82d127f7.fffff5704.4 +0 -0
  5. package/data/.nfs.82d127fe.fffff5704.4 +0 -0
  6. package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
  7. package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
  8. package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
  9. package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
  10. package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
  11. package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
  12. package/data/.nfs.82d18536.fffff5e24.4 +0 -0
  13. package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
  14. package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
  15. package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
  16. package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
  17. package/data/.nfs.8314122e.fffffb044.4 +0 -0
  18. package/data/.nfs.83141236.fffffb044.4 +0 -0
  19. package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
  20. package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
  21. package/data/logs/starr.log +66244 -0
  22. package/data/starr.db +0 -0
  23. package/data/starr.db-shm +0 -0
  24. package/data/starr.db-wal +0 -0
  25. package/data/starr.jwt +1 -0
  26. package/data/starr.key +1 -0
  27. package/dist/auth/auth-service.d.ts +42 -0
  28. package/dist/auth/auth-service.d.ts.map +1 -0
  29. package/dist/auth/auth-service.js +226 -0
  30. package/dist/auth/auth-service.js.map +1 -0
  31. package/dist/auth/http-server.d.ts +16 -0
  32. package/dist/auth/http-server.d.ts.map +1 -0
  33. package/dist/auth/http-server.js +207 -0
  34. package/dist/auth/http-server.js.map +1 -0
  35. package/dist/auth/jwt-secret.d.ts +6 -0
  36. package/dist/auth/jwt-secret.d.ts.map +1 -0
  37. package/dist/auth/jwt-secret.js +51 -0
  38. package/dist/auth/jwt-secret.js.map +1 -0
  39. package/dist/auth/oidc/oidc-flow.d.ts +60 -0
  40. package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
  41. package/dist/auth/oidc/oidc-flow.js +201 -0
  42. package/dist/auth/oidc/oidc-flow.js.map +1 -0
  43. package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
  44. package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
  45. package/dist/auth/oidc/oidc-provider-service.js +124 -0
  46. package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
  47. package/dist/auth/oidc/user-service.d.ts +67 -0
  48. package/dist/auth/oidc/user-service.d.ts.map +1 -0
  49. package/dist/auth/oidc/user-service.js +177 -0
  50. package/dist/auth/oidc/user-service.js.map +1 -0
  51. package/dist/auth/rate-limiter.d.ts +31 -0
  52. package/dist/auth/rate-limiter.d.ts.map +1 -0
  53. package/dist/auth/rate-limiter.js +70 -0
  54. package/dist/auth/rate-limiter.js.map +1 -0
  55. package/dist/config/config-service.d.ts +83 -0
  56. package/dist/config/config-service.d.ts.map +1 -0
  57. package/dist/config/config-service.js +309 -0
  58. package/dist/config/config-service.js.map +1 -0
  59. package/dist/config/validate.d.ts +12 -0
  60. package/dist/config/validate.d.ts.map +1 -0
  61. package/dist/config/validate.js +140 -0
  62. package/dist/config/validate.js.map +1 -0
  63. package/dist/content-types/registry.d.ts +75 -0
  64. package/dist/content-types/registry.d.ts.map +1 -0
  65. package/dist/content-types/registry.js +171 -0
  66. package/dist/content-types/registry.js.map +1 -0
  67. package/dist/datastore/db.d.ts +9 -0
  68. package/dist/datastore/db.d.ts.map +1 -0
  69. package/dist/datastore/db.js +27 -0
  70. package/dist/datastore/db.js.map +1 -0
  71. package/dist/datastore/encrypt.d.ts +9 -0
  72. package/dist/datastore/encrypt.d.ts.map +1 -0
  73. package/dist/datastore/encrypt.js +84 -0
  74. package/dist/datastore/encrypt.js.map +1 -0
  75. package/dist/datastore/migrations.d.ts +10 -0
  76. package/dist/datastore/migrations.d.ts.map +1 -0
  77. package/dist/datastore/migrations.js +340 -0
  78. package/dist/datastore/migrations.js.map +1 -0
  79. package/dist/datastore/storage.d.ts +4 -0
  80. package/dist/datastore/storage.d.ts.map +1 -0
  81. package/dist/datastore/storage.js +60 -0
  82. package/dist/datastore/storage.js.map +1 -0
  83. package/dist/events/event-bus.d.ts +15 -0
  84. package/dist/events/event-bus.d.ts.map +1 -0
  85. package/dist/events/event-bus.js +140 -0
  86. package/dist/events/event-bus.js.map +1 -0
  87. package/dist/events/spine.d.ts +31 -0
  88. package/dist/events/spine.d.ts.map +1 -0
  89. package/dist/events/spine.js +68 -0
  90. package/dist/events/spine.js.map +1 -0
  91. package/dist/events/trace.d.ts +24 -0
  92. package/dist/events/trace.d.ts.map +1 -0
  93. package/dist/events/trace.js +33 -0
  94. package/dist/events/trace.js.map +1 -0
  95. package/dist/host.d.ts +65 -0
  96. package/dist/host.d.ts.map +1 -0
  97. package/dist/host.js +94 -0
  98. package/dist/host.js.map +1 -0
  99. package/dist/library/importer.d.ts +29 -0
  100. package/dist/library/importer.d.ts.map +1 -0
  101. package/dist/library/importer.js +145 -0
  102. package/dist/library/importer.js.map +1 -0
  103. package/dist/library/library-cache.d.ts +46 -0
  104. package/dist/library/library-cache.d.ts.map +1 -0
  105. package/dist/library/library-cache.js +152 -0
  106. package/dist/library/library-cache.js.map +1 -0
  107. package/dist/library/library-config.d.ts +12 -0
  108. package/dist/library/library-config.d.ts.map +1 -0
  109. package/dist/library/library-config.js +10 -0
  110. package/dist/library/library-config.js.map +1 -0
  111. package/dist/library/library-scanner.d.ts +62 -0
  112. package/dist/library/library-scanner.d.ts.map +1 -0
  113. package/dist/library/library-scanner.js +336 -0
  114. package/dist/library/library-scanner.js.map +1 -0
  115. package/dist/library/library-service.d.ts +65 -0
  116. package/dist/library/library-service.d.ts.map +1 -0
  117. package/dist/library/library-service.js +197 -0
  118. package/dist/library/library-service.js.map +1 -0
  119. package/dist/library/metadata-matcher.d.ts +56 -0
  120. package/dist/library/metadata-matcher.d.ts.map +1 -0
  121. package/dist/library/metadata-matcher.js +207 -0
  122. package/dist/library/metadata-matcher.js.map +1 -0
  123. package/dist/library/naming.d.ts +8 -0
  124. package/dist/library/naming.d.ts.map +1 -0
  125. package/dist/library/naming.js +47 -0
  126. package/dist/library/naming.js.map +1 -0
  127. package/dist/library/scan-controller.d.ts +95 -0
  128. package/dist/library/scan-controller.d.ts.map +1 -0
  129. package/dist/library/scan-controller.js +379 -0
  130. package/dist/library/scan-controller.js.map +1 -0
  131. package/dist/library/source-resolver.d.ts +17 -0
  132. package/dist/library/source-resolver.d.ts.map +1 -0
  133. package/dist/library/source-resolver.js +75 -0
  134. package/dist/library/source-resolver.js.map +1 -0
  135. package/dist/logging/logger.d.ts +3 -0
  136. package/dist/logging/logger.d.ts.map +1 -0
  137. package/dist/logging/logger.js +107 -0
  138. package/dist/logging/logger.js.map +1 -0
  139. package/dist/main.d.ts +36 -0
  140. package/dist/main.d.ts.map +1 -0
  141. package/dist/main.js +229 -0
  142. package/dist/main.js.map +1 -0
  143. package/dist/plugins/context.d.ts +10 -0
  144. package/dist/plugins/context.d.ts.map +1 -0
  145. package/dist/plugins/context.js +110 -0
  146. package/dist/plugins/context.js.map +1 -0
  147. package/dist/plugins/discovery.d.ts +2 -0
  148. package/dist/plugins/discovery.d.ts.map +1 -0
  149. package/dist/plugins/discovery.js +32 -0
  150. package/dist/plugins/discovery.js.map +1 -0
  151. package/dist/plugins/loader.d.ts +9 -0
  152. package/dist/plugins/loader.d.ts.map +1 -0
  153. package/dist/plugins/loader.js +245 -0
  154. package/dist/plugins/loader.js.map +1 -0
  155. package/dist/state/request-intake.d.ts +26 -0
  156. package/dist/state/request-intake.d.ts.map +1 -0
  157. package/dist/state/request-intake.js +74 -0
  158. package/dist/state/request-intake.js.map +1 -0
  159. package/dist/state/state-store.d.ts +146 -0
  160. package/dist/state/state-store.d.ts.map +1 -0
  161. package/dist/state/state-store.js +465 -0
  162. package/dist/state/state-store.js.map +1 -0
  163. package/dist/state/trace-store.d.ts +12 -0
  164. package/dist/state/trace-store.d.ts.map +1 -0
  165. package/dist/state/trace-store.js +24 -0
  166. package/dist/state/trace-store.js.map +1 -0
  167. package/dist/types/plugin.d.ts +93 -0
  168. package/dist/types/plugin.d.ts.map +1 -0
  169. package/dist/types/plugin.js +4 -0
  170. package/dist/types/plugin.js.map +1 -0
  171. package/dist/web/index.d.ts +10 -0
  172. package/dist/web/index.d.ts.map +1 -0
  173. package/dist/web/index.js +153 -0
  174. package/dist/web/index.js.map +1 -0
  175. package/dist/web/routes/activity.d.ts +6 -0
  176. package/dist/web/routes/activity.d.ts.map +1 -0
  177. package/dist/web/routes/activity.js +31 -0
  178. package/dist/web/routes/activity.js.map +1 -0
  179. package/dist/web/routes/auth.d.ts +6 -0
  180. package/dist/web/routes/auth.d.ts.map +1 -0
  181. package/dist/web/routes/auth.js +142 -0
  182. package/dist/web/routes/auth.js.map +1 -0
  183. package/dist/web/routes/config.d.ts +7 -0
  184. package/dist/web/routes/config.d.ts.map +1 -0
  185. package/dist/web/routes/config.js +204 -0
  186. package/dist/web/routes/config.js.map +1 -0
  187. package/dist/web/routes/content-types.d.ts +6 -0
  188. package/dist/web/routes/content-types.d.ts.map +1 -0
  189. package/dist/web/routes/content-types.js +29 -0
  190. package/dist/web/routes/content-types.js.map +1 -0
  191. package/dist/web/routes/discovery.d.ts +6 -0
  192. package/dist/web/routes/discovery.d.ts.map +1 -0
  193. package/dist/web/routes/discovery.js +111 -0
  194. package/dist/web/routes/discovery.js.map +1 -0
  195. package/dist/web/routes/library-import.d.ts +6 -0
  196. package/dist/web/routes/library-import.d.ts.map +1 -0
  197. package/dist/web/routes/library-import.js +120 -0
  198. package/dist/web/routes/library-import.js.map +1 -0
  199. package/dist/web/routes/library-management.d.ts +6 -0
  200. package/dist/web/routes/library-management.d.ts.map +1 -0
  201. package/dist/web/routes/library-management.js +120 -0
  202. package/dist/web/routes/library-management.js.map +1 -0
  203. package/dist/web/routes/library.d.ts +6 -0
  204. package/dist/web/routes/library.d.ts.map +1 -0
  205. package/dist/web/routes/library.js +28 -0
  206. package/dist/web/routes/library.js.map +1 -0
  207. package/dist/web/routes/oidc.d.ts +9 -0
  208. package/dist/web/routes/oidc.d.ts.map +1 -0
  209. package/dist/web/routes/oidc.js +203 -0
  210. package/dist/web/routes/oidc.js.map +1 -0
  211. package/dist/web/routes/requests.d.ts +6 -0
  212. package/dist/web/routes/requests.d.ts.map +1 -0
  213. package/dist/web/routes/requests.js +183 -0
  214. package/dist/web/routes/requests.js.map +1 -0
  215. package/dist/web/routes/sse.d.ts +6 -0
  216. package/dist/web/routes/sse.d.ts.map +1 -0
  217. package/dist/web/routes/sse.js +54 -0
  218. package/dist/web/routes/sse.js.map +1 -0
  219. package/package.json +33 -0
  220. package/package.json.license +2 -0
  221. package/src/auth/auth-service.ts +333 -0
  222. package/src/auth/http-server.ts +249 -0
  223. package/src/auth/jwt-secret.ts +65 -0
  224. package/src/auth/oidc/oidc-flow.ts +266 -0
  225. package/src/auth/oidc/oidc-provider-service.ts +174 -0
  226. package/src/auth/oidc/user-service.ts +227 -0
  227. package/src/auth/rate-limiter.ts +85 -0
  228. package/src/config/config-service.ts +434 -0
  229. package/src/config/validate.ts +153 -0
  230. package/src/content-types/registry.ts +194 -0
  231. package/src/datastore/db.ts +38 -0
  232. package/src/datastore/encrypt.ts +107 -0
  233. package/src/datastore/migrations.ts +367 -0
  234. package/src/datastore/storage.ts +83 -0
  235. package/src/events/event-bus.ts +180 -0
  236. package/src/events/spine.ts +87 -0
  237. package/src/events/trace.ts +38 -0
  238. package/src/host.ts +130 -0
  239. package/src/library/importer.ts +174 -0
  240. package/src/library/library-cache.ts +212 -0
  241. package/src/library/library-config.ts +22 -0
  242. package/src/library/library-scanner.ts +404 -0
  243. package/src/library/library-service.ts +282 -0
  244. package/src/library/metadata-matcher.ts +285 -0
  245. package/src/library/naming.ts +59 -0
  246. package/src/library/scan-controller.ts +490 -0
  247. package/src/library/source-resolver.ts +92 -0
  248. package/src/logging/logger.ts +115 -0
  249. package/src/main.ts +364 -0
  250. package/src/plugins/context.ts +138 -0
  251. package/src/plugins/discovery.ts +29 -0
  252. package/src/plugins/loader.ts +309 -0
  253. package/src/state/request-intake.ts +101 -0
  254. package/src/state/state-store.ts +621 -0
  255. package/src/state/trace-store.ts +27 -0
  256. package/src/types/plugin.ts +110 -0
  257. package/src/web/index.ts +182 -0
  258. package/src/web/routes/activity.ts +41 -0
  259. package/src/web/routes/auth.ts +170 -0
  260. package/src/web/routes/config.ts +282 -0
  261. package/src/web/routes/content-types.ts +42 -0
  262. package/src/web/routes/discovery.ts +156 -0
  263. package/src/web/routes/library-import.ts +196 -0
  264. package/src/web/routes/library-management.ts +140 -0
  265. package/src/web/routes/library.ts +46 -0
  266. package/src/web/routes/oidc.ts +260 -0
  267. package/src/web/routes/requests.ts +250 -0
  268. package/src/web/routes/sse.ts +92 -0
  269. package/tests/auth.test.ts +510 -0
  270. package/tests/config-route.test.ts +345 -0
  271. package/tests/config.test.ts +612 -0
  272. package/tests/content-types.test.ts +376 -0
  273. package/tests/datastore.test.ts +345 -0
  274. package/tests/e2e.test.ts +430 -0
  275. package/tests/event-bus.test.ts +925 -0
  276. package/tests/host.test.ts +449 -0
  277. package/tests/library-cache.test.ts +171 -0
  278. package/tests/library-scanner.test.ts +177 -0
  279. package/tests/library.test.ts +210 -0
  280. package/tests/loader-context.test.ts +644 -0
  281. package/tests/logger-jsonl.test.ts +154 -0
  282. package/tests/metadata-matcher.test.ts +198 -0
  283. package/tests/oidc.test.ts +218 -0
  284. package/tests/scan-controller.test.ts +328 -0
  285. package/tests/source-resolver.test.ts +100 -0
  286. package/tests/starr-video-integration.test.ts +153 -0
  287. package/tests/state-store.test.ts +700 -0
  288. package/tests/trace-history.test.ts +356 -0
  289. package/tsconfig.json +8 -0
  290. package/tsconfig.json.license +2 -0
@@ -0,0 +1,700 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { describe, it, expect } from "vitest";
5
+ import Database from "better-sqlite3";
6
+ import { EventBusImpl } from "../src/events/event-bus.js";
7
+ import { createLogger } from "../src/logging/logger.js";
8
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
9
+ import { StateStore } from "../src/state/state-store.js";
10
+ import { requestIntake } from "../src/state/request-intake.js";
11
+ import { ContentTypeRegistryImpl } from "../src/content-types/registry.js";
12
+ import type { EventBus } from "../src/types/plugin.js";
13
+ import type { ContentItemBase } from "@digitalpaws/starr-types-core";
14
+
15
+ // ── Helpers ──────────────────────────────────────────────────────────
16
+
17
+ function setup() {
18
+ // In-memory database with schema
19
+ const db = new Database(":memory:");
20
+ db.pragma("journal_mode = WAL");
21
+ db.pragma("foreign_keys = ON");
22
+ const logger = createLogger("test", "silent");
23
+ runMigrations(db, CORE_MIGRATIONS, logger);
24
+
25
+ // Real event bus
26
+ const impl = new EventBusImpl(logger);
27
+ const coreBus: EventBus = impl.coreBus();
28
+
29
+ // Content type registry with a movie descriptor for identity tests
30
+ const registry = new ContentTypeRegistryImpl();
31
+ registry.register({
32
+ id: "starr-video/movie@1",
33
+ name: "Movie",
34
+ identity: ["tmdb", "imdb"],
35
+ fields: [],
36
+ });
37
+
38
+ // State store
39
+ const store = new StateStore(db, coreBus, logger);
40
+
41
+ return { db, impl, coreBus, registry, store };
42
+ }
43
+
44
+ /** Minimal movie item for tests. */
45
+ function movieItem(tmdbId: string): ContentItemBase {
46
+ return {
47
+ contentTypeId: "starr-video/movie@1",
48
+ title: `Movie ${tmdbId}`,
49
+ externalIds: { tmdb: tmdbId },
50
+ };
51
+ }
52
+
53
+ // ── State transitions (happy path) ──────────────────────────────────
54
+
55
+ describe("state transitions (happy path)", () => {
56
+ it("requested → searching → downloading → downloaded", () => {
57
+ const { coreBus, registry, store } = setup();
58
+
59
+ // Insert a requested request directly (simulating intake)
60
+ const reqId = "req-001";
61
+ store.insert({
62
+ requestId: reqId,
63
+ contentTypeId: "starr-video/movie@1",
64
+ contentIdentity: "starr-video/movie@1::tmdb=123",
65
+ item: movieItem("123"),
66
+ ownerUserId: "admin",
67
+ });
68
+
69
+ let req = store.getByRequestId(reqId)!;
70
+ expect(req.status).toBe("requested");
71
+
72
+ // Emit release.found → searching
73
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
74
+ req = store.getByRequestId(reqId)!;
75
+ expect(req.status).toBe("searching");
76
+
77
+ // Emit download.started → downloading
78
+ coreBus.emit("download.started", { requestId: reqId, release: { id: "rel-1", title: "Dune 1080p" } });
79
+ req = store.getByRequestId(reqId)!;
80
+ expect(req.status).toBe("downloading");
81
+ expect(req.chosenReleaseId).toBe("rel-1");
82
+ expect(req.attempts).toBe(1);
83
+
84
+ // Emit download.completed → downloaded (terminal)
85
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
86
+ req = store.getByRequestId(reqId)!;
87
+ expect(req.status).toBe("downloaded");
88
+ });
89
+
90
+ it("any state → failed via request.failed (terminal)", () => {
91
+ const { coreBus, store } = setup();
92
+
93
+ const reqId = "req-002";
94
+ store.insert({
95
+ requestId: reqId,
96
+ contentTypeId: "starr-video/movie@1",
97
+ contentIdentity: "starr-video/movie@1::tmdb=456",
98
+ item: movieItem("456"),
99
+ ownerUserId: "admin",
100
+ });
101
+
102
+ // Move to searching first
103
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
104
+ expect(store.getByRequestId(reqId)!.status).toBe("searching");
105
+
106
+ // Terminal failure
107
+ coreBus.emit("request.failed", { requestId: reqId, reason: "no acceptable candidates" });
108
+ const req = store.getByRequestId(reqId)!;
109
+ expect(req.status).toBe("failed");
110
+ expect(req.statusDetail).toBe("no acceptable candidates");
111
+ });
112
+
113
+ it("download.failed is transient: downloading → searching with retry detail", () => {
114
+ const { coreBus, store } = setup();
115
+
116
+ const reqId = "req-003";
117
+ store.insert({
118
+ requestId: reqId,
119
+ contentTypeId: "starr-video/movie@1",
120
+ contentIdentity: "starr-video/movie@1::tmdb=789",
121
+ item: movieItem("789"),
122
+ ownerUserId: "admin",
123
+ });
124
+
125
+ // Move to downloading
126
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
127
+ coreBus.emit("download.started", { requestId: reqId, release: { id: "rel-2" } });
128
+ expect(store.getByRequestId(reqId)!.status).toBe("downloading");
129
+ expect(store.getByRequestId(reqId)!.attempts).toBe(1);
130
+
131
+ // Transient failure → back to searching, NOT failed
132
+ coreBus.emit("download.failed", { requestId: reqId, release: {}, error: "connection reset" });
133
+ const req = store.getByRequestId(reqId)!;
134
+ expect(req.status).toBe("searching");
135
+ expect(req.statusDetail).toContain("retrying");
136
+ expect(req.statusDetail).toContain("attempt 1");
137
+ });
138
+
139
+ it("multiple release.found events keep status at searching", () => {
140
+ const { coreBus, store } = setup();
141
+
142
+ const reqId = "req-004";
143
+ store.insert({
144
+ requestId: reqId,
145
+ contentTypeId: "starr-video/movie@1",
146
+ contentIdentity: "starr-video/movie@1::tmdb=999",
147
+ item: movieItem("999"),
148
+ ownerUserId: "admin",
149
+ });
150
+
151
+ // First release.found: requested → searching
152
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: { title: "A" } });
153
+ expect(store.getByRequestId(reqId)!.status).toBe("searching");
154
+
155
+ // Second release.found: stays searching
156
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: { title: "B" } });
157
+ expect(store.getByRequestId(reqId)!.status).toBe("searching");
158
+ });
159
+ });
160
+
161
+ // ── item.updated emission ───────────────────────────────────────────
162
+
163
+ describe("item.updated emission", () => {
164
+ it("emits item.updated on each status transition", () => {
165
+ const { coreBus, store } = setup();
166
+ const updates: Array<{ requestId: string; status: string }> = [];
167
+
168
+ coreBus.on("item.updated", (e) => {
169
+ updates.push(e.payload as { requestId: string; status: string });
170
+ });
171
+
172
+ const reqId = "req-010";
173
+ store.insert({
174
+ requestId: reqId,
175
+ contentTypeId: "starr-video/movie@1",
176
+ contentIdentity: "starr-video/movie@1::tmdb=010",
177
+ item: movieItem("010"),
178
+ ownerUserId: "admin",
179
+ });
180
+
181
+ // Note: insert does NOT emit item.updated (the item was created,
182
+ // not transitioned). The first emission comes from the first
183
+ // observed spine event.
184
+
185
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
186
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
187
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
188
+
189
+ expect(updates.length).toBe(3);
190
+ expect(updates[0]).toEqual({ requestId: reqId, status: "searching" });
191
+ expect(updates[1]).toEqual({ requestId: reqId, status: "downloading" });
192
+ expect(updates[2]).toEqual({ requestId: reqId, status: "downloaded" });
193
+ });
194
+
195
+ it("includes detail in item.updated when present", () => {
196
+ const { coreBus, store } = setup();
197
+ const updates: Array<{ requestId: string; status: string; detail?: string }> = [];
198
+
199
+ coreBus.on("item.updated", (e) => {
200
+ updates.push(e.payload as { requestId: string; status: string; detail?: string });
201
+ });
202
+
203
+ const reqId = "req-011";
204
+ store.insert({
205
+ requestId: reqId,
206
+ contentTypeId: "starr-video/movie@1",
207
+ contentIdentity: "starr-video/movie@1::tmdb=011",
208
+ item: movieItem("011"),
209
+ ownerUserId: "admin",
210
+ });
211
+
212
+ // Move to downloading, then fail transiently
213
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
214
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
215
+ coreBus.emit("download.failed", { requestId: reqId, release: {}, error: "boom" });
216
+
217
+ // The download.failed event should include a detail
218
+ const failedUpdate = updates.find((u) => u.status === "searching" && u.detail);
219
+ expect(failedUpdate).toBeDefined();
220
+ expect(failedUpdate!.detail).toContain("retrying");
221
+ });
222
+ });
223
+
224
+ // ── Request intake de-duplication ───────────────────────────────────
225
+
226
+ describe("request intake de-duplication", () => {
227
+ it("first request creates a new tracked request and emits content.requested", () => {
228
+ const { coreBus, registry, store } = setup();
229
+ let contentRequestedEmitted = false;
230
+
231
+ coreBus.on("content.requested", () => {
232
+ contentRequestedEmitted = true;
233
+ });
234
+
235
+ const requestId = requestIntake(
236
+ { userId: "admin", item: movieItem("111") },
237
+ store,
238
+ registry,
239
+ coreBus,
240
+ createLogger("test", "silent"),
241
+ );
242
+
243
+ expect(requestId).toBeTruthy();
244
+ expect(contentRequestedEmitted).toBe(true);
245
+
246
+ const req = store.getByRequestId(requestId)!;
247
+ expect(req.status).toBe("requested");
248
+ expect(req.ownerUserId).toBe("admin");
249
+ expect(req.contentIdentity).toContain("tmdb=111");
250
+ });
251
+
252
+ it("second request for same item attaches as watcher (DU2), does NOT re-emit content.requested", () => {
253
+ const { coreBus, registry, store } = setup();
254
+ let emitCount = 0;
255
+
256
+ coreBus.on("content.requested", () => {
257
+ emitCount++;
258
+ });
259
+
260
+ // First request
261
+ const firstId = requestIntake(
262
+ { userId: "admin", item: movieItem("222") },
263
+ store,
264
+ registry,
265
+ coreBus,
266
+ createLogger("test", "silent"),
267
+ );
268
+
269
+ expect(emitCount).toBe(1);
270
+
271
+ // Second request — same item, same user
272
+ const secondId = requestIntake(
273
+ { userId: "admin", item: movieItem("222") },
274
+ store,
275
+ registry,
276
+ coreBus,
277
+ createLogger("test", "silent"),
278
+ );
279
+
280
+ // Same requestId returned, NO second emission
281
+ expect(secondId).toBe(firstId);
282
+ expect(emitCount).toBe(1);
283
+ });
284
+
285
+ it("re-requesting a failed item creates a NEW request (DU3)", () => {
286
+ const { coreBus, registry, store } = setup();
287
+ let emitCount = 0;
288
+
289
+ coreBus.on("content.requested", () => {
290
+ emitCount++;
291
+ });
292
+
293
+ // First request
294
+ const firstId = requestIntake(
295
+ { userId: "admin", item: movieItem("333") },
296
+ store,
297
+ registry,
298
+ coreBus,
299
+ createLogger("test", "silent"),
300
+ );
301
+ expect(emitCount).toBe(1);
302
+
303
+ // Mark it as failed
304
+ coreBus.emit("request.failed", { requestId: firstId, reason: "no candidates" });
305
+ expect(store.getByRequestId(firstId)!.status).toBe("failed");
306
+
307
+ // Second request — should create NEW request, not re-attach
308
+ const secondId = requestIntake(
309
+ { userId: "admin", item: movieItem("333") },
310
+ store,
311
+ registry,
312
+ coreBus,
313
+ createLogger("test", "silent"),
314
+ );
315
+
316
+ expect(secondId).not.toBe(firstId);
317
+ expect(emitCount).toBe(2);
318
+
319
+ const newReq = store.getByRequestId(secondId)!;
320
+ expect(newReq.status).toBe("requested");
321
+ });
322
+
323
+ it("adds user to watchers on re-attach (different user)", () => {
324
+ const { coreBus, registry, store } = setup();
325
+
326
+ // First request by admin
327
+ const firstId = requestIntake(
328
+ { userId: "admin", item: movieItem("444") },
329
+ store,
330
+ registry,
331
+ coreBus,
332
+ createLogger("test", "silent"),
333
+ );
334
+
335
+ // Second request by alice — should add alice as watcher
336
+ const secondId = requestIntake(
337
+ { userId: "alice", item: movieItem("444") },
338
+ store,
339
+ registry,
340
+ coreBus,
341
+ createLogger("test", "silent"),
342
+ );
343
+
344
+ expect(secondId).toBe(firstId);
345
+
346
+ const req = store.getByRequestId(firstId)!;
347
+ expect(req.ownerUserId).toBe("admin");
348
+ expect(req.watchers).toContain("alice");
349
+ });
350
+ });
351
+
352
+ // ── resetAndRetry ────────────────────────────────────────────────
353
+
354
+ describe("resetAndRetry", () => {
355
+ it("resets a failed request to requested", () => {
356
+ const { coreBus, registry, store } = setup();
357
+
358
+ const reqId = "retry-test-1";
359
+ store.insert({
360
+ requestId: reqId,
361
+ contentTypeId: "starr-video/movie@1",
362
+ contentIdentity: "starr-video/movie@1::tmdb=999",
363
+ item: movieItem("999"),
364
+ ownerUserId: "admin",
365
+ });
366
+
367
+ // Move to failed via spine event
368
+ coreBus.emit("request.failed", { requestId: reqId, reason: "test" });
369
+ expect(store.getByRequestId(reqId)!.status).toBe("failed");
370
+
371
+ store.resetAndRetry(reqId);
372
+
373
+ const updated = store.getByRequestId(reqId);
374
+ expect(updated).toBeDefined();
375
+ expect(updated!.status).toBe("requested");
376
+ expect(updated!.attempts).toBe(0);
377
+ expect(updated!.chosenReleaseId).toBeUndefined();
378
+ });
379
+
380
+ it("resets a downloaded request to requested", () => {
381
+ const { coreBus, registry, store } = setup();
382
+
383
+ const reqId = "retry-test-2";
384
+ store.insert({
385
+ requestId: reqId,
386
+ contentTypeId: "starr-video/movie@1",
387
+ contentIdentity: "starr-video/movie@1::tmdb=998",
388
+ item: movieItem("998"),
389
+ ownerUserId: "admin",
390
+ });
391
+
392
+ // Move to downloaded via spine events
393
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
394
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
395
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
396
+ expect(store.getByRequestId(reqId)!.status).toBe("downloaded");
397
+
398
+ store.resetAndRetry(reqId);
399
+
400
+ const updated = store.getByRequestId(reqId);
401
+ expect(updated).toBeDefined();
402
+ expect(updated!.status).toBe("requested");
403
+ expect(updated!.attempts).toBe(0);
404
+ });
405
+
406
+ it("clears chosenReleaseId on retry", () => {
407
+ const { coreBus, store } = setup();
408
+
409
+ const reqId = "retry-test-3";
410
+ store.insert({
411
+ requestId: reqId,
412
+ contentTypeId: "starr-video/movie@1",
413
+ contentIdentity: "starr-video/movie@1::tmdb=997",
414
+ item: movieItem("997"),
415
+ ownerUserId: "admin",
416
+ });
417
+
418
+ // Set a chosenReleaseId by going through downloading
419
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
420
+ coreBus.emit("download.started", { requestId: reqId, release: { id: "rel-retry", title: "Test" } });
421
+ expect(store.getByRequestId(reqId)!.chosenReleaseId).toBe("rel-retry");
422
+
423
+ // Move to failed
424
+ coreBus.emit("request.failed", { requestId: reqId, reason: "test" });
425
+
426
+ // Retry should clear chosenReleaseId
427
+ store.resetAndRetry(reqId);
428
+
429
+ const updated = store.getByRequestId(reqId);
430
+ expect(updated!.status).toBe("requested");
431
+ expect(updated!.chosenReleaseId).toBeUndefined();
432
+ });
433
+ });
434
+
435
+ // ── Import transitions ───────────────────────────────────────────
436
+
437
+ describe("import transitions", () => {
438
+ it("downloaded → importing via import.started", () => {
439
+ const { coreBus, store } = setup();
440
+
441
+ const reqId = "import-test-1";
442
+ store.insert({
443
+ requestId: reqId,
444
+ contentTypeId: "starr-video/movie@1",
445
+ contentIdentity: "starr-video/movie@1::tmdb=1001",
446
+ item: movieItem("1001"),
447
+ ownerUserId: "admin",
448
+ });
449
+
450
+ // Move to downloaded
451
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
452
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
453
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
454
+ expect(store.getByRequestId(reqId)!.status).toBe("downloaded");
455
+
456
+ // Emit import.started → importing
457
+ coreBus.emit("import.started", { requestId: reqId });
458
+ expect(store.getByRequestId(reqId)!.status).toBe("importing");
459
+ });
460
+
461
+ it("importing → imported via import.completed (terminal)", () => {
462
+ const { coreBus, store } = setup();
463
+
464
+ const reqId = "import-test-2";
465
+ store.insert({
466
+ requestId: reqId,
467
+ contentTypeId: "starr-video/movie@1",
468
+ contentIdentity: "starr-video/movie@1::tmdb=1002",
469
+ item: movieItem("1002"),
470
+ ownerUserId: "admin",
471
+ });
472
+
473
+ // Move to importing
474
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
475
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
476
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
477
+ coreBus.emit("import.started", { requestId: reqId });
478
+ expect(store.getByRequestId(reqId)!.status).toBe("importing");
479
+
480
+ // Emit import.completed → imported
481
+ coreBus.emit("import.completed", { requestId: reqId });
482
+ expect(store.getByRequestId(reqId)!.status).toBe("imported");
483
+ });
484
+
485
+ it("importing → downloaded via import.failed (with detail)", () => {
486
+ const { coreBus, store } = setup();
487
+
488
+ const reqId = "import-test-3";
489
+ store.insert({
490
+ requestId: reqId,
491
+ contentTypeId: "starr-video/movie@1",
492
+ contentIdentity: "starr-video/movie@1::tmdb=1003",
493
+ item: movieItem("1003"),
494
+ ownerUserId: "admin",
495
+ });
496
+
497
+ // Move to importing
498
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
499
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
500
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
501
+ coreBus.emit("import.started", { requestId: reqId });
502
+ expect(store.getByRequestId(reqId)!.status).toBe("importing");
503
+
504
+ // Emit import.failed → back to downloaded with detail
505
+ coreBus.emit("import.failed", { requestId: reqId, error: "disk full" });
506
+ const req = store.getByRequestId(reqId)!;
507
+ expect(req.status).toBe("downloaded");
508
+ expect(req.statusDetail).toContain("Import failed:");
509
+ expect(req.statusDetail).toContain("disk full");
510
+ });
511
+
512
+ it("imported is terminal — no further transitions", () => {
513
+ const { coreBus, store } = setup();
514
+
515
+ const reqId = "import-test-4";
516
+ store.insert({
517
+ requestId: reqId,
518
+ contentTypeId: "starr-video/movie@1",
519
+ contentIdentity: "starr-video/movie@1::tmdb=1004",
520
+ item: movieItem("1004"),
521
+ ownerUserId: "admin",
522
+ });
523
+
524
+ // Move to imported
525
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
526
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
527
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
528
+ coreBus.emit("import.started", { requestId: reqId });
529
+ coreBus.emit("import.completed", { requestId: reqId });
530
+ expect(store.getByRequestId(reqId)!.status).toBe("imported");
531
+
532
+ // These should NOT change the status
533
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
534
+ expect(store.getByRequestId(reqId)!.status).toBe("imported");
535
+
536
+ coreBus.emit("request.failed", { requestId: reqId, reason: "late failure" });
537
+ expect(store.getByRequestId(reqId)!.status).toBe("imported");
538
+
539
+ coreBus.emit("import.started", { requestId: reqId });
540
+ expect(store.getByRequestId(reqId)!.status).toBe("imported");
541
+
542
+ coreBus.emit("import.failed", { requestId: reqId, error: "x" });
543
+ expect(store.getByRequestId(reqId)!.status).toBe("imported");
544
+ });
545
+
546
+ it("import events for unknown requestId are silent (no throw)", () => {
547
+ const { coreBus, store } = setup();
548
+
549
+ // None of these should throw
550
+ expect(() => coreBus.emit("import.started", { requestId: "nonexistent" })).not.toThrow();
551
+ expect(() => coreBus.emit("import.completed", { requestId: "nonexistent" })).not.toThrow();
552
+ expect(() => coreBus.emit("import.failed", { requestId: "nonexistent", error: "x" })).not.toThrow();
553
+ });
554
+ });
555
+
556
+ // ── deleteRequest ─────────────────────────────────────────────────
557
+
558
+ describe("deleteRequest", () => {
559
+ it("deletes an existing request and its trace entries", () => {
560
+ const { store } = setup();
561
+
562
+ store.insert({
563
+ requestId: "delete-test-1",
564
+ contentTypeId: "starr-video/movie@1",
565
+ contentIdentity: "starr-video/movie@1::tmdb=996",
566
+ item: movieItem("996"),
567
+ ownerUserId: "admin",
568
+ });
569
+ expect(store.getByRequestId("delete-test-1")).toBeDefined();
570
+
571
+ const result = store.deleteRequest("delete-test-1");
572
+ expect(result).toBe(true);
573
+ expect(store.getByRequestId("delete-test-1")).toBeUndefined();
574
+ });
575
+
576
+ it("returns false for a non-existent requestId", () => {
577
+ const { store } = setup();
578
+ const result = store.deleteRequest("non-existent-id");
579
+ expect(result).toBe(false);
580
+ });
581
+
582
+ it("deletes requests in any status", () => {
583
+ const { coreBus, store } = setup();
584
+
585
+ // Insert and transition to searching
586
+ const reqId = "delete-test-2";
587
+ store.insert({
588
+ requestId: reqId,
589
+ contentTypeId: "starr-video/movie@1",
590
+ contentIdentity: "starr-video/movie@1::tmdb=995",
591
+ item: movieItem("995"),
592
+ ownerUserId: "admin",
593
+ });
594
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
595
+ expect(store.getByRequestId(reqId)!.status).toBe("searching");
596
+
597
+ // Delete while in active acquisition
598
+ const result = store.deleteRequest(reqId);
599
+ expect(result).toBe(true);
600
+ expect(store.getByRequestId(reqId)).toBeUndefined();
601
+ });
602
+ });
603
+
604
+ // ── Defensive / edge-case tests ─────────────────────────────────────
605
+
606
+ describe("defensive behavior", () => {
607
+ it("spine events for unknown requestId are logged but do not throw", () => {
608
+ const { coreBus, store } = setup();
609
+
610
+ // None of these should throw
611
+ expect(() => coreBus.emit("release.found", { requestId: "nonexistent", release: {} })).not.toThrow();
612
+ expect(() => coreBus.emit("download.started", { requestId: "nonexistent", release: {} })).not.toThrow();
613
+ expect(() => coreBus.emit("download.completed", { requestId: "nonexistent", release: {} })).not.toThrow();
614
+ expect(() => coreBus.emit("download.failed", { requestId: "nonexistent", release: {}, error: "x" })).not.toThrow();
615
+ expect(() => coreBus.emit("request.failed", { requestId: "nonexistent", reason: "x" })).not.toThrow();
616
+ });
617
+
618
+ it("terminal states do not transition further", () => {
619
+ const { coreBus, store } = setup();
620
+
621
+ const reqId = "req-term";
622
+ store.insert({
623
+ requestId: reqId,
624
+ contentTypeId: "starr-video/movie@1",
625
+ contentIdentity: "starr-video/movie@1::tmdb=555",
626
+ item: movieItem("555"),
627
+ ownerUserId: "admin",
628
+ });
629
+
630
+ // Move to downloaded (terminal)
631
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
632
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
633
+ coreBus.emit("download.completed", { requestId: reqId, release: {} });
634
+ expect(store.getByRequestId(reqId)!.status).toBe("downloaded");
635
+
636
+ // These should not change the status
637
+ coreBus.emit("download.failed", { requestId: reqId, release: {}, error: "x" });
638
+ expect(store.getByRequestId(reqId)!.status).toBe("downloaded");
639
+
640
+ coreBus.emit("request.failed", { requestId: reqId, reason: "x" });
641
+ expect(store.getByRequestId(reqId)!.status).toBe("downloaded");
642
+ });
643
+
644
+ it("listByUser returns requests where user is owner or watcher", () => {
645
+ const { store } = setup();
646
+
647
+ // Owner: admin, watcher: alice
648
+ store.insert({
649
+ requestId: "r1",
650
+ contentTypeId: "starr-video/movie@1",
651
+ contentIdentity: "starr-video/movie@1::tmdb=100",
652
+ item: movieItem("100"),
653
+ ownerUserId: "admin",
654
+ });
655
+
656
+ // Owner: bob
657
+ store.insert({
658
+ requestId: "r2",
659
+ contentTypeId: "starr-video/movie@1",
660
+ contentIdentity: "starr-video/movie@1::tmdb=200",
661
+ item: movieItem("200"),
662
+ ownerUserId: "bob",
663
+ });
664
+
665
+ // Admin sees r1 (owner) but not r2 (bob's)
666
+ const adminList = store.listByUser("admin");
667
+ expect(adminList.map((r) => r.requestId)).toContain("r1");
668
+ expect(adminList.map((r) => r.requestId)).not.toContain("r2");
669
+
670
+ // Bob sees r2 (owner) but not r1 (admin's)
671
+ const bobList = store.listByUser("bob");
672
+ expect(bobList.map((r) => r.requestId)).toContain("r2");
673
+ expect(bobList.map((r) => r.requestId)).not.toContain("r1");
674
+ });
675
+
676
+ it("attempts counter increments on each download.started", () => {
677
+ const { coreBus, store } = setup();
678
+
679
+ const reqId = "req-att";
680
+ store.insert({
681
+ requestId: reqId,
682
+ contentTypeId: "starr-video/movie@1",
683
+ contentIdentity: "starr-video/movie@1::tmdb=600",
684
+ item: movieItem("600"),
685
+ ownerUserId: "admin",
686
+ });
687
+
688
+ expect(store.getByRequestId(reqId)!.attempts).toBe(0);
689
+
690
+ // First attempt
691
+ coreBus.emit("release.found", { requestId: reqId, contentTypeId: "starr-video/movie@1", release: {} });
692
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
693
+ expect(store.getByRequestId(reqId)!.attempts).toBe(1);
694
+
695
+ // Transient fail, then retry
696
+ coreBus.emit("download.failed", { requestId: reqId, release: {}, error: "x" });
697
+ coreBus.emit("download.started", { requestId: reqId, release: {} });
698
+ expect(store.getByRequestId(reqId)!.attempts).toBe(2);
699
+ });
700
+ });