Files
hanarang-rails/tests/discord-notifier.test.ts
이랑이 08ea92f540 feat(bridge): Discord 봇 — 슬래시 커맨드 트리거 + 실시간 알림
v0.1.4 — 옵션 3 (outbound + inbound). DISCORD_TOKEN 이 설정되지 않으면
bridge 는 no-op 이라 기존 배포는 영향 없음.

## Outbound (rails → Discord)

- runner.ts: PipelineLifecycleEvent emitter 추가
    started / stage-done / stage-failed / completed / failed / escalated
- DiscordNotifier: 이벤트 → Discord 메시지 렌더링
    thread mode (슬래시 커맨드 트리거) vs channel mode (CLI/HTTP 트리거)
- EscalationNotifier 인터페이스도 구현 — escalate.ts 에서 사용자에게 알림
- runPipeline opts 에 onEvent + notifier 주입

## Inbound (Discord → rails)

- /rails start project:<name> requirements:<text> — 파이프라인 기동
    → defer reply → POST /pipelines/start-async → thread 생성 → 실시간 업데이트
- /rails status <id> — 상태 조회 (ephemeral)
- /rails abort <id> — 강제 종료 (ephemeral)

## Async start 엔드포인트

- POST /pipelines/start-async: pipelineId 즉시 리턴 후 background 에서
  runPipeline 실행. Discord 의 3초 ACK 타임아웃을 회피.
- runPipeline 에 opts.pipelineId 지원: async 엔드포인트가 미리 만든
  row 위에 파이프라인을 그대로 얹을 수 있게.

## 부트스트랩

- rails serve 가 DISCORD_TOKEN/GUILD_ID/NOTIFY_CHANNEL_ID 세 개가 모두
  있으면 DiscordBridge 를 자동 시작. 없으면 "skip" 로그 남기고 무시.
- discord.js ^14.26 의존성 추가.

## 문서/테스트

- .env.example: Discord 섹션 전면 재작성 (동작 설명 포함)
- tests/discord-notifier.test.ts (7 tests): fake DiscordClientWrapper 로
  라우팅/렌더링/바인딩 해제 로직 검증
- 총 111 → 118 테스트 통과
2026-04-10 22:13:26 +09:00

216 lines
6.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import { DiscordNotifier } from "../src/bridge/discord-notifier.js";
import type { DiscordClientWrapper } from "../src/bridge/discord-client.js";
import type { PipelineLifecycleEvent } from "../src/orchestrator/runner.js";
/**
* The DiscordNotifier is the interesting unit — it contains the routing
* logic (channel vs thread) and the message rendering. We exercise it
* against a fake DiscordClientWrapper that records every call.
*/
function makeFakeDiscord(): {
fake: DiscordClientWrapper;
channelPosts: Array<{ channelId: string; content: string }>;
threadPosts: Array<{ threadId: string; content: string }>;
} {
const channelPosts: Array<{ channelId: string; content: string }> = [];
const threadPosts: Array<{ threadId: string; content: string }> = [];
const fake = {
config: {
token: "x",
guildId: "g",
notifyChannelId: "channel-123",
},
async postToChannel(channelId: string, content: string) {
channelPosts.push({ channelId, content });
return "msg-id";
},
async postToThread(threadId: string, content: string) {
threadPosts.push({ threadId, content });
return "msg-id";
},
} as unknown as DiscordClientWrapper;
return { fake, channelPosts, threadPosts };
}
async function flush(): Promise<void> {
// The notifier dispatches via .catch on a floating promise — give
// microtasks a chance to run before we assert.
await new Promise((r) => setTimeout(r, 10));
}
describe("DiscordNotifier", () => {
let fake: ReturnType<typeof makeFakeDiscord>;
let notifier: DiscordNotifier;
beforeEach(() => {
fake = makeFakeDiscord();
notifier = new DiscordNotifier(fake.fake);
});
it("posts to channel when no thread is bound", async () => {
const listener = notifier.asListener();
const evt: PipelineLifecycleEvent = {
type: "started",
pipelineId: "01HXYZTEST1234567890ABCDE",
projectName: "demo",
requirements: "make a toy app",
};
listener(evt);
await flush();
expect(fake.channelPosts).toHaveLength(1);
expect(fake.threadPosts).toHaveLength(0);
expect(fake.channelPosts[0]!.channelId).toBe("channel-123");
expect(fake.channelPosts[0]!.content).toContain("파이프라인 시작");
expect(fake.channelPosts[0]!.content).toContain("demo");
});
it("posts to thread when one is bound", async () => {
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-777");
const listener = notifier.asListener();
listener({
type: "stage-done",
pipelineId: "01HXYZTEST1234567890ABCDE",
stage: "plan",
text: "plan ok",
});
await flush();
expect(fake.threadPosts).toHaveLength(1);
expect(fake.channelPosts).toHaveLength(0);
expect(fake.threadPosts[0]!.threadId).toBe("thread-777");
expect(fake.threadPosts[0]!.content).toContain("기획 완료");
});
it("renders every lifecycle event type", async () => {
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-1");
const listener = notifier.asListener();
const events: PipelineLifecycleEvent[] = [
{
type: "started",
pipelineId: "01HXYZTEST1234567890ABCDE",
projectName: "p",
requirements: "r",
},
{
type: "stage-done",
pipelineId: "01HXYZTEST1234567890ABCDE",
stage: "implement",
text: "impl ok",
},
{
type: "stage-failed",
pipelineId: "01HXYZTEST1234567890ABCDE",
stage: "review",
reason: "something broke",
},
{
type: "escalated",
pipelineId: "01HXYZTEST1234567890ABCDE",
stage: "implement",
reason: "3 retries failed",
attempts: 3,
},
{
type: "completed",
pipelineId: "01HXYZTEST1234567890ABCDE",
finalState: "done",
transitions: 5,
},
];
for (const e of events) listener(e);
await flush();
const contents = fake.threadPosts.map((p) => p.content);
expect(contents.some((c) => c.includes("파이프라인 시작"))).toBe(true);
expect(contents.some((c) => c.includes("구현 완료"))).toBe(true);
expect(contents.some((c) => c.includes("검토 실패"))).toBe(true);
expect(contents.some((c) => c.includes("에스컬레이션"))).toBe(true);
expect(contents.some((c) => c.includes("파이프라인 완료"))).toBe(true);
});
it("unbinds thread after terminal 'completed' event", async () => {
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-2");
const listener = notifier.asListener();
listener({
type: "completed",
pipelineId: "01HXYZTEST1234567890ABCDE",
finalState: "done",
transitions: 5,
});
await flush();
// A subsequent event should fall back to channel (thread unbound)
listener({
type: "started",
pipelineId: "01HXYZTEST1234567890ABCDE",
projectName: "x",
requirements: "y",
});
await flush();
// First post to thread, second to channel
expect(fake.threadPosts).toHaveLength(1);
expect(fake.channelPosts).toHaveLength(1);
});
it("keeps thread binding after 'escalated' (user may resume)", async () => {
notifier.bindPipelineThread("01HXYZTEST1234567890ABCDE", "thread-3");
const listener = notifier.asListener();
listener({
type: "escalated",
pipelineId: "01HXYZTEST1234567890ABCDE",
stage: "plan",
reason: "halt",
attempts: 2,
});
await flush();
listener({
type: "stage-done",
pipelineId: "01HXYZTEST1234567890ABCDE",
stage: "plan",
text: "resumed",
});
await flush();
expect(fake.threadPosts).toHaveLength(2);
expect(fake.channelPosts).toHaveLength(0);
});
it("EscalationNotifier.notify posts to channel", async () => {
await notifier.notify({
title: "🚨 pipeline halted",
body: "three retries exhausted",
mentionUser: true,
});
expect(fake.channelPosts).toHaveLength(1);
expect(fake.channelPosts[0]!.content).toContain("pipeline halted");
expect(fake.channelPosts[0]!.content).toContain("three retries exhausted");
});
it("swallows listener exceptions from postToChannel (non-fatal)", async () => {
const warnSpy = vi.fn();
// Inject a failing postToChannel
const failingFake = makeFakeDiscord();
(failingFake.fake as unknown as {
postToChannel: () => Promise<string>;
}).postToChannel = async () => {
throw new Error("boom");
};
const failingNotifier = new DiscordNotifier(failingFake.fake);
failingNotifier.asListener()({
type: "started",
pipelineId: "01HXYZTEST1234567890ABCDE",
projectName: "p",
requirements: "r",
});
await flush();
// If we get here without an uncaught rejection, the test passes.
expect(warnSpy).toBeDefined();
});
});