@knpkv/jira-clockify 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/package.json +6 -6
  2. package/dist/src/cli/auth.js +19 -4
  3. package/dist/src/cli/auth.js.map +1 -1
  4. package/dist/src/cli/config.js.map +1 -1
  5. package/dist/src/cli/fetchTicket.js +9 -4
  6. package/dist/src/cli/fetchTicket.js.map +1 -1
  7. package/dist/src/cli/fuzzySelect.js.map +1 -1
  8. package/dist/src/cli/layers.js.map +1 -1
  9. package/dist/src/cli/setup.js +36 -6
  10. package/dist/src/cli/setup.js.map +1 -1
  11. package/dist/src/cli/timer/edit.js +14 -7
  12. package/dist/src/cli/timer/edit.js.map +1 -1
  13. package/dist/src/cli/timer/start.js +9 -5
  14. package/dist/src/cli/timer/start.js.map +1 -1
  15. package/dist/src/cli/timer/status.js.map +1 -1
  16. package/dist/src/cli/timer/stop.js +48 -8
  17. package/dist/src/cli/timer/stop.js.map +1 -1
  18. package/dist/src/services/ClockifyAuth.js.map +1 -1
  19. package/dist/src/services/ConfigService.js +34 -1
  20. package/dist/src/services/ConfigService.js.map +1 -1
  21. package/dist/src/services/ReconcileService.js +0 -0
  22. package/dist/src/services/ReconcileService.js.map +1 -1
  23. package/dist/src/services/StateWriter.js +25 -1
  24. package/dist/src/services/StateWriter.js.map +1 -1
  25. package/dist/src/services/TicketService.js +10 -5
  26. package/dist/src/services/TicketService.js.map +1 -1
  27. package/dist/src/services/TimerService.js +34 -6
  28. package/dist/src/services/TimerService.js.map +1 -1
  29. package/dist/src/tui/App.js +69 -9
  30. package/dist/src/tui/App.js.map +1 -1
  31. package/dist/src/tui/components/BigTimer.js +1 -1
  32. package/dist/src/tui/components/BigTimer.js.map +1 -1
  33. package/dist/src/tui/components/Footer.js +11 -2
  34. package/dist/src/tui/components/Footer.js.map +1 -1
  35. package/dist/src/tui/components/Header.js +2 -1
  36. package/dist/src/tui/components/Header.js.map +1 -1
  37. package/dist/src/tui/components/PopupInput.js +10 -5
  38. package/dist/src/tui/components/PopupInput.js.map +1 -1
  39. package/dist/src/tui/components/PopupMessage.js +7 -3
  40. package/dist/src/tui/components/PopupMessage.js.map +1 -1
  41. package/dist/src/tui/components/TicketList.js +7 -8
  42. package/dist/src/tui/components/TicketList.js.map +1 -1
  43. package/dist/src/tui/components/TicketRow.js +7 -6
  44. package/dist/src/tui/components/TicketRow.js.map +1 -1
  45. package/dist/src/tui/components/TimerDisplay.js +1 -1
  46. package/dist/src/tui/components/TimerDisplay.js.map +1 -1
  47. package/dist/src/tui/hooks/useTerminalSize.js +3 -1
  48. package/dist/src/tui/hooks/useTerminalSize.js.map +1 -1
  49. package/dist/src/utils/time.js +72 -0
  50. package/dist/src/utils/time.js.map +1 -1
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +9 -9
  53. package/dist/test/ReconcileService.test.js +0 -146
  54. package/dist/test/ReconcileService.test.js.map +0 -1
  55. package/dist/test/TimerService.test.js +0 -607
  56. package/dist/test/TimerService.test.js.map +0 -1
  57. package/dist/test/commandSurface.test.js +0 -42
  58. package/dist/test/commandSurface.test.js.map +0 -1
  59. package/dist/test/fetchTicket.test.js +0 -89
  60. package/dist/test/fetchTicket.test.js.map +0 -1
  61. package/dist/test/stopPrompts.test.js +0 -93
  62. package/dist/test/stopPrompts.test.js.map +0 -1
  63. package/dist/test/time.test.js +0 -84
  64. package/dist/test/time.test.js.map +0 -1
@@ -1,607 +0,0 @@
1
- import { describe, expect, it } from "@effect/vitest";
2
- import { ClockifyApiClient } from "@knpkv/clockify-api-client";
3
- import { FetchClientError, JiraApiClient } from "@knpkv/jira-api-client";
4
- import { JiraAuth } from "@knpkv/jira-cli/JiraAuth";
5
- import * as Effect from "effect/Effect";
6
- import * as Layer from "effect/Layer";
7
- import * as Redacted from "effect/Redacted";
8
- import * as SubscriptionRef from "effect/SubscriptionRef";
9
- import { TestClock } from "effect/testing";
10
- import { HttpClient, HttpClientResponse } from "effect/unstable/http";
11
- import { ClockifyAuth } from "../src/services/ClockifyAuth.js";
12
- import { ConfigService } from "../src/services/ConfigService.js";
13
- import { StateWriter } from "../src/services/StateWriter.js";
14
- import { layer as timerLayer, TimerError, TimerService } from "../src/services/TimerService.js";
15
- // ---------------------------------------------------------------------------
16
- // Helpers
17
- // ---------------------------------------------------------------------------
18
- const WORKSPACE_ID = "ws-1";
19
- const USER_ID = "user-1";
20
- const MANUAL_NOW = Date.parse("2025-01-01T10:00:00.000Z");
21
- const makeTicket = (overrides) => ({
22
- key: "PROJ-123",
23
- summary: "Fix the widget",
24
- status: "In Progress",
25
- priority: "High",
26
- assignee: "dev",
27
- type: "Bug",
28
- labels: ["backend"],
29
- updated: new Date().toISOString(),
30
- ...overrides
31
- });
32
- let createdEntries = [];
33
- let updatedEntries = [];
34
- let deletedEntries = [];
35
- let stoppedTimers = [];
36
- const resetCaptures = () => {
37
- createdEntries = [];
38
- updatedEntries = [];
39
- deletedEntries = [];
40
- stoppedTimers = [];
41
- };
42
- const makeTimeEntry = (id, description, startedAt, projectId) => ({
43
- id,
44
- description,
45
- billable: true,
46
- ...(projectId ? { projectId } : {}),
47
- userId: USER_ID,
48
- workspaceId: WORKSPACE_ID,
49
- timeInterval: { start: startedAt.toISOString() },
50
- tagIds: [],
51
- type: "REGULAR",
52
- isLocked: false
53
- });
54
- const mockClockify = {
55
- api: {},
56
- getUser: () => Effect.succeed({
57
- id: USER_ID,
58
- name: "Test",
59
- email: "t@t.com",
60
- activeWorkspace: WORKSPACE_ID,
61
- defaultWorkspace: WORKSPACE_ID,
62
- profilePicture: "",
63
- status: "ACTIVE"
64
- }),
65
- getWorkspaces: () => Effect.succeed([{ id: WORKSPACE_ID, name: "WS", imageUrl: "" }]),
66
- getProjects: () => Effect.succeed([]),
67
- getProjectByName: () => Effect.succeed(null),
68
- createTimeEntry: (workspaceId, params) => {
69
- createdEntries.push({ workspaceId, params });
70
- return Effect.succeed(makeTimeEntry("entry-1", params.description, new Date(params.start)));
71
- },
72
- stopTimer: (workspaceId, userId, params) => {
73
- stoppedTimers.push({ workspaceId, userId, params });
74
- return Effect.succeed(makeTimeEntry("entry-1", "", new Date()));
75
- },
76
- getTimeEntries: () => Effect.succeed([]),
77
- getRunningTimer: () => Effect.succeed(null),
78
- getTags: () => Effect.succeed([]),
79
- createTag: (_ws, name) => Effect.succeed({ id: `tag-${name}`, name, workspaceId: WORKSPACE_ID, archived: false }),
80
- findOrCreateTag: (_ws, name) => Effect.succeed({ id: `tag-${name}`, name, workspaceId: WORKSPACE_ID, archived: false }),
81
- getTimeEntry: (_ws, id) => Effect.succeed(makeTimeEntry(id, "[PROJ-123] Fix the widget", new Date())),
82
- deleteTimeEntry: (workspaceId, id) => {
83
- deletedEntries.push({ workspaceId, id });
84
- return Effect.void;
85
- },
86
- updateTimeEntry: (workspaceId, id, params) => {
87
- updatedEntries.push({ workspaceId, id, params });
88
- return Effect.succeed(makeTimeEntry(id, "", new Date()));
89
- }
90
- };
91
- const MockClockifyLayer = Layer.succeed(ClockifyApiClient, mockClockify);
92
- // JiraApiClient — unused by start/stop logic directly, but required by layer construction
93
- const MockJiraApiClientLayer = Layer.succeed(JiraApiClient, { v3: {} });
94
- const MockClockifyAuthLayer = Layer.succeed(ClockifyAuth, {
95
- getConfig: Effect.succeed({
96
- apiKey: Redacted.make("key"),
97
- workspaceId: WORKSPACE_ID,
98
- userId: USER_ID,
99
- baseUrl: "https://api.clockify.me/api"
100
- }),
101
- save: () => Effect.void,
102
- isConfigured: Effect.succeed(true)
103
- });
104
- const MockConfigLayer = Layer.succeed(ConfigService, {
105
- get: Effect.succeed({
106
- defaultJql: "",
107
- refreshInterval: 30,
108
- projectMap: {},
109
- workspaceId: null,
110
- defaultProjectId: null,
111
- defaultProjectName: null,
112
- defaultBillable: true
113
- }),
114
- set: () => Effect.void,
115
- configDir: Effect.succeed("/tmp/.jcf")
116
- });
117
- let writtenStates = [];
118
- let cleared = false;
119
- const MockStateWriterLayer = Layer.succeed(StateWriter, {
120
- write: (state) => Effect.sync(() => {
121
- writtenStates.push(state);
122
- }),
123
- read: Effect.succeed({
124
- active: false,
125
- ticketKey: null,
126
- summary: null,
127
- project: null,
128
- startedAt: null,
129
- startedAt_unix: null,
130
- elapsed: 0,
131
- clockifyEntryId: null
132
- }),
133
- clear: Effect.sync(() => {
134
- cleared = true;
135
- })
136
- });
137
- const mockJiraAuthProfileMethods = {
138
- getActiveProfile: () => Effect.succeed(null),
139
- listProfiles: () => Effect.succeed([]),
140
- switchProfile: () => Effect.succeed(null),
141
- removeProfile: () => Effect.succeed(null)
142
- };
143
- const MockJiraAuthLayer = Layer.succeed(JiraAuth, {
144
- configure: () => Effect.void,
145
- isConfigured: () => Effect.succeed(true),
146
- login: () => Effect.void,
147
- logout: () => Effect.void,
148
- getAccessToken: () => Effect.succeed(Redacted.make("jira-token")),
149
- getCloudId: () => Effect.succeed("cloud-1"),
150
- getSiteUrl: () => Effect.succeed("https://test.atlassian.net"),
151
- getCurrentUser: () => Effect.succeed(null),
152
- ...mockJiraAuthProfileMethods,
153
- isLoggedIn: () => Effect.succeed(true)
154
- });
155
- // JiraAuth with no usable token — postJiraWorklog short-circuits to NotLoggedIn.
156
- const MockJiraAuthLoggedOutLayer = Layer.succeed(JiraAuth, {
157
- configure: () => Effect.void,
158
- isConfigured: () => Effect.succeed(false),
159
- login: () => Effect.void,
160
- logout: () => Effect.void,
161
- getAccessToken: () => Effect.succeed(Redacted.make("")),
162
- getCloudId: () => Effect.succeed(""),
163
- getSiteUrl: () => Effect.succeed(""),
164
- getCurrentUser: () => Effect.succeed(null),
165
- ...mockJiraAuthProfileMethods,
166
- isLoggedIn: () => Effect.succeed(false)
167
- });
168
- // Mock HttpClient that returns 201 for Jira worklog POST
169
- const MockHttpClientLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(JSON.stringify({ id: "wl-1" }), {
170
- status: 201,
171
- headers: { "content-type": "application/json" }
172
- })))));
173
- // HttpClient that returns a 400 for any Jira worklog POST (simulates Jira failure)
174
- const MockHttpClientFailLayer = Layer.succeed(HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(JSON.stringify({ errorMessages: ["nope"] }), {
175
- status: 400,
176
- headers: { "content-type": "application/json" }
177
- })))));
178
- // HttpClient that fails the Jira worklog POST `failures` times, then succeeds —
179
- // models a transient Jira outage so a retry can recover.
180
- const makeFlakyHttpClientLayer = (failures) => {
181
- let calls = 0;
182
- return Layer.succeed(HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, calls++ < failures
183
- ? new Response(JSON.stringify({ errorMessages: ["nope"] }), {
184
- status: 400,
185
- headers: { "content-type": "application/json" }
186
- })
187
- : new Response(JSON.stringify({ id: "wl-1" }), {
188
- status: 201,
189
- headers: { "content-type": "application/json" }
190
- })))));
191
- };
192
- const TestLayer = timerLayer.pipe(Layer.provide(MockClockifyLayer), Layer.provide(MockJiraApiClientLayer), Layer.provide(MockClockifyAuthLayer), Layer.provide(MockConfigLayer), Layer.provide(MockStateWriterLayer), Layer.provide(MockJiraAuthLayer), Layer.provide(MockHttpClientLayer));
193
- // Build a TimerService layer with overridden Clockify / HttpClient / JiraAuth mocks.
194
- const makeTestLayer = (clockify = mockClockify, httpLayer = MockHttpClientLayer, jiraAuthLayer = MockJiraAuthLayer) => timerLayer.pipe(Layer.provide(Layer.succeed(ClockifyApiClient, clockify)), Layer.provide(MockJiraApiClientLayer), Layer.provide(MockClockifyAuthLayer), Layer.provide(MockConfigLayer), Layer.provide(MockStateWriterLayer), Layer.provide(jiraAuthLayer), Layer.provide(httpLayer));
195
- // ---------------------------------------------------------------------------
196
- // Tests
197
- // ---------------------------------------------------------------------------
198
- describe("TimerService", () => {
199
- describe("state transitions", () => {
200
- // Initial state must be idle — ensures no stale state from previous sessions
201
- it.effect("starts idle", () => Effect.gen(function* () {
202
- resetCaptures();
203
- writtenStates = [];
204
- cleared = false;
205
- const svc = yield* TimerService;
206
- const state = yield* SubscriptionRef.get(svc.state);
207
- expect(state.active).toBe(false);
208
- expect(state.ticketKey).toBeNull();
209
- }).pipe(Effect.provide(TestLayer)));
210
- // Core lifecycle: start creates Clockify entry + writes state file + updates SubscriptionRef
211
- it.effect("idle -> active on start", () => Effect.gen(function* () {
212
- resetCaptures();
213
- writtenStates = [];
214
- cleared = false;
215
- const svc = yield* TimerService;
216
- yield* svc.start(makeTicket());
217
- const state = yield* SubscriptionRef.get(svc.state);
218
- expect(state.active).toBe(true);
219
- expect(state.ticketKey).toBe("PROJ-123");
220
- expect(state.summary).toBe("Fix the widget");
221
- expect(state.startedViaJcf).toBe(true);
222
- expect(state.clockifyEntryId).toBe("entry-1");
223
- expect(createdEntries).toHaveLength(1);
224
- expect(writtenStates.length).toBeGreaterThanOrEqual(1);
225
- }).pipe(Effect.provide(TestLayer)));
226
- // Core lifecycle: stop updates Clockify entry + posts Jira worklog + clears state
227
- it.effect("active -> stopped on stop", () => Effect.gen(function* () {
228
- resetCaptures();
229
- writtenStates = [];
230
- cleared = false;
231
- const svc = yield* TimerService;
232
- yield* svc.start(makeTicket());
233
- const result = yield* svc.stop();
234
- expect(result.clockifyLogged).toBe(true);
235
- expect(result.jiraWorklog?._tag).toBe("Posted");
236
- const state = yield* SubscriptionRef.get(svc.state);
237
- expect(state.active).toBe(false);
238
- expect(state.ticketKey).toBeNull();
239
- expect(cleared).toBe(true);
240
- }).pipe(Effect.provide(TestLayer)));
241
- // Stop without active timer must fail — prevents orphaned Clockify/Jira state
242
- it.effect("stop with no active timer fails", () => Effect.gen(function* () {
243
- resetCaptures();
244
- const svc = yield* TimerService;
245
- const exit = yield* svc.stop().pipe(Effect.flip);
246
- expect(exit).toBeInstanceOf(TimerError);
247
- expect(exit.message).toBe("No active timer");
248
- }).pipe(Effect.provide(TestLayer)));
249
- });
250
- describe("auto-stop", () => {
251
- // Starting a new timer must stop the existing one first — prevents orphaned Clockify entries
252
- it.effect("starting new timer auto-stops existing one", () => Effect.gen(function* () {
253
- resetCaptures();
254
- writtenStates = [];
255
- cleared = false;
256
- const svc = yield* TimerService;
257
- yield* svc.start(makeTicket({ key: "PROJ-1", summary: "First" }));
258
- expect(createdEntries).toHaveLength(1);
259
- // Start a second timer — should auto-stop the first
260
- yield* svc.start(makeTicket({ key: "PROJ-2", summary: "Second" }));
261
- expect(createdEntries).toHaveLength(2);
262
- // The auto-stop calls updateTimeEntry on the first entry
263
- expect(updatedEntries).toHaveLength(1);
264
- const state = yield* SubscriptionRef.get(svc.state);
265
- expect(state.ticketKey).toBe("PROJ-2");
266
- expect(state.summary).toBe("Second");
267
- }).pipe(Effect.provide(TestLayer)));
268
- });
269
- describe("60s Jira worklog floor", () => {
270
- // Jira rejects worklogs <60s — verify near-instant timers still log successfully (floored to 60s)
271
- it.effect("floors timeSpentSeconds to 60 for short durations", () => Effect.gen(function* () {
272
- resetCaptures();
273
- writtenStates = [];
274
- cleared = false;
275
- const svc = yield* TimerService;
276
- yield* svc.start(makeTicket());
277
- // Stop immediately — elapsed ~0ms, should still log with 60s floor
278
- const result = yield* svc.stop();
279
- expect(result.jiraWorklog?._tag).toBe("Posted");
280
- expect(result.clockifyLogged).toBe(true);
281
- }).pipe(Effect.provide(TestLayer)));
282
- });
283
- describe("discard", () => {
284
- // Discard deletes Clockify entry and clears state — no Jira worklog should be created
285
- it.effect("discards active timer without Jira worklog", () => Effect.gen(function* () {
286
- resetCaptures();
287
- writtenStates = [];
288
- cleared = false;
289
- const svc = yield* TimerService;
290
- yield* svc.start(makeTicket());
291
- yield* svc.discard;
292
- const state = yield* SubscriptionRef.get(svc.state);
293
- expect(state.active).toBe(false);
294
- expect(deletedEntries).toHaveLength(1);
295
- expect(deletedEntries[0].id).toBe("entry-1");
296
- expect(cleared).toBe(true);
297
- }).pipe(Effect.provide(TestLayer)));
298
- // Discard without active timer must fail — prevents accidental entry deletion
299
- it.effect("discard with no active timer fails", () => Effect.gen(function* () {
300
- resetCaptures();
301
- const svc = yield* TimerService;
302
- const exit = yield* svc.discard.pipe(Effect.flip);
303
- expect(exit).toBeInstanceOf(TimerError);
304
- expect(exit.message).toBe("No active timer to discard");
305
- }).pipe(Effect.provide(TestLayer)));
306
- });
307
- describe("detectRunning", () => {
308
- // Detects externally-started Clockify timers with "[KEY] summary" format (jcf native format)
309
- it.effect("parses bracket format [KEY] summary", () => {
310
- const runningEntry = makeTimeEntry("ext-1", "[PROJ-42] Implement feature", new Date("2025-01-01T10:00:00Z"), "proj-id");
311
- const clockifyWithRunning = {
312
- ...mockClockify,
313
- getRunningTimer: () => Effect.succeed(runningEntry),
314
- getProjects: () => Effect.succeed([{
315
- id: "proj-id",
316
- name: "MyProject",
317
- color: "",
318
- archived: false,
319
- billable: true,
320
- public: true,
321
- workspaceId: WORKSPACE_ID,
322
- note: ""
323
- }])
324
- };
325
- return Effect.gen(function* () {
326
- resetCaptures();
327
- writtenStates = [];
328
- cleared = false;
329
- const svc = yield* TimerService;
330
- yield* svc.detectRunning;
331
- // State was updated via the layer's SubscriptionRef, so read from svc
332
- const state = yield* SubscriptionRef.get(svc.state);
333
- expect(state.active).toBe(true);
334
- expect(state.ticketKey).toBe("PROJ-42");
335
- expect(state.summary).toBe("Implement feature");
336
- expect(state.projectId).toBe("proj-id");
337
- expect(state.projectName).toBe("MyProject");
338
- expect(state.startedViaJcf).toBe(false);
339
- }).pipe(Effect.provide(makeTestLayer(clockifyWithRunning)));
340
- });
341
- // Also detects "KEY: summary" format — common when timers are started manually in Clockify
342
- it.effect("parses colon format KEY: summary", () => {
343
- const runningEntry = makeTimeEntry("ext-2", "PROJ-99: Review PR", new Date("2025-01-01T10:00:00Z"));
344
- const clockifyWithRunning = {
345
- ...mockClockify,
346
- getRunningTimer: () => Effect.succeed(runningEntry)
347
- };
348
- return Effect.gen(function* () {
349
- resetCaptures();
350
- writtenStates = [];
351
- const svc = yield* TimerService;
352
- yield* svc.detectRunning;
353
- const state = yield* SubscriptionRef.get(svc.state);
354
- expect(state.active).toBe(true);
355
- expect(state.ticketKey).toBe("PROJ-99");
356
- expect(state.summary).toBe("Review PR");
357
- expect(state.startedViaJcf).toBe(false);
358
- }).pipe(Effect.provide(makeTestLayer(clockifyWithRunning)));
359
- });
360
- // No running timer in Clockify must leave local state unchanged — polling should be safe no-op
361
- it.effect("no running timer is a no-op", () => Effect.gen(function* () {
362
- resetCaptures();
363
- writtenStates = [];
364
- const svc = yield* TimerService;
365
- yield* svc.detectRunning;
366
- const state = yield* SubscriptionRef.get(svc.state);
367
- expect(state.active).toBe(false);
368
- }).pipe(Effect.provide(TestLayer)));
369
- });
370
- describe("stop options", () => {
371
- // Stop options override timer state — used when TUI prompts for projectId/billable on stop
372
- it.effect("stop merges projectId and billable from options", () => Effect.gen(function* () {
373
- resetCaptures();
374
- writtenStates = [];
375
- cleared = false;
376
- const svc = yield* TimerService;
377
- yield* svc.start(makeTicket());
378
- const result = yield* svc.stop({ projectId: "proj-override", billable: false });
379
- expect(result.needsProjectId).toBe(false);
380
- expect(result.needsBillable).toBe(false);
381
- // Check that the updateTimeEntry was called with the overridden values
382
- expect(updatedEntries).toHaveLength(1);
383
- const params = updatedEntries[0].params;
384
- expect(params.projectId).toBe("proj-override");
385
- expect(params.billable).toBe(false);
386
- }).pipe(Effect.provide(TestLayer)));
387
- // needsProjectId=true signals TUI to prompt user — must be true when no project resolved
388
- it.effect("needsProjectId true when no projectId provided", () => Effect.gen(function* () {
389
- resetCaptures();
390
- writtenStates = [];
391
- cleared = false;
392
- const svc = yield* TimerService;
393
- yield* svc.start(makeTicket());
394
- const result = yield* svc.stop();
395
- expect(result.needsProjectId).toBe(true);
396
- }).pipe(Effect.provide(TestLayer)));
397
- });
398
- describe("logManual (correction interval)", () => {
399
- // Logging a forgotten interval writes a closed Clockify entry (start + end) and a Jira worklog
400
- it.effect("creates a closed Clockify entry and posts a Jira worklog", () => Effect.gen(function* () {
401
- yield* TestClock.setTime(MANUAL_NOW);
402
- resetCaptures();
403
- writtenStates = [];
404
- cleared = false;
405
- const svc = yield* TimerService;
406
- const start = new Date("2025-01-01T09:00:00.000Z");
407
- const result = yield* svc.logManual(makeTicket(), { start, durationSeconds: 1800 });
408
- expect(result.clockifyLogged).toBe(true);
409
- expect(result.jiraWorklogLogged).toBe(true);
410
- expect(createdEntries).toHaveLength(1);
411
- const params = createdEntries[0].params;
412
- expect(params.start).toBe(start.toISOString());
413
- expect(params.end).toBe(new Date(start.getTime() + 1800 * 1000).toISOString());
414
- expect(params.description).toBe("[PROJ-123] Fix the widget");
415
- }).pipe(Effect.provide(TestLayer)));
416
- // A correction must never disturb the running-timer state (there was no running timer)
417
- it.effect("leaves timer state inactive", () => Effect.gen(function* () {
418
- yield* TestClock.setTime(MANUAL_NOW);
419
- resetCaptures();
420
- writtenStates = [];
421
- cleared = false;
422
- const svc = yield* TimerService;
423
- yield* svc.logManual(makeTicket(), { start: new Date("2025-01-01T09:00:00.000Z"), durationSeconds: 600 });
424
- const state = yield* SubscriptionRef.get(svc.state);
425
- expect(state.active).toBe(false);
426
- expect(state.ticketKey).toBeNull();
427
- }).pipe(Effect.provide(TestLayer)));
428
- // Explicit projectId/billable options flow through to the Clockify entry
429
- it.effect("honours explicit projectId and billable options", () => Effect.gen(function* () {
430
- yield* TestClock.setTime(MANUAL_NOW);
431
- resetCaptures();
432
- writtenStates = [];
433
- cleared = false;
434
- const svc = yield* TimerService;
435
- const result = yield* svc.logManual(makeTicket(), {
436
- start: new Date("2025-01-01T09:00:00.000Z"),
437
- durationSeconds: 600,
438
- projectId: "proj-x",
439
- billable: false
440
- });
441
- expect(result.projectId).toBe("proj-x");
442
- expect(result.billable).toBe(false);
443
- const params = createdEntries[0].params;
444
- expect(params.projectId).toBe("proj-x");
445
- expect(params.billable).toBe(false);
446
- }).pipe(Effect.provide(TestLayer)));
447
- // A failing Clockify createTimeEntry must flip clockifyLogged to false (not crash)
448
- it.effect("clockifyLogged is false when the Clockify entry fails", () => Effect.gen(function* () {
449
- yield* TestClock.setTime(MANUAL_NOW);
450
- resetCaptures();
451
- const svc = yield* TimerService;
452
- const result = yield* svc.logManual(makeTicket(), {
453
- start: new Date("2025-01-01T09:00:00.000Z"),
454
- durationSeconds: 600
455
- });
456
- // Jira worklog still succeeds via the 201 mock; only Clockify failed.
457
- expect(result.clockifyLogged).toBe(false);
458
- expect(result.jiraWorklogLogged).toBe(true);
459
- }).pipe(Effect.provide(makeTestLayer({
460
- ...mockClockify,
461
- createTimeEntry: () => Effect.fail(new FetchClientError({ error: "boom", status: 500, message: "boom" }))
462
- }, MockHttpClientLayer))));
463
- // A failing Jira worklog POST must flip jiraWorklogLogged to false (not crash)
464
- it.effect("jiraWorklogLogged is false when the Jira worklog POST fails", () => Effect.gen(function* () {
465
- yield* TestClock.setTime(MANUAL_NOW);
466
- resetCaptures();
467
- const svc = yield* TimerService;
468
- const result = yield* svc.logManual(makeTicket(), {
469
- start: new Date("2025-01-01T09:00:00.000Z"),
470
- durationSeconds: 600
471
- });
472
- expect(result.clockifyLogged).toBe(true);
473
- expect(result.jiraWorklogLogged).toBe(false);
474
- }).pipe(Effect.provide(makeTestLayer(mockClockify, MockHttpClientFailLayer))));
475
- // The 60s Jira floor applies to backdated/manual logs too: a <60s duration
476
- // must still post a worklog (floored), so jiraWorklogLogged stays true.
477
- it.effect("applies the 60s worklog floor on a sub-minute manual log", () => Effect.gen(function* () {
478
- yield* TestClock.setTime(MANUAL_NOW);
479
- resetCaptures();
480
- const svc = yield* TimerService;
481
- const result = yield* svc.logManual(makeTicket(), {
482
- start: new Date("2025-01-01T09:00:00.000Z"),
483
- durationSeconds: 30
484
- });
485
- expect(result.jiraWorklogLogged).toBe(true);
486
- }).pipe(Effect.provide(TestLayer)));
487
- // Future start times are rejected by the shared guard in logManual
488
- it.effect("fails when the start time is in the future", () => Effect.gen(function* () {
489
- yield* TestClock.setTime(MANUAL_NOW);
490
- resetCaptures();
491
- const svc = yield* TimerService;
492
- const future = new Date(MANUAL_NOW + 60 * 60 * 1000);
493
- const error = yield* svc.logManual(makeTicket(), { start: future, durationSeconds: 600 }).pipe(Effect.flip);
494
- expect(error).toBeInstanceOf(TimerError);
495
- expect(error.message).toMatch(/future/i);
496
- // No Clockify entry should have been created.
497
- expect(createdEntries).toHaveLength(0);
498
- }).pipe(Effect.provide(TestLayer)));
499
- // The whole manual interval must be in the past; otherwise Clockify/Jira
500
- // would receive a worklog whose end time has not happened yet.
501
- it.effect("fails when the manual interval would end in the future", () => Effect.gen(function* () {
502
- yield* TestClock.setTime(MANUAL_NOW);
503
- resetCaptures();
504
- const svc = yield* TimerService;
505
- const fiveMinutesAgo = new Date(MANUAL_NOW - 5 * 60 * 1000);
506
- const error = yield* svc.logManual(makeTicket(), {
507
- start: fiveMinutesAgo,
508
- durationSeconds: 10 * 60
509
- }).pipe(Effect.flip);
510
- expect(error).toBeInstanceOf(TimerError);
511
- expect(error.message).toMatch(/end time is in the future/i);
512
- expect(createdEntries).toHaveLength(0);
513
- }).pipe(Effect.provide(TestLayer)));
514
- });
515
- describe("worklog retry", () => {
516
- // A successful stop leaves nothing to retry — outcome Posted, no retry params.
517
- it.effect("stop reports Posted with no retry params when Jira succeeds", () => Effect.gen(function* () {
518
- resetCaptures();
519
- const svc = yield* TimerService;
520
- yield* svc.start(makeTicket());
521
- const result = yield* svc.stop({ comment: "done" });
522
- expect(result.jiraWorklog?._tag).toBe("Posted");
523
- expect(result.worklog).toBeNull();
524
- }).pipe(Effect.provide(TestLayer)));
525
- // A transient Jira failure (4xx) must report Failed with a message and surface retry params.
526
- it.effect("stop reports Failed with a message and retry params when Jira rejects", () => Effect.gen(function* () {
527
- resetCaptures();
528
- const svc = yield* TimerService;
529
- yield* svc.start(makeTicket());
530
- const result = yield* svc.stop({ comment: "done" });
531
- expect(result.clockifyLogged).toBe(true);
532
- expect(result.jiraWorklog?._tag).toBe("Failed");
533
- if (result.jiraWorklog?._tag === "Failed") {
534
- // The Jira error body is summarised into the message.
535
- expect(result.jiraWorklog.message).toContain("400");
536
- expect(result.jiraWorklog.message).toContain("nope");
537
- }
538
- expect(result.worklog).not.toBeNull();
539
- expect(result.worklog?.ticketKey).toBe("PROJ-123");
540
- expect(result.worklog?.comment).toBe("done");
541
- }).pipe(Effect.provide(makeTestLayer(mockClockify, MockHttpClientFailLayer))));
542
- // Not logged in must report NotLoggedIn (unrecoverable) and expose NO retry params.
543
- it.effect("stop reports NotLoggedIn and no retry params when there is no token", () => Effect.gen(function* () {
544
- resetCaptures();
545
- const svc = yield* TimerService;
546
- yield* svc.start(makeTicket());
547
- const result = yield* svc.stop({ comment: "done" });
548
- expect(result.clockifyLogged).toBe(true);
549
- expect(result.jiraWorklog?._tag).toBe("NotLoggedIn");
550
- // NotLoggedIn can't be fixed by retrying, so the retry payload is withheld.
551
- expect(result.worklog).toBeNull();
552
- }).pipe(Effect.provide(makeTestLayer(mockClockify, MockHttpClientLayer, MockJiraAuthLoggedOutLayer))));
553
- // logWorklog reposts in isolation and succeeds once Jira recovers — the retry round-trip.
554
- it.effect("logWorklog recovers after a transient Jira failure", () => Effect.gen(function* () {
555
- resetCaptures();
556
- const svc = yield* TimerService;
557
- yield* svc.start(makeTicket());
558
- const result = yield* svc.stop({ comment: "done" });
559
- expect(result.jiraWorklog?._tag).toBe("Failed");
560
- expect(result.worklog).not.toBeNull();
561
- // Jira was flaky on the first POST (during stop) but recovers on retry.
562
- const retried = yield* svc.logWorklog(result.worklog);
563
- expect(retried._tag).toBe("Posted");
564
- }).pipe(Effect.provide(makeTestLayer(mockClockify, makeFlakyHttpClientLayer(1)))));
565
- // A retry against a still-down Jira reports Failed (with reason) rather than crashing.
566
- it.effect("logWorklog reports Failed while Jira is still failing", () => Effect.gen(function* () {
567
- resetCaptures();
568
- const svc = yield* TimerService;
569
- const retried = yield* svc.logWorklog({
570
- ticketKey: "PROJ-123",
571
- startedAt: new Date("2025-01-01T09:00:00.000Z"),
572
- durationSeconds: 600,
573
- comment: "done"
574
- });
575
- expect(retried._tag).toBe("Failed");
576
- if (retried._tag === "Failed")
577
- expect(retried.message).toContain("400");
578
- }).pipe(Effect.provide(makeTestLayer(mockClockify, MockHttpClientFailLayer))));
579
- // logWorklog reports NotLoggedIn when there is no token, so callers can skip retrying.
580
- it.effect("logWorklog reports NotLoggedIn when there is no token", () => Effect.gen(function* () {
581
- resetCaptures();
582
- const svc = yield* TimerService;
583
- const retried = yield* svc.logWorklog({
584
- ticketKey: "PROJ-123",
585
- startedAt: new Date("2025-01-01T09:00:00.000Z"),
586
- durationSeconds: 600
587
- });
588
- expect(retried._tag).toBe("NotLoggedIn");
589
- }).pipe(Effect.provide(makeTestLayer(mockClockify, MockHttpClientLayer, MockJiraAuthLoggedOutLayer))));
590
- });
591
- describe("start backdating", () => {
592
- // A backdated start records the corrected start time on the Clockify entry and state
593
- it.effect("uses the provided startedAt instead of now", () => Effect.gen(function* () {
594
- resetCaptures();
595
- writtenStates = [];
596
- cleared = false;
597
- const svc = yield* TimerService;
598
- const startedAt = new Date("2025-01-01T08:30:00.000Z");
599
- yield* svc.start(makeTicket(), { startedAt });
600
- const params = createdEntries[0].params;
601
- expect(params.start).toBe(startedAt.toISOString());
602
- const state = yield* SubscriptionRef.get(svc.state);
603
- expect(state.startedAt?.toISOString()).toBe(startedAt.toISOString());
604
- }).pipe(Effect.provide(TestLayer)));
605
- });
606
- });
607
- //# sourceMappingURL=TimerService.test.js.map