| 1 | import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch" |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices" |
| 3 | import { assert, describe, it } from "@effect/vitest" |
| 4 | import * as Effect from "effect/Effect" |
| 5 | import * as Exit from "effect/Exit" |
| 6 | import * as Path from "effect/Path" |
| 7 | |
| 8 | const testLayer = NodeServices.layer |
| 9 | |
| 10 | describe("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 | }) |