151 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|