A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules
1import { afterEach, describe, expect, it, vi } from "vitest";
2import {
3 createAccountComment,
4 createAccountLabel,
5 createAccountReport,
6} from "../accountModeration.js";
7import {
8 checkAccountThreshold,
9 loadThresholdConfigs,
10} from "../accountThreshold.js";
11import { logger } from "../logger.js";
12import {
13 accountLabelsThresholdAppliedCounter,
14 accountThresholdChecksCounter,
15 accountThresholdMetCounter,
16} from "../metrics.js";
17import {
18 getPostLabelCountInWindow,
19 trackPostLabelForAccount,
20} from "../redis.js";
21
22vi.mock("../logger.js", () => ({
23 logger: {
24 info: vi.fn(),
25 warn: vi.fn(),
26 error: vi.fn(),
27 debug: vi.fn(),
28 },
29}));
30
31vi.mock("../../rules/accountThreshold.js", () => ({
32 ACCOUNT_THRESHOLD_CONFIGS: [
33 {
34 labels: ["test-label"],
35 threshold: 3,
36 accountLabel: "test-account-label",
37 accountComment: "Test comment",
38 window: 5,
39 windowUnit: "days",
40 reportAcct: false,
41 commentAcct: false,
42 toLabel: true,
43 },
44 {
45 labels: ["label-1", "label-2", "label-3"],
46 threshold: 5,
47 accountLabel: "multi-label-account",
48 accountComment: "Multi label comment",
49 window: 7,
50 windowUnit: "days",
51 reportAcct: true,
52 commentAcct: true,
53 toLabel: true,
54 },
55 {
56 labels: "monitor-only-label",
57 threshold: 2,
58 accountLabel: "monitored",
59 accountComment: "Monitoring comment",
60 window: 3,
61 windowUnit: "days",
62 reportAcct: true,
63 commentAcct: false,
64 toLabel: false,
65 },
66 {
67 labels: ["label-1", "shared-label"],
68 threshold: 2,
69 accountLabel: "shared-config",
70 accountComment: "Shared config comment",
71 window: 4,
72 windowUnit: "days",
73 reportAcct: false,
74 commentAcct: false,
75 toLabel: true,
76 },
77 ],
78}));
79
80vi.mock("../redis.js", () => ({
81 trackPostLabelForAccount: vi.fn(),
82 getPostLabelCountInWindow: vi.fn(),
83}));
84
85vi.mock("../accountModeration.js", () => ({
86 createAccountLabel: vi.fn(),
87 createAccountReport: vi.fn(),
88 createAccountComment: vi.fn(),
89}));
90
91vi.mock("../metrics.js", () => ({
92 accountLabelsThresholdAppliedCounter: {
93 inc: vi.fn(),
94 },
95 accountThresholdChecksCounter: {
96 inc: vi.fn(),
97 },
98 accountThresholdMetCounter: {
99 inc: vi.fn(),
100 },
101}));
102
103describe("Account Threshold Logic", () => {
104 afterEach(() => {
105 vi.clearAllMocks();
106 });
107
108 describe("loadThresholdConfigs", () => {
109 it("should load and cache configs successfully", () => {
110 const configs = loadThresholdConfigs();
111 expect(configs).toHaveLength(4);
112 expect(configs[0].labels).toEqual(["test-label"]);
113 expect(configs[1].labels).toEqual(["label-1", "label-2", "label-3"]);
114 });
115
116 it("should return cached configs on subsequent calls", () => {
117 const configs1 = loadThresholdConfigs();
118 const configs2 = loadThresholdConfigs();
119 expect(configs1).toBe(configs2);
120 });
121 });
122
123 describe("checkAccountThreshold", () => {
124 const testDid = "did:plc:test123";
125 const testUri = "at://did:plc:test123/app.bsky.feed.post/abc123";
126 const testTimestamp = 1640000000000000;
127
128 it("should not check threshold for non-matching labels", async () => {
129 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
130 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0);
131
132 await checkAccountThreshold(
133 testDid,
134 testUri,
135 "non-matching-label",
136 testTimestamp,
137 );
138
139 expect(trackPostLabelForAccount).not.toHaveBeenCalled();
140 expect(getPostLabelCountInWindow).not.toHaveBeenCalled();
141 });
142
143 it("should track and check threshold for matching single label", async () => {
144 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
145 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
146
147 await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
148
149 expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({
150 post_label: "test-label",
151 });
152 expect(trackPostLabelForAccount).toHaveBeenCalledWith(
153 testDid,
154 "test-label",
155 testTimestamp,
156 5,
157 "days",
158 );
159 expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
160 testDid,
161 ["test-label"],
162 5,
163 "days",
164 testTimestamp,
165 );
166 });
167
168 it("should apply account label when threshold is met", async () => {
169 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
170 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3);
171 vi.mocked(createAccountLabel).mockResolvedValue();
172
173 await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
174
175 expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({
176 account_label: "test-account-label",
177 });
178 expect(createAccountLabel).toHaveBeenCalledWith(
179 testDid,
180 "test-account-label",
181 `Test comment\n\nThreshold: 3/3 in 5 days\n\nPost: ${testUri}\n\nPost Label: test-label`,
182 );
183 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
184 account_label: "test-account-label",
185 action: "label",
186 });
187 });
188
189 it("should not apply label when threshold not met", async () => {
190 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
191 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
192
193 await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
194
195 expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled();
196 expect(createAccountLabel).not.toHaveBeenCalled();
197 });
198
199 it("should handle multi-label config with OR logic", async () => {
200 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
201 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(5);
202 vi.mocked(createAccountLabel).mockResolvedValue();
203 vi.mocked(createAccountReport).mockResolvedValue();
204 vi.mocked(createAccountComment).mockResolvedValue();
205
206 await checkAccountThreshold(testDid, testUri, "label-2", testTimestamp);
207
208 expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
209 testDid,
210 ["label-1", "label-2", "label-3"],
211 7,
212 "days",
213 testTimestamp,
214 );
215 expect(createAccountLabel).toHaveBeenCalledWith(
216 testDid,
217 "multi-label-account",
218 `Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`,
219 );
220 expect(createAccountReport).toHaveBeenCalledWith(
221 testDid,
222 `Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`,
223 );
224 expect(createAccountComment).toHaveBeenCalled();
225 });
226
227 it("should track but not label when toLabel is false", async () => {
228 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
229 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
230 vi.mocked(createAccountReport).mockResolvedValue();
231
232 await checkAccountThreshold(
233 testDid,
234 testUri,
235 "monitor-only-label",
236 testTimestamp,
237 );
238
239 expect(trackPostLabelForAccount).toHaveBeenCalled();
240 expect(getPostLabelCountInWindow).toHaveBeenCalled();
241 expect(createAccountLabel).not.toHaveBeenCalled();
242 expect(createAccountReport).toHaveBeenCalled();
243 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
244 account_label: "monitored",
245 action: "report",
246 });
247 });
248
249 it("should increment all action metrics when threshold met", async () => {
250 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
251 vi.mocked(getPostLabelCountInWindow)
252 .mockResolvedValueOnce(5)
253 .mockResolvedValueOnce(1);
254 vi.mocked(createAccountLabel).mockResolvedValue();
255 vi.mocked(createAccountReport).mockResolvedValue();
256 vi.mocked(createAccountComment).mockResolvedValue();
257
258 await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp);
259
260 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3);
261 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
262 account_label: "multi-label-account",
263 action: "label",
264 });
265 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
266 account_label: "multi-label-account",
267 action: "report",
268 });
269 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
270 account_label: "multi-label-account",
271 action: "comment",
272 });
273 });
274
275 it("should handle Redis errors in trackPostLabelForAccount", async () => {
276 const redisError = new Error("Redis connection failed");
277 vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError);
278
279 await expect(
280 checkAccountThreshold(testDid, testUri, "test-label", testTimestamp),
281 ).rejects.toThrow("Redis connection failed");
282
283 expect(logger.error).toHaveBeenCalled();
284 });
285
286 it("should handle Redis errors in getPostLabelCountInWindow", async () => {
287 const redisError = new Error("Redis query failed");
288 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
289 vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError);
290
291 await expect(
292 checkAccountThreshold(testDid, testUri, "test-label", testTimestamp),
293 ).rejects.toThrow("Redis query failed");
294
295 expect(logger.error).toHaveBeenCalled();
296 });
297
298 it("should handle multiple matching configs", async () => {
299 vi.mocked(trackPostLabelForAccount).mockResolvedValue();
300 vi.mocked(getPostLabelCountInWindow)
301 .mockResolvedValueOnce(5)
302 .mockResolvedValueOnce(3);
303 vi.mocked(createAccountLabel).mockResolvedValue();
304 vi.mocked(createAccountReport).mockResolvedValue();
305 vi.mocked(createAccountComment).mockResolvedValue();
306
307 await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp);
308
309 expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2);
310 expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2);
311 expect(createAccountLabel).toHaveBeenCalledTimes(2);
312 });
313 });
314});