A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules
1// Import the mocked redis first to get a reference to the mock client
2import { createClient } from "redis";
3import { afterEach, describe, expect, it, vi } from "vitest";
4import { logger } from "../logger.js";
5// Import the modules to be tested
6import {
7 connectRedis,
8 disconnectRedis,
9 getPostLabelCountInWindow,
10 getStarterPackCountInWindow,
11 trackPostLabelForAccount,
12 trackStarterPackForAccount,
13 tryClaimAccountLabel,
14 tryClaimPostLabel,
15} from "../redis.js";
16
17// Mock the 'redis' module in a way that avoids hoisting issues.
18// The mock implementation is self-contained.
19vi.mock("redis", () => {
20 const mockClient = {
21 on: vi.fn(),
22 connect: vi.fn(),
23 quit: vi.fn(),
24 exists: vi.fn(),
25 set: vi.fn(),
26 zAdd: vi.fn(),
27 zRemRangeByScore: vi.fn(),
28 zCount: vi.fn(),
29 expire: vi.fn(),
30 };
31 return {
32 createClient: vi.fn(() => mockClient),
33 };
34});
35
36const mockRedisClient = createClient();
37
38// Suppress logger output during tests
39vi.mock("../logger.js", () => ({
40 logger: {
41 info: vi.fn(),
42 warn: vi.fn(),
43 error: vi.fn(),
44 debug: vi.fn(),
45 },
46}));
47
48describe("Redis Cache Logic", () => {
49 afterEach(() => {
50 vi.clearAllMocks();
51 });
52
53 describe("Connection", () => {
54 it("should call redisClient.connect on connectRedis", async () => {
55 await connectRedis();
56 expect(mockRedisClient.connect).toHaveBeenCalled();
57 });
58
59 it("should call redisClient.quit on disconnectRedis", async () => {
60 await disconnectRedis();
61 expect(mockRedisClient.quit).toHaveBeenCalled();
62 });
63 });
64
65 describe("tryClaimPostLabel", () => {
66 it("should return true and set key if key does not exist", async () => {
67 vi.mocked(mockRedisClient.set).mockResolvedValue("OK");
68 const result = await tryClaimPostLabel("at://uri", "test-label");
69 expect(result).toBe(true);
70 expect(mockRedisClient.set).toHaveBeenCalledWith(
71 "post-label:at://uri:test-label",
72 "1",
73 { NX: true, EX: 60 * 60 * 24 * 7 },
74 );
75 });
76
77 it("should return false if key already exists", async () => {
78 vi.mocked(mockRedisClient.set).mockResolvedValue(null);
79 const result = await tryClaimPostLabel("at://uri", "test-label");
80 expect(result).toBe(false);
81 });
82
83 it("should return true and log warning on Redis error", async () => {
84 const redisError = new Error("Redis down");
85 vi.mocked(mockRedisClient.set).mockRejectedValue(redisError);
86 const result = await tryClaimPostLabel("at://uri", "test-label");
87 expect(result).toBe(true);
88 expect(logger.warn).toHaveBeenCalledWith(
89 { err: redisError, atURI: "at://uri", label: "test-label" },
90 "Error claiming post label in Redis, allowing through",
91 );
92 });
93 });
94
95 describe("tryClaimAccountLabel", () => {
96 it("should return true and set key if key does not exist", async () => {
97 vi.mocked(mockRedisClient.set).mockResolvedValue("OK");
98 const result = await tryClaimAccountLabel("did:plc:123", "test-label");
99 expect(result).toBe(true);
100 expect(mockRedisClient.set).toHaveBeenCalledWith(
101 "account-label:did:plc:123:test-label",
102 "1",
103 { NX: true, EX: 60 * 60 * 24 * 7 },
104 );
105 });
106
107 it("should return false if key already exists", async () => {
108 vi.mocked(mockRedisClient.set).mockResolvedValue(null);
109 const result = await tryClaimAccountLabel("did:plc:123", "test-label");
110 expect(result).toBe(false);
111 });
112 });
113
114 describe("trackPostLabelForAccount", () => {
115 it("should track post label with correct timestamp and TTL", async () => {
116 vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0);
117 vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1);
118 vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
119
120 const timestamp = 1640000000000000; // microseconds
121 const window = 5;
122 const windowUnit = "days" as const;
123
124 await trackPostLabelForAccount(
125 "did:plc:123",
126 "test-label",
127 timestamp,
128 window,
129 windowUnit,
130 );
131
132 const expectedKey = "account-post-labels:did:plc:123:test-label:5days";
133 const windowStartTime = timestamp - window * 24 * 60 * 60 * 1000000;
134
135 expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
136 expectedKey,
137 "-inf",
138 windowStartTime,
139 );
140 expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, {
141 score: timestamp,
142 value: timestamp.toString(),
143 });
144 expect(mockRedisClient.expire).toHaveBeenCalledWith(
145 expectedKey,
146 window * 24 * 60 * 60 + 60 * 60,
147 );
148 });
149
150 it("should throw error on Redis failure", async () => {
151 const redisError = new Error("Redis down");
152 vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError);
153
154 await expect(
155 trackPostLabelForAccount(
156 "did:plc:123",
157 "test-label",
158 1640000000000000,
159 5,
160 "days",
161 ),
162 ).rejects.toThrow("Redis down");
163
164 expect(logger.error).toHaveBeenCalled();
165 });
166 });
167
168 describe("trackStarterPackForAccount", () => {
169 it("should track starter pack with correct timestamp and TTL", async () => {
170 vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0);
171 vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1);
172 vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
173
174 const timestamp = 1640000000000000;
175 const window = 24;
176 const windowUnit = "hours" as const;
177
178 await trackStarterPackForAccount(
179 "did:plc:123",
180 "at://did:plc:123/app.bsky.graph.starterpack/abc",
181 timestamp,
182 window,
183 windowUnit,
184 );
185
186 const expectedKey = "starterpack:threshold:did:plc:123:24hours";
187 const windowStartTime = timestamp - window * 60 * 60 * 1000000;
188
189 expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
190 expectedKey,
191 "-inf",
192 windowStartTime,
193 );
194 expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, {
195 score: timestamp,
196 value: "at://did:plc:123/app.bsky.graph.starterpack/abc",
197 });
198 expect(mockRedisClient.expire).toHaveBeenCalledWith(
199 expectedKey,
200 window * 60 * 60 + 60 * 60,
201 );
202 });
203
204 it("should throw error on Redis failure", async () => {
205 const redisError = new Error("Redis down");
206 vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError);
207
208 await expect(
209 trackStarterPackForAccount(
210 "did:plc:123",
211 "at://did:plc:123/app.bsky.graph.starterpack/abc",
212 1640000000000000,
213 24,
214 "hours",
215 ),
216 ).rejects.toThrow("Redis down");
217
218 expect(logger.error).toHaveBeenCalled();
219 });
220 });
221
222 describe("getStarterPackCountInWindow", () => {
223 it("should count starter packs in window", async () => {
224 vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
225
226 const currentTime = 1640000000000000;
227 const window = 24;
228 const windowUnit = "hours" as const;
229 const count = await getStarterPackCountInWindow(
230 "did:plc:123",
231 window,
232 windowUnit,
233 currentTime,
234 );
235
236 expect(count).toBe(3);
237 const windowStartTime = currentTime - window * 60 * 60 * 1000000;
238 expect(mockRedisClient.zCount).toHaveBeenCalledWith(
239 "starterpack:threshold:did:plc:123:24hours",
240 windowStartTime,
241 "+inf",
242 );
243 });
244
245 it("should throw error on Redis failure", async () => {
246 const redisError = new Error("Redis down");
247 vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError);
248
249 await expect(
250 getStarterPackCountInWindow("did:plc:123", 24, "hours", 1640000000000000),
251 ).rejects.toThrow("Redis down");
252
253 expect(logger.error).toHaveBeenCalled();
254 });
255 });
256
257 describe("getPostLabelCountInWindow", () => {
258 it("should count posts for single label", async () => {
259 vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
260
261 const currentTime = 1640000000000000;
262 const window = 5;
263 const windowUnit = "days" as const;
264 const count = await getPostLabelCountInWindow(
265 "did:plc:123",
266 ["test-label"],
267 window,
268 windowUnit,
269 currentTime,
270 );
271
272 expect(count).toBe(3);
273 const windowStartTime = currentTime - window * 24 * 60 * 60 * 1000000;
274 expect(mockRedisClient.zCount).toHaveBeenCalledWith(
275 "account-post-labels:did:plc:123:test-label:5days",
276 windowStartTime,
277 "+inf",
278 );
279 });
280
281 it("should sum counts for multiple labels (OR logic)", async () => {
282 vi.mocked(mockRedisClient.zCount)
283 .mockResolvedValueOnce(3)
284 .mockResolvedValueOnce(2)
285 .mockResolvedValueOnce(1);
286
287 const currentTime = 1640000000000000;
288 const window = 5;
289 const windowUnit = "days" as const;
290 const count = await getPostLabelCountInWindow(
291 "did:plc:123",
292 ["label-1", "label-2", "label-3"],
293 window,
294 windowUnit,
295 currentTime,
296 );
297
298 expect(count).toBe(6);
299 expect(mockRedisClient.zCount).toHaveBeenCalledTimes(3);
300 });
301
302 it("should return 0 when no posts in window", async () => {
303 vi.mocked(mockRedisClient.zCount).mockResolvedValue(0);
304
305 const count = await getPostLabelCountInWindow(
306 "did:plc:123",
307 ["test-label"],
308 5,
309 "days",
310 1640000000000000,
311 );
312
313 expect(count).toBe(0);
314 });
315
316 it("should throw error on Redis failure", async () => {
317 const redisError = new Error("Redis down");
318 vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError);
319
320 await expect(
321 getPostLabelCountInWindow(
322 "did:plc:123",
323 ["test-label"],
324 5,
325 "days",
326 1640000000000000,
327 ),
328 ).rejects.toThrow("Redis down");
329
330 expect(logger.error).toHaveBeenCalled();
331 });
332 });
333});