165 lines
5.4 KiB
TypeScript
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 }
|
|
);
|
|
});
|
|
});
|