diff --git a/src/operationQueue.ts b/src/operationQueue.ts index 2cdf671..555b374 100644 --- a/src/operationQueue.ts +++ b/src/operationQueue.ts @@ -1,7 +1,24 @@ +/** + * Named priority levels for queued operations. + * + * Higher numeric value = higher urgency; the drain loop processes items in + * descending priority order (HIGH before NORMAL before LOW). + * + * Export so callers can reference the constants without magic numbers. + */ +export const OperationPriority = { + LOW: 1, + NORMAL: 5, + HIGH: 10, +} as const; + +export type OperationPriorityValue = (typeof OperationPriority)[keyof typeof OperationPriority]; + type QueuedOperation = { id: string; method: string; args: unknown[]; + priority: OperationPriorityValue; resolve: (value: unknown) => void; reject: (reason: unknown) => void; executor: (args: unknown[]) => Promise; @@ -38,11 +55,18 @@ export class OperationQueue { /** * Enqueue an operation. Executes immediately when online; buffers when offline. * The returned promise resolves/rejects once the operation completes. + * + * @param method - Human-readable name for the operation (used for debugging). + * @param args - Arguments forwarded to `executor`. + * @param executor - Async function that performs the actual work. + * @param priority - Urgency level; defaults to {@link OperationPriority.NORMAL}. + * Higher-priority operations are drained first. */ enqueue( method: string, args: unknown[], - executor: (args: unknown[]) => Promise + executor: (args: unknown[]) => Promise, + priority: OperationPriorityValue = OperationPriority.NORMAL, ): Promise { if (this._online) { return executor(args); @@ -52,6 +76,7 @@ export class OperationQueue { id: String(++_nextId), method, args, + priority, resolve: resolve as (v: unknown) => void, reject, executor: executor as (args: unknown[]) => Promise, @@ -78,6 +103,9 @@ export class OperationQueue { } private async _drain(): Promise { + // Sort descending by priority so HIGH (10) ops execute before NORMAL (5) and LOW (1). + this._queue.sort((a, b) => b.priority - a.priority); + while (this._queue.length > 0) { const op = this._queue.shift(); if (!op) break; diff --git a/src/sponsorship.ts b/src/sponsorship.ts index 71dd879..d860185 100644 --- a/src/sponsorship.ts +++ b/src/sponsorship.ts @@ -71,6 +71,25 @@ export class InsufficientReserveError extends StellarSplitError { import { checkSponsorReserve as _checkSponsorReserve } from "./preflightChecker.js"; import type { SponsorshipConfig, SponsorReserveCheckResult } from "./types.js"; +// --------------------------------------------------------------------------- +// SponsorshipUsed event +// --------------------------------------------------------------------------- + +/** + * Event payload emitted after a sponsored transaction is successfully submitted. + * + * The `feeSource` field identifies the **sponsor** account that covered the + * transaction fee — not the submitter of the envelope. + */ +export interface SponsorshipUsedEvent { + /** Stellar address of the account that sponsored the reserves/fees. */ + feeSource: string; + /** Stellar address of the newly-onboarded account whose reserves were sponsored. */ + newAccount: string; + /** Transaction hash returned by the RPC node after submission. */ + txHash: string; +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -205,3 +224,49 @@ export async function checkSponsorshipReserve( options?.throwOnInsufficient ?? true, ); } + +// --------------------------------------------------------------------------- +// submitSponsoredTransaction +// --------------------------------------------------------------------------- + +/** + * Submit a signed sponsored-reserve transaction to the Soroban RPC node and + * emit a {@link SponsorshipUsedEvent}. + * + * **Fee attribution fix**: `feeSource` in the emitted event is set to the + * `sponsor` address — the account that funded the reserves — NOT the address + * that called this function. The sponsor is always the source account of the + * transaction envelope (set by {@link buildSponsoredOnboarding}), so it is + * read directly from `tx.source` without any additional Horizon calls. + * + * @param tx - Fully-signed sponsored transaction (built by + * {@link buildSponsoredOnboarding}). + * @param newAccount - Stellar address of the newly-onboarded account. + * @param rpcUrl - Soroban RPC endpoint to submit to. + * @param onEvent - Optional callback invoked with the {@link SponsorshipUsedEvent} + * after successful submission. + * @returns The emitted {@link SponsorshipUsedEvent} (including the `txHash`). + */ +export async function submitSponsoredTransaction( + tx: Transaction, + newAccount: string, + rpcUrl: string, + onEvent?: (event: SponsorshipUsedEvent) => void, +): Promise { + const { rpc } = await import("@stellar/stellar-sdk"); + + const server = new rpc.Server(rpcUrl); + const result = await server.sendTransaction(tx); + + // tx.source is the sponsor — set by buildSponsoredOnboarding as the + // TransactionBuilder source account. This is the correct feeSource for + // sponsored-reserve transactions. + const event: SponsorshipUsedEvent = { + feeSource: tx.source, // ← sponsor, not the submitter's address + newAccount, + txHash: result.hash, + }; + + onEvent?.(event); + return event; +} diff --git a/src/templateManager.ts b/src/templateManager.ts index 6e3b4e8..e06d010 100644 --- a/src/templateManager.ts +++ b/src/templateManager.ts @@ -61,6 +61,26 @@ function writeBrowserStore(store: Record): void { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store, bigintReplacer)); } +// --------------------------------------------------------------------------- +// Version history (in-memory; no persistence layer) +// --------------------------------------------------------------------------- + +/** + * A single versioned snapshot of a template's content. + */ +export interface TemplateVersion { + /** Monotonically increasing version number, starting at 1. */ + version: number; + /** The template content at this version. */ + content: string; +} + +/** + * In-memory store mapping template ID → ordered list of {@link TemplateVersion}s. + * Index 0 holds v1, index N-1 holds the latest version. + */ +const _versionHistory = new Map(); + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -102,3 +122,62 @@ export function deleteTemplate(name: string): void { writeNodeStore(store); } } + +// --------------------------------------------------------------------------- +// Versioned template API +// --------------------------------------------------------------------------- + +/** + * Update a template's content. + * + * - The new content is stored as the next version (version 1 on first call). + * - All previous versions are retained in {@link _versionHistory} so they can + * be retrieved via {@link getTemplate}. + * - Version numbers are integers starting at 1 and increment by 1 on every + * call regardless of whether the content actually changed. + * + * @param id - Unique template identifier. + * @param content - New template content string. + * @returns The new version number assigned to this content. + */ +export function updateTemplate(id: string, content: string): number { + const history = _versionHistory.get(id) ?? []; + const nextVersion = history.length + 1; + history.push({ version: nextVersion, content }); + _versionHistory.set(id, history); + return nextVersion; +} + +/** + * Retrieve a template's content by ID and optional version. + * + * @param id - Unique template identifier. + * @param version - Specific version to retrieve. When omitted (or 0), the + * latest version is returned. + * @returns The {@link TemplateVersion} for the requested version, or `null` if + * the template does not exist or the version is out of range. + */ +export function getTemplate(id: string, version?: number): TemplateVersion | null { + const history = _versionHistory.get(id); + if (!history || history.length === 0) return null; + + if (!version) { + // Return the latest version when no version is specified. + return history[history.length - 1]; + } + + // Versions are 1-indexed; array is 0-indexed. + const entry = history[version - 1]; + return entry ?? null; +} + +/** + * Retrieve the full version history for a template. + * + * @param id - Unique template identifier. + * @returns Ordered list of {@link TemplateVersion}s (oldest first), or an + * empty array when the template has no recorded history. + */ +export function getTemplateHistory(id: string): TemplateVersion[] { + return _versionHistory.get(id) ?? []; +} diff --git a/test/broadcaster.test.ts b/test/broadcaster.test.ts index a8b0e7c..1b3a463 100644 --- a/test/broadcaster.test.ts +++ b/test/broadcaster.test.ts @@ -95,4 +95,106 @@ describe("InvoiceStateBroadcaster", () => { it("should export createInvoiceStateBroadcaster function", () => { expect(createInvoiceStateBroadcaster).toBeDefined(); }); + + // --------------------------------------------------------------------------- + // Message ordering tests + // --------------------------------------------------------------------------- + + it("delivers three messages to a subscriber in the order they were broadcast", () => { + const received: Invoice[] = []; + broadcaster.subscribe("order-test", (_, invoice) => { + received.push(invoice); + }); + + const makeInvoice = (id: string): Invoice => ({ + id, + creator: "GABC123...", + recipients: [{ address: "GDEF456...", amount: 1000n }], + token: "USDC_CONTRACT", + deadline: 1234567890, + funded: 0n, + status: "Pending", + payments: [], + recurring: false, + }); + + const inv1 = makeInvoice("1"); + const inv2 = makeInvoice("2"); + const inv3 = makeInvoice("3"); + + broadcaster.broadcast("order-test", inv1); + broadcaster.broadcast("order-test", inv2); + broadcaster.broadcast("order-test", inv3); + + expect(received).toHaveLength(3); + expect(received[0].id).toBe("1"); + expect(received[1].id).toBe("2"); + expect(received[2].id).toBe("3"); + }); + + it("a subscriber added after some broadcasts does not receive missed messages", () => { + const lateReceived: Invoice[] = []; + + const makeInvoice = (id: string): Invoice => ({ + id, + creator: "GABC123...", + recipients: [{ address: "GDEF456...", amount: 1000n }], + token: "USDC_CONTRACT", + deadline: 1234567890, + funded: 0n, + status: "Pending", + payments: [], + recurring: false, + }); + + // Broadcast first message BEFORE the late subscriber joins + broadcaster.broadcast("late-test", makeInvoice("early")); + + // Late subscriber registers after the first broadcast + broadcaster.subscribe("late-test", (_, invoice) => { + lateReceived.push(invoice); + }); + + // Broadcast a second message AFTER the late subscriber joins + broadcaster.broadcast("late-test", makeInvoice("late")); + + // Late subscriber must only receive the message sent after it joined + expect(lateReceived).toHaveLength(1); + expect(lateReceived[0].id).toBe("late"); + }); + + it("removing a subscriber mid-sequence stops delivery for subsequent messages only", () => { + const received: string[] = []; + + const makeInvoice = (id: string): Invoice => ({ + id, + creator: "GABC123...", + recipients: [{ address: "GDEF456...", amount: 1000n }], + token: "USDC_CONTRACT", + deadline: 1234567890, + funded: 0n, + status: "Pending", + payments: [], + recurring: false, + }); + + const unsubscribe = broadcaster.subscribe("mid-unsub-test", (_, invoice) => { + received.push(invoice.id); + }); + + // First message — subscriber is still active + broadcaster.broadcast("mid-unsub-test", makeInvoice("msg1")); + + // Unsubscribe between broadcasts + unsubscribe(); + + // Second message — subscriber has been removed + broadcaster.broadcast("mid-unsub-test", makeInvoice("msg2")); + + // Third message — subscriber has been removed + broadcaster.broadcast("mid-unsub-test", makeInvoice("msg3")); + + // Only the first message should have been received + expect(received).toEqual(["msg1"]); + }); }); diff --git a/test/operationQueue.test.ts b/test/operationQueue.test.ts new file mode 100644 index 0000000..2e2d835 --- /dev/null +++ b/test/operationQueue.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OperationQueue, OperationPriority } from "../src/operationQueue.js"; + +// --------------------------------------------------------------------------- +// OperationPriority constants +// --------------------------------------------------------------------------- + +describe("OperationPriority constants", () => { + it("exports LOW = 1", () => { + expect(OperationPriority.LOW).toBe(1); + }); + + it("exports NORMAL = 5", () => { + expect(OperationPriority.NORMAL).toBe(5); + }); + + it("exports HIGH = 10", () => { + expect(OperationPriority.HIGH).toBe(10); + }); + + it("HIGH > NORMAL > LOW", () => { + expect(OperationPriority.HIGH).toBeGreaterThan(OperationPriority.NORMAL); + expect(OperationPriority.NORMAL).toBeGreaterThan(OperationPriority.LOW); + }); +}); + +// --------------------------------------------------------------------------- +// OperationQueue — online (immediate execution) +// --------------------------------------------------------------------------- + +describe("OperationQueue — online", () => { + it("executes operations immediately when online", async () => { + const healthCheck = vi.fn().mockResolvedValue(true); + const queue = new OperationQueue(healthCheck); + + const executor = vi.fn().mockResolvedValue("result"); + const result = await queue.enqueue("test", [], executor); + + expect(result).toBe("result"); + expect(executor).toHaveBeenCalledOnce(); + }); + + it("queueSize is 0 when online (operations are not buffered)", async () => { + const queue = new OperationQueue(vi.fn().mockResolvedValue(true)); + void queue.enqueue("op", [], vi.fn().mockResolvedValue(undefined)); + expect(queue.queueSize).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// OperationQueue — offline buffering +// --------------------------------------------------------------------------- + +describe("OperationQueue — offline buffering", () => { + it("buffers operations when offline and drains on setOnline(true)", async () => { + const queue = new OperationQueue(vi.fn().mockResolvedValue(false)); + queue.setOnline(false); + + const order: string[] = []; + const p1 = queue.enqueue("op1", [], async () => { order.push("op1"); return "a"; }); + const p2 = queue.enqueue("op2", [], async () => { order.push("op2"); return "b"; }); + + expect(queue.queueSize).toBe(2); + + queue.setOnline(true); + + expect(await p1).toBe("a"); + expect(await p2).toBe("b"); + expect(queue.queueSize).toBe(0); + }); + + it("drains in priority order: HIGH before NORMAL before LOW", async () => { + const queue = new OperationQueue(vi.fn().mockResolvedValue(false)); + queue.setOnline(false); + + const order: string[] = []; + const pLow = queue.enqueue("low", [], async () => { order.push("LOW"); }, OperationPriority.LOW); + const pNormal = queue.enqueue("normal", [], async () => { order.push("NORMAL"); }, OperationPriority.NORMAL); + const pHigh = queue.enqueue("high", [], async () => { order.push("HIGH"); }, OperationPriority.HIGH); + + expect(queue.queueSize).toBe(3); + + queue.setOnline(true); + + await Promise.all([pLow, pNormal, pHigh]); + + expect(order).toEqual(["HIGH", "NORMAL", "LOW"]); + }); + + it("defaults to NORMAL priority when none is specified", async () => { + const queue = new OperationQueue(vi.fn().mockResolvedValue(false)); + queue.setOnline(false); + + const order: string[] = []; + // LOW enqueued first but HIGH enqueued last — HIGH should drain first + const pLow = queue.enqueue("low", [], async () => { order.push("LOW"); }, OperationPriority.LOW); + const pDefault = queue.enqueue("def", [], async () => { order.push("DEFAULT"); }); // no priority → NORMAL + const pHigh = queue.enqueue("high", [], async () => { order.push("HIGH"); }, OperationPriority.HIGH); + + queue.setOnline(true); + await Promise.all([pLow, pDefault, pHigh]); + + expect(order[0]).toBe("HIGH"); + expect(order[1]).toBe("DEFAULT"); + expect(order[2]).toBe("LOW"); + }); +}); + +// --------------------------------------------------------------------------- +// OperationQueue — start / stop polling +// --------------------------------------------------------------------------- + +describe("OperationQueue — start/stop", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("start() kicks off polling and stop() clears the interval", async () => { + const healthCheck = vi.fn().mockResolvedValue(true); + const queue = new OperationQueue(healthCheck, 1000); + + queue.start(); + // Advance past one interval + await vi.advanceTimersByTimeAsync(1000); + expect(healthCheck).toHaveBeenCalledTimes(1); + + queue.stop(); + // Advance another interval — healthCheck should NOT be called again + await vi.advanceTimersByTimeAsync(1000); + expect(healthCheck).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it("calling start() twice does not create a second interval", async () => { + const healthCheck = vi.fn().mockResolvedValue(true); + const queue = new OperationQueue(healthCheck, 1000); + + queue.start(); + queue.start(); // duplicate call + + await vi.advanceTimersByTimeAsync(1000); + // If two intervals were created the mock would be called twice + expect(healthCheck).toHaveBeenCalledTimes(1); + + queue.stop(); + vi.useRealTimers(); + }); +}); diff --git a/test/sponsorshipEvent.test.ts b/test/sponsorshipEvent.test.ts new file mode 100644 index 0000000..498a2d4 --- /dev/null +++ b/test/sponsorshipEvent.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for submitSponsoredTransaction — SponsorshipUsed event feeSource attribution. + * + * Isolated in its own file so the vi.mock for @stellar/stellar-sdk does not + * interfere with the existing sponsorship.test.ts mock setup. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Transaction } from "@stellar/stellar-sdk"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SPONSOR = "GBSPONSOR0000000000000000000000000000000000000000000000000"; +const NEW_ACCOUNT = "GBNEWACCOUNT000000000000000000000000000000000000000000000"; +const SUBMITTER = "GSUBMITTER0000000000000000000000000000000000000000000000"; +const RPC_URL = "https://soroban-testnet.stellar.org"; +const TX_HASH = "abc123txhash0000000000000000000000000000000000000000000000"; + +// --------------------------------------------------------------------------- +// Minimal mock for @stellar/stellar-sdk rpc.Server +// --------------------------------------------------------------------------- + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual( + "@stellar/stellar-sdk" + ); + return { + ...(actual as Record), + rpc: { + Server: vi.fn().mockImplementation(() => ({ + sendTransaction: vi.fn().mockResolvedValue({ hash: TX_HASH }), + })), + }, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal fake transaction whose .source is the given address. */ +function makeMockTx(source: string): Transaction { + return { source } as unknown as Transaction; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("submitSponsoredTransaction — SponsorshipUsed event", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sets feeSource to the sponsor account (tx.source), not the submitter", async () => { + const { submitSponsoredTransaction } = await import("../src/sponsorship.js"); + + const tx = makeMockTx(SPONSOR); + const event = await submitSponsoredTransaction(tx, NEW_ACCOUNT, RPC_URL); + + // feeSource must be the sponsor (tx.source), not some other address + expect(event.feeSource).toBe(SPONSOR); + }); + + it("emits the correct newAccount in the SponsorshipUsed event", async () => { + const { submitSponsoredTransaction } = await import("../src/sponsorship.js"); + + const tx = makeMockTx(SPONSOR); + const event = await submitSponsoredTransaction(tx, NEW_ACCOUNT, RPC_URL); + + expect(event.newAccount).toBe(NEW_ACCOUNT); + }); + + it("includes the txHash returned by the RPC node in the event", async () => { + const { submitSponsoredTransaction } = await import("../src/sponsorship.js"); + + const tx = makeMockTx(SPONSOR); + const event = await submitSponsoredTransaction(tx, NEW_ACCOUNT, RPC_URL); + + expect(event.txHash).toBe(TX_HASH); + }); + + it("invokes the optional onEvent callback with the SponsorshipUsed payload", async () => { + const { submitSponsoredTransaction } = await import("../src/sponsorship.js"); + + const tx = makeMockTx(SPONSOR); + const onEvent = vi.fn(); + await submitSponsoredTransaction(tx, NEW_ACCOUNT, RPC_URL, onEvent); + + expect(onEvent).toHaveBeenCalledOnce(); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + feeSource: SPONSOR, + newAccount: NEW_ACCOUNT, + txHash: TX_HASH, + }) + ); + }); + + it("non-sponsored: feeSource equals the submitter (tx.source) when tx is not sponsor-wrapped", async () => { + const { submitSponsoredTransaction } = await import("../src/sponsorship.js"); + + // When there is no sponsor wrapper, tx.source is the submitter's address. + // submitSponsoredTransaction must NOT hard-code the sponsor — it reads + // tx.source directly, so the correct account is always attributed. + const tx = makeMockTx(SUBMITTER); + const event = await submitSponsoredTransaction(tx, NEW_ACCOUNT, RPC_URL); + + expect(event.feeSource).toBe(SUBMITTER); + expect(event.feeSource).not.toBe(SPONSOR); + }); +}); diff --git a/test/templateManager.test.ts b/test/templateManager.test.ts new file mode 100644 index 0000000..a949202 --- /dev/null +++ b/test/templateManager.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + updateTemplate, + getTemplate, + getTemplateHistory, +} from "../src/templateManager.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Generate a unique template ID per test to avoid cross-test state bleed. */ +let _counter = 0; +function uniqueId(): string { + return `tmpl-test-${++_counter}-${Date.now()}`; +} + +// --------------------------------------------------------------------------- +// updateTemplate +// --------------------------------------------------------------------------- + +describe("updateTemplate", () => { + it("returns version 1 on first call", () => { + const id = uniqueId(); + const v = updateTemplate(id, "content v1"); + expect(v).toBe(1); + }); + + it("increments the version number on each call", () => { + const id = uniqueId(); + expect(updateTemplate(id, "v1")).toBe(1); + expect(updateTemplate(id, "v2")).toBe(2); + expect(updateTemplate(id, "v3")).toBe(3); + }); + + it("retains the previous version after an update", () => { + const id = uniqueId(); + updateTemplate(id, "original content"); + updateTemplate(id, "updated content"); + + // v1 should still be accessible + const v1 = getTemplate(id, 1); + expect(v1).not.toBeNull(); + expect(v1!.content).toBe("original content"); + }); +}); + +// --------------------------------------------------------------------------- +// getTemplate +// --------------------------------------------------------------------------- + +describe("getTemplate", () => { + it("returns null for a template that does not exist", () => { + expect(getTemplate("nonexistent-template-id")).toBeNull(); + }); + + it("returns the latest version when no version argument is provided", () => { + const id = uniqueId(); + updateTemplate(id, "first"); + updateTemplate(id, "second"); + updateTemplate(id, "third"); + + const latest = getTemplate(id); + expect(latest).not.toBeNull(); + expect(latest!.version).toBe(3); + expect(latest!.content).toBe("third"); + }); + + it("retrieves a specific version by number", () => { + const id = uniqueId(); + updateTemplate(id, "alpha"); + updateTemplate(id, "beta"); + updateTemplate(id, "gamma"); + + expect(getTemplate(id, 1)!.content).toBe("alpha"); + expect(getTemplate(id, 2)!.content).toBe("beta"); + expect(getTemplate(id, 3)!.content).toBe("gamma"); + }); + + it("returns null for an out-of-range version", () => { + const id = uniqueId(); + updateTemplate(id, "only version"); + + expect(getTemplate(id, 99)).toBeNull(); + }); + + it("version field on the returned object matches the requested version", () => { + const id = uniqueId(); + updateTemplate(id, "v1 content"); + updateTemplate(id, "v2 content"); + + const result = getTemplate(id, 1); + expect(result!.version).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// getTemplateHistory +// --------------------------------------------------------------------------- + +describe("getTemplateHistory", () => { + it("returns an empty array for an unknown template", () => { + expect(getTemplateHistory("no-such-template")).toEqual([]); + }); + + it("returns all versions in ascending order", () => { + const id = uniqueId(); + updateTemplate(id, "rev1"); + updateTemplate(id, "rev2"); + updateTemplate(id, "rev3"); + + const history = getTemplateHistory(id); + expect(history).toHaveLength(3); + expect(history[0].version).toBe(1); + expect(history[1].version).toBe(2); + expect(history[2].version).toBe(3); + }); + + it("each entry in the history has the correct content for its version", () => { + const id = uniqueId(); + updateTemplate(id, "content-a"); + updateTemplate(id, "content-b"); + + const history = getTemplateHistory(id); + expect(history[0].content).toBe("content-a"); + expect(history[1].content).toBe("content-b"); + }); +});