A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules
1import { describe, it, expect, vi, beforeEach } from "vitest";
2import { getAllAccountLabels, negateAccountLabel } from "../accountModeration.js";
3import { agent } from "../agent.js";
4
5vi.mock("../agent.js", () => ({
6 agent: {
7 did: "did:plc:test-moderator",
8 tools: {
9 ozone: {
10 moderation: {
11 getRepo: vi.fn(),
12 emitEvent: vi.fn(),
13 },
14 },
15 },
16 },
17 isLoggedIn: Promise.resolve(),
18}));
19
20vi.mock("../logger.js", () => ({
21 logger: {
22 info: vi.fn(),
23 debug: vi.fn(),
24 error: vi.fn(),
25 warn: vi.fn(),
26 },
27}));
28
29vi.mock("../limits.js", () => ({
30 limit: vi.fn((fn) => fn()),
31}));
32
33vi.mock("../redis.js", () => ({
34 deleteAccountLabelClaim: vi.fn().mockResolvedValue(undefined),
35}));
36
37vi.mock("../metrics.js", () => ({
38 unlabelsRemovedCounter: {
39 inc: vi.fn(),
40 },
41 labelsAppliedCounter: {
42 inc: vi.fn(),
43 },
44 labelsCachedCounter: {
45 inc: vi.fn(),
46 },
47}));
48
49const mockAgent = agent as any;
50
51describe("getAllAccountLabels", () => {
52 beforeEach(() => {
53 vi.clearAllMocks();
54 });
55
56 it("should return array of label strings from API response", async () => {
57 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
58 data: {
59 labels: [{ val: "blue-heart-emoji" }, { val: "hammer-sickle" }],
60 },
61 });
62
63 const labels = await getAllAccountLabels("did:plc:test123");
64
65 expect(labels).toEqual(["blue-heart-emoji", "hammer-sickle"]);
66 expect(mockAgent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith(
67 { did: "did:plc:test123" },
68 expect.objectContaining({
69 headers: expect.any(Object),
70 }),
71 );
72 });
73
74 it("should return empty array when account has no labels", async () => {
75 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
76 data: {
77 labels: undefined,
78 },
79 });
80
81 const labels = await getAllAccountLabels("did:plc:test123");
82
83 expect(labels).toEqual([]);
84 });
85
86 it("should return empty array when labels array is empty", async () => {
87 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
88 data: {
89 labels: [],
90 },
91 });
92
93 const labels = await getAllAccountLabels("did:plc:test123");
94
95 expect(labels).toEqual([]);
96 });
97
98 it("should return empty array on API error", async () => {
99 mockAgent.tools.ozone.moderation.getRepo.mockRejectedValueOnce(
100 new Error("API Error"),
101 );
102
103 const labels = await getAllAccountLabels("did:plc:test123");
104
105 expect(labels).toEqual([]);
106 });
107});
108
109describe("negateAccountLabel", () => {
110 beforeEach(() => {
111 vi.clearAllMocks();
112 });
113
114 it("should emit moderation event to remove label", async () => {
115 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
116 data: {
117 labels: [{ val: "blue-heart-emoji" }],
118 },
119 });
120
121 mockAgent.tools.ozone.moderation.emitEvent.mockResolvedValueOnce({});
122
123 await negateAccountLabel(
124 "did:plc:test123",
125 "blue-heart-emoji",
126 "Test removal",
127 );
128
129 expect(mockAgent.tools.ozone.moderation.emitEvent).toHaveBeenCalledWith(
130 expect.objectContaining({
131 event: expect.objectContaining({
132 $type: "tools.ozone.moderation.defs#modEventLabel",
133 createLabelVals: [],
134 negateLabelVals: ["blue-heart-emoji"],
135 comment: "Test removal",
136 }),
137 subject: expect.objectContaining({
138 $type: "com.atproto.admin.defs#repoRef",
139 did: "did:plc:test123",
140 }),
141 }),
142 expect.any(Object),
143 );
144 });
145
146 it("should not emit event if label does not exist on account", async () => {
147 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
148 data: {
149 labels: [{ val: "other-label" }],
150 },
151 });
152
153 await negateAccountLabel(
154 "did:plc:test123",
155 "blue-heart-emoji",
156 "Test removal",
157 );
158
159 expect(mockAgent.tools.ozone.moderation.emitEvent).not.toHaveBeenCalled();
160 });
161
162 it("should not emit event if account has no labels", async () => {
163 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
164 data: {
165 labels: [],
166 },
167 });
168
169 await negateAccountLabel(
170 "did:plc:test123",
171 "blue-heart-emoji",
172 "Test removal",
173 );
174
175 expect(mockAgent.tools.ozone.moderation.emitEvent).not.toHaveBeenCalled();
176 });
177
178 it("should delete Redis cache key on successful removal", async () => {
179 const { deleteAccountLabelClaim } = await import("../redis.js");
180
181 mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({
182 data: {
183 labels: [{ val: "blue-heart-emoji" }],
184 },
185 });
186
187 mockAgent.tools.ozone.moderation.emitEvent.mockResolvedValueOnce({});
188
189 await negateAccountLabel(
190 "did:plc:test123",
191 "blue-heart-emoji",
192 "Test removal",
193 );
194
195 expect(deleteAccountLabelClaim).toHaveBeenCalledWith(
196 "did:plc:test123",
197 "blue-heart-emoji",
198 );
199 });
200
201 it("should log error if API call fails", async () => {
202 const { logger } = await import("../logger.js");
203
204 mockAgent.tools.ozone.moderation.getRepo.mockRejectedValueOnce(
205 new Error("API Error"),
206 );
207
208 await negateAccountLabel(
209 "did:plc:test123",
210 "blue-heart-emoji",
211 "Test removal",
212 );
213
214 expect(logger.error).toHaveBeenCalled();
215 });
216});