@knpkv/clockify-api-client 1.1.1 → 3.0.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.
@@ -1,45 +1,67 @@
1
- import { describe, expect, it } from "@effect/vitest"
1
+ import { assert, describe, expect, it } from "@effect/vitest"
2
2
  import * as Effect from "effect/Effect"
3
+ import * as HttpClient from "effect/http/HttpClient"
4
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest"
5
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
3
6
  import * as Layer from "effect/Layer"
4
7
  import * as Predicate from "effect/Predicate"
5
8
  import * as Redacted from "effect/Redacted"
6
- import * as HttpClient from "effect/unstable/http/HttpClient"
7
- import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
8
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
9
9
  import { ClockifyApiClient, ClockifyApiConfig, make } from "../src/index.js"
10
10
 
11
- const clientLayer = (
12
- response: { readonly status: number; readonly body: unknown },
13
- requests: Array<HttpClientRequest.HttpClientRequest>
14
- ) =>
15
- ClockifyApiClient.layer.pipe(
16
- Layer.provide(Layer.succeed(ClockifyApiConfig, {
17
- apiKey: Redacted.make("secret"),
18
- workspaceId: "workspace-1",
19
- userId: "user-1",
20
- baseUrl: "https://clockify.test/api"
21
- })),
22
- Layer.provide(Layer.succeed(
23
- HttpClient.HttpClient,
24
- HttpClient.make((request) =>
25
- Effect.sync(() => {
26
- requests.push(request)
27
- return HttpClientResponse.fromWeb(
28
- request,
29
- new Response(JSON.stringify(response.body), {
30
- status: response.status,
31
- headers: { "content-type": "application/json" }
32
- })
33
- )
34
- })
35
- )
36
- ))
37
- )
11
+ /**
12
+ * What the fake transport answers next, and what it was asked.
13
+ *
14
+ * Held at module scope rather than built per test so the client can be composed once, by
15
+ * `it.layer`, instead of provided inside each test body. Each test calls {@link reply} first, which
16
+ * both sets its response and clears the recorded requests, so no test can read another's.
17
+ */
18
+ let requests: Array<HttpClientRequest.HttpClientRequest> = []
19
+ let respond: (request: HttpClientRequest.HttpClientRequest, callIndex: number) => Response = () =>
20
+ new Response("null", { status: 200, headers: { "content-type": "application/json" } })
38
21
 
39
- describe("ClockifyApiClient", () => {
40
- it.effect("authenticates requests and decodes responses with Schema", () => {
41
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
42
- return Effect.gen(function*() {
22
+ const json = <ResponseBody>(status: number, body: ResponseBody): Response =>
23
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } })
24
+
25
+ /** Answer every request in this test the same way. */
26
+ const reply = <ResponseBody>(status: number, body: ResponseBody): void => {
27
+ requests = []
28
+ respond = () => json(status, body)
29
+ }
30
+
31
+ /** Answer request `n` of this test from a function of its index — for paging. */
32
+ const replyPerCall = <ResponseBody>(
33
+ answer: (callIndex: number) => { status: number; body: ResponseBody }
34
+ ): void => {
35
+ requests = []
36
+ respond = (_request, callIndex) => {
37
+ const { body, status } = answer(callIndex)
38
+ return json(status, body)
39
+ }
40
+ }
41
+
42
+ const suite = ClockifyApiClient.layer.pipe(
43
+ Layer.provide(Layer.succeed(ClockifyApiConfig, {
44
+ apiKey: Redacted.make("secret"),
45
+ workspaceId: "workspace-1",
46
+ userId: "user-1",
47
+ baseUrl: "https://clockify.test/api"
48
+ })),
49
+ Layer.provide(Layer.succeed(
50
+ HttpClient.HttpClient,
51
+ HttpClient.make((request) =>
52
+ Effect.sync(() => {
53
+ const callIndex = requests.length
54
+ requests.push(request)
55
+ return HttpClientResponse.fromWeb(request, respond(request, callIndex))
56
+ })
57
+ )
58
+ ))
59
+ )
60
+
61
+ it.layer(suite)("ClockifyApiClient", (it) => {
62
+ it.effect("authenticates requests and decodes responses with Schema", () =>
63
+ Effect.gen(function*() {
64
+ reply(200, { id: "user-1", name: "Ada", email: "ada@example.com", status: "ACTIVE" })
43
65
  const client = yield* ClockifyApiClient
44
66
  const user = yield* client.getUser()
45
67
  expect(user.id).toBe("user-1")
@@ -47,43 +69,38 @@ describe("ClockifyApiClient", () => {
47
69
  expect(requests).toHaveLength(1)
48
70
  expect(requests[0]?.url).toBe("https://clockify.test/api/v1/user")
49
71
  expect(requests[0]?.headers["x-api-key"]).toBe("secret")
50
- }).pipe(
51
- Effect.provide(clientLayer({
52
- status: 200,
53
- body: { id: "user-1", name: "Ada", email: "ada@example.com", status: "ACTIVE" }
54
- }, requests))
55
- )
56
- })
72
+ }))
57
73
 
58
- it.effect("decodes nullable interval fields for a running time entry", () => {
59
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
60
- return Effect.gen(function*() {
74
+ it.effect("decodes nullable interval fields for a running time entry", () =>
75
+ Effect.gen(function*() {
76
+ reply(200, [{
77
+ id: "entry-1",
78
+ description: "In progress",
79
+ billable: false,
80
+ userId: "user-1",
81
+ workspaceId: "workspace-1",
82
+ timeInterval: { start: "2026-07-11T08:00:00Z", end: null, duration: null }
83
+ }])
61
84
  const client = yield* ClockifyApiClient
62
85
  const entry = yield* client.getRunningTimer("workspace-1", "user-1")
63
86
  expect(entry?.timeInterval?.end).toBeNull()
64
87
  expect(entry?.timeInterval?.duration).toBeNull()
65
- }).pipe(
66
- Effect.provide(clientLayer({
67
- status: 200,
68
- body: [{
69
- id: "entry-1",
70
- description: "In progress",
71
- billable: false,
72
- userId: "user-1",
73
- workspaceId: "workspace-1",
74
- timeInterval: {
75
- start: "2026-07-11T08:00:00Z",
76
- end: null,
77
- duration: null
78
- }
79
- }]
80
- }, requests))
81
- )
82
- })
88
+ }))
83
89
 
84
- it.effect("requests hydrated time-entry details when required by the caller", () => {
85
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
86
- return Effect.gen(function*() {
90
+ it.effect("requests hydrated time-entry details when required by the caller", () =>
91
+ Effect.gen(function*() {
92
+ reply(200, {
93
+ id: "entry-1",
94
+ workspaceId: "workspace-1",
95
+ userId: "user-1",
96
+ description: "Review payment safeguards",
97
+ billable: true,
98
+ customFieldValues: [],
99
+ projectId: "project-1",
100
+ taskId: "task-1",
101
+ tagIds: ["delivery"],
102
+ timeInterval: { start: "2026-07-11T08:00:00Z", end: "2026-07-11T09:00:00Z", duration: "PT1H" }
103
+ })
87
104
  const client = yield* ClockifyApiClient
88
105
  const entry = yield* client.getTimeEntry("workspace-1", "entry-1", { hydrated: true })
89
106
  yield* client.getTimeEntry("workspace-1", "entry-1")
@@ -92,81 +109,33 @@ describe("ClockifyApiClient", () => {
92
109
  expect(new Map(requests[0]?.urlParams ?? []).get("hydrated")).toBe("true")
93
110
  expect(new Map(requests[1]?.urlParams ?? []).has("hydrated")).toBe(false)
94
111
  expect(new Map(requests[2]?.urlParams ?? []).get("hydrated")).toBe("false")
95
- }).pipe(
96
- Effect.provide(clientLayer({
97
- status: 200,
98
- body: {
99
- id: "entry-1",
100
- workspaceId: "workspace-1",
101
- userId: "user-1",
102
- description: "Review payment safeguards",
103
- billable: true,
104
- customFieldValues: [],
105
- projectId: "project-1",
106
- taskId: "task-1",
107
- tagIds: ["delivery"],
108
- timeInterval: {
109
- start: "2026-07-11T08:00:00Z",
110
- end: "2026-07-11T09:00:00Z",
111
- duration: "PT1H"
112
- }
113
- }
114
- }, requests))
115
- )
116
- })
112
+ }))
117
113
 
118
- it.effect("forwards hydration when listing canonical time-entry snapshots", () => {
119
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
120
- return Effect.gen(function*() {
114
+ it.effect("forwards hydration when listing canonical time-entry snapshots", () =>
115
+ Effect.gen(function*() {
116
+ reply(200, [])
121
117
  const client = yield* ClockifyApiClient
122
- yield* client.getTimeEntries("workspace-1", "user-1", {
123
- hydrated: true,
124
- page: 2,
125
- pageSize: 10
126
- })
118
+ yield* client.getTimeEntries("workspace-1", "user-1", { hydrated: true, page: 2, pageSize: 10 })
127
119
  expect(new Map(requests[0]?.urlParams ?? []).get("hydrated")).toBe("true")
128
120
  expect(new Map(requests[0]?.urlParams ?? []).get("page")).toBe("2")
129
121
  expect(new Map(requests[0]?.urlParams ?? []).get("page-size")).toBe("10")
130
- }).pipe(
131
- Effect.provide(clientLayer({ status: 200, body: [] }, requests))
132
- )
133
- })
134
-
135
- it.effect("pages through the complete workspace user directory", () => {
136
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
137
- const firstPage = Array.from({ length: 500 }, (_, index) => ({
138
- id: `user-${index}`,
139
- name: `User ${index}`,
140
- email: `user-${index}@example.test`,
141
- status: "ACTIVE"
142
122
  }))
143
- const layer = ClockifyApiClient.layer.pipe(
144
- Layer.provide(Layer.succeed(ClockifyApiConfig, {
145
- apiKey: Redacted.make("secret"),
146
- workspaceId: "workspace-1",
147
- userId: "user-1",
148
- baseUrl: "https://clockify.test/api"
149
- })),
150
- Layer.provide(Layer.succeed(
151
- HttpClient.HttpClient,
152
- HttpClient.make((request) =>
153
- Effect.sync(() => {
154
- requests.push(request)
155
- const body = requests.length <= 20
156
- ? firstPage
157
- : [{ id: "configured-user", name: "Configured User", email: "configured@example.test", status: "ACTIVE" }]
158
- return HttpClientResponse.fromWeb(
159
- request,
160
- new Response(JSON.stringify(body), {
161
- status: 200,
162
- headers: { "content-type": "application/json" }
163
- })
164
- )
165
- })
166
- )
167
- ))
168
- )
169
- return Effect.gen(function*() {
123
+
124
+ it.effect("pages through the complete workspace user directory", () =>
125
+ Effect.gen(function*() {
126
+ const firstPage = Array.from({ length: 500 }, (_, index) => ({
127
+ id: `user-${index}`,
128
+ name: `User ${index}`,
129
+ email: `user-${index}@example.test`,
130
+ status: "ACTIVE"
131
+ }))
132
+ // Twenty full pages, then a short one — the only thing that ends the walk.
133
+ replyPerCall((callIndex) => ({
134
+ status: 200,
135
+ body: callIndex < 20
136
+ ? firstPage
137
+ : [{ id: "configured-user", name: "Configured User", email: "configured@example.test", status: "ACTIVE" }]
138
+ }))
170
139
  const client = yield* ClockifyApiClient
171
140
  if (client.getWorkspaceUsers === undefined) return assert.fail("expected workspace user discovery")
172
141
  const users = yield* client.getWorkspaceUsers("workspace-1")
@@ -179,16 +148,26 @@ describe("ClockifyApiClient", () => {
179
148
  "ACTIVE,PENDING_EMAIL_VERIFICATION,DELETED,NOT_REGISTERED,LIMITED,LIMITED_DELETED"
180
149
  )
181
150
  expect(new Map(requests[0]?.urlParams ?? []).get("memberships")).toBe("ALL")
182
- }).pipe(Effect.provide(layer))
183
- })
151
+ }))
184
152
 
185
- it.effect("decodes a created time entry whose optional id references are null", () => {
186
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
187
- return Effect.gen(function*() {
188
- const client = yield* ClockifyApiClient
153
+ it.effect("decodes a created time entry whose optional id references are null", () =>
154
+ Effect.gen(function*() {
189
155
  // Clockify returns kioskId/projectId/taskId as explicit null (not absent) when unset.
156
+ reply(201, {
157
+ id: "entry-1",
158
+ description: "[ENG-6169] review transaction mapper",
159
+ billable: true,
160
+ userId: "user-1",
161
+ workspaceId: "workspace-1",
162
+ kioskId: null,
163
+ projectId: null,
164
+ taskId: null,
165
+ tagIds: null,
166
+ timeInterval: { start: "2026-07-20T09:00:00Z", end: null, duration: null }
167
+ })
168
+ const client = yield* ClockifyApiClient
190
169
  const entry = yield* client.createTimeEntry("workspace-1", {
191
- description: "[RPS-6169] review transaction mapper",
170
+ description: "[ENG-6169] review transaction mapper",
192
171
  start: "2026-07-20T09:00:00Z"
193
172
  })
194
173
  expect(entry.id).toBe("entry-1")
@@ -196,69 +175,74 @@ describe("ClockifyApiClient", () => {
196
175
  expect(entry.projectId).toBeNull()
197
176
  expect(entry.taskId).toBeNull()
198
177
  expect(entry.tagIds).toBeNull()
199
- }).pipe(
200
- Effect.provide(clientLayer({
201
- status: 201,
202
- body: {
203
- id: "entry-1",
204
- description: "[RPS-6169] review transaction mapper",
205
- billable: true,
206
- userId: "user-1",
207
- workspaceId: "workspace-1",
208
- kioskId: null,
209
- projectId: null,
210
- taskId: null,
211
- tagIds: null,
212
- timeInterval: {
213
- start: "2026-07-20T09:00:00Z",
214
- end: null,
215
- duration: null
216
- }
217
- }
218
- }, requests))
219
- )
220
- })
178
+ }))
221
179
 
222
- it.effect("decodes a time-entries list whose entries have null tagIds", () => {
223
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
224
- return Effect.gen(function*() {
225
- const client = yield* ClockifyApiClient
180
+ it.effect("decodes a time-entries list whose entries have null tagIds", () =>
181
+ Effect.gen(function*() {
226
182
  // `jcf sync reconcile` fetches time entries; Clockify returns tagIds as
227
183
  // null (not an empty array) for entries with no tags.
184
+ reply(200, [{
185
+ id: "entry-1",
186
+ description: "[ENG-6169] review transaction mapper",
187
+ billable: true,
188
+ userId: "user-1",
189
+ workspaceId: "workspace-1",
190
+ tagIds: null,
191
+ timeInterval: { start: "2026-07-20T09:00:00Z", end: "2026-07-20T11:00:00Z", duration: "PT2H" }
192
+ }])
193
+ const client = yield* ClockifyApiClient
228
194
  const entries = yield* client.getTimeEntries("workspace-1", "user-1")
229
195
  expect(entries).toHaveLength(1)
230
196
  expect(entries[0]?.tagIds).toBeNull()
231
- }).pipe(
232
- Effect.provide(clientLayer({
233
- status: 200,
234
- body: [{
235
- id: "entry-1",
236
- description: "[RPS-6169] review transaction mapper",
237
- billable: true,
238
- userId: "user-1",
239
- workspaceId: "workspace-1",
240
- tagIds: null,
241
- timeInterval: {
242
- start: "2026-07-20T09:00:00Z",
243
- end: "2026-07-20T11:00:00Z",
244
- duration: "PT2H"
245
- }
246
- }]
247
- }, requests))
248
- )
249
- })
197
+ }))
250
198
 
199
+ it.effect("decodes a time-entries list whose entries have null rates", () =>
200
+ Effect.gen(function*() {
201
+ // Clockify returns costRate/hourlyRate as explicit null when no rate applies.
202
+ reply(200, [{
203
+ id: "entry-1",
204
+ description: "[ENG-6169] review transaction mapper",
205
+ billable: true,
206
+ userId: "user-1",
207
+ workspaceId: "workspace-1",
208
+ costRate: null,
209
+ hourlyRate: null,
210
+ timeInterval: { start: "2026-07-20T09:00:00Z", end: "2026-07-20T11:00:00Z", duration: "PT2H" }
211
+ }])
212
+ const client = yield* ClockifyApiClient
213
+ const entries = yield* client.getTimeEntries("workspace-1", "user-1")
214
+ expect(entries[0]?.costRate).toBeNull()
215
+ expect(entries[0]?.hourlyRate).toBeNull()
216
+ }))
217
+
218
+ it.effect("fails when a successful response violates the generated schema", () =>
219
+ Effect.gen(function*() {
220
+ reply(200, { id: "user-1" })
221
+ const client = yield* ClockifyApiClient
222
+ const result = yield* Effect.result(client.getUser())
223
+ expect(result._tag).toBe("Failure")
224
+ }))
225
+
226
+ it.effect("fails on non-success status codes", () =>
227
+ Effect.gen(function*() {
228
+ reply(401, { message: "Unauthorized" })
229
+ const client = yield* ClockifyApiClient
230
+ const result = yield* Effect.result(client.getUser())
231
+ expect(result._tag).toBe("Failure")
232
+ }))
233
+ })
234
+
235
+ // Builds its own client rather than taking the suite's: the boundary under test is `make` itself,
236
+ // which is where the multipart request is constructed.
237
+ describe("ClockifyApiClient multipart", () => {
251
238
  it.effect("leaves multipart content type unset so the transport can add its boundary", () => {
252
- const requests: Array<HttpClientRequest.HttpClientRequest> = []
239
+ const seen: Array<HttpClientRequest.HttpClientRequest> = []
253
240
  const httpClient = HttpClient.make((request) =>
254
241
  Effect.sync(() => {
255
- requests.push(request)
242
+ seen.push(request)
256
243
  return HttpClientResponse.fromWeb(
257
244
  request,
258
- new Response(JSON.stringify({ name: "avatar.png", url: "https://clockify.test/avatar.png" }), {
259
- status: 200,
260
- headers: { "content-type": "application/json" }
261
- })
245
+ json(200, { name: "avatar.png", url: "https://clockify.test/avatar.png" })
262
246
  )
263
247
  })
264
248
  )
@@ -269,28 +253,14 @@ describe("ClockifyApiClient", () => {
269
253
 
270
254
  return Effect.gen(function*() {
271
255
  yield* client.uploadImage({ file: new Blob(["avatar bytes"], { type: "image/png" }) })
272
- expect(requests[0]?.headers["content-type"]).toBeUndefined()
273
- expect(requests[0]?.body._tag).toBe("FormData")
274
- if (requests[0]?.body._tag !== "FormData") throw new Error("Expected a FormData request body")
275
- const file = requests[0].body.formData.get("file")
276
- expect(requests[0].body.formData).toBeInstanceOf(FormData)
256
+ expect(seen[0]?.headers["content-type"]).toBeUndefined()
257
+ expect(seen[0]?.body._tag).toBe("FormData")
258
+ if (seen[0]?.body._tag !== "FormData") throw new Error("Expected a FormData request body")
259
+ const file = seen[0].body.formData.get("file")
260
+ expect(seen[0].body.formData).toBeInstanceOf(FormData)
277
261
  if (file === null || Predicate.isString(file)) throw new Error("Expected a file field")
278
262
  expect(yield* Effect.promise(() => file.text())).toBe("avatar bytes")
279
- expect(requests[0]?.headers["x-api-key"]).toBe("secret")
263
+ expect(seen[0]?.headers["x-api-key"]).toBe("secret")
280
264
  })
281
265
  })
282
-
283
- it.effect("fails when a successful response violates the generated schema", () =>
284
- Effect.gen(function*() {
285
- const client = yield* ClockifyApiClient
286
- const result = yield* Effect.result(client.getUser())
287
- expect(result._tag).toBe("Failure")
288
- }).pipe(Effect.provide(clientLayer({ status: 200, body: { id: "user-1" } }, []))))
289
-
290
- it.effect("fails on non-success status codes", () =>
291
- Effect.gen(function*() {
292
- const client = yield* ClockifyApiClient
293
- const result = yield* Effect.result(client.getUser())
294
- expect(result._tag).toBe("Failure")
295
- }).pipe(Effect.provide(clientLayer({ status: 401, body: { message: "Unauthorized" } }, []))))
296
266
  })
@@ -0,0 +1,18 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Schema from "effect/Schema"
3
+ import { ClockifyApi } from "../src/index.js"
4
+
5
+ // The spec declares custom-field values as objects, but Clockify sends and accepts scalars and arrays.
6
+ describe("custom field values", () => {
7
+ it("decode the scalar values Clockify returns", () => {
8
+ for (const value of ["new value", 42, true, ["a", "b"]]) {
9
+ const decoded = Schema.decodeUnknownSync(ClockifyApi.CustomFieldValueDtoV1)({ customFieldId: "cf", value })
10
+ assert.deepStrictEqual(decoded.value, value)
11
+ }
12
+ })
13
+
14
+ it("encode a scalar update value", () => {
15
+ const encoded = Schema.encodeSync(ClockifyApi.UpdateCustomFieldRequest)({ customFieldId: "cf", value: "new value" })
16
+ assert.strictEqual(encoded.value, "new value")
17
+ })
18
+ })
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Schema from "effect/Schema"
4
+ import { FeaturePlan, WorkspaceDtoV1, WorkspaceSettingsDtoV1 } from "../src/generated/ClockifyApi.js"
5
+
6
+ describe("generated workspace settings schema", () => {
7
+ // Preserve both upstream spellings in responses without rewriting either value.
8
+ it.effect.each(["ADMINS", "ADMINS_AND_PROJECT_MANAGERS", "ANYONE", "EVERYONE"])(
9
+ "preserves scheduling assignment creator %s",
10
+ (creator) =>
11
+ Effect.gen(function*() {
12
+ const settings = yield* Schema.decodeUnknownEffect(WorkspaceSettingsDtoV1)({
13
+ schedulingSettings: { whoCanCreateAssignments: creator }
14
+ })
15
+ expect(settings.schedulingSettings?.whoCanCreateAssignments).toBe(creator)
16
+ })
17
+ )
18
+
19
+ it.effect("rejects undocumented scheduling assignment creators", () =>
20
+ Effect.gen(function*() {
21
+ const failure = yield* Schema.decodeUnknownEffect(WorkspaceSettingsDtoV1)({
22
+ schedulingSettings: { whoCanCreateAssignments: "OWNER_ONLY" }
23
+ }).pipe(Effect.flip)
24
+ expect(failure._tag).toBe("SchemaError")
25
+ }))
26
+ })
27
+
28
+ describe("generated feature plan schema", () => {
29
+ it.effect("decodes feature plans after removing the upstream recursive oneOf", () =>
30
+ Effect.gen(function*() {
31
+ const plan: FeaturePlan = {
32
+ addonSubscriptionPlan: "PRO",
33
+ featurePermissions: ["AUDIT_LOG"],
34
+ paidPlan: true,
35
+ weight: 3
36
+ }
37
+ expect(yield* Schema.decodeUnknownEffect(FeaturePlan)(plan)).toEqual(plan)
38
+ }))
39
+
40
+ it.effect("rejects feature plans with a fractional weight", () =>
41
+ Effect.gen(function*() {
42
+ const failure = yield* Schema.decodeUnknownEffect(FeaturePlan)({ weight: 1.5 }).pipe(Effect.flip)
43
+ expect(failure._tag).toBe("SchemaError")
44
+ }))
45
+ })
46
+
47
+ describe("generated workspace schema", () => {
48
+ // Live /v1/workspaces responses: the plan is a string, features are strings, settings are not modelled.
49
+ it.effect("accepts the plan, features and settings shapes the API returns", () =>
50
+ Effect.gen(function*() {
51
+ const workspace = yield* Schema.decodeUnknownEffect(WorkspaceDtoV1)({
52
+ id: "workspace-1",
53
+ name: "Delivery",
54
+ featureSubscriptionType: "FREE_2026",
55
+ features: ["TIME_TRACKING"],
56
+ workspaceSettings: { automaticLock: null, adminOnlyPages: [] },
57
+ memberships: [{ userId: "u1", costRate: null, hourlyRate: null }],
58
+ subdomain: { enabled: false, name: null }
59
+ })
60
+ expect(workspace.featureSubscriptionType).toBe("FREE_2026")
61
+ expect(workspace.features).toEqual(["TIME_TRACKING"])
62
+ }))
63
+
64
+ it.effect.each([42, "oops", [], true])(
65
+ "rejects workspace settings that are not an object: %s",
66
+ (settings) =>
67
+ Effect.gen(function*() {
68
+ const failure = yield* Schema.decodeUnknownEffect(WorkspaceDtoV1)({
69
+ id: "workspace-1",
70
+ name: "Delivery",
71
+ workspaceSettings: settings
72
+ }).pipe(Effect.flip)
73
+ expect(failure._tag).toBe("SchemaError")
74
+ })
75
+ )
76
+ })
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Schema } from "effect"
3
+ import { GetWorkspacesOfUser200 } from "../src/generated/ClockifyApi.js"
4
+ import workspaces from "./fixtures/clockify-workspaces.json" with { type: "json" }
5
+
6
+ describe("workspace subscription plan", () => {
7
+ it("decodes a real-shaped /v1/workspaces response, whose featureSubscriptionType is a string", () => {
8
+ const [workspace] = Schema.decodeUnknownSync(GetWorkspacesOfUser200)(workspaces)
9
+ expect(workspace?.featureSubscriptionType).toBe("FREE_2026")
10
+ })
11
+ })