Files
2026-05-27 23:47:53 +01:00

165 lines
5.4 KiB
TypeScript

/**
* Property-based tests using fast-check.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { IdempotencyGuard } from "../src/guard/idempotency-guard.js";
import {
compositeKey,
fingerprintPayload,
generateIdempotencyKey,
} from "../src/key/key-generator.js";
import { MemoryIdempotencyStore } from "../src/store/memory-store.js";
import { KeyVersioning } from "../src/versioning/key-versioning.js";
describe("Property: compositeKey", () => {
it("is deterministic for equal inputs", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1 }), {
minLength: 1,
maxLength: 5,
}),
(parts) => {
const k1 = compositeKey(...parts);
const k2 = compositeKey(...parts);
expect(k1).toBe(k2);
}
),
{ numRuns: 200 }
);
});
it("produces a hex string for any non-empty input", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1 }), {
minLength: 1,
maxLength: 4,
}),
(parts) => {
const k = compositeKey(...parts);
expect(k).toMatch(/^[0-9a-f]+$/);
}
),
{ numRuns: 200 }
);
});
});
describe("Property: fingerprintPayload", () => {
it("produces same hash for JSON-equivalent objects (key-order independent)", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.dictionary(
fc.string({ minLength: 1, maxLength: 10 }),
fc.oneof(fc.integer(), fc.string(), fc.boolean())
),
(obj) => {
// Reconstruct with reversed key order
const reversed = Object.fromEntries(
Object.entries(obj).reverse()
);
const fp1 = fingerprintPayload(obj);
const fp2 = fingerprintPayload(reversed);
expect(fp1).toBe(fp2);
}
),
{ numRuns: 200 }
);
});
it("produces different hashes for different values", {
timeout: 30000,
}, () => {
// Not 100% true for collisions but holds for random non-equal primitives
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1_000_000 }),
fc.integer({ min: 1_000_001, max: 2_000_000 }),
(a, b) => {
expect(fingerprintPayload(a)).not.toBe(
fingerprintPayload(b)
);
}
),
{ numRuns: 100 }
);
});
});
describe("Property: generateIdempotencyKey", () => {
it("produces non-empty strings", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constant(undefined), () => {
const k = generateIdempotencyKey();
expect(k.length).toBeGreaterThan(0);
}),
{ numRuns: 100 }
);
});
});
describe("Property: IdempotencyGuard never executes fn twice for completed key", {
timeout: 30000,
}, () => {
it("fn called at most once across N concurrent calls", async () => {
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 2, max: 10 }),
async (concurrency) => {
const store = new MemoryIdempotencyStore();
const guard = new IdempotencyGuard({ store });
let execCount = 0;
const fn = async () => {
execCount++;
return "result";
};
// Sequential calls (concurrency not fully simulated in single-thread)
for (let i = 0; i < concurrency; i++) {
await guard.execute("prop-key", fn);
}
expect(execCount).toBe(1);
}
),
{ numRuns: 20 }
);
});
});
describe("Property: KeyVersioning never exceeds maxVersions", {
timeout: 30000,
}, () => {
it("history length stays within maxVersions", () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 20 }),
fc.integer({ min: 1, max: 50 }),
(maxVersions, insertCount) => {
const store = new MemoryIdempotencyStore();
const versioning = new KeyVersioning({
store,
maxVersions,
});
for (let i = 0; i < insertCount; i++) {
versioning.addVersionEntry("k", {
version: i + 1,
fingerprint: `fp${i}`,
status: "completed",
createdAt: Date.now(),
});
}
const history = versioning.getHistory("k");
expect(history.length).toBeLessThanOrEqual(maxVersions);
}
),
{ numRuns: 100 }
);
});
});