import { describe, expect, it } from "vitest"; import { of } from "rxjs"; import { firstValueFrom } from "rxjs"; import { tap } from "rxjs/operators"; import type { Observable } from "rxjs"; import { BigIntSerializationInterceptor } from "../interceptors/bigint-serialization.interceptor"; /** * Nest's InterceptorsConsumer nests interceptors in provider order: the * FIRST in the array is outermost; on the way back the INNER interceptor's * operator applies first. Production order (AppModule) is * [Idempotency, BigInt] — BigInt is INNER, so it converts before the * idempotency `tap` observes (and would cache) the body. * * The third test reproduces that exact nesting (mirroring * InterceptorsConsumer.intercept) and asserts the replay-correctness * property: the value the idempotency `tap` sees is already bigint-free and * JSON-serializable. `IdempotencyInterceptor.store` runs `JSON.stringify` * on that value — a raw `bigint` there throws (the exact regression that * 500'd `POST /api/v1/auth/register`). */ describe("BigIntSerializationInterceptor", () => { const make = () => new BigIntSerializationInterceptor(); it("converts bigint leaves in nested plain objects to decimal strings", async () => { const body = { workspace: { id: "ws_1", usageStorageBytes: 10737418240n }, list: [{ bytes: 42n }], untouched: "text", count: 7, }; const out = (await firstValueFrom(make().intercept({} as never, { handle: () => of(body) }))) as { workspace: { usageStorageBytes: string }; list: { bytes: string }[]; untouched: string; count: number; }; expect(out.workspace.usageStorageBytes).toBe("10737418240"); expect(out.list[0].bytes).toBe("42"); expect(out.untouched).toBe("text"); expect(out.count).toBe(7); // The converted body is JSON-serializable (the raw body throws). expect(() => JSON.stringify(out)).not.toThrow(); }); it("passes through null/arrays and preserves Date instances", async () => { const d = new Date("2026-01-02T03:04:05Z"); const out = (await firstValueFrom( make().intercept({} as never, { handle: () => of({ when: d, empty: null, arr: [1n, 2] }), }), )) as { when: Date; empty: null; arr: (string | number)[] }; expect(out.when).toBe(d); // Date is not a plain object → untouched expect(out.empty).toBeNull(); expect(out.arr).toEqual(["1", 2]); expect(JSON.stringify({ when: out.when })).toBe('{"when":"2026-01-02T03:04:05.000Z"}'); }); it( "in production order [idempotency, bigint], the idempotency tap sees a JSON-safe body", async () => { const captured: unknown[] = []; const outer = (obs: Observable) => obs.pipe(tap((v) => captured.push(v))); // Reproduce the production nesting: handler value with bigints flows // through the BigInt interceptor (inner) then the outer tap // (idempotency stand-in), exactly as InterceptorsConsumer builds it. const handlerValue = { ok: true, file: { size: 12345n, bytes: 9007199254740993n } }; const obs = outer( make().intercept({} as never, { handle: () => of(handlerValue) }), ); const out = await firstValueFrom(obs); // The outer tap (idempotency stand-in) captured the CONVERTED body — // exactly what its real tap() would cache for the 24 h replay. expect(captured).toHaveLength(1); expect(() => JSON.stringify(captured[0])).not.toThrow(); expect((captured[0] as { file: { size: string; bytes: string } }).file.size).toBe("12345"); expect((captured[0] as { file: { bytes: string } }).file.bytes).toBe("9007199254740993"); expect(JSON.stringify(captured[0])).toBe(JSON.stringify(out)); }, ); it("documents the root cause: JSON.stringify on a raw bigint throws", () => { // This is the TypeError that surfaced as 500 "Do not know how to // serialize a BigInt" through the exceptions filter. expect(() => JSON.stringify({ usageStorageBytes: 1n })).toThrow(); }); it("fast path: a bigint-free body is returned by REFERENCE (no clone)", async () => { const body = { id: "ws_1", items: [{ n: 1, s: "x" }], at: new Date() }; const out = await firstValueFrom( make().intercept({} as never, { handle: () => of(body) }), ); expect(out).toBe(body); }); });