Files
suaveplan/packages/networking/events/event-sourcing/tests/integration.test.ts
2026-05-27 23:47:53 +01:00

151 lines
4.6 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { Aggregate } from "../src/core/aggregate/aggregate.js";
import { createAggregateRepository } from "../src/core/aggregate-repository/aggregate-repository.js";
import { createMemoryEventStore } from "../src/core/event-store/memory-event-store.js";
import { createProjection } from "../src/projection/builder/builder.js";
import type { DomainEvent } from "../src/types/event-sourcing.types.js";
interface AccountState {
balance: number;
open: boolean;
}
class BankAccount extends Aggregate<AccountState> {
public constructor(id: string) {
super(id, { balance: 0, open: true });
}
public deposit(amount: number): void {
this.raise("Deposited", { amount });
}
public withdraw(amount: number): void {
if (amount > this.state.balance) throw new Error("Insufficient funds");
this.raise("Withdrawn", { amount });
}
public close(): void {
this.raise("AccountClosed", {});
}
protected applyEvent(event: DomainEvent): void {
const p = event.payload as { amount: number };
switch (event.type) {
case "Deposited":
this.state = {
...this.state,
balance: this.state.balance + p.amount,
};
break;
case "Withdrawn":
this.state = {
...this.state,
balance: this.state.balance - p.amount,
};
break;
case "AccountClosed":
this.state = { ...this.state, open: false };
break;
}
}
public get balance(): number {
return this.state.balance;
}
public get isOpen(): boolean {
return this.state.open;
}
}
describe("event-sourcing integration", () => {
it("full lifecycle: create, save, reload", { timeout: 30000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
});
const account = new BankAccount("acc-1");
account.deposit(1000);
account.withdraw(250);
await repo.save(account);
const loaded = await repo.load("acc-1");
expect(loaded.balance).toBe(750);
expect(loaded.getVersion()).toBe(2);
});
it("project events to read model", { timeout: 30000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
});
const account = new BankAccount("acc-2");
account.deposit(500);
account.deposit(200);
account.withdraw(100);
await repo.save(account);
const totalDeposits = createProjection({
initialState: 0,
handlers: {
Deposited: (s, e) =>
s + (e.payload as { amount: number }).amount,
},
});
const events = await store.load("acc-2");
expect(totalDeposits.project(events)).toBe(700);
});
it("snapshot reduces replay events", { timeout: 30000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
snapshotThreshold: 3,
});
const account = new BankAccount("acc-3");
account.deposit(100);
account.deposit(200);
account.deposit(300);
await repo.save(account); // should snapshot at version 3
const snap = await store.loadSnapshot("acc-3");
expect(snap).toBeDefined();
expect(snap?.version).toBe(3);
const loaded = await repo.load("acc-3");
expect(loaded.balance).toBe(600);
});
it("concurrent save raises concurrency conflict", {
timeout: 30000,
}, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
});
// First save
const a1 = new BankAccount("acc-4");
a1.deposit(100);
await repo.save(a1);
// Load same aggregate twice
const b1 = await repo.load("acc-4");
const b2 = await repo.load("acc-4");
b1.deposit(50);
b2.withdraw(50);
await repo.save(b1);
// b2 has stale version — should conflict
await expect(repo.save(b2)).rejects.toThrow();
});
});