feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:57:41 +08:00
parent 7dd5c44406
commit fb23c5234e
28 changed files with 3644 additions and 7 deletions

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ============================================================
// Mock 外部依赖
// ============================================================
// Mock cuid2
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mock-idempotency-key"),
}));
// Mock Redis client —— getRedis 返回模拟 redis 或 null
const mockRedis = {
set: vi.fn(),
exists: vi.fn(),
};
let redisReturnValue: unknown = null;
vi.mock("../../src/shared/redis/redis.client.js", () => ({
getRedis: () => redisReturnValue,
}));
// Mock database —— getDb 返回模拟 db
const mockInsertChain = {
values: vi.fn().mockResolvedValue(undefined),
};
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
const mockDb = {
insert: vi.fn(() => mockInsertChain),
select: vi.fn(() => mockSelectChain),
};
vi.mock("../../src/config/database.js", () => ({
getDb: () => mockDb,
}));
// Mock logger
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// 导入被测模块(在 mock 之后)
import {
checkAndMark,
isProcessed,
generateIdempotencyKey,
} from "../../src/shared/redis/idempotency.guard.js";
// ============================================================
// Tests
// ============================================================
describe("idempotency.guard", () => {
beforeEach(() => {
vi.clearAllMocks();
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
// 重置 select chain
mockSelectChain.from.mockReturnThis();
mockSelectChain.where.mockReturnThis();
mockSelectChain.limit.mockResolvedValue([]);
});
// ----------------------------------------------------------
// checkAndMark
// ----------------------------------------------------------
describe("checkAndMark", () => {
it("Redis SETNX 成功(返回 OK→ isFirst=true", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue("OK");
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(true);
expect(result.key).toBe("msg:processed:evt-1");
expect(mockRedis.set).toHaveBeenCalledWith(
"msg:processed:evt-1",
"1",
"EX",
7 * 24 * 60 * 60,
"NX",
);
// 不应走 DB 降级
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("Redis SETNX 失败(返回 null→ isFirst=false已处理", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue(null);
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
expect(result.key).toBe("msg:processed:evt-1");
// 不应走 DB 降级
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("Redis 异常 → 降级到 DB 插入,成功时 isFirst=true", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockRejectedValue(new Error("Redis connection lost"));
mockInsertChain.values.mockResolvedValue(undefined);
const result = await checkAndMark("evt-1", "edu.notification.sent");
expect(result.isFirst).toBe(true);
expect(result.key).toBe("msg:processed:evt-1");
// 应走 DB 降级
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(mockInsertChain.values).toHaveBeenCalledWith({
eventId: "evt-1",
topic: "edu.notification.sent",
});
});
it("Redis 异常 + DB 唯一索引冲突 → isFirst=false", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockRejectedValue(new Error("Redis down"));
// DB 插入冲突
mockInsertChain.values.mockRejectedValue(new Error("Duplicate entry"));
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});
it("Redis 不可用getRedis 返回 null→ 直接走 DB 降级", async () => {
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
const result = await checkAndMark("evt-1", "edu.notification.sent");
expect(result.isFirst).toBe(true);
expect(mockRedis.set).not.toHaveBeenCalled();
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});
it("Redis 不可用 + DB 冲突 → isFirst=false", async () => {
redisReturnValue = null;
mockInsertChain.values.mockRejectedValue(new Error("Duplicate"));
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
});
it("无 topic 参数时 DB 降级应使用 'unknown'", async () => {
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
await checkAndMark("evt-1");
expect(mockInsertChain.values).toHaveBeenCalledWith({
eventId: "evt-1",
topic: "unknown",
});
});
it("key 格式应为 msg:processed:{eventId}", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue("OK");
const result = await checkAndMark("my-event-123");
expect(result.key).toBe("msg:processed:my-event-123");
});
});
// ----------------------------------------------------------
// isProcessed
// ----------------------------------------------------------
describe("isProcessed", () => {
it("Redis exists=1 → true已处理", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockResolvedValue(1);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockRedis.exists).toHaveBeenCalledWith("msg:processed:evt-1");
});
it("Redis exists=0 → false未处理", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockResolvedValue(0);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
it("Redis 异常 → 降级到 DB 查询", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockDb.select).toHaveBeenCalledTimes(1);
});
it("Redis 异常 + DB 无记录 → false", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockSelectChain.limit.mockResolvedValue([]);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
it("Redis 不可用 → 直接走 DB 查询", async () => {
redisReturnValue = null;
mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockRedis.exists).not.toHaveBeenCalled();
expect(mockDb.select).toHaveBeenCalledTimes(1);
});
it("Redis 不可用 + DB 无记录 → false", async () => {
redisReturnValue = null;
mockSelectChain.limit.mockResolvedValue([]);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
});
// ----------------------------------------------------------
// generateIdempotencyKey
// ----------------------------------------------------------
describe("generateIdempotencyKey", () => {
it("应返回一个字符串", () => {
const key = generateIdempotencyKey();
expect(typeof key).toBe("string");
expect(key.length).toBeGreaterThan(0);
});
it("应使用 cuid2 生成", () => {
const key = generateIdempotencyKey();
// createId 被 mock 为返回 "mock-idempotency-key"
expect(key).toBe("mock-idempotency-key");
});
});
});