@knpkv/clockify-api-client 1.1.1 → 2.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.
- package/CHANGELOG.md +23 -0
- package/dist/ClockifyApiClient.d.ts +2 -2
- package/dist/ClockifyApiClient.d.ts.map +1 -1
- package/dist/ClockifyApiClient.js +3 -3
- package/dist/ClockifyApiClient.js.map +1 -1
- package/dist/generated/ClockifyApi.d.ts +5165 -4178
- package/dist/generated/ClockifyApi.d.ts.map +1 -1
- package/dist/generated/ClockifyApi.js +336 -329
- package/dist/generated/ClockifyApi.js.map +1 -1
- package/package.json +9 -9
- package/scripts/regenerate.ts +10 -8
- package/src/ClockifyApiClient.ts +4 -4
- package/src/generated/ClockifyApi.ts +570 -544
- package/test/ClockifyApiClient.test.ts +164 -213
- package/test/CustomFieldValues.test.ts +18 -0
- package/test/GeneratedSchemas.test.ts +60 -0
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
})
|
|
183
|
-
})
|
|
151
|
+
}))
|
|
184
152
|
|
|
185
|
-
it.effect("decodes a created time entry whose optional id references are null", () =>
|
|
186
|
-
|
|
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: "[
|
|
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,55 @@ describe("ClockifyApiClient", () => {
|
|
|
196
175
|
expect(entry.projectId).toBeNull()
|
|
197
176
|
expect(entry.taskId).toBeNull()
|
|
198
177
|
expect(entry.tagIds).toBeNull()
|
|
199
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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("fails when a successful response violates the generated schema", () =>
|
|
200
|
+
Effect.gen(function*() {
|
|
201
|
+
reply(200, { id: "user-1" })
|
|
202
|
+
const client = yield* ClockifyApiClient
|
|
203
|
+
const result = yield* Effect.result(client.getUser())
|
|
204
|
+
expect(result._tag).toBe("Failure")
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
it.effect("fails on non-success status codes", () =>
|
|
208
|
+
Effect.gen(function*() {
|
|
209
|
+
reply(401, { message: "Unauthorized" })
|
|
210
|
+
const client = yield* ClockifyApiClient
|
|
211
|
+
const result = yield* Effect.result(client.getUser())
|
|
212
|
+
expect(result._tag).toBe("Failure")
|
|
213
|
+
}))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Builds its own client rather than taking the suite's: the boundary under test is `make` itself,
|
|
217
|
+
// which is where the multipart request is constructed.
|
|
218
|
+
describe("ClockifyApiClient multipart", () => {
|
|
251
219
|
it.effect("leaves multipart content type unset so the transport can add its boundary", () => {
|
|
252
|
-
const
|
|
220
|
+
const seen: Array<HttpClientRequest.HttpClientRequest> = []
|
|
253
221
|
const httpClient = HttpClient.make((request) =>
|
|
254
222
|
Effect.sync(() => {
|
|
255
|
-
|
|
223
|
+
seen.push(request)
|
|
256
224
|
return HttpClientResponse.fromWeb(
|
|
257
225
|
request,
|
|
258
|
-
|
|
259
|
-
status: 200,
|
|
260
|
-
headers: { "content-type": "application/json" }
|
|
261
|
-
})
|
|
226
|
+
json(200, { name: "avatar.png", url: "https://clockify.test/avatar.png" })
|
|
262
227
|
)
|
|
263
228
|
})
|
|
264
229
|
)
|
|
@@ -269,28 +234,14 @@ describe("ClockifyApiClient", () => {
|
|
|
269
234
|
|
|
270
235
|
return Effect.gen(function*() {
|
|
271
236
|
yield* client.uploadImage({ file: new Blob(["avatar bytes"], { type: "image/png" }) })
|
|
272
|
-
expect(
|
|
273
|
-
expect(
|
|
274
|
-
if (
|
|
275
|
-
const file =
|
|
276
|
-
expect(
|
|
237
|
+
expect(seen[0]?.headers["content-type"]).toBeUndefined()
|
|
238
|
+
expect(seen[0]?.body._tag).toBe("FormData")
|
|
239
|
+
if (seen[0]?.body._tag !== "FormData") throw new Error("Expected a FormData request body")
|
|
240
|
+
const file = seen[0].body.formData.get("file")
|
|
241
|
+
expect(seen[0].body.formData).toBeInstanceOf(FormData)
|
|
277
242
|
if (file === null || Predicate.isString(file)) throw new Error("Expected a file field")
|
|
278
243
|
expect(yield* Effect.promise(() => file.text())).toBe("avatar bytes")
|
|
279
|
-
expect(
|
|
244
|
+
expect(seen[0]?.headers["x-api-key"]).toBe("secret")
|
|
280
245
|
})
|
|
281
246
|
})
|
|
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
247
|
})
|
|
@@ -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,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import { type FeaturePlan, WorkspaceDtoV1 } from "../src/generated/ClockifyApi.js"
|
|
5
|
+
|
|
6
|
+
describe("generated workspace 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 in workspace responses",
|
|
10
|
+
(creator) =>
|
|
11
|
+
Effect.gen(function*() {
|
|
12
|
+
const workspace = yield* Schema.decodeUnknownEffect(WorkspaceDtoV1)({
|
|
13
|
+
id: "workspace-1",
|
|
14
|
+
name: "Delivery",
|
|
15
|
+
workspaceSettings: {
|
|
16
|
+
schedulingSettings: { whoCanCreateAssignments: creator }
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
expect(workspace.workspaceSettings?.schedulingSettings?.whoCanCreateAssignments).toBe(creator)
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
it.effect("rejects undocumented scheduling assignment creators", () =>
|
|
24
|
+
Effect.gen(function*() {
|
|
25
|
+
const failure = yield* Schema.decodeUnknownEffect(WorkspaceDtoV1)({
|
|
26
|
+
id: "workspace-1",
|
|
27
|
+
name: "Delivery",
|
|
28
|
+
workspaceSettings: {
|
|
29
|
+
schedulingSettings: { whoCanCreateAssignments: "OWNER_ONLY" }
|
|
30
|
+
}
|
|
31
|
+
}).pipe(Effect.flip)
|
|
32
|
+
expect(failure._tag).toBe("SchemaError")
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
it.effect("decodes feature plans after removing the upstream recursive oneOf", () =>
|
|
36
|
+
Effect.gen(function*() {
|
|
37
|
+
const plan: FeaturePlan = {
|
|
38
|
+
addonSubscriptionPlan: "PRO",
|
|
39
|
+
featurePermissions: ["AUDIT_LOG"],
|
|
40
|
+
paidPlan: true,
|
|
41
|
+
weight: 3
|
|
42
|
+
}
|
|
43
|
+
const workspace = yield* Schema.decodeUnknownEffect(WorkspaceDtoV1)({
|
|
44
|
+
id: "workspace-1",
|
|
45
|
+
name: "Delivery",
|
|
46
|
+
featureSubscriptionType: plan
|
|
47
|
+
})
|
|
48
|
+
expect(workspace.featureSubscriptionType).toEqual(plan)
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
it.effect("rejects feature plans with a fractional weight", () =>
|
|
52
|
+
Effect.gen(function*() {
|
|
53
|
+
const failure = yield* Schema.decodeUnknownEffect(WorkspaceDtoV1)({
|
|
54
|
+
id: "workspace-1",
|
|
55
|
+
name: "Delivery",
|
|
56
|
+
featureSubscriptionType: { weight: 1.5 }
|
|
57
|
+
}).pipe(Effect.flip)
|
|
58
|
+
expect(failure._tag).toBe("SchemaError")
|
|
59
|
+
}))
|
|
60
|
+
})
|