A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules

Formatting

Skywatch e794f14c c91f68e6

+2 -2
compose.yaml
··· 72 72 - ./prometheus.yml:/etc/prometheus/prometheus.yml 73 73 - prometheus-data:/prometheus 74 74 command: 75 - - '--config.file=/etc/prometheus/prometheus.yml' 76 - - '--storage.tsdb.path=/prometheus' 75 + - "--config.file=/etc/prometheus/prometheus.yml" 76 + - "--storage.tsdb.path=/prometheus" 77 77 networks: 78 78 - skywatch-network 79 79 depends_on:
+38 -27
eslint.config.mjs
··· 1 1 import eslint from "@eslint/js"; 2 2 import stylistic from "@stylistic/eslint-plugin"; 3 + import { defineConfig } from "eslint/config"; 3 4 import prettier from "eslint-config-prettier"; 4 5 import importPlugin from "eslint-plugin-import"; 5 6 import tseslint from "typescript-eslint"; 6 7 7 - export default tseslint.config( 8 + export default defineConfig( 8 9 eslint.configs.recommended, 9 10 ...tseslint.configs.strictTypeChecked, 10 11 ...tseslint.configs.stylisticTypeChecked, ··· 25 26 rules: { 26 27 // TypeScript specific rules 27 28 "@typescript-eslint/no-unused-vars": [ 28 - "error", 29 + "warn", 29 30 { argsIgnorePattern: "^_" }, 30 31 ], 31 - "@typescript-eslint/no-explicit-any": "error", 32 + "@typescript-eslint/no-explicit-any": "warn", 32 33 "@typescript-eslint/no-unsafe-assignment": "error", 33 34 "@typescript-eslint/no-unsafe-member-access": "error", 34 35 "@typescript-eslint/no-unsafe-call": "error", 35 36 "@typescript-eslint/no-unsafe-return": "error", 36 37 "@typescript-eslint/no-unsafe-argument": "error", 37 - "@typescript-eslint/prefer-nullish-coalescing": "error", 38 - "@typescript-eslint/prefer-optional-chain": "error", 38 + "@typescript-eslint/prefer-nullish-coalescing": "warn", 39 + "@typescript-eslint/prefer-optional-chain": "warn", 39 40 "@typescript-eslint/no-non-null-assertion": "error", 40 41 "@typescript-eslint/consistent-type-imports": "error", 41 42 "@typescript-eslint/consistent-type-exports": "error", ··· 45 46 "no-console": "warn", 46 47 "no-debugger": "error", 47 48 "no-var": "error", 48 - "prefer-const": "error", 49 - "prefer-template": "error", 50 - "object-shorthand": "error", 51 - "prefer-destructuring": ["error", { object: true, array: false }], 49 + "prefer-const": "warn", 50 + "prefer-template": "warn", 51 + "object-shorthand": "warn", 52 + "prefer-destructuring": ["warn", { object: true, array: false }], 52 53 53 54 // Import rules 54 55 "import/order": [ 55 - "error", 56 + "warn", 56 57 { 57 58 groups: [ 58 59 "builtin", ··· 62 63 "sibling", 63 64 "index", 64 65 ], 65 - "newlines-between": "always", 66 + pathGroups: [ 67 + { 68 + pattern: "@atproto/**", 69 + group: "external", 70 + position: "after", 71 + }, 72 + { 73 + pattern: "@skyware/**", 74 + group: "external", 75 + position: "after", 76 + }, 77 + { 78 + pattern: "@clavata/**", 79 + group: "external", 80 + position: "after", 81 + }, 82 + ], 83 + pathGroupsExcludedImportTypes: ["builtin"], 84 + "newlines-between": "never", 66 85 alphabetize: { order: "asc", caseInsensitive: true }, 67 86 }, 68 87 ], 69 - "import/no-duplicates": "error", 88 + "import/no-duplicates": "warn", 70 89 "import/no-unresolved": "off", // TypeScript handles this 71 90 72 91 // Security-focused rules ··· 81 100 "no-unreachable": "error", 82 101 "no-unreachable-loop": "error", 83 102 84 - // Style preferences 85 - "@stylistic/indent": ["error", 2], 86 - "@stylistic/quotes": ["error", "double"], 87 - "@stylistic/semi": ["error", "always"], 88 - //"@stylistic/comma-dangle": ["error", "es5"], 89 - "@stylistic/object-curly-spacing": ["error", "always"], 90 - "@stylistic/array-bracket-spacing": ["error", "never"], 91 - "@stylistic/space-before-function-paren": [ 92 - "error", 93 - { 94 - anonymous: "always", 95 - named: "never", 96 - asyncArrow: "always", 97 - }, 98 - ], 103 + // Style preferences (prettier handles these) 104 + "@stylistic/indent": "off", 105 + "@stylistic/quotes": "off", 106 + "@stylistic/semi": "off", 107 + "@stylistic/object-curly-spacing": "off", 108 + "@stylistic/array-bracket-spacing": "off", 109 + "@stylistic/space-before-function-paren": "off", 99 110 }, 100 111 }, 101 112 {
+3 -3
prometheus.yml
··· 3 3 evaluation_interval: 15s 4 4 5 5 scrape_configs: 6 - - job_name: 'skywatch-automod' 6 + - job_name: "skywatch-automod" 7 7 static_configs: 8 - - targets: ['automod:4101'] 8 + - targets: ["automod:4101"] 9 9 labels: 10 - service: 'automod' 10 + service: "automod"
+5 -1
src/accountThreshold.ts
··· 133 133 const shouldLabel = config.toLabel !== false; 134 134 135 135 if (shouldLabel) { 136 - await createAccountLabel(did, config.accountLabel, config.accountComment); 136 + await createAccountLabel( 137 + did, 138 + config.accountLabel, 139 + config.accountComment, 140 + ); 137 141 accountLabelsThresholdAppliedCounter.inc({ 138 142 account_label: config.accountLabel, 139 143 action: "label",
+4 -2
src/agent.ts
··· 1 1 import { Agent, setGlobalDispatcher } from "undici"; 2 2 import { AtpAgent } from "@atproto/api"; 3 3 import { BSKY_HANDLE, BSKY_PASSWORD, OZONE_PDS } from "./config.js"; 4 - import { loadSession, saveSession, type SessionData } from "./session.js"; 5 4 import { updateRateLimitState } from "./limits.js"; 6 5 import { logger } from "./logger.js"; 6 + import { type SessionData, loadSession, saveSession } from "./session.js"; 7 7 8 8 setGlobalDispatcher( 9 9 new Agent({ ··· 64 64 } 65 65 66 66 const refreshIn = JWT_LIFETIME_MS * REFRESH_AT_PERCENT; 67 - logger.debug(`Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`); 67 + logger.debug( 68 + `Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`, 69 + ); 68 70 69 71 refreshTimer = setTimeout(() => { 70 72 refreshSession().catch((error) => {
+3 -3
src/limits.ts
··· 1 1 import { pRateLimit } from "p-ratelimit"; 2 - import { logger } from "./logger.js"; 3 2 import { Counter, Gauge, Histogram } from "prom-client"; 3 + import { logger } from "./logger.js"; 4 4 5 5 interface RateLimitState { 6 6 limit: number; ··· 76 76 remaining: rateLimitState.remaining, 77 77 resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000), 78 78 }, 79 - "Rate limit state updated" 79 + "Rate limit state updated", 80 80 ); 81 81 } 82 82 ··· 93 93 94 94 if (delayMs > 0) { 95 95 logger.warn( 96 - `Rate limit critical (${state.remaining}/${state.limit} remaining). Waiting ${delaySeconds}s until reset...` 96 + `Rate limit critical (${state.remaining}/${state.limit} remaining). Waiting ${delaySeconds}s until reset...`, 97 97 ); 98 98 99 99 const waitStart = Date.now();
-2
src/moderation.ts
··· 130 130 }); 131 131 }; 132 132 133 - 134 133 export const createPostReport = async ( 135 134 uri: string, 136 135 cid: string, ··· 176 175 } 177 176 }); 178 177 }; 179 - 180 178 181 179 export const checkRecordLabels = async ( 182 180 uri: string,
+6 -3
src/rules/account/age.ts
··· 1 + import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js"; 2 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 3 + import { 4 + checkAccountLabels, 5 + createAccountLabel, 6 + } from "../../accountModeration.js"; 1 7 import { agent, isLoggedIn } from "../../agent.js"; 2 8 import { PLC_URL } from "../../config.js"; 3 - import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 4 9 import { logger } from "../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../accountModeration.js"; 6 - import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js"; 7 10 8 11 interface InteractionContext { 9 12 // For replies
+1 -1
src/rules/account/countStarterPacks.ts
··· 1 + import { createAccountLabel } from "../../accountModeration.js"; 1 2 import { agent, isLoggedIn } from "../../agent.js"; 2 3 import { limit } from "../../limits.js"; 3 4 import { logger } from "../../logger.js"; 4 - import { createAccountLabel } from "../../accountModeration.js"; 5 5 6 6 const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"]; 7 7
+6 -3
src/rules/account/tests/age.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 3 + import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 + import { 5 + checkAccountLabels, 6 + createAccountLabel, 7 + } from "../../../accountModeration.js"; 2 8 import { agent } from "../../../agent.js"; 3 - import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 9 import { logger } from "../../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../../accountModeration.js"; 6 10 import { 7 11 calculateAccountAge, 8 12 checkAccountAge, 9 13 getAccountCreationDate, 10 14 } from "../age.js"; 11 - import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 12 15 13 16 // Mock dependencies 14 17 vi.mock("../../../agent.js", () => ({
+1 -1
src/rules/account/tests/countStarterPacks.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createAccountLabel } from "../../../accountModeration.js"; 2 3 import { agent } from "../../../agent.js"; 3 4 import { limit } from "../../../limits.js"; 4 5 import { logger } from "../../../logger.js"; 5 - import { createAccountLabel } from "../../../accountModeration.js"; 6 6 import { countStarterPacks } from "../countStarterPacks.js"; 7 7 8 8 // Mock dependencies
+1 -1
src/rules/facets/facets.ts
··· 1 - import { logger } from "../../logger.js"; 2 1 import { createAccountLabel } from "../../accountModeration.js"; 2 + import { logger } from "../../logger.js"; 3 3 import { Facet } from "../../types.js"; 4 4 5 5 // Threshold for duplicate facet positions before flagging as spam
+1 -1
src/rules/facets/tests/facets.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 2 import { createAccountLabel } from "../../../accountModeration.js"; 3 + import { logger } from "../../../logger.js"; 4 4 import { Facet } from "../../../types.js"; 5 5 import { 6 6 FACET_SPAM_ALLOWLIST,
+2 -2
src/rules/handles/checkHandles.ts
··· 1 1 import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 - import { logger } from "../../logger.js"; 2 + import { HANDLE_CHECKS } from "../../../rules/handles.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 7 } from "../../accountModeration.js"; 8 - import { HANDLE_CHECKS } from "../../../rules/handles.js"; 8 + import { logger } from "../../logger.js"; 9 9 10 10 export const checkHandle = async ( 11 11 did: string,
+2 -2
src/rules/profiles/checkProfiles.ts
··· 1 1 import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 - import { logger } from "../../logger.js"; 2 + import { PROFILE_CHECKS } from "../../../rules/profiles.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 7 } from "../../accountModeration.js"; 8 - import { PROFILE_CHECKS } from "../../../rules/profiles.js"; 8 + import { logger } from "../../logger.js"; 9 9 import { getLanguage } from "../../utils/getLanguage.js"; 10 10 11 11 export const checkDescription = async (
+1 -2
src/rules/profiles/tests/checkProfiles.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 2 import { 4 3 createAccountComment, 5 4 createAccountLabel, 6 5 createAccountReport, 7 6 } from "../../../accountModeration.js"; 7 + import { logger } from "../../../logger.js"; 8 8 import { getLanguage } from "../../../utils/getLanguage.js"; 9 9 import { checkDescription, checkDisplayName } from "../checkProfiles.js"; 10 10 ··· 357 357 expect.any(String), 358 358 ); 359 359 }); 360 - 361 360 }); 362 361 }); 363 362
+11 -2
src/session.ts
··· 1 - import { readFileSync, writeFileSync, unlinkSync, chmodSync, existsSync } from "node:fs"; 1 + import { 2 + chmodSync, 3 + existsSync, 4 + readFileSync, 5 + unlinkSync, 6 + writeFileSync, 7 + } from "node:fs"; 2 8 import { join } from "node:path"; 3 9 import { logger } from "./logger.js"; 4 10 ··· 34 40 logger.info("Loaded existing session from file"); 35 41 return session; 36 42 } catch (error) { 37 - logger.error({ error }, "Failed to load session file, will authenticate fresh"); 43 + logger.error( 44 + { error }, 45 + "Failed to load session file, will authenticate fresh", 46 + ); 38 47 return null; 39 48 } 40 49 }
+19 -20
src/tests/accountThreshold.test.ts
··· 1 1 import { afterEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + createAccountComment, 4 + createAccountLabel, 5 + createAccountReport, 6 + } from "../accountModeration.js"; 7 + import { 8 + checkAccountThreshold, 9 + loadThresholdConfigs, 10 + } from "../accountThreshold.js"; 11 + import { logger } from "../logger.js"; 12 + import { 13 + accountLabelsThresholdAppliedCounter, 14 + accountThresholdChecksCounter, 15 + accountThresholdMetCounter, 16 + } from "../metrics.js"; 17 + import { 18 + getPostLabelCountInWindow, 19 + trackPostLabelForAccount, 20 + } from "../redis.js"; 2 21 3 22 vi.mock("../logger.js", () => ({ 4 23 logger: { ··· 76 95 inc: vi.fn(), 77 96 }, 78 97 })); 79 - 80 - import { 81 - checkAccountThreshold, 82 - loadThresholdConfigs, 83 - } from "../accountThreshold.js"; 84 - import { logger } from "../logger.js"; 85 - import { 86 - accountLabelsThresholdAppliedCounter, 87 - accountThresholdChecksCounter, 88 - accountThresholdMetCounter, 89 - } from "../metrics.js"; 90 - import { 91 - createAccountComment, 92 - createAccountLabel, 93 - createAccountReport, 94 - } from "../accountModeration.js"; 95 - import { 96 - getPostLabelCountInWindow, 97 - trackPostLabelForAccount, 98 - } from "../redis.js"; 99 98 100 99 describe("Account Threshold Logic", () => { 101 100 afterEach(() => {
+55 -27
src/tests/moderation.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + // --- Imports Second --- 3 + import { checkAccountLabels } from "../accountModeration.js"; 4 + import { agent } from "../agent.js"; 5 + import { logger } from "../logger.js"; 6 + import { createPostLabel } from "../moderation.js"; 7 + import { tryClaimPostLabel } from "../redis.js"; 2 8 3 9 // --- Mocks First --- 4 10 ··· 39 45 limit: vi.fn((fn) => fn()), 40 46 })); 41 47 42 - // --- Imports Second --- 43 - 44 - import { checkAccountLabels } from "../accountModeration.js"; 45 - import { agent } from "../agent.js"; 46 - import { logger } from "../logger.js"; 47 - import { createPostLabel } from "../moderation.js"; 48 - import { tryClaimPostLabel } from "../redis.js"; 49 - 50 48 describe("Moderation Logic", () => { 51 49 beforeEach(() => { 52 50 vi.clearAllMocks(); ··· 57 55 vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 58 56 data: { 59 57 labels: [ 60 - { val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }, 61 - { val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" } 62 - ] 58 + { 59 + val: "spam", 60 + src: "did:plc:test", 61 + uri: "at://test", 62 + cts: "2024-01-01T00:00:00Z", 63 + }, 64 + { 65 + val: "window-reply", 66 + src: "did:plc:test", 67 + uri: "at://test", 68 + cts: "2024-01-01T00:00:00Z", 69 + }, 70 + ], 63 71 }, 64 72 } as any); 65 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 73 + const result = await checkAccountLabels( 74 + "did:plc:test123", 75 + "window-reply", 76 + ); 66 77 expect(result).toBe(true); 67 78 }); 68 79 }); ··· 79 90 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 80 91 81 92 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 82 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled(); 83 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 93 + expect( 94 + vi.mocked(agent.tools.ozone.moderation.getRecord), 95 + ).not.toHaveBeenCalled(); 96 + expect( 97 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 98 + ).not.toHaveBeenCalled(); 84 99 }); 85 100 86 101 it("should skip event if claimed but already labeled via API", async () => { 87 102 vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 88 103 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 89 - data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] }, 104 + data: { 105 + labels: [ 106 + { 107 + val: LABEL, 108 + src: "did:plc:test", 109 + uri: URI, 110 + cts: "2024-01-01T00:00:00Z", 111 + }, 112 + ], 113 + }, 90 114 } as any); 91 115 92 116 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 93 117 94 118 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 95 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 96 - { uri: URI }, 97 - expect.any(Object), 98 - ); 99 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 119 + expect( 120 + vi.mocked(agent.tools.ozone.moderation.getRecord), 121 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 122 + expect( 123 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 124 + ).not.toHaveBeenCalled(); 100 125 }); 101 126 102 127 it("should emit event if claimed and not labeled anywhere", async () => { ··· 104 129 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 105 130 data: { labels: [] }, 106 131 } as any); 107 - vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any); 132 + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ 133 + success: true, 134 + } as any); 108 135 109 136 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 110 137 111 138 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 112 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 113 - { uri: URI }, 114 - expect.any(Object), 115 - ); 116 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled(); 139 + expect( 140 + vi.mocked(agent.tools.ozone.moderation.getRecord), 141 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 142 + expect( 143 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 144 + ).toHaveBeenCalled(); 117 145 }); 118 146 }); 119 - }); 147 + });
+88 -74
src/tests/redis.test.ts
··· 1 - import { afterEach, describe, expect, it, vi } from 'vitest'; 1 + // Import the mocked redis first to get a reference to the mock client 2 + import { createClient } from "redis"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { logger } from "../logger.js"; 5 + // Import the modules to be tested 6 + import { 7 + connectRedis, 8 + disconnectRedis, 9 + getPostLabelCountInWindow, 10 + trackPostLabelForAccount, 11 + tryClaimAccountLabel, 12 + tryClaimPostLabel, 13 + } from "../redis.js"; 2 14 3 15 // Mock the 'redis' module in a way that avoids hoisting issues. 4 16 // The mock implementation is self-contained. 5 - vi.mock('redis', () => { 17 + vi.mock("redis", () => { 6 18 const mockClient = { 7 19 on: vi.fn(), 8 20 connect: vi.fn(), ··· 19 31 }; 20 32 }); 21 33 22 - // Import the mocked redis first to get a reference to the mock client 23 - import { createClient } from 'redis'; 24 34 const mockRedisClient = createClient(); 25 35 26 - // Import the modules to be tested 27 - import { 28 - tryClaimPostLabel, 29 - tryClaimAccountLabel, 30 - connectRedis, 31 - disconnectRedis, 32 - trackPostLabelForAccount, 33 - getPostLabelCountInWindow, 34 - } from '../redis.js'; 35 - import { logger } from '../logger.js'; 36 - 37 36 // Suppress logger output during tests 38 - vi.mock('../logger.js', () => ({ 37 + vi.mock("../logger.js", () => ({ 39 38 logger: { 40 39 info: vi.fn(), 41 40 warn: vi.fn(), ··· 44 43 }, 45 44 })); 46 45 47 - describe('Redis Cache Logic', () => { 46 + describe("Redis Cache Logic", () => { 48 47 afterEach(() => { 49 48 vi.clearAllMocks(); 50 49 }); 51 50 52 - describe('Connection', () => { 53 - it('should call redisClient.connect on connectRedis', async () => { 51 + describe("Connection", () => { 52 + it("should call redisClient.connect on connectRedis", async () => { 54 53 await connectRedis(); 55 54 expect(mockRedisClient.connect).toHaveBeenCalled(); 56 55 }); 57 56 58 - it('should call redisClient.quit on disconnectRedis', async () => { 57 + it("should call redisClient.quit on disconnectRedis", async () => { 59 58 await disconnectRedis(); 60 59 expect(mockRedisClient.quit).toHaveBeenCalled(); 61 60 }); 62 61 }); 63 62 64 - describe('tryClaimPostLabel', () => { 65 - it('should return true and set key if key does not exist', async () => { 66 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 67 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 63 + describe("tryClaimPostLabel", () => { 64 + it("should return true and set key if key does not exist", async () => { 65 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 66 + const result = await tryClaimPostLabel("at://uri", "test-label"); 68 67 expect(result).toBe(true); 69 68 expect(mockRedisClient.set).toHaveBeenCalledWith( 70 - 'post-label:at://uri:test-label', 71 - '1', 72 - { NX: true, EX: 60 * 60 * 24 * 7 } 69 + "post-label:at://uri:test-label", 70 + "1", 71 + { NX: true, EX: 60 * 60 * 24 * 7 }, 73 72 ); 74 73 }); 75 74 76 - it('should return false if key already exists', async () => { 75 + it("should return false if key already exists", async () => { 77 76 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 78 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 77 + const result = await tryClaimPostLabel("at://uri", "test-label"); 79 78 expect(result).toBe(false); 80 79 }); 81 80 82 - it('should return true and log warning on Redis error', async () => { 83 - const redisError = new Error('Redis down'); 81 + it("should return true and log warning on Redis error", async () => { 82 + const redisError = new Error("Redis down"); 84 83 vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 85 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 84 + const result = await tryClaimPostLabel("at://uri", "test-label"); 86 85 expect(result).toBe(true); 87 86 expect(logger.warn).toHaveBeenCalledWith( 88 - { err: redisError, atURI: 'at://uri', label: 'test-label' }, 89 - 'Error claiming post label in Redis, allowing through' 87 + { err: redisError, atURI: "at://uri", label: "test-label" }, 88 + "Error claiming post label in Redis, allowing through", 90 89 ); 91 90 }); 92 91 }); 93 92 94 - describe('tryClaimAccountLabel', () => { 95 - it('should return true and set key if key does not exist', async () => { 96 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 97 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 93 + describe("tryClaimAccountLabel", () => { 94 + it("should return true and set key if key does not exist", async () => { 95 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 96 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 98 97 expect(result).toBe(true); 99 98 expect(mockRedisClient.set).toHaveBeenCalledWith( 100 - 'account-label:did:plc:123:test-label', 101 - '1', 102 - { NX: true, EX: 60 * 60 * 24 * 7 } 99 + "account-label:did:plc:123:test-label", 100 + "1", 101 + { NX: true, EX: 60 * 60 * 24 * 7 }, 103 102 ); 104 103 }); 105 104 106 - it('should return false if key already exists', async () => { 105 + it("should return false if key already exists", async () => { 107 106 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 108 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 107 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 109 108 expect(result).toBe(false); 110 109 }); 111 110 }); 112 111 113 - describe('trackPostLabelForAccount', () => { 114 - it('should track post label with correct timestamp and TTL', async () => { 112 + describe("trackPostLabelForAccount", () => { 113 + it("should track post label with correct timestamp and TTL", async () => { 115 114 vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0); 116 115 vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1); 117 116 vi.mocked(mockRedisClient.expire).mockResolvedValue(true); ··· 119 118 const timestamp = 1640000000000000; // microseconds 120 119 const windowDays = 5; 121 120 122 - await trackPostLabelForAccount('did:plc:123', 'test-label', timestamp, windowDays); 121 + await trackPostLabelForAccount( 122 + "did:plc:123", 123 + "test-label", 124 + timestamp, 125 + windowDays, 126 + ); 123 127 124 - const expectedKey = 'account-post-labels:did:plc:123:test-label:5'; 128 + const expectedKey = "account-post-labels:did:plc:123:test-label:5"; 125 129 const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 126 130 127 131 expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith( 128 132 expectedKey, 129 - '-inf', 130 - windowStartTime 133 + "-inf", 134 + windowStartTime, 131 135 ); 132 136 expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, { 133 137 score: timestamp, ··· 135 139 }); 136 140 expect(mockRedisClient.expire).toHaveBeenCalledWith( 137 141 expectedKey, 138 - (windowDays + 1) * 24 * 60 * 60 142 + (windowDays + 1) * 24 * 60 * 60, 139 143 ); 140 144 }); 141 145 142 - it('should throw error on Redis failure', async () => { 143 - const redisError = new Error('Redis down'); 146 + it("should throw error on Redis failure", async () => { 147 + const redisError = new Error("Redis down"); 144 148 vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError); 145 149 146 150 await expect( 147 - trackPostLabelForAccount('did:plc:123', 'test-label', 1640000000000000, 5) 148 - ).rejects.toThrow('Redis down'); 151 + trackPostLabelForAccount( 152 + "did:plc:123", 153 + "test-label", 154 + 1640000000000000, 155 + 5, 156 + ), 157 + ).rejects.toThrow("Redis down"); 149 158 150 159 expect(logger.error).toHaveBeenCalled(); 151 160 }); 152 161 }); 153 162 154 - describe('getPostLabelCountInWindow', () => { 155 - it('should count posts for single label', async () => { 163 + describe("getPostLabelCountInWindow", () => { 164 + it("should count posts for single label", async () => { 156 165 vi.mocked(mockRedisClient.zCount).mockResolvedValue(3); 157 166 158 167 const currentTime = 1640000000000000; 159 168 const windowDays = 5; 160 169 const count = await getPostLabelCountInWindow( 161 - 'did:plc:123', 162 - ['test-label'], 170 + "did:plc:123", 171 + ["test-label"], 163 172 windowDays, 164 - currentTime 173 + currentTime, 165 174 ); 166 175 167 176 expect(count).toBe(3); 168 177 const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 169 178 expect(mockRedisClient.zCount).toHaveBeenCalledWith( 170 - 'account-post-labels:did:plc:123:test-label:5', 179 + "account-post-labels:did:plc:123:test-label:5", 171 180 windowStartTime, 172 - '+inf' 181 + "+inf", 173 182 ); 174 183 }); 175 184 176 - it('should sum counts for multiple labels (OR logic)', async () => { 185 + it("should sum counts for multiple labels (OR logic)", async () => { 177 186 vi.mocked(mockRedisClient.zCount) 178 187 .mockResolvedValueOnce(3) 179 188 .mockResolvedValueOnce(2) ··· 182 191 const currentTime = 1640000000000000; 183 192 const windowDays = 5; 184 193 const count = await getPostLabelCountInWindow( 185 - 'did:plc:123', 186 - ['label-1', 'label-2', 'label-3'], 194 + "did:plc:123", 195 + ["label-1", "label-2", "label-3"], 187 196 windowDays, 188 - currentTime 197 + currentTime, 189 198 ); 190 199 191 200 expect(count).toBe(6); 192 201 expect(mockRedisClient.zCount).toHaveBeenCalledTimes(3); 193 202 }); 194 203 195 - it('should return 0 when no posts in window', async () => { 204 + it("should return 0 when no posts in window", async () => { 196 205 vi.mocked(mockRedisClient.zCount).mockResolvedValue(0); 197 206 198 207 const count = await getPostLabelCountInWindow( 199 - 'did:plc:123', 200 - ['test-label'], 208 + "did:plc:123", 209 + ["test-label"], 201 210 5, 202 - 1640000000000000 211 + 1640000000000000, 203 212 ); 204 213 205 214 expect(count).toBe(0); 206 215 }); 207 216 208 - it('should throw error on Redis failure', async () => { 209 - const redisError = new Error('Redis down'); 217 + it("should throw error on Redis failure", async () => { 218 + const redisError = new Error("Redis down"); 210 219 vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError); 211 220 212 221 await expect( 213 - getPostLabelCountInWindow('did:plc:123', ['test-label'], 5, 1640000000000000) 214 - ).rejects.toThrow('Redis down'); 222 + getPostLabelCountInWindow( 223 + "did:plc:123", 224 + ["test-label"], 225 + 5, 226 + 1640000000000000, 227 + ), 228 + ).rejects.toThrow("Redis down"); 215 229 216 230 expect(logger.error).toHaveBeenCalled(); 217 231 }); 218 232 }); 219 - }); 233 + });