@hellcoder/companion 0.96.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 (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,1510 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ─── Mock settings-manager ──────────────────────────────────────────────────
4
+ // Returns a settings object; tests override linearApiKey as needed.
5
+ const mockSettings = {
6
+ linearApiKey: "lin_api_test_key",
7
+ linearAutoTransition: false,
8
+ linearAutoTransitionStateId: "",
9
+ linearAutoTransitionStateName: "",
10
+ linearArchiveTransition: false,
11
+ linearArchiveTransitionStateId: "",
12
+ linearArchiveTransitionStateName: "",
13
+ };
14
+
15
+ vi.mock("../settings-manager.js", () => ({
16
+ getSettings: vi.fn(() => ({ ...mockSettings })),
17
+ }));
18
+
19
+ // ─── Mock linear-cache ──────────────────────────────────────────────────────
20
+ // By default getOrFetch simply executes the fetcher so that we can test the
21
+ // fetch / response-parsing logic inside each route handler.
22
+ vi.mock("../linear-cache.js", () => ({
23
+ linearCache: {
24
+ getOrFetch: vi.fn(async (_key: string, _ttl: number, fetcher: () => Promise<unknown>) => fetcher()),
25
+ invalidate: vi.fn(),
26
+ clear: vi.fn(),
27
+ },
28
+ }));
29
+
30
+ // ─── Mock session-linear-issues ─────────────────────────────────────────────
31
+ vi.mock("../session-linear-issues.js", () => ({
32
+ getLinearIssue: vi.fn(() => undefined),
33
+ setLinearIssue: vi.fn(),
34
+ removeLinearIssue: vi.fn(),
35
+ }));
36
+
37
+ // ─── Mock linear-project-manager ────────────────────────────────────────────
38
+ vi.mock("../linear-project-manager.js", () => ({
39
+ getMapping: vi.fn(() => null),
40
+ listMappings: vi.fn(() => []),
41
+ upsertMapping: vi.fn((_root: string, data: { projectId: string; projectName: string }) => ({
42
+ repoRoot: _root,
43
+ ...data,
44
+ createdAt: 1000,
45
+ updatedAt: 1000,
46
+ })),
47
+ removeMapping: vi.fn(() => false),
48
+ }));
49
+
50
+ // ─── Mock linear-connections ────────────────────────────────────────────────
51
+ // resolveApiKey returns the test key by default; tests set it to null for "no key" scenarios.
52
+ const mockResolveApiKey = vi.fn<() => { apiKey: string; connectionId: string } | null>(
53
+ () => ({ apiKey: "lin_api_test_key", connectionId: "test-conn" }),
54
+ );
55
+ const mockGetConnection = vi.fn(() => null);
56
+
57
+ vi.mock("../linear-connections.js", () => ({
58
+ resolveApiKey: (...args: unknown[]) => mockResolveApiKey(),
59
+ getConnection: (...args: unknown[]) => mockGetConnection(),
60
+ }));
61
+
62
+ // ─── Imports (after mocks are declared) ─────────────────────────────────────
63
+ import { Hono } from "hono";
64
+ import { getSettings } from "../settings-manager.js";
65
+ import { linearCache } from "../linear-cache.js";
66
+ import * as sessionLinearIssues from "../session-linear-issues.js";
67
+ import * as linearProjectManager from "../linear-project-manager.js";
68
+ import { registerLinearRoutes, transitionLinearIssue, fetchLinearTeamStates } from "./linear-routes.js";
69
+
70
+ // ─── Test setup ─────────────────────────────────────────────────────────────
71
+
72
+ let app: Hono;
73
+
74
+ // Save original global fetch so we can restore it
75
+ const originalFetch = globalThis.fetch;
76
+
77
+ /** Helper to mock globalThis.fetch without TS errors about missing `preconnect` */
78
+ function mockFetch() {
79
+ const fn = vi.fn();
80
+ globalThis.fetch = fn as any;
81
+ return fn;
82
+ }
83
+
84
+ beforeEach(() => {
85
+ vi.clearAllMocks();
86
+
87
+ // Reset settings to defaults for each test
88
+ mockSettings.linearApiKey = "lin_api_test_key";
89
+ mockSettings.linearAutoTransition = false;
90
+ mockSettings.linearAutoTransitionStateId = "";
91
+ mockSettings.linearAutoTransitionStateName = "";
92
+
93
+ // Reset linear-connections mock to return the test key by default
94
+ mockResolveApiKey.mockReturnValue({ apiKey: "lin_api_test_key", connectionId: "test-conn" });
95
+ mockGetConnection.mockReturnValue(null);
96
+
97
+ // Restore global fetch to prevent leaks between tests
98
+ globalThis.fetch = originalFetch;
99
+
100
+ app = new Hono();
101
+ const api = new Hono();
102
+ registerLinearRoutes(api);
103
+ app.route("/api", api);
104
+ });
105
+
106
+ // ─── Helpers ────────────────────────────────────────────────────────────────
107
+
108
+ /** Build a successful Linear GraphQL response. */
109
+ function linearOk(data: Record<string, unknown>) {
110
+ return new Response(JSON.stringify({ data }), {
111
+ status: 200,
112
+ headers: { "Content-Type": "application/json" },
113
+ });
114
+ }
115
+
116
+ /** Build a Linear GraphQL error response. */
117
+ function linearError(message: string, status = 200) {
118
+ return new Response(
119
+ JSON.stringify({ errors: [{ message }] }),
120
+ { status, headers: { "Content-Type": "application/json" } },
121
+ );
122
+ }
123
+
124
+ /** Build a non-ok HTTP response (e.g. 500). */
125
+ function linearHttpError(statusText = "Internal Server Error", status = 500) {
126
+ return new Response(JSON.stringify({}), {
127
+ status,
128
+ statusText,
129
+ headers: { "Content-Type": "application/json" },
130
+ });
131
+ }
132
+
133
+ /** Make a standard issue node from the Linear search response shape. */
134
+ function makeIssueNode(overrides: Record<string, unknown> = {}) {
135
+ return {
136
+ id: "issue-1",
137
+ identifier: "COMP-1",
138
+ title: "Test issue",
139
+ description: "A test issue description",
140
+ url: "https://linear.app/test/issue/COMP-1",
141
+ branchName: "comp-1-test-issue",
142
+ priorityLabel: "High",
143
+ state: { name: "In Progress", type: "started" },
144
+ team: { id: "team-1", key: "COMP", name: "Companion" },
145
+ ...overrides,
146
+ };
147
+ }
148
+
149
+ // =============================================================================
150
+ // GET /api/linear/issues
151
+ // =============================================================================
152
+
153
+ describe("GET /api/linear/issues", () => {
154
+ it("returns empty array when no query is provided (covers line 22)", async () => {
155
+ // When no query param is provided, the route returns { issues: [] } early
156
+ const res = await app.request("/api/linear/issues");
157
+ expect(res.status).toBe(200);
158
+ expect(await res.json()).toEqual({ issues: [] });
159
+ });
160
+
161
+ it("returns empty array when query is whitespace only", async () => {
162
+ const res = await app.request("/api/linear/issues?query= ");
163
+ expect(res.status).toBe(200);
164
+ expect(await res.json()).toEqual({ issues: [] });
165
+ });
166
+
167
+ it("returns 400 when Linear API key is not configured", async () => {
168
+ mockSettings.linearApiKey = "";
169
+ mockResolveApiKey.mockReturnValue(null);
170
+ const res = await app.request("/api/linear/issues?query=test");
171
+ expect(res.status).toBe(400);
172
+ const json = await res.json();
173
+ expect(json.error).toMatch(/no linear connection configured/i);
174
+ });
175
+
176
+ it("searches Linear and returns mapped issues, filtering out completed/canceled (covers lines 87-108)", async () => {
177
+ // Mock global fetch to simulate Linear API response with mixed states
178
+ mockFetch().mockResolvedValue(
179
+ linearOk({
180
+ searchIssues: {
181
+ nodes: [
182
+ makeIssueNode({ id: "1", identifier: "C-1", state: { name: "In Progress", type: "started" } }),
183
+ makeIssueNode({ id: "2", identifier: "C-2", state: { name: "Todo", type: "unstarted" } }),
184
+ makeIssueNode({ id: "3", identifier: "C-3", state: { name: "Done", type: "completed" } }),
185
+ makeIssueNode({ id: "4", identifier: "C-4", state: { name: "Cancelled", type: "cancelled" } }),
186
+ ],
187
+ },
188
+ }),
189
+ );
190
+
191
+ const res = await app.request("/api/linear/issues?query=test&limit=10");
192
+ expect(res.status).toBe(200);
193
+ const json = await res.json();
194
+
195
+ // Completed/canceled issues should be filtered out
196
+ expect(json.issues).toHaveLength(2);
197
+ // Unstarted (0) should sort before started (1)
198
+ expect(json.issues[0].identifier).toBe("C-2");
199
+ expect(json.issues[1].identifier).toBe("C-1");
200
+
201
+ // Verify mapped fields are present (covers lines 95-99)
202
+ const issue = json.issues[0];
203
+ expect(issue).toHaveProperty("stateName");
204
+ expect(issue).toHaveProperty("stateType");
205
+ expect(issue).toHaveProperty("teamName");
206
+ expect(issue).toHaveProperty("teamKey");
207
+ expect(issue).toHaveProperty("teamId");
208
+ });
209
+
210
+ it("clamps limit between 1 and 20", async () => {
211
+ mockFetch().mockResolvedValue(
212
+ linearOk({ searchIssues: { nodes: [] } }),
213
+ );
214
+
215
+ // Test limit > 20 gets clamped
216
+ await app.request("/api/linear/issues?query=test&limit=100");
217
+ const fetchCall = vi.mocked(globalThis.fetch as any).mock.calls[0];
218
+ const body = JSON.parse(fetchCall[1]?.body as string);
219
+ expect(body.variables.first).toBe(20);
220
+ });
221
+
222
+ it("returns 502 when Linear API returns errors (covers lines 106-108)", async () => {
223
+ mockFetch().mockResolvedValue(
224
+ linearError("Authentication failed"),
225
+ );
226
+
227
+ const res = await app.request("/api/linear/issues?query=test");
228
+ expect(res.status).toBe(502);
229
+ const json = await res.json();
230
+ expect(json.error).toBe("Authentication failed");
231
+ });
232
+
233
+ it("returns 502 when Linear API returns non-ok HTTP status", async () => {
234
+ mockFetch().mockResolvedValue(
235
+ linearHttpError("Unauthorized", 401),
236
+ );
237
+
238
+ const res = await app.request("/api/linear/issues?query=test");
239
+ expect(res.status).toBe(502);
240
+ const json = await res.json();
241
+ expect(json.error).toBe("Unauthorized");
242
+ });
243
+
244
+ it("returns 502 when fetch itself throws (network error)", async () => {
245
+ mockFetch().mockRejectedValue(new Error("ECONNREFUSED"));
246
+
247
+ const res = await app.request("/api/linear/issues?query=test");
248
+ expect(res.status).toBe(502);
249
+ const json = await res.json();
250
+ expect(json.error).toMatch(/Failed to connect to Linear/);
251
+ });
252
+
253
+ it("handles issues with null optional fields gracefully (covers lines 87-100)", async () => {
254
+ mockFetch().mockResolvedValue(
255
+ linearOk({
256
+ searchIssues: {
257
+ nodes: [
258
+ {
259
+ id: "issue-null",
260
+ identifier: "C-99",
261
+ title: "Null fields",
262
+ description: null,
263
+ url: "https://linear.app/test",
264
+ branchName: null,
265
+ priorityLabel: null,
266
+ state: null,
267
+ team: null,
268
+ },
269
+ ],
270
+ },
271
+ }),
272
+ );
273
+
274
+ const res = await app.request("/api/linear/issues?query=test");
275
+ expect(res.status).toBe(200);
276
+ const json = await res.json();
277
+ expect(json.issues).toHaveLength(1);
278
+ expect(json.issues[0].description).toBe("");
279
+ expect(json.issues[0].branchName).toBe("");
280
+ expect(json.issues[0].priorityLabel).toBe("");
281
+ expect(json.issues[0].stateName).toBe("");
282
+ expect(json.issues[0].stateType).toBe("");
283
+ expect(json.issues[0].teamName).toBe("");
284
+ expect(json.issues[0].teamKey).toBe("");
285
+ expect(json.issues[0].teamId).toBe("");
286
+ });
287
+ });
288
+
289
+ // =============================================================================
290
+ // GET /api/linear/connection
291
+ // =============================================================================
292
+
293
+ describe("GET /api/linear/connection", () => {
294
+ it("returns 400 when API key is empty (covers lines 113-116)", async () => {
295
+ mockSettings.linearApiKey = "";
296
+ mockResolveApiKey.mockReturnValue(null);
297
+ const res = await app.request("/api/linear/connection");
298
+ expect(res.status).toBe(400);
299
+ const json = await res.json();
300
+ expect(json.error).toMatch(/no linear connection configured/i);
301
+ });
302
+
303
+ it("returns connection info with viewer and team (covers lines 118-120, 124-128)", async () => {
304
+ mockFetch().mockResolvedValue(
305
+ linearOk({
306
+ viewer: { id: "user-1", name: "Test User", email: "test@example.com" },
307
+ teams: { nodes: [{ id: "team-1", key: "COMP", name: "Companion" }] },
308
+ }),
309
+ );
310
+
311
+ const res = await app.request("/api/linear/connection");
312
+ expect(res.status).toBe(200);
313
+ const json = await res.json();
314
+ expect(json.connected).toBe(true);
315
+ expect(json.viewerName).toBe("Test User");
316
+ expect(json.viewerEmail).toBe("test@example.com");
317
+ expect(json.teamName).toBe("Companion");
318
+ expect(json.teamKey).toBe("COMP");
319
+ });
320
+
321
+ it("returns 502 when Linear API returns errors", async () => {
322
+ mockFetch().mockResolvedValue(
323
+ linearError("Invalid API key"),
324
+ );
325
+
326
+ const res = await app.request("/api/linear/connection");
327
+ expect(res.status).toBe(502);
328
+ const json = await res.json();
329
+ expect(json.error).toBe("Invalid API key");
330
+ });
331
+
332
+ it("returns 502 when fetch throws a network error", async () => {
333
+ mockFetch().mockRejectedValue(new Error("Network down"));
334
+
335
+ const res = await app.request("/api/linear/connection");
336
+ expect(res.status).toBe(502);
337
+ const json = await res.json();
338
+ expect(json.error).toMatch(/Failed to connect to Linear/);
339
+ });
340
+ });
341
+
342
+ // =============================================================================
343
+ // PUT /api/sessions/:id/linear-issue
344
+ // =============================================================================
345
+
346
+ describe("PUT /api/sessions/:id/linear-issue", () => {
347
+ it("returns 400 when required fields are missing (covers line 172-173)", async () => {
348
+ const res = await app.request("/api/sessions/sess-1/linear-issue", {
349
+ method: "PUT",
350
+ headers: { "Content-Type": "application/json" },
351
+ body: JSON.stringify({ id: "issue-1" }), // missing identifier, title, url
352
+ });
353
+ expect(res.status).toBe(400);
354
+ const json = await res.json();
355
+ expect(json.error).toMatch(/required/i);
356
+ });
357
+
358
+ it("stores the linear issue and returns ok (covers lines 167-191)", async () => {
359
+ const issueBody = {
360
+ id: "issue-1",
361
+ identifier: "COMP-1",
362
+ title: "Test Issue",
363
+ description: "Some description",
364
+ url: "https://linear.app/test/issue/COMP-1",
365
+ branchName: "comp-1-test",
366
+ priorityLabel: "High",
367
+ stateName: "In Progress",
368
+ stateType: "started",
369
+ teamName: "Companion",
370
+ teamKey: "COMP",
371
+ teamId: "team-1",
372
+ assigneeName: "John Doe",
373
+ updatedAt: "2025-01-01T00:00:00Z",
374
+ };
375
+
376
+ const res = await app.request("/api/sessions/sess-1/linear-issue", {
377
+ method: "PUT",
378
+ headers: { "Content-Type": "application/json" },
379
+ body: JSON.stringify(issueBody),
380
+ });
381
+
382
+ expect(res.status).toBe(200);
383
+ expect(await res.json()).toEqual({ ok: true });
384
+ expect(sessionLinearIssues.setLinearIssue).toHaveBeenCalledWith(
385
+ "sess-1",
386
+ expect.objectContaining({
387
+ id: "issue-1",
388
+ identifier: "COMP-1",
389
+ title: "Test Issue",
390
+ assigneeName: "John Doe",
391
+ updatedAt: "2025-01-01T00:00:00Z",
392
+ }),
393
+ );
394
+ });
395
+
396
+ it("stores issue with optional fields defaulting to empty string (covers lines 179-190)", async () => {
397
+ const issueBody = {
398
+ id: "issue-2",
399
+ identifier: "COMP-2",
400
+ title: "Minimal Issue",
401
+ url: "https://linear.app/test/issue/COMP-2",
402
+ // No optional fields: description, branchName, etc.
403
+ };
404
+
405
+ const res = await app.request("/api/sessions/sess-2/linear-issue", {
406
+ method: "PUT",
407
+ headers: { "Content-Type": "application/json" },
408
+ body: JSON.stringify(issueBody),
409
+ });
410
+
411
+ expect(res.status).toBe(200);
412
+ expect(sessionLinearIssues.setLinearIssue).toHaveBeenCalledWith(
413
+ "sess-2",
414
+ expect.objectContaining({
415
+ description: "",
416
+ branchName: "",
417
+ priorityLabel: "",
418
+ assigneeName: undefined,
419
+ updatedAt: undefined,
420
+ }),
421
+ );
422
+ });
423
+
424
+ it("handles malformed JSON body gracefully", async () => {
425
+ const res = await app.request("/api/sessions/sess-1/linear-issue", {
426
+ method: "PUT",
427
+ headers: { "Content-Type": "application/json" },
428
+ body: "not-json",
429
+ });
430
+ // Body parses as {} so required fields are missing
431
+ expect(res.status).toBe(400);
432
+ });
433
+ });
434
+
435
+ // =============================================================================
436
+ // GET /api/sessions/:id/linear-issue
437
+ // =============================================================================
438
+
439
+ describe("GET /api/sessions/:id/linear-issue", () => {
440
+ it("returns null when no issue is stored (covers lines 195-197)", async () => {
441
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue(undefined);
442
+
443
+ const res = await app.request("/api/sessions/sess-1/linear-issue");
444
+ expect(res.status).toBe(200);
445
+ expect(await res.json()).toEqual({ issue: null });
446
+ });
447
+
448
+ it("returns stored issue without refresh by default (covers lines 199-200)", async () => {
449
+ const stored = {
450
+ id: "issue-1",
451
+ identifier: "COMP-1",
452
+ title: "Stored issue",
453
+ description: "",
454
+ url: "https://linear.app/test",
455
+ branchName: "",
456
+ priorityLabel: "",
457
+ stateName: "Todo",
458
+ stateType: "unstarted",
459
+ teamName: "Comp",
460
+ teamKey: "COMP",
461
+ teamId: "team-1",
462
+ };
463
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue(stored);
464
+
465
+ const res = await app.request("/api/sessions/sess-1/linear-issue");
466
+ expect(res.status).toBe(200);
467
+ const json = await res.json();
468
+ expect(json.issue).toEqual(stored);
469
+ });
470
+
471
+ it("returns stored issue when refresh=true but no API key (covers line 205)", async () => {
472
+ const stored = {
473
+ id: "issue-1",
474
+ identifier: "COMP-1",
475
+ title: "Stored issue",
476
+ description: "",
477
+ url: "https://linear.app/test",
478
+ branchName: "",
479
+ priorityLabel: "",
480
+ stateName: "Todo",
481
+ stateType: "unstarted",
482
+ teamName: "Comp",
483
+ teamKey: "COMP",
484
+ teamId: "team-1",
485
+ };
486
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue(stored);
487
+ mockSettings.linearApiKey = "";
488
+ mockResolveApiKey.mockReturnValue(null);
489
+
490
+ const res = await app.request("/api/sessions/sess-1/linear-issue?refresh=true");
491
+ expect(res.status).toBe(200);
492
+ const json = await res.json();
493
+ expect(json.issue).toEqual(stored);
494
+ });
495
+
496
+ it("refreshes from Linear API and returns updated data with comments/labels (covers refresh path)", async () => {
497
+ const stored = {
498
+ id: "issue-1",
499
+ identifier: "COMP-1",
500
+ title: "Old title",
501
+ description: "",
502
+ url: "https://linear.app/test",
503
+ branchName: "",
504
+ priorityLabel: "",
505
+ stateName: "Todo",
506
+ stateType: "unstarted",
507
+ teamName: "Comp",
508
+ teamKey: "COMP",
509
+ teamId: "team-1",
510
+ };
511
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue(stored);
512
+
513
+ mockFetch().mockResolvedValue(
514
+ linearOk({
515
+ issue: {
516
+ id: "issue-1",
517
+ identifier: "COMP-1",
518
+ title: "Updated title",
519
+ description: "Updated desc",
520
+ url: "https://linear.app/test/issue/COMP-1",
521
+ branchName: "comp-1-updated",
522
+ priorityLabel: "Urgent",
523
+ state: { name: "In Progress", type: "started" },
524
+ team: { id: "team-1", key: "COMP", name: "Companion" },
525
+ comments: {
526
+ nodes: [
527
+ {
528
+ id: "comment-1",
529
+ body: "A comment",
530
+ createdAt: "2025-01-01T00:00:00Z",
531
+ user: { name: "John", displayName: "Johnny", avatarUrl: "https://avatar.url" },
532
+ },
533
+ ],
534
+ },
535
+ assignee: { name: "Jane", displayName: "Jane Doe", avatarUrl: "https://jane.url" },
536
+ labels: { nodes: [{ id: "label-1", name: "Bug", color: "#ff0000" }] },
537
+ },
538
+ }),
539
+ );
540
+
541
+ const res = await app.request("/api/sessions/sess-1/linear-issue?refresh=true");
542
+ expect(res.status).toBe(200);
543
+ const json = await res.json();
544
+
545
+ // Updated issue fields
546
+ expect(json.issue.title).toBe("Updated title");
547
+ expect(json.issue.description).toBe("Updated desc");
548
+ expect(json.issue.stateName).toBe("In Progress");
549
+ expect(json.issue.assigneeName).toBe("Jane Doe");
550
+
551
+ // Comments
552
+ expect(json.comments).toHaveLength(1);
553
+ expect(json.comments[0].body).toBe("A comment");
554
+ expect(json.comments[0].userName).toBe("Johnny");
555
+ expect(json.comments[0].userAvatarUrl).toBe("https://avatar.url");
556
+
557
+ // Assignee
558
+ expect(json.assignee.name).toBe("Jane Doe");
559
+ expect(json.assignee.avatarUrl).toBe("https://jane.url");
560
+
561
+ // Labels
562
+ expect(json.labels).toHaveLength(1);
563
+ expect(json.labels[0].name).toBe("Bug");
564
+
565
+ // setLinearIssue should have been called with updated data
566
+ expect(sessionLinearIssues.setLinearIssue).toHaveBeenCalledWith(
567
+ "sess-1",
568
+ expect.objectContaining({ title: "Updated title" }),
569
+ );
570
+ });
571
+
572
+ it("falls back to stored issue when refresh fetch throws", async () => {
573
+ const stored = {
574
+ id: "issue-1",
575
+ identifier: "COMP-1",
576
+ title: "Stored",
577
+ description: "",
578
+ url: "https://linear.app/test",
579
+ branchName: "",
580
+ priorityLabel: "",
581
+ stateName: "Todo",
582
+ stateType: "unstarted",
583
+ teamName: "Comp",
584
+ teamKey: "COMP",
585
+ teamId: "team-1",
586
+ };
587
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue(stored);
588
+
589
+ // Make the cache's getOrFetch throw so we exercise the catch block
590
+ vi.mocked(linearCache.getOrFetch).mockRejectedValueOnce(new Error("Network error"));
591
+
592
+ const res = await app.request("/api/sessions/sess-1/linear-issue?refresh=true");
593
+ expect(res.status).toBe(200);
594
+ const json = await res.json();
595
+ expect(json.issue).toEqual(stored);
596
+ });
597
+
598
+ it("falls back to stored issue when Linear returns null issue", async () => {
599
+ const stored = {
600
+ id: "issue-1",
601
+ identifier: "COMP-1",
602
+ title: "Stored",
603
+ description: "",
604
+ url: "https://linear.app/test",
605
+ branchName: "",
606
+ priorityLabel: "",
607
+ stateName: "Todo",
608
+ stateType: "unstarted",
609
+ teamName: "Comp",
610
+ teamKey: "COMP",
611
+ teamId: "team-1",
612
+ };
613
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue(stored);
614
+
615
+ mockFetch().mockResolvedValue(
616
+ linearOk({ issue: null }),
617
+ );
618
+
619
+ const res = await app.request("/api/sessions/sess-1/linear-issue?refresh=true");
620
+ expect(res.status).toBe(200);
621
+ const json = await res.json();
622
+ // Falls through to stored data since result is null
623
+ expect(json.issue).toEqual(stored);
624
+ });
625
+ });
626
+
627
+ // =============================================================================
628
+ // DELETE /api/sessions/:id/linear-issue
629
+ // =============================================================================
630
+
631
+ describe("DELETE /api/sessions/:id/linear-issue", () => {
632
+ it("removes the issue and returns ok (covers lines 311-315)", async () => {
633
+ const res = await app.request("/api/sessions/sess-1/linear-issue", {
634
+ method: "DELETE",
635
+ });
636
+ expect(res.status).toBe(200);
637
+ expect(await res.json()).toEqual({ ok: true });
638
+ expect(sessionLinearIssues.removeLinearIssue).toHaveBeenCalledWith("sess-1");
639
+ });
640
+ });
641
+
642
+ // =============================================================================
643
+ // POST /api/linear/issues/:issueId/comments
644
+ // =============================================================================
645
+
646
+ describe("POST /api/linear/issues/:issueId/comments", () => {
647
+ it("returns 400 when body text is missing (covers lines 320-322)", async () => {
648
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
649
+ method: "POST",
650
+ headers: { "Content-Type": "application/json" },
651
+ body: JSON.stringify({}),
652
+ });
653
+ expect(res.status).toBe(400);
654
+ const json = await res.json();
655
+ expect(json.error).toMatch(/body is required/i);
656
+ });
657
+
658
+ it("returns 400 when body is whitespace only", async () => {
659
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
660
+ method: "POST",
661
+ headers: { "Content-Type": "application/json" },
662
+ body: JSON.stringify({ body: " " }),
663
+ });
664
+ expect(res.status).toBe(400);
665
+ });
666
+
667
+ it("returns 400 when Linear API key is not configured (covers lines 324-328)", async () => {
668
+ mockSettings.linearApiKey = "";
669
+ mockResolveApiKey.mockReturnValue(null);
670
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
671
+ method: "POST",
672
+ headers: { "Content-Type": "application/json" },
673
+ body: JSON.stringify({ body: "Hello" }),
674
+ });
675
+ expect(res.status).toBe(400);
676
+ const json = await res.json();
677
+ expect(json.error).toMatch(/no linear connection configured/i);
678
+ });
679
+
680
+ it("creates a comment and returns it (covers lines 330-388)", async () => {
681
+ mockFetch().mockResolvedValue(
682
+ linearOk({
683
+ commentCreate: {
684
+ success: true,
685
+ comment: {
686
+ id: "comment-1",
687
+ body: "Test comment",
688
+ createdAt: "2025-01-01T00:00:00Z",
689
+ user: { name: "Test", displayName: "Test User" },
690
+ },
691
+ },
692
+ }),
693
+ );
694
+
695
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
696
+ method: "POST",
697
+ headers: { "Content-Type": "application/json" },
698
+ body: JSON.stringify({ body: "Test comment" }),
699
+ });
700
+
701
+ expect(res.status).toBe(200);
702
+ const json = await res.json();
703
+ expect(json.ok).toBe(true);
704
+ expect(json.comment.id).toBe("comment-1");
705
+ expect(json.comment.userName).toBe("Test User");
706
+ expect(json.comment.userAvatarUrl).toBeNull();
707
+
708
+ // Should invalidate cache for the issue with connectionId prefix
709
+ expect(linearCache.invalidate).toHaveBeenCalledWith("test-conn:issue:issue-1");
710
+ });
711
+
712
+ it("returns 502 when Linear returns GraphQL errors (covers lines 366-369)", async () => {
713
+ mockFetch().mockResolvedValue(
714
+ linearError("Issue not found"),
715
+ );
716
+
717
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
718
+ method: "POST",
719
+ headers: { "Content-Type": "application/json" },
720
+ body: JSON.stringify({ body: "Test" }),
721
+ });
722
+
723
+ expect(res.status).toBe(502);
724
+ const json = await res.json();
725
+ expect(json.error).toBe("Issue not found");
726
+ });
727
+
728
+ it("returns 502 when Linear returns non-ok HTTP (covers lines 366-369)", async () => {
729
+ mockFetch().mockResolvedValue(
730
+ linearHttpError("Server Error", 500),
731
+ );
732
+
733
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
734
+ method: "POST",
735
+ headers: { "Content-Type": "application/json" },
736
+ body: JSON.stringify({ body: "Test" }),
737
+ });
738
+
739
+ expect(res.status).toBe(502);
740
+ });
741
+
742
+ it("returns 502 when commentCreate reports failure (covers lines 371-373)", async () => {
743
+ mockFetch().mockResolvedValue(
744
+ linearOk({
745
+ commentCreate: { success: false, comment: null },
746
+ }),
747
+ );
748
+
749
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
750
+ method: "POST",
751
+ headers: { "Content-Type": "application/json" },
752
+ body: JSON.stringify({ body: "Test" }),
753
+ });
754
+
755
+ expect(res.status).toBe(502);
756
+ const json = await res.json();
757
+ expect(json.error).toBe("Comment creation failed");
758
+ });
759
+
760
+ it("handles fetch network error by throwing (covers line 347-348)", async () => {
761
+ mockFetch().mockRejectedValue(new Error("ECONNREFUSED"));
762
+
763
+ const res = await app.request("/api/linear/issues/issue-1/comments", {
764
+ method: "POST",
765
+ headers: { "Content-Type": "application/json" },
766
+ body: JSON.stringify({ body: "Test" }),
767
+ });
768
+
769
+ // The route does not have a try/catch around the fetch for comments,
770
+ // so Hono's error handler will produce a 500
771
+ expect(res.status).toBe(500);
772
+ });
773
+ });
774
+
775
+ // =============================================================================
776
+ // GET /api/linear/states
777
+ // =============================================================================
778
+
779
+ describe("GET /api/linear/states", () => {
780
+ it("returns 400 when API key is empty", async () => {
781
+ mockSettings.linearApiKey = "";
782
+ mockResolveApiKey.mockReturnValue(null);
783
+ const res = await app.request("/api/linear/states");
784
+ expect(res.status).toBe(400);
785
+ const json = await res.json();
786
+ expect(json.error).toMatch(/no linear connection configured/i);
787
+ });
788
+
789
+ it("returns mapped team states (covers lines 455-467)", async () => {
790
+ mockFetch().mockResolvedValue(
791
+ linearOk({
792
+ teams: {
793
+ nodes: [
794
+ {
795
+ id: "team-1",
796
+ key: "COMP",
797
+ name: "Companion",
798
+ states: {
799
+ nodes: [
800
+ { id: "state-1", name: "Todo", type: "unstarted" },
801
+ { id: "state-2", name: "In Progress", type: "started" },
802
+ ],
803
+ },
804
+ },
805
+ ],
806
+ },
807
+ }),
808
+ );
809
+
810
+ const res = await app.request("/api/linear/states");
811
+ expect(res.status).toBe(200);
812
+ const json = await res.json();
813
+ expect(json.teams).toHaveLength(1);
814
+ expect(json.teams[0].key).toBe("COMP");
815
+ expect(json.teams[0].states).toHaveLength(2);
816
+ expect(json.teams[0].states[0].name).toBe("Todo");
817
+ });
818
+
819
+ it("handles null/empty fields in team states (covers lines 456-463)", async () => {
820
+ mockFetch().mockResolvedValue(
821
+ linearOk({
822
+ teams: {
823
+ nodes: [
824
+ {
825
+ id: undefined,
826
+ key: null,
827
+ name: null,
828
+ states: { nodes: [{ id: undefined, name: null, type: null }] },
829
+ },
830
+ ],
831
+ },
832
+ }),
833
+ );
834
+
835
+ const res = await app.request("/api/linear/states");
836
+ expect(res.status).toBe(200);
837
+ const json = await res.json();
838
+ expect(json.teams[0].id).toBe("");
839
+ expect(json.teams[0].key).toBe("");
840
+ expect(json.teams[0].name).toBe("");
841
+ expect(json.teams[0].states[0].id).toBe("");
842
+ expect(json.teams[0].states[0].name).toBe("");
843
+ expect(json.teams[0].states[0].type).toBe("");
844
+ });
845
+
846
+ it("returns 502 when fetchLinearTeamStates returns empty (Linear API error)", async () => {
847
+ // fetchLinearTeamStates catches errors internally and returns [].
848
+ // When it returns empty, the route returns a generic 502.
849
+ mockFetch().mockResolvedValue(
850
+ linearError("Rate limited"),
851
+ );
852
+
853
+ const res = await app.request("/api/linear/states");
854
+ expect(res.status).toBe(502);
855
+ const json = await res.json();
856
+ expect(json.error).toBe("Failed to fetch Linear workflow states");
857
+ });
858
+
859
+ it("returns 502 when fetchLinearTeamStates returns empty (network error)", async () => {
860
+ mockFetch().mockRejectedValue(new Error("timeout"));
861
+
862
+ const res = await app.request("/api/linear/states");
863
+ expect(res.status).toBe(502);
864
+ const json = await res.json();
865
+ expect(json.error).toBe("Failed to fetch Linear workflow states");
866
+ });
867
+ });
868
+
869
+ // =============================================================================
870
+ // GET /api/linear/project-issues
871
+ // =============================================================================
872
+
873
+ describe("GET /api/linear/project-issues", () => {
874
+ it("returns 400 when projectId is missing", async () => {
875
+ const res = await app.request("/api/linear/project-issues");
876
+ expect(res.status).toBe(400);
877
+ const json = await res.json();
878
+ expect(json.error).toMatch(/projectId is required/i);
879
+ });
880
+
881
+ it("returns 400 when API key is not configured", async () => {
882
+ mockSettings.linearApiKey = "";
883
+ mockResolveApiKey.mockReturnValue(null);
884
+ const res = await app.request("/api/linear/project-issues?projectId=proj-1");
885
+ expect(res.status).toBe(400);
886
+ const json = await res.json();
887
+ expect(json.error).toMatch(/no linear connection configured/i);
888
+ });
889
+
890
+ it("returns mapped project issues, filtering done and sorting by state (covers lines 580-628)", async () => {
891
+ mockFetch().mockResolvedValue(
892
+ linearOk({
893
+ issues: {
894
+ nodes: [
895
+ {
896
+ id: "i-1",
897
+ identifier: "C-10",
898
+ title: "Started issue",
899
+ description: "desc",
900
+ url: "https://linear.app/test",
901
+ priorityLabel: "Medium",
902
+ state: { name: "In Progress", type: "started" },
903
+ team: { key: "COMP", name: "Companion" },
904
+ assignee: { name: "Alice" },
905
+ updatedAt: "2025-01-01T00:00:00Z",
906
+ },
907
+ {
908
+ id: "i-2",
909
+ identifier: "C-11",
910
+ title: "Unstarted issue",
911
+ description: null,
912
+ url: "https://linear.app/test",
913
+ priorityLabel: null,
914
+ state: { name: "Backlog", type: "backlog" },
915
+ team: { key: "COMP", name: "Companion" },
916
+ assignee: null,
917
+ updatedAt: null,
918
+ },
919
+ {
920
+ id: "i-3",
921
+ identifier: "C-12",
922
+ title: "Done issue",
923
+ description: "",
924
+ url: "https://linear.app/test",
925
+ priorityLabel: "Low",
926
+ state: { name: "Done", type: "completed" },
927
+ team: { key: "COMP", name: "Companion" },
928
+ assignee: { name: "Bob" },
929
+ updatedAt: "2025-01-02",
930
+ },
931
+ ],
932
+ },
933
+ }),
934
+ );
935
+
936
+ const res = await app.request("/api/linear/project-issues?projectId=proj-1&limit=15");
937
+ expect(res.status).toBe(200);
938
+ const json = await res.json();
939
+
940
+ // Completed issue should be filtered out
941
+ expect(json.issues).toHaveLength(2);
942
+ // Backlog (0) sorts before started (1)
943
+ expect(json.issues[0].identifier).toBe("C-11");
944
+ expect(json.issues[1].identifier).toBe("C-10");
945
+
946
+ // Verify null field defaults
947
+ expect(json.issues[0].description).toBe("");
948
+ expect(json.issues[0].priorityLabel).toBe("");
949
+ expect(json.issues[0].assigneeName).toBe("");
950
+ expect(json.issues[0].updatedAt).toBe("");
951
+ });
952
+
953
+ it("returns 502 when Linear API returns errors (covers lines 603-605)", async () => {
954
+ mockFetch().mockResolvedValue(
955
+ linearError("Auth error"),
956
+ );
957
+
958
+ const res = await app.request("/api/linear/project-issues?projectId=proj-1");
959
+ expect(res.status).toBe(502);
960
+ const json = await res.json();
961
+ expect(json.error).toBe("Auth error");
962
+ });
963
+
964
+ it("returns 502 on network error (covers lines 627-628)", async () => {
965
+ mockFetch().mockRejectedValue(new Error("connection reset"));
966
+
967
+ const res = await app.request("/api/linear/project-issues?projectId=proj-1");
968
+ expect(res.status).toBe(502);
969
+ const json = await res.json();
970
+ expect(json.error).toMatch(/Failed to connect to Linear/);
971
+ });
972
+
973
+ it("clamps limit to max 50 and min 1", async () => {
974
+ mockFetch().mockResolvedValue(
975
+ linearOk({ issues: { nodes: [] } }),
976
+ );
977
+
978
+ await app.request("/api/linear/project-issues?projectId=proj-1&limit=999");
979
+ const fetchCall = vi.mocked(globalThis.fetch as any).mock.calls[0];
980
+ const body = JSON.parse(fetchCall[1]?.body as string);
981
+ expect(body.variables.first).toBe(50);
982
+ });
983
+ });
984
+
985
+ // =============================================================================
986
+ // GET /api/linear/project-mappings
987
+ // =============================================================================
988
+
989
+ describe("GET /api/linear/project-mappings", () => {
990
+ it("returns a specific mapping when repoRoot is provided", async () => {
991
+ const mapping = {
992
+ repoRoot: "/home/user/project",
993
+ projectId: "proj-1",
994
+ projectName: "Project One",
995
+ createdAt: 1000,
996
+ updatedAt: 1000,
997
+ };
998
+ vi.mocked(linearProjectManager.getMapping).mockReturnValue(mapping);
999
+
1000
+ const res = await app.request("/api/linear/project-mappings?repoRoot=/home/user/project");
1001
+ expect(res.status).toBe(200);
1002
+ const json = await res.json();
1003
+ expect(json.mapping).toEqual(mapping);
1004
+ });
1005
+
1006
+ it("returns null mapping when repoRoot is not found", async () => {
1007
+ vi.mocked(linearProjectManager.getMapping).mockReturnValue(null);
1008
+
1009
+ const res = await app.request("/api/linear/project-mappings?repoRoot=/nonexistent");
1010
+ expect(res.status).toBe(200);
1011
+ const json = await res.json();
1012
+ expect(json.mapping).toBeNull();
1013
+ });
1014
+
1015
+ it("returns all mappings when no repoRoot is provided", async () => {
1016
+ const mappings = [
1017
+ { repoRoot: "/a", projectId: "p1", projectName: "P1", createdAt: 1, updatedAt: 1 },
1018
+ { repoRoot: "/b", projectId: "p2", projectName: "P2", createdAt: 2, updatedAt: 2 },
1019
+ ];
1020
+ vi.mocked(linearProjectManager.listMappings).mockReturnValue(mappings);
1021
+
1022
+ const res = await app.request("/api/linear/project-mappings");
1023
+ expect(res.status).toBe(200);
1024
+ const json = await res.json();
1025
+ expect(json.mappings).toHaveLength(2);
1026
+ });
1027
+ });
1028
+
1029
+ // =============================================================================
1030
+ // PUT /api/linear/project-mappings
1031
+ // =============================================================================
1032
+
1033
+ describe("PUT /api/linear/project-mappings", () => {
1034
+ it("returns 400 when required fields are missing", async () => {
1035
+ const res = await app.request("/api/linear/project-mappings", {
1036
+ method: "PUT",
1037
+ headers: { "Content-Type": "application/json" },
1038
+ body: JSON.stringify({ repoRoot: "/test" }), // missing projectId, projectName
1039
+ });
1040
+ expect(res.status).toBe(400);
1041
+ const json = await res.json();
1042
+ expect(json.error).toMatch(/required/i);
1043
+ });
1044
+
1045
+ it("creates/updates a mapping and returns it", async () => {
1046
+ const mapping = {
1047
+ repoRoot: "/test",
1048
+ projectId: "proj-1",
1049
+ projectName: "Project One",
1050
+ createdAt: 1000,
1051
+ updatedAt: 1000,
1052
+ };
1053
+ vi.mocked(linearProjectManager.upsertMapping).mockReturnValue(mapping);
1054
+
1055
+ const res = await app.request("/api/linear/project-mappings", {
1056
+ method: "PUT",
1057
+ headers: { "Content-Type": "application/json" },
1058
+ body: JSON.stringify({
1059
+ repoRoot: "/test",
1060
+ projectId: "proj-1",
1061
+ projectName: "Project One",
1062
+ }),
1063
+ });
1064
+
1065
+ expect(res.status).toBe(200);
1066
+ const json = await res.json();
1067
+ expect(json.mapping).toEqual(mapping);
1068
+ expect(linearProjectManager.upsertMapping).toHaveBeenCalledWith("/test", {
1069
+ projectId: "proj-1",
1070
+ projectName: "Project One",
1071
+ });
1072
+ });
1073
+ });
1074
+
1075
+ // =============================================================================
1076
+ // DELETE /api/linear/project-mappings
1077
+ // =============================================================================
1078
+
1079
+ describe("DELETE /api/linear/project-mappings", () => {
1080
+ it("returns 400 when repoRoot is missing", async () => {
1081
+ const res = await app.request("/api/linear/project-mappings", {
1082
+ method: "DELETE",
1083
+ headers: { "Content-Type": "application/json" },
1084
+ body: JSON.stringify({}),
1085
+ });
1086
+ expect(res.status).toBe(400);
1087
+ const json = await res.json();
1088
+ expect(json.error).toMatch(/repoRoot is required/i);
1089
+ });
1090
+
1091
+ it("returns 404 when mapping is not found", async () => {
1092
+ vi.mocked(linearProjectManager.removeMapping).mockReturnValue(false);
1093
+
1094
+ const res = await app.request("/api/linear/project-mappings", {
1095
+ method: "DELETE",
1096
+ headers: { "Content-Type": "application/json" },
1097
+ body: JSON.stringify({ repoRoot: "/nonexistent" }),
1098
+ });
1099
+ expect(res.status).toBe(404);
1100
+ const json = await res.json();
1101
+ expect(json.error).toMatch(/not found/i);
1102
+ });
1103
+
1104
+ it("deletes the mapping and returns ok", async () => {
1105
+ vi.mocked(linearProjectManager.removeMapping).mockReturnValue(true);
1106
+
1107
+ const res = await app.request("/api/linear/project-mappings", {
1108
+ method: "DELETE",
1109
+ headers: { "Content-Type": "application/json" },
1110
+ body: JSON.stringify({ repoRoot: "/test" }),
1111
+ });
1112
+ expect(res.status).toBe(200);
1113
+ expect(await res.json()).toEqual({ ok: true });
1114
+ });
1115
+ });
1116
+
1117
+ // =============================================================================
1118
+ // POST /api/linear/issues/:id/transition
1119
+ // =============================================================================
1120
+
1121
+ describe("POST /api/linear/issues/:id/transition", () => {
1122
+ it("returns 400 when API key is not configured (covers line 670+)", async () => {
1123
+ mockSettings.linearApiKey = "";
1124
+ mockResolveApiKey.mockReturnValue(null);
1125
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1126
+ method: "POST",
1127
+ });
1128
+ expect(res.status).toBe(400);
1129
+ const json = await res.json();
1130
+ expect(json.error).toMatch(/no linear connection configured/i);
1131
+ });
1132
+
1133
+ it("returns skipped when auto-transition is disabled", async () => {
1134
+ mockSettings.linearAutoTransition = false;
1135
+
1136
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1137
+ method: "POST",
1138
+ });
1139
+ expect(res.status).toBe(200);
1140
+ const json = await res.json();
1141
+ expect(json.ok).toBe(true);
1142
+ expect(json.skipped).toBe(true);
1143
+ expect(json.reason).toBe("auto_transition_disabled");
1144
+ });
1145
+
1146
+ it("returns skipped when no target state is configured", async () => {
1147
+ mockSettings.linearAutoTransition = true;
1148
+ mockSettings.linearAutoTransitionStateId = "";
1149
+
1150
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1151
+ method: "POST",
1152
+ });
1153
+ expect(res.status).toBe(200);
1154
+ const json = await res.json();
1155
+ expect(json.ok).toBe(true);
1156
+ expect(json.skipped).toBe(true);
1157
+ expect(json.reason).toBe("no_target_state_configured");
1158
+ });
1159
+
1160
+ it("transitions the issue successfully and invalidates cache", async () => {
1161
+ mockSettings.linearAutoTransition = true;
1162
+ mockSettings.linearAutoTransitionStateId = "state-in-progress";
1163
+
1164
+ mockFetch().mockResolvedValue(
1165
+ linearOk({
1166
+ issueUpdate: {
1167
+ success: true,
1168
+ issue: {
1169
+ id: "issue-1",
1170
+ identifier: "COMP-1",
1171
+ state: { name: "In Progress", type: "started" },
1172
+ },
1173
+ },
1174
+ }),
1175
+ );
1176
+
1177
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1178
+ method: "POST",
1179
+ });
1180
+
1181
+ expect(res.status).toBe(200);
1182
+ const json = await res.json();
1183
+ expect(json.ok).toBe(true);
1184
+ expect(json.skipped).toBe(false);
1185
+ expect(json.issue.identifier).toBe("COMP-1");
1186
+ expect(json.issue.stateName).toBe("In Progress");
1187
+ expect(json.issue.stateType).toBe("started");
1188
+
1189
+ // Cache should be invalidated with connection prefix
1190
+ expect(linearCache.invalidate).toHaveBeenCalledWith("test-conn:issue:issue-1");
1191
+ });
1192
+
1193
+ it("returns 502 when Linear returns GraphQL errors", async () => {
1194
+ mockSettings.linearAutoTransition = true;
1195
+ mockSettings.linearAutoTransitionStateId = "state-1";
1196
+
1197
+ mockFetch().mockResolvedValue(
1198
+ linearError("State not found"),
1199
+ );
1200
+
1201
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1202
+ method: "POST",
1203
+ });
1204
+
1205
+ expect(res.status).toBe(502);
1206
+ const json = await res.json();
1207
+ expect(json.error).toBe("State not found");
1208
+ });
1209
+
1210
+ it("returns 502 when Linear returns non-ok HTTP", async () => {
1211
+ mockSettings.linearAutoTransition = true;
1212
+ mockSettings.linearAutoTransitionStateId = "state-1";
1213
+
1214
+ mockFetch().mockResolvedValue(
1215
+ linearHttpError("Bad Gateway", 502),
1216
+ );
1217
+
1218
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1219
+ method: "POST",
1220
+ });
1221
+
1222
+ expect(res.status).toBe(502);
1223
+ });
1224
+
1225
+ it("returns 502 when fetch throws a network error (covers lines 747-748)", async () => {
1226
+ mockSettings.linearAutoTransition = true;
1227
+ mockSettings.linearAutoTransitionStateId = "state-1";
1228
+
1229
+ mockFetch().mockRejectedValue(new Error("ECONNREFUSED"));
1230
+
1231
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1232
+ method: "POST",
1233
+ });
1234
+
1235
+ expect(res.status).toBe(502);
1236
+ const json = await res.json();
1237
+ expect(json.error).toMatch(/Linear transition failed/);
1238
+ expect(json.error).toMatch(/ECONNREFUSED/);
1239
+ });
1240
+
1241
+ it("handles missing issue data in successful response", async () => {
1242
+ mockSettings.linearAutoTransition = true;
1243
+ mockSettings.linearAutoTransitionStateId = "state-1";
1244
+
1245
+ mockFetch().mockResolvedValue(
1246
+ linearOk({
1247
+ issueUpdate: {
1248
+ success: true,
1249
+ issue: null, // issue data is missing
1250
+ },
1251
+ }),
1252
+ );
1253
+
1254
+ const res = await app.request("/api/linear/issues/issue-1/transition", {
1255
+ method: "POST",
1256
+ });
1257
+
1258
+ expect(res.status).toBe(200);
1259
+ const json = await res.json();
1260
+ // Should fall back to the issueId param and empty strings
1261
+ expect(json.issue.id).toBe("issue-1");
1262
+ expect(json.issue.identifier).toBe("");
1263
+ expect(json.issue.stateName).toBe("");
1264
+ expect(json.issue.stateType).toBe("");
1265
+ });
1266
+ });
1267
+
1268
+ // =============================================================================
1269
+ // GET /api/linear/projects
1270
+ // =============================================================================
1271
+
1272
+ describe("GET /api/linear/projects", () => {
1273
+ it("returns 400 when API key is not configured", async () => {
1274
+ mockSettings.linearApiKey = "";
1275
+ mockResolveApiKey.mockReturnValue(null);
1276
+ const res = await app.request("/api/linear/projects");
1277
+ expect(res.status).toBe(400);
1278
+ const json = await res.json();
1279
+ expect(json.error).toMatch(/no linear connection configured/i);
1280
+ });
1281
+
1282
+ it("returns mapped projects", async () => {
1283
+ mockFetch().mockResolvedValue(
1284
+ linearOk({
1285
+ projects: {
1286
+ nodes: [
1287
+ { id: "p1", name: "Project Alpha", state: "started" },
1288
+ { id: "p2", name: "Project Beta", state: "planned" },
1289
+ ],
1290
+ },
1291
+ }),
1292
+ );
1293
+
1294
+ const res = await app.request("/api/linear/projects");
1295
+ expect(res.status).toBe(200);
1296
+ const json = await res.json();
1297
+ expect(json.projects).toHaveLength(2);
1298
+ expect(json.projects[0]).toEqual({ id: "p1", name: "Project Alpha", state: "started" });
1299
+ });
1300
+
1301
+ it("returns 502 on Linear API error", async () => {
1302
+ mockFetch().mockResolvedValue(
1303
+ linearError("Rate limit exceeded"),
1304
+ );
1305
+
1306
+ const res = await app.request("/api/linear/projects");
1307
+ expect(res.status).toBe(502);
1308
+ const json = await res.json();
1309
+ expect(json.error).toBe("Rate limit exceeded");
1310
+ });
1311
+
1312
+ it("returns 502 on network error", async () => {
1313
+ mockFetch().mockRejectedValue(new Error("DNS lookup failed"));
1314
+
1315
+ const res = await app.request("/api/linear/projects");
1316
+ expect(res.status).toBe(502);
1317
+ const json = await res.json();
1318
+ expect(json.error).toMatch(/Failed to connect to Linear/);
1319
+ });
1320
+
1321
+ it("handles null fields in project data", async () => {
1322
+ mockFetch().mockResolvedValue(
1323
+ linearOk({
1324
+ projects: {
1325
+ nodes: [{ id: undefined, name: null, state: null }],
1326
+ },
1327
+ }),
1328
+ );
1329
+
1330
+ const res = await app.request("/api/linear/projects");
1331
+ expect(res.status).toBe(200);
1332
+ const json = await res.json();
1333
+ expect(json.projects[0]).toEqual({ id: "", name: "", state: "" });
1334
+ });
1335
+ });
1336
+
1337
+ // =============================================================================
1338
+ // linearIssueStateCategory helper (tested indirectly through routes)
1339
+ // =============================================================================
1340
+
1341
+ describe("linearIssueStateCategory (via issue filtering)", () => {
1342
+ // This tests the helper function at lines 7-15 which categorizes issue states.
1343
+ // We test it through the search endpoint which uses it for filtering/sorting.
1344
+
1345
+ it("categorizes 'canceled' stateType as done (filtered out)", async () => {
1346
+ mockFetch().mockResolvedValue(
1347
+ linearOk({
1348
+ searchIssues: {
1349
+ nodes: [
1350
+ makeIssueNode({ id: "1", state: { name: "Canceled", type: "canceled" } }),
1351
+ ],
1352
+ },
1353
+ }),
1354
+ );
1355
+
1356
+ const res = await app.request("/api/linear/issues?query=test");
1357
+ const json = await res.json();
1358
+ expect(json.issues).toHaveLength(0);
1359
+ });
1360
+
1361
+ it("categorizes 'cancelled' stateType as done (filtered out)", async () => {
1362
+ mockFetch().mockResolvedValue(
1363
+ linearOk({
1364
+ searchIssues: {
1365
+ nodes: [
1366
+ makeIssueNode({ id: "1", state: { name: "Cancelled", type: "cancelled" } }),
1367
+ ],
1368
+ },
1369
+ }),
1370
+ );
1371
+
1372
+ const res = await app.request("/api/linear/issues?query=test");
1373
+ const json = await res.json();
1374
+ expect(json.issues).toHaveLength(0);
1375
+ });
1376
+
1377
+ it("categorizes 'done' stateName as done (filtered out)", async () => {
1378
+ mockFetch().mockResolvedValue(
1379
+ linearOk({
1380
+ searchIssues: {
1381
+ nodes: [
1382
+ makeIssueNode({ id: "1", state: { name: "done", type: "custom" } }),
1383
+ ],
1384
+ },
1385
+ }),
1386
+ );
1387
+
1388
+ const res = await app.request("/api/linear/issues?query=test");
1389
+ const json = await res.json();
1390
+ expect(json.issues).toHaveLength(0);
1391
+ });
1392
+
1393
+ it("keeps 'started' issues and sorts them after unstarted", async () => {
1394
+ mockFetch().mockResolvedValue(
1395
+ linearOk({
1396
+ searchIssues: {
1397
+ nodes: [
1398
+ makeIssueNode({ id: "started", identifier: "S-1", state: { name: "Working", type: "started" } }),
1399
+ makeIssueNode({ id: "backlog", identifier: "B-1", state: { name: "Backlog", type: "triage" } }),
1400
+ ],
1401
+ },
1402
+ }),
1403
+ );
1404
+
1405
+ const res = await app.request("/api/linear/issues?query=test");
1406
+ const json = await res.json();
1407
+ expect(json.issues).toHaveLength(2);
1408
+ // triage (0) before started (1)
1409
+ expect(json.issues[0].identifier).toBe("B-1");
1410
+ expect(json.issues[1].identifier).toBe("S-1");
1411
+ });
1412
+ });
1413
+
1414
+ // =============================================================================
1415
+ // transitionLinearIssue helper (exported function)
1416
+ // =============================================================================
1417
+
1418
+ describe("transitionLinearIssue helper", () => {
1419
+ afterEach(() => {
1420
+ globalThis.fetch = originalFetch;
1421
+ });
1422
+
1423
+ it("returns success with issue details on successful transition", async () => {
1424
+ mockFetch().mockResolvedValue(
1425
+ linearOk({
1426
+ issueUpdate: {
1427
+ success: true,
1428
+ issue: {
1429
+ id: "issue-1",
1430
+ identifier: "ENG-42",
1431
+ state: { name: "Backlog", type: "backlog" },
1432
+ },
1433
+ },
1434
+ }),
1435
+ );
1436
+
1437
+ const result = await transitionLinearIssue("issue-1", "state-backlog", "lin_api_key");
1438
+ expect(result.ok).toBe(true);
1439
+ expect(result.issue).toEqual({
1440
+ id: "issue-1",
1441
+ identifier: "ENG-42",
1442
+ stateName: "Backlog",
1443
+ stateType: "backlog",
1444
+ });
1445
+ // Cache invalidation uses no prefix when connectionId is not passed
1446
+ expect(linearCache.invalidate).toHaveBeenCalledWith("issue:issue-1");
1447
+ });
1448
+
1449
+ it("returns error when Linear returns GraphQL errors", async () => {
1450
+ mockFetch().mockResolvedValue(linearError("State not found"));
1451
+
1452
+ const result = await transitionLinearIssue("issue-1", "bad-state", "lin_api_key");
1453
+ expect(result.ok).toBe(false);
1454
+ expect(result.error).toBe("State not found");
1455
+ });
1456
+
1457
+ it("returns error when fetch throws", async () => {
1458
+ mockFetch().mockRejectedValue(new Error("Network error"));
1459
+
1460
+ const result = await transitionLinearIssue("issue-1", "state-1", "lin_api_key");
1461
+ expect(result.ok).toBe(false);
1462
+ expect(result.error).toMatch(/Network error/);
1463
+ });
1464
+ });
1465
+
1466
+ // =============================================================================
1467
+ // fetchLinearTeamStates helper (exported function)
1468
+ // =============================================================================
1469
+
1470
+ describe("fetchLinearTeamStates helper", () => {
1471
+ afterEach(() => {
1472
+ globalThis.fetch = originalFetch;
1473
+ });
1474
+
1475
+ it("returns team states from Linear API", async () => {
1476
+ // linearCache.getOrFetch executes the fetcher (mocked above)
1477
+ mockFetch().mockResolvedValue(
1478
+ linearOk({
1479
+ teams: {
1480
+ nodes: [
1481
+ {
1482
+ id: "team-1",
1483
+ key: "ENG",
1484
+ name: "Engineering",
1485
+ states: {
1486
+ nodes: [
1487
+ { id: "s1", name: "Backlog", type: "backlog" },
1488
+ { id: "s2", name: "In Progress", type: "started" },
1489
+ ],
1490
+ },
1491
+ },
1492
+ ],
1493
+ },
1494
+ }),
1495
+ );
1496
+
1497
+ const teams = await fetchLinearTeamStates("lin_api_key");
1498
+ expect(teams).toHaveLength(1);
1499
+ expect(teams[0].id).toBe("team-1");
1500
+ expect(teams[0].states).toHaveLength(2);
1501
+ expect(teams[0].states[0].type).toBe("backlog");
1502
+ });
1503
+
1504
+ it("returns empty array on fetch error", async () => {
1505
+ mockFetch().mockRejectedValue(new Error("Network error"));
1506
+
1507
+ const teams = await fetchLinearTeamStates("lin_api_key");
1508
+ expect(teams).toEqual([]);
1509
+ });
1510
+ });