|
1 | 1 | import { EventEmitter } from "node:events"; |
2 | 2 | import { Metadata, type ServiceError, status } from "@grpc/grpc-js"; |
3 | 3 | import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; |
4 | | -import { DecreeError } from "../src/errors.js"; |
| 4 | +import { DecreeError, TypeMismatchError } from "../src/errors.js"; |
5 | 5 | import type { Change } from "../src/types.js"; |
6 | 6 | import { ConfigWatcher, WatchedField } from "../src/watcher.js"; |
7 | 7 |
|
@@ -283,6 +283,72 @@ describe("WatchedField", () => { |
283 | 283 | expect(field.droppedChanges).toBe(0); |
284 | 284 | }); |
285 | 285 | }); |
| 286 | + |
| 287 | + describe("conversionError handling", () => { |
| 288 | + it("falls back to default and emits conversionError when _loadInitial value is unconvertible", () => { |
| 289 | + const field = new WatchedField("payments.fee", Number, { default: 0.01 }); |
| 290 | + |
| 291 | + const errors: Array<{ err: DecreeError; raw: string }> = []; |
| 292 | + field.on("conversionError", (err, raw) => errors.push({ err, raw })); |
| 293 | + |
| 294 | + field._loadInitial("not-a-number"); |
| 295 | + |
| 296 | + expect(field.value).toBe(0.01); |
| 297 | + expect(errors).toHaveLength(1); |
| 298 | + expect(errors[0]?.err).toBeInstanceOf(TypeMismatchError); |
| 299 | + expect(errors[0]?.raw).toBe("not-a-number"); |
| 300 | + }); |
| 301 | + |
| 302 | + it("retains current value and emits conversionError when _update value is unconvertible (type-flip)", () => { |
| 303 | + const field = new WatchedField("payments.fee", Number, { default: 0.01 }); |
| 304 | + field._loadInitial("0.05"); |
| 305 | + expect(field.value).toBe(0.05); |
| 306 | + |
| 307 | + const errors: Array<{ err: DecreeError; raw: string }> = []; |
| 308 | + field.on("conversionError", (err, raw) => errors.push({ err, raw })); |
| 309 | + |
| 310 | + const changeHandler = vi.fn(); |
| 311 | + field.on("change", changeHandler); |
| 312 | + |
| 313 | + const change: Change = { |
| 314 | + fieldPath: "payments.fee", |
| 315 | + oldValue: "0.05", |
| 316 | + newValue: "not-a-number", |
| 317 | + version: 2, |
| 318 | + changedBy: "admin", |
| 319 | + }; |
| 320 | + field._update("not-a-number", change); |
| 321 | + |
| 322 | + // Value must be retained, not overwritten. |
| 323 | + expect(field.value).toBe(0.05); |
| 324 | + // No change event should fire. |
| 325 | + expect(changeHandler).not.toHaveBeenCalled(); |
| 326 | + // conversionError must be emitted. |
| 327 | + expect(errors).toHaveLength(1); |
| 328 | + expect(errors[0]?.err).toBeInstanceOf(TypeMismatchError); |
| 329 | + expect(errors[0]?.raw).toBe("not-a-number"); |
| 330 | + }); |
| 331 | + |
| 332 | + it("does not fire change event after a failed conversion in _update", () => { |
| 333 | + const field = new WatchedField("feature.enabled", Boolean, { default: false }); |
| 334 | + field._loadInitial("true"); |
| 335 | + |
| 336 | + const changeHandler = vi.fn(); |
| 337 | + field.on("change", changeHandler); |
| 338 | + |
| 339 | + const change: Change = { |
| 340 | + fieldPath: "feature.enabled", |
| 341 | + oldValue: "true", |
| 342 | + newValue: "maybe", |
| 343 | + version: 2, |
| 344 | + changedBy: "admin", |
| 345 | + }; |
| 346 | + field._update("maybe", change); |
| 347 | + |
| 348 | + expect(field.value).toBe(true); |
| 349 | + expect(changeHandler).not.toHaveBeenCalled(); |
| 350 | + }); |
| 351 | + }); |
286 | 352 | }); |
287 | 353 |
|
288 | 354 | describe("ConfigWatcher", () => { |
@@ -616,6 +682,98 @@ describe("ConfigWatcher", () => { |
616 | 682 |
|
617 | 683 | await watcher.stop(); |
618 | 684 | }); |
| 685 | + |
| 686 | + it("does not crash the stream when convertValue throws (type-flip mid-stream)", async () => { |
| 687 | + const watcher = createWatcher(); |
| 688 | + const fee = watcher.field("payments.fee", Number, { default: 0.01 }); |
| 689 | + |
| 690 | + mockGetConfigSuccess([{ fieldPath: "payments.fee", value: { numberValue: 0.05 } }]); |
| 691 | + |
| 692 | + const conversionErrors: Array<DecreeError> = []; |
| 693 | + fee.on("conversionError", (err) => conversionErrors.push(err)); |
| 694 | + |
| 695 | + await watcher.start(); |
| 696 | + |
| 697 | + // Simulate server flipping the field type to a non-numeric string. |
| 698 | + mockStream.emit("data", { |
| 699 | + change: { |
| 700 | + tenantId: "tenant-1", |
| 701 | + version: 2, |
| 702 | + fieldPath: "payments.fee", |
| 703 | + oldValue: { numberValue: 0.05 }, |
| 704 | + newValue: { stringValue: "not-a-number" }, |
| 705 | + changedBy: "admin", |
| 706 | + changedAt: new Date(), |
| 707 | + }, |
| 708 | + }); |
| 709 | + |
| 710 | + // Value must remain at last good value (0.05). |
| 711 | + expect(fee.value).toBe(0.05); |
| 712 | + // conversionError must have been emitted. |
| 713 | + expect(conversionErrors).toHaveLength(1); |
| 714 | + expect(conversionErrors[0]).toBeInstanceOf(TypeMismatchError); |
| 715 | + |
| 716 | + // Stream must still be alive — subsequent valid update must apply. |
| 717 | + mockStream.emit("data", { |
| 718 | + change: { |
| 719 | + tenantId: "tenant-1", |
| 720 | + version: 3, |
| 721 | + fieldPath: "payments.fee", |
| 722 | + oldValue: { numberValue: 0.05 }, |
| 723 | + newValue: { numberValue: 0.99 }, |
| 724 | + changedBy: "admin", |
| 725 | + changedAt: new Date(), |
| 726 | + }, |
| 727 | + }); |
| 728 | + |
| 729 | + expect(fee.value).toBe(0.99); |
| 730 | + |
| 731 | + await watcher.stop(); |
| 732 | + }); |
| 733 | + |
| 734 | + it("continues processing other fields after one field has a conversion error", async () => { |
| 735 | + const watcher = createWatcher(); |
| 736 | + const fee = watcher.field("payments.fee", Number, { default: 0.01 }); |
| 737 | + const label = watcher.field("payments.label", String, { default: "default" }); |
| 738 | + |
| 739 | + mockGetConfigSuccess([ |
| 740 | + { fieldPath: "payments.fee", value: { numberValue: 0.05 } }, |
| 741 | + { fieldPath: "payments.label", value: { stringValue: "original" } }, |
| 742 | + ]); |
| 743 | + |
| 744 | + await watcher.start(); |
| 745 | + |
| 746 | + // Bad update for fee (type-flip). |
| 747 | + mockStream.emit("data", { |
| 748 | + change: { |
| 749 | + tenantId: "tenant-1", |
| 750 | + version: 2, |
| 751 | + fieldPath: "payments.fee", |
| 752 | + oldValue: { numberValue: 0.05 }, |
| 753 | + newValue: { stringValue: "bad" }, |
| 754 | + changedBy: "admin", |
| 755 | + changedAt: new Date(), |
| 756 | + }, |
| 757 | + }); |
| 758 | + |
| 759 | + // Good update for label — must still apply. |
| 760 | + mockStream.emit("data", { |
| 761 | + change: { |
| 762 | + tenantId: "tenant-1", |
| 763 | + version: 3, |
| 764 | + fieldPath: "payments.label", |
| 765 | + oldValue: { stringValue: "original" }, |
| 766 | + newValue: { stringValue: "updated" }, |
| 767 | + changedBy: "admin", |
| 768 | + changedAt: new Date(), |
| 769 | + }, |
| 770 | + }); |
| 771 | + |
| 772 | + expect(fee.value).toBe(0.05); // unchanged due to conversion error |
| 773 | + expect(label.value).toBe("updated"); // updated successfully |
| 774 | + |
| 775 | + await watcher.stop(); |
| 776 | + }); |
619 | 777 | }); |
620 | 778 |
|
621 | 779 | describe("reconnection", () => { |
|
0 commit comments