Initial Commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user