Skip to content

Commit d90e5c9

Browse files
committed
Add state store contract tests
1 parent b8d59cb commit d90e5c9

6 files changed

Lines changed: 875 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
import {
3+
loadCursorState,
4+
saveCursorState,
5+
updateCursor,
6+
isValidCursor,
7+
CursorManager,
8+
getCachedCursor,
9+
forcePersistCursor,
10+
} from "../../src/state/cursorStateStore.js";
11+
import { getStateStore, resetStoreCache } from "../../src/state/storeFactory.js";
12+
import type { CursorState } from "../../src/state/stateStore.js";
13+
14+
/**
15+
* Cursor state store: persistence, throttled writes, out-of-order rejection,
16+
* restart reads last persisted cursor.
17+
*/
18+
19+
beforeEach(() => {
20+
delete process.env.USE_REDIS;
21+
resetStoreCache();
22+
});
23+
24+
describe("cursorStateStore", () => {
25+
describe("set and get cursor", () => {
26+
it("saveCursorState and loadCursorState round-trip", async () => {
27+
const state: CursorState = {
28+
since_id: "1000",
29+
last_fetch_at: new Date().toISOString(),
30+
fetched_count: 5,
31+
version: 1,
32+
};
33+
await saveCursorState(state);
34+
const loaded = await loadCursorState();
35+
expect(loaded.since_id).toBe("1000");
36+
expect(loaded.fetched_count).toBe(5);
37+
});
38+
39+
it("loadCursorState returns valid state shape with version", async () => {
40+
const loaded = await loadCursorState();
41+
expect(loaded).toHaveProperty("version", 1);
42+
expect(loaded).toHaveProperty("since_id");
43+
expect(loaded).toHaveProperty("last_fetch_at");
44+
expect(loaded).toHaveProperty("fetched_count");
45+
});
46+
});
47+
48+
describe("throttled persistence", () => {
49+
it("updateCursor persists; after throttle interval next update persists", async () => {
50+
vi.useFakeTimers();
51+
const store = getStateStore();
52+
await saveCursorState({
53+
since_id: null,
54+
last_fetch_at: new Date(0).toISOString(),
55+
fetched_count: 0,
56+
version: 1,
57+
});
58+
vi.advanceTimersByTime(61_000);
59+
60+
await updateCursor("100", 1);
61+
let cursor = await store.getCursor();
62+
expect(cursor?.since_id).toBe("100");
63+
64+
vi.advanceTimersByTime(61_000);
65+
await updateCursor("200", 2);
66+
cursor = await store.getCursor();
67+
expect(cursor?.since_id).toBe("200");
68+
69+
vi.useRealTimers();
70+
});
71+
});
72+
73+
describe("out-of-order cursor rejected", () => {
74+
it("isValidCursor rejects older cursor", () => {
75+
expect(isValidCursor("100", "200")).toBe(false);
76+
expect(isValidCursor("200", "100")).toBe(true);
77+
expect(isValidCursor("100", null)).toBe(true);
78+
});
79+
80+
it("CursorManager onFetchSuccess ignores out-of-order cursor", async () => {
81+
const state = await loadCursorState();
82+
state.since_id = "200";
83+
const manager = new CursorManager(state);
84+
await manager.onFetchSuccess("100", 1);
85+
expect(manager.getStats().since_id).toBe("200");
86+
await manager.onFetchSuccess("300", 2);
87+
expect(manager.getStats().since_id).toBe("300");
88+
});
89+
});
90+
91+
describe("new instance sees latest valid cursor", () => {
92+
it("after saveCursorState, loadCursorState returns same state", async () => {
93+
const state: CursorState = {
94+
since_id: "9999",
95+
last_fetch_at: new Date().toISOString(),
96+
fetched_count: 42,
97+
version: 1,
98+
};
99+
await saveCursorState(state);
100+
const loaded = await loadCursorState();
101+
expect(loaded.since_id).toBe("9999");
102+
expect(loaded.fetched_count).toBe(42);
103+
});
104+
105+
it("getCachedCursor reflects last loaded/saved cursor", async () => {
106+
await saveCursorState({
107+
since_id: "cache_1",
108+
last_fetch_at: new Date().toISOString(),
109+
fetched_count: 1,
110+
version: 1,
111+
});
112+
expect(getCachedCursor()).toBe("cache_1");
113+
});
114+
});
115+
116+
describe("forcePersistCursor", () => {
117+
it("forcePersistCursor does not throw", async () => {
118+
await saveCursorState({
119+
since_id: "force_1",
120+
last_fetch_at: new Date().toISOString(),
121+
fetched_count: 1,
122+
version: 1,
123+
});
124+
await expect(forcePersistCursor()).resolves.not.toThrow();
125+
});
126+
});
127+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import {
3+
recordEventSeen,
4+
recordEventProcessed,
5+
recordPublishAttempt,
6+
recordPublishSuccess,
7+
recordPublishFailure,
8+
isPublished,
9+
getEventState,
10+
shouldRetryPublish,
11+
publishWithRetry,
12+
resetEventStates,
13+
} from "../../src/state/eventStateStore.js";
14+
import { resetStoreCache } from "../../src/state/storeFactory.js";
15+
16+
/**
17+
* EventStateStore (store-backed): state transitions, persistence across instances,
18+
* hot-cache correctness, cache eviction, publish lock + idempotency.
19+
*/
20+
21+
beforeEach(() => {
22+
delete process.env.USE_REDIS;
23+
resetStoreCache();
24+
});
25+
26+
afterEach(async () => {
27+
await resetEventStates();
28+
});
29+
30+
function uniqueEventId(prefix: string): string {
31+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
32+
}
33+
34+
describe("eventStateStore", () => {
35+
36+
describe("state transitions", () => {
37+
it("event_seen -> processed_ok -> publish_attempted -> publish_succeeded", async () => {
38+
const eventId = uniqueEventId("trans");
39+
40+
await recordEventSeen(eventId);
41+
let state = await getEventState(eventId);
42+
expect(state?.state).toBe("event_seen");
43+
expect(state?.attempts).toBe(0);
44+
45+
await recordEventProcessed(eventId);
46+
state = await getEventState(eventId);
47+
expect(state?.state).toBe("processed_ok");
48+
49+
await recordPublishAttempt(eventId);
50+
state = await getEventState(eventId);
51+
expect(state?.state).toBe("publish_attempted");
52+
expect(state?.attempts).toBe(1);
53+
54+
await recordPublishSuccess(eventId, "tweet_123");
55+
state = await getEventState(eventId);
56+
expect(state?.state).toBe("publish_succeeded");
57+
expect(state?.tweetId).toBe("tweet_123");
58+
});
59+
60+
it("success path: recordEventSeen then recordPublishSuccess", async () => {
61+
const eventId = uniqueEventId("success");
62+
await recordEventSeen(eventId);
63+
await recordEventProcessed(eventId);
64+
await recordPublishAttempt(eventId);
65+
await recordPublishSuccess(eventId, "tweet_456");
66+
const published = await isPublished(eventId);
67+
expect(published.published).toBe(true);
68+
expect(published.tweetId).toBe("tweet_456");
69+
});
70+
71+
it("records error on recordPublishFailure", async () => {
72+
const eventId = uniqueEventId("fail");
73+
await recordPublishAttempt(eventId);
74+
await recordPublishFailure(eventId, "Network timeout");
75+
const state = await getEventState(eventId);
76+
expect(state?.error).toBe("Network timeout");
77+
});
78+
});
79+
80+
describe("duplicate event and idempotency", () => {
81+
it("isPublished returns false for unpublished event", async () => {
82+
const r = await isPublished("never_published");
83+
expect(r.published).toBe(false);
84+
});
85+
86+
it("isPublished returns true with tweetId after publish_succeeded", async () => {
87+
const eventId = uniqueEventId("idem");
88+
await recordEventSeen(eventId);
89+
await recordPublishSuccess(eventId, "tweet_idem");
90+
const r = await isPublished(eventId);
91+
expect(r.published).toBe(true);
92+
expect(r.tweetId).toBe("tweet_idem");
93+
});
94+
95+
it("publishWithRetry prevents duplicate publish", async () => {
96+
const eventId = uniqueEventId("dup");
97+
const publishFn = vi.fn().mockResolvedValue({ tweetId: "tweet_dup" });
98+
const first = await publishWithRetry(eventId, publishFn);
99+
expect(first.success).toBe(true);
100+
expect(first.tweetId).toBe("tweet_dup");
101+
const result = await publishWithRetry(eventId, publishFn);
102+
expect(result.success).toBe(true);
103+
expect(result.tweetId).toBe("tweet_dup");
104+
expect(publishFn).toHaveBeenCalledTimes(1);
105+
});
106+
});
107+
108+
describe("persisted state survives new manager instance", () => {
109+
it("re-read after resetEventStates (cache clear) still sees persisted state", async () => {
110+
const eventId = uniqueEventId("persist");
111+
await recordEventSeen(eventId);
112+
await recordPublishSuccess(eventId, "tweet_persist");
113+
await resetEventStates();
114+
const r = await isPublished(eventId);
115+
expect(r.published).toBe(true);
116+
expect(r.tweetId).toBe("tweet_persist");
117+
});
118+
119+
it("getEventState after cache clear reloads from backend", async () => {
120+
const eventId = uniqueEventId("reload");
121+
await recordEventSeen(eventId);
122+
await recordEventProcessed(eventId);
123+
await resetEventStates();
124+
const state = await getEventState(eventId);
125+
expect(state?.state).toBe("processed_ok");
126+
});
127+
});
128+
129+
describe("shouldRetryPublish and publish lock flow", () => {
130+
it("shouldRetryPublish blocks retry after publish_succeeded", async () => {
131+
const eventId = uniqueEventId("retry_done");
132+
await recordEventSeen(eventId);
133+
await recordPublishSuccess(eventId, "tweet_done");
134+
const r = await shouldRetryPublish(eventId);
135+
expect(r.shouldRetry).toBe(false);
136+
});
137+
138+
it("shouldRetryPublish allows retry with delay for publish_attempted", async () => {
139+
const eventId = uniqueEventId("retry_attempted");
140+
await recordPublishAttempt(eventId);
141+
const r = await shouldRetryPublish(eventId);
142+
expect(r.shouldRetry).toBe(true);
143+
expect(r.delayMs).toBeGreaterThan(0);
144+
});
145+
});
146+
});

0 commit comments

Comments
 (0)