1import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
2import * as NodeServices from "@effect/platform-node/NodeServices"
3import { assert, describe, it } from "@effect/vitest"
4import * as Effect from "effect/Effect"
5import * as Exit from "effect/Exit"
6import * as Path from "effect/Path"
7
8const testLayer = NodeServices.layer
9
10describe("OpenApiPatch", () => {
11 describe("parsePatchInput", () => {
12 describe("inline JSON", () => {
13 it.effect("parses valid inline JSON patch", () =>
14 Effect.gen(function*() {
15 const result = yield* OpenApiPatch.parsePatchInput(
16 "[{\"op\":\"add\",\"path\":\"/foo\",\"value\":\"bar\"}]"
17 )
18 assert.deepStrictEqual(result, [{ op: "add", path: "/foo", value: "bar" }])
19 }).pipe(Effect.provide(testLayer)))
20
21 it.effect("parses inline JSON with multiple operations", () =>
22 Effect.gen(function*() {
23 const result = yield* OpenApiPatch.parsePatchInput(
24 "[{\"op\":\"add\",\"path\":\"/a\",\"value\":1},{\"op\":\"remove\",\"path\":\"/b\"},{\"op\":\"replace\",\"path\":\"/c\",\"value\":true}]"
25 )
26 assert.strictEqual(result.length, 3)
27 assert.strictEqual(result[0].op, "add")
28 assert.strictEqual(result[1].op, "remove")
29 assert.strictEqual(result[2].op, "replace")
30 }).pipe(Effect.provide(testLayer)))
31
32 it.effect("fails on invalid JSON syntax", () =>
33 Effect.gen(function*() {
34 const exit = yield* Effect.exit(
35 OpenApiPatch.parsePatchInput("[{\"op\":\"add\" \"path\":\"/foo\"}]")
36 )
37 assert.isTrue(Exit.isFailure(exit))
38 }).pipe(Effect.provide(testLayer)))
39
40 it.effect("fails on unsupported operation", () =>
41 Effect.gen(function*() {
42 const exit = yield* Effect.exit(
43 OpenApiPatch.parsePatchInput("[{\"op\":\"copy\",\"from\":\"/a\",\"path\":\"/b\"}]")
44 )
45 assert.isTrue(Exit.isFailure(exit))
46 }).pipe(Effect.provide(testLayer)))
47
48 it.effect("fails on missing path field", () =>
49 Effect.gen(function*() {
50 const exit = yield* Effect.exit(
51 OpenApiPatch.parsePatchInput("[{\"op\":\"add\",\"value\":\"test\"}]")
52 )
53 assert.isTrue(Exit.isFailure(exit))
54 }).pipe(Effect.provide(testLayer)))
55
56 it.effect("fails on missing value for add operation", () =>
57 Effect.gen(function*() {
58 const exit = yield* Effect.exit(
59 OpenApiPatch.parsePatchInput("[{\"op\":\"add\",\"path\":\"/foo\"}]")
60 )
61 assert.isTrue(Exit.isFailure(exit))
62 }).pipe(Effect.provide(testLayer)))
63
64 it.effect("allows missing value for remove operation", () =>
65 Effect.gen(function*() {
66 const result = yield* OpenApiPatch.parsePatchInput(
67 "[{\"op\":\"remove\",\"path\":\"/foo\"}]"
68 )
69 assert.deepStrictEqual(result, [{ op: "remove", path: "/foo" }])
70 }).pipe(Effect.provide(testLayer)))
71 })
72
73 describe("file paths", () => {
74 it.effect("parses valid JSON file", () =>
75 Effect.gen(function*() {
76 const pathService = yield* Path.Path
77 const filePath = pathService.join(
78 import.meta.dirname,
79 "fixtures/patches/valid-add.json"
80 )
81 const result = yield* OpenApiPatch.parsePatchInput(filePath)
82 assert.strictEqual(result.length, 1)
83 assert.strictEqual(result[0].op, "add")
84 assert.strictEqual(result[0].path, "/info/x-custom")
85 }).pipe(Effect.provide(testLayer)))
86
87 it.effect("parses valid YAML file", () =>
88 Effect.gen(function*() {
89 const pathService = yield* Path.Path
90 const filePath = pathService.join(
91 import.meta.dirname,
92 "fixtures/patches/valid-patch.yaml"
93 )
94 const result = yield* OpenApiPatch.parsePatchInput(filePath)
95 assert.deepStrictEqual(result, [
96 { op: "replace", path: "/info/title", value: "YAML Patched Title" },
97 { op: "add", path: "/info/x-yaml-patch", value: true }
98 ])
99 }).pipe(Effect.provide(testLayer)))
100
101 it.effect("parses multiple operations from JSON file", () =>
102 Effect.gen(function*() {
103 const pathService = yield* Path.Path
104 const filePath = pathService.join(
105 import.meta.dirname,
106 "fixtures/patches/valid-multiple.json"
107 )
108 const result = yield* OpenApiPatch.parsePatchInput(filePath)
109 assert.strictEqual(result.length, 3)
110 }).pipe(Effect.provide(testLayer)))
111
112 it.effect("fails on file with unsupported operation", () =>
113 Effect.gen(function*() {
114 const pathService = yield* Path.Path
115 const filePath = pathService.join(
116 import.meta.dirname,
117 "fixtures/patches/invalid-op.json"
118 )
119 const exit = yield* Effect.exit(OpenApiPatch.parsePatchInput(filePath))
120 assert.isTrue(Exit.isFailure(exit))
121 }).pipe(Effect.provide(testLayer)))
122
123 it.effect("fails on file with missing path", () =>
124 Effect.gen(function*() {
125 const pathService = yield* Path.Path
126 const filePath = pathService.join(
127 import.meta.dirname,
128 "fixtures/patches/missing-path.json"
129 )
130 const exit = yield* Effect.exit(OpenApiPatch.parsePatchInput(filePath))
131 assert.isTrue(Exit.isFailure(exit))
132 }).pipe(Effect.provide(testLayer)))
133
134 it.effect("falls back to inline JSON when file does not exist", () =>
135 Effect.gen(function*() {
136 const result = yield* OpenApiPatch.parsePatchInput(
137 "[{\"op\":\"add\",\"path\":\"/x\",\"value\":1}]"
138 )
139 assert.deepStrictEqual(result, [{ op: "add", path: "/x", value: 1 }])
140 }).pipe(Effect.provide(testLayer)))
141 })
142 })
143
144 describe("applyPatches", () => {
145 it.effect("applies single add patch", () =>
146 Effect.gen(function*() {
147 const document = { info: { title: "Test" } }
148 const patches = [{
149 source: "test",
150 patch: [{ op: "add" as const, path: "/info/version", value: "1.0.0" }]
151 }]
152 const result = yield* OpenApiPatch.applyPatches(patches, document)
153 assert.deepStrictEqual(result, { info: { title: "Test", version: "1.0.0" } })
154 }))
155
156 it.effect("applies single remove patch", () =>
157 Effect.gen(function*() {
158 const document = { info: { title: "Test", deprecated: true } }
159 const patches = [{
160 source: "test",
161 patch: [{ op: "remove" as const, path: "/info/deprecated" }]
162 }]
163 const result = yield* OpenApiPatch.applyPatches(patches, document)
164 assert.deepStrictEqual(result, { info: { title: "Test" } })
165 }))
166
167 it.effect("applies single replace patch", () =>
168 Effect.gen(function*() {
169 const document = { info: { title: "Old Title" } }
170 const patches = [{
171 source: "test",
172 patch: [{ op: "replace" as const, path: "/info/title", value: "New Title" }]
173 }]
174 const result = yield* OpenApiPatch.applyPatches(patches, document)
175 assert.deepStrictEqual(result, { info: { title: "New Title" } })
176 }))
177
178 it.effect("applies multiple patches in sequence", () =>
179 Effect.gen(function*() {
180 const document = { info: { title: "Original", version: "0.0.1" } }
181 const patches = [
182 {
183 source: "patch1",
184 patch: [{ op: "replace" as const, path: "/info/title", value: "Step 1" }]
185 },
186 {
187 source: "patch2",
188 patch: [{ op: "replace" as const, path: "/info/version", value: "1.0.0" }]
189 },
190 {
191 source: "patch3",
192 patch: [{ op: "add" as const, path: "/info/x-patched", value: true }]
193 }
194 ]
195 const result = yield* OpenApiPatch.applyPatches(patches, document)
196 assert.deepStrictEqual(result, {
197 info: { title: "Step 1", version: "1.0.0", "x-patched": true }
198 })
199 }))
200
201 it.effect("applies multiple operations within a single patch", () =>
202 Effect.gen(function*() {
203 const document = { info: { title: "Test" }, paths: {} }
204 const patches = [{
205 source: "test",
206 patch: [
207 { op: "replace" as const, path: "/info/title", value: "Updated" },
208 { op: "add" as const, path: "/info/version", value: "2.0.0" }
209 ]
210 }]
211 const result = yield* OpenApiPatch.applyPatches(patches, document)
212 assert.deepStrictEqual(result, {
213 info: { title: "Updated", version: "2.0.0" },
214 paths: {}
215 })
216 }))
217
218 it.effect("fails when path does not exist for replace", () =>
219 Effect.gen(function*() {
220 const document = { info: { title: "Test" } }
221 const patches = [{
222 source: "test",
223 patch: [{ op: "replace" as const, path: "/info/nonexistent", value: "x" }]
224 }]
225 const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
226 assert.isTrue(Exit.isFailure(exit))
227 }))
228
229 it.effect("fails when path does not exist for remove", () =>
230 Effect.gen(function*() {
231 const document = { info: { title: "Test" } }
232 const patches = [{
233 source: "test",
234 patch: [{ op: "remove" as const, path: "/info/nonexistent" }]
235 }]
236 const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
237 assert.isTrue(Exit.isFailure(exit))
238 }))
239
240 it.effect("accumulates multiple errors", () =>
241 Effect.gen(function*() {
242 const document = { info: { title: "Test" } }
243 const patches = [{
244 source: "test.json",
245 patch: [
246 { op: "replace" as const, path: "/info/nonexistent1", value: "x" },
247 { op: "remove" as const, path: "/info/nonexistent2" },
248 { op: "replace" as const, path: "/info/nonexistent3", value: "y" }
249 ]
250 }]
251 const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
252 assert.isTrue(Exit.isFailure(exit))
253 if (Exit.isFailure(exit)) {
254 const failure = exit.cause.reasons[0]
255 if (failure._tag === "Fail") {
256 assert.strictEqual(failure.error._tag, "JsonPatchAggregateError")
257 assert.strictEqual(failure.error.errors.length, 3)
258 assert.include(failure.error.message, "3 patch operations failed")
259 assert.include(failure.error.message, "/info/nonexistent1")
260 assert.include(failure.error.message, "/info/nonexistent2")
261 assert.include(failure.error.message, "/info/nonexistent3")
262 }
263 }
264 }))
265
266 it.effect("accumulates errors across multiple patches", () =>
267 Effect.gen(function*() {
268 const document = { info: { title: "Test" } }
269 const patches = [
270 {
271 source: "patch1.json",
272 patch: [{ op: "remove" as const, path: "/info/missing1" }]
273 },
274 {
275 source: "patch2.json",
276 patch: [{ op: "remove" as const, path: "/info/missing2" }]
277 }
278 ]
279 const exit = yield* Effect.exit(OpenApiPatch.applyPatches(patches, document))
280 assert.isTrue(Exit.isFailure(exit))
281 if (Exit.isFailure(exit)) {
282 const failure = exit.cause.reasons[0]
283 if (failure._tag === "Fail") {
284 assert.strictEqual(failure.error.errors.length, 2)
285 assert.include(failure.error.message, "patch1.json")
286 assert.include(failure.error.message, "patch2.json")
287 }
288 }
289 }))
290
291 it.effect("preserves unmodified parts of document", () =>
292 Effect.gen(function*() {
293 const document = {
294 info: { title: "Test", description: "Unchanged" },
295 paths: { "/users": { get: {} } },
296 components: { schemas: {} }
297 }
298 const patches = [{
299 source: "test",
300 patch: [{ op: "replace" as const, path: "/info/title", value: "Changed" }]
301 }]
302 const result = yield* OpenApiPatch.applyPatches(patches, document)
303 assert.strictEqual((result as { info: { description: string } }).info.description, "Unchanged")
304 assert.deepStrictEqual((result as { paths: object }).paths, { "/users": { get: {} } })
305 assert.deepStrictEqual((result as { components: object }).components, { schemas: {} })
306 }))
307
308 it.effect("returns original document when no patches provided", () =>
309 Effect.gen(function*() {
310 const document = { info: { title: "Test" } }
311 const result = yield* OpenApiPatch.applyPatches([], document)
312 assert.deepStrictEqual(result, document)
313 }))
314 })
315
316 describe("error messages", () => {
317 it.effect("JsonPatchParseError has descriptive message", () =>
318 Effect.gen(function*() {
319 const error = new OpenApiPatch.JsonPatchParseError({
320 source: "./fix.json",
321 reason: "Unexpected token"
322 })
323 assert.strictEqual(
324 error.message,
325 "Failed to parse patch from ./fix.json: Unexpected token"
326 )
327 }))
328
329 it.effect("JsonPatchValidationError has descriptive message", () =>
330 Effect.gen(function*() {
331 const error = new OpenApiPatch.JsonPatchValidationError({
332 source: "inline",
333 reason: "Missing 'path' field"
334 })
335 assert.strictEqual(
336 error.message,
337 "Invalid JSON Patch from inline: Missing 'path' field"
338 )
339 }))
340
341 it.effect("JsonPatchApplicationError has descriptive message", () =>
342 Effect.gen(function*() {
343 const error = new OpenApiPatch.JsonPatchApplicationError({
344 source: "./fix.json",
345 operationIndex: 2,
346 operation: "remove",
347 path: "/info/x",
348 reason: "Property does not exist"
349 })
350 assert.strictEqual(
351 error.message,
352 "Failed to apply patch from ./fix.json: operation 2 (remove at /info/x): Property does not exist"
353 )
354 }))
355
356 it.effect("JsonPatchAggregateError has descriptive message", () =>
357 Effect.gen(function*() {
358 const error = new OpenApiPatch.JsonPatchAggregateError({
359 errors: [
360 new OpenApiPatch.JsonPatchApplicationError({
361 source: "./fix.json",
362 operationIndex: 0,
363 operation: "replace",
364 path: "/info/x",
365 reason: "Property does not exist"
366 }),
367 new OpenApiPatch.JsonPatchApplicationError({
368 source: "./other.json",
369 operationIndex: 1,
370 operation: "remove",
371 path: "/paths/~1users",
372 reason: "Path not found"
373 })
374 ]
375 })
376 assert.include(error.message, "2 patch operations failed")
377 assert.include(error.message, "1. [./fix.json] op 0 (replace at /info/x)")
378 assert.include(error.message, "2. [./other.json] op 1 (remove at /paths/~1users)")
379 }))
380 })
381})