Initial Commit
This commit is contained in:
164
packages/foundation/flow/idempotency/tests/property.test.ts
Normal file
164
packages/foundation/flow/idempotency/tests/property.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user