Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/lib/deterministic-mock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";

import { DeterministicMockData } from "./deterministic-mock";

describe("DeterministicMockData (#975)", () => {
describe("Sequential error injection", () => {
it("injects sequential transient errors before succeeding", async () => {
const mock = new DeterministicMockData(12345);
mock.setFailureSequence([
new Error("Transient network timeout #1"),
new Error("Transient rate limit #2"),
"success",
]);

expect(mock.getRemainingFailures()).toBe(3);

await expect(
mock.executeWithFailureSequence(() => "ok"),
).rejects.toThrow("Transient network timeout #1");
expect(mock.getRemainingFailures()).toBe(2);

await expect(
mock.executeWithFailureSequence(() => "ok"),
).rejects.toThrow("Transient rate limit #2");
expect(mock.getRemainingFailures()).toBe(1);

const res = await mock.executeWithFailureSequence(() => "recovered-ok");
expect(res).toBe("recovered-ok");
expect(mock.getRemainingFailures()).toBe(0);

// Subsequent calls continue to succeed once queue is drained
const resAfter = await mock.executeWithFailureSequence(() => "still-ok");
expect(resAfter).toBe("still-ok");
});

it("supports string error messages and resets failure sequence on reset()", async () => {
const mock = new DeterministicMockData(999);
mock.setFailureSequence(["horizon_503"]);
mock.reset();
expect(mock.getRemainingFailures()).toBe(0);
await expect(mock.executeWithFailureSequence(() => 42)).resolves.toBe(42);
});
});

describe("Controllable mock clock", () => {
it("advances time deterministically without real timers and reflects in generated items", () => {
const initialMs = 1700000000000;
const mock = new DeterministicMockData(12345, initialMs);

expect(mock.getCurrentTime()).toBe(initialMs);
const historyBefore = mock.generateMockHistory(1);
expect(historyBefore[0].createdAt).toBe(new Date(initialMs).toISOString());

mock.advanceTime(60_000);
expect(mock.getCurrentTime()).toBe(initialMs + 60_000);

const historyAfter = mock.generateMockHistory(1);
expect(historyAfter[0].createdAt).toBe(
new Date(initialMs + 60_000).toISOString(),
);

mock.reset();
expect(mock.getCurrentTime()).toBe(initialMs);
});

it("validates setCurrentTime and advanceTime inputs", () => {
const mock = new DeterministicMockData(12345);
expect(() => mock.setCurrentTime(-1)).toThrow(/non-negative/);
expect(() => mock.advanceTime(Number.NaN)).toThrow(/finite/);
});
});

describe("Seed reproducibility across runs", () => {
it("produces identical hashes, history, and events for identical seeds", () => {
const runA = new DeterministicMockData(77777, 1700000000000);
const runB = new DeterministicMockData(77777, 1700000000000);

expect(runA.generateMockHistory(10)).toEqual(runB.generateMockHistory(10));
expect(runA.generateMockEvents(5)).toEqual(runB.generateMockEvents(5));

runA.reset();
const firstPass = runA.generateMockHistory(4);
runA.reset();
const secondPass = runA.generateMockHistory(4);
expect(firstPass).toEqual(secondPass);
});
});
});
190 changes: 130 additions & 60 deletions src/lib/deterministic-mock.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,96 @@
/**
* Deterministic mock data generation for reproducible tests.
* Uses a seeded LCG random number generator for consistent snapshots.
*
* Design: each high-level generator (generateMockHistory, generateMockEvents)
* resets the internal seed before running so that calling them in any order
* always produces the same output. The low-level generateHex / generateEventId /
* generateTransactionHash methods DO advance the seed sequentially; only use
* them directly when you need a continuous sequence.
* Uses a seeded LCG random number generator for consistent snapshots,
* a controllable mock clock for deterministic timestamps, and a sequential
* error-injection queue for resilience testing.
*/

export type FailureSequenceStep = Error | string | null | "success";

export class DeterministicMockData {
private initialSeed: number;
private seed: number;
private initialTimeMs: number;
private currentTimeMs: number;
private failureSequence: FailureSequenceStep[] = [];

constructor(seedValue: number = 12345) {
constructor(seedValue: number = 12345, initialTimeMs: number = 1700000000000) {
this.initialSeed = seedValue;
this.seed = seedValue;
this.initialTimeMs = initialTimeMs;
this.currentTimeMs = initialTimeMs;
}

/** Reset the seed back to its initial value. */
/** Reset the seed, mock clock, and failure sequence back to initial state. */
reset(): void {
this.seed = this.initialSeed;
this.currentTimeMs = this.initialTimeMs;
this.failureSequence = [];
}

/**
* Configure a sequence of transient errors or successes to inject into mock operations.
* For example: `[new Error('timeout'), new Error('rate_limit'), 'success']`.
*/
setFailureSequence(sequence: FailureSequenceStep[]): void {
this.failureSequence = [...sequence];
}

/** Return the number of remaining entries in the failure sequence. */
getRemainingFailures(): number {
return this.failureSequence.length;
}

/**
* Consume the next step in the failure sequence.
* Throws an Error if the next step is an Error or error string, or returns null if it succeeds.
*/
consumeNextStep(): Error | null {
if (this.failureSequence.length === 0) {
return null;
}
const next = this.failureSequence.shift()!;
if (next === null || next === "success") {
return null;
}
const err = next instanceof Error ? next : new Error(String(next));
throw err;
}

/**
* Execute an operation with the configured failure sequence applied.
* If the current step in the sequence is a failure, throws that Error; otherwise runs `fn`.
*/
async executeWithFailureSequence<T>(fn: () => T | Promise<T>): Promise<T> {
this.consumeNextStep();
return await fn();
}

/** Return the current mock clock timestamp in milliseconds. */
getCurrentTime(): number {
return this.currentTimeMs;
}

/** Return the current mock clock timestamp as an ISO-8601 string. */
getCurrentIsoTime(): string {
return new Date(this.currentTimeMs).toISOString();
}

/** Set the mock clock to a specific timestamp in milliseconds. */
setCurrentTime(timestampMs: number): void {
if (!Number.isFinite(timestampMs) || timestampMs < 0) {
throw new Error("Mock clock timestamp must be a non-negative finite number");
}
this.currentTimeMs = timestampMs;
}

/** Advance the mock clock deterministically by `ms` milliseconds. */
advanceTime(ms: number): number {
if (!Number.isFinite(ms)) {
throw new Error("Time delta must be a finite number");
}
this.currentTimeMs += ms;
return this.currentTimeMs;
}

/**
Expand All @@ -37,63 +107,63 @@ export class DeterministicMockData {
* @param length - Number of hex characters to generate.
*/
generateHex(length: number = 64): string {
let result = '';
let result = "";
for (let i = 0; i < length; i++) {
result += Math.floor(this.seededRandom() * 16).toString(16);
}
return result;
}

/**
* Generate deterministic transaction hash
*/
generateTransactionHash(): string {
return this.generateHex(64);
}

/**
* Generate deterministic event ID
*/
generateEventId(): string {
return this.generateHex(32);
}

/**
* Generate mock transaction history with deterministic values
*/
generateMockHistory(count: number = 5) {
const history = [];
for (let i = 0; i < count; i++) {
history.push({
hash: this.generateTransactionHash(),
ledger: 12345 - i,
createdAt: new Date(1700000000000 - i * 1000).toISOString(),
successful: true,
operationCount: 1,
feePaid: '100',
});
}
return history;
}

/**
* Generate mock events with deterministic values
*/
generateMockEvents(count: number = 3) {
const events = [];
for (let i = 0; i < count; i++) {
events.push({
id: this.generateEventId(),
contractId: this.generateHex(56),
type: 'contract',
ledger: 12345 - i,
createdAt: new Date(1700000000000 - i * 500).toISOString(),
topics: [this.generateHex(32), this.generateHex(32)],
value: this.generateHex(64),
});
}
return events;
}
/**
* Generate deterministic transaction hash
*/
generateTransactionHash(): string {
return this.generateHex(64);
}

/**
* Generate deterministic event ID
*/
generateEventId(): string {
return this.generateHex(32);
}

/**
* Generate mock transaction history with deterministic values and mock clock timestamps
*/
generateMockHistory(count: number = 5) {
const history = [];
for (let i = 0; i < count; i++) {
history.push({
hash: this.generateTransactionHash(),
ledger: 12345 - i,
createdAt: new Date(this.currentTimeMs - i * 1000).toISOString(),
successful: true,
operationCount: 1,
feePaid: "100",
});
}
return history;
}

/**
* Generate mock events with deterministic values and mock clock timestamps
*/
generateMockEvents(count: number = 3) {
const events = [];
for (let i = 0; i < count; i++) {
events.push({
id: this.generateEventId(),
contractId: this.generateHex(56),
type: "contract",
ledger: 12345 - i,
createdAt: new Date(this.currentTimeMs - i * 500).toISOString(),
topics: [this.generateHex(32), this.generateHex(32)],
value: this.generateHex(64),
});
}
return events;
}
}

/** Singleton with a fixed seed for consistent test data across imports. */
Expand Down