import axios from 'axios';
import { AlertSettingsService } from './alert-settings.service';
import {
  AlertFinding,
  AlertRule,
  AlertState,
  STATE_KEY,
  SuppressEntry,
} from './alert.entity';
import { AlertChannelRow, leaseOwner } from './alert.repository';
import { AlertService, TEST_BANNER } from './alert.service';

jest.mock('axios');
//* jest.mock 은 import 위로 호이스팅되므로 여기서 받는 axios 는 이미 목이다.
const post = axios.post as unknown as jest.Mock;

const URL_A = 'https://chat.googleapis.com/a';
const URL_B = 'https://hooks.slack.com/b';
const PUBLIC_ADDR = [{ address: '142.250.207.106', family: 4 }];

/**
 * 리스·상태 KV 를 흉내내는 in-memory 저장소.
 * setStateConditional 은 실제 SQL 과 같은 "리스 토큰이 내 것일 때만 쓴다"만 재현한다 —
 * 파생테이블 EXISTS·행 잠금 시 WHERE 재평가 같은 SQL 의미론은 fake 가 증명할 수 없다(실 MySQL 몫).
 */
class FakeRepo {
  settings = new Map<string, string>();
  channels: AlertChannelRow[] = [];
  lease = '0';
  setSetting = jest.fn(async (k: string, v: string) => {
    this.settings.set(k, v);
  });
  ensureLeaseRow = jest.fn(async () => {
    if (this.lease === null) this.lease = '0';
  });
  releaseLease = jest.fn(async (runId: string) => {
    if (leaseOwner(this.lease) === runId) this.lease = '0';
  });

  //--- M3 ---//
  //* 이력은 추가 전용이라 fake 도 배열 push 하나면 충분하다. `throws` 로 기록 실패 축을 만든다.
  history: any[] = [];
  insertHistoryThrows = false;
  insertHistory = jest.fn(async (row: any) => {
    if (this.insertHistoryThrows) throw new Error('insert failed');
    this.history.push(row);
  });
  //* 보존기간 정리. 실제 문장은 `alh_created_at` 을 보지만 fake 는 그 축을 안 들고 있어
  //*  호출 사실만 재현한다 — 이 회차 경로가 발송을 막지 않는지가 검증 대상이다.
  pruneHistoryThrows = false;
  pruneHistory = jest.fn(async (_days: number, _batch?: number) => {
    if (this.pruneHistoryThrows) throw new Error('prune failed');
  });

  async findChannels(): Promise<AlertChannelRow[]> {
    return this.channels;
  }
  async getSetting(key: string): Promise<string | null> {
    if (key === STATE_KEY.lease) return this.lease;
    return this.settings.get(key) ?? null;
  }
  async getSettingsByPrefix(prefix: string): Promise<Record<string, string>> {
    const out: Record<string, string> = {};
    for (const [k, v] of this.settings) if (k.startsWith(prefix)) out[k] = v;
    return out;
  }
  async tryAcquireLease(runId: string, ttlMs: number): Promise<void> {
    const exp = Number(String(this.lease).split(':')[0]);
    if (!Number.isFinite(exp) || exp < Date.now())
      this.lease = `${Date.now() + ttlMs}:${runId}`;
  }
  async readLeaseValue(): Promise<string | null> {
    return this.lease;
  }
  setStateConditional = jest.fn(async (key: string, value: string, runId: string) => {
    if (leaseOwner(this.lease) === runId) this.settings.set(key, value);
  });

  state(): AlertState {
    const raw = this.settings.get(STATE_KEY.suppress);
    return raw ? JSON.parse(raw) : {};
  }
  putState(state: Record<string, SuppressEntry | number>): void {
    this.settings.set(STATE_KEY.suppress, JSON.stringify(state));
  }
}

const channel = (over: Partial<AlertChannelRow> = {}): AlertChannelRow => ({
  alcId: over.alcId ?? 1,
  alcName: over.alcName ?? 'ch',
  alcUrl: over.alcUrl ?? URL_A,
  alcTypes: over.alcTypes === undefined ? null : over.alcTypes,
  alcEnabled: over.alcEnabled ?? 1,
});

const finding = (over: Partial<AlertFinding> = {}): AlertFinding => ({
  key: over.key ?? '경고1',
  level: over.level ?? 'critical',
  text: over.text ?? '경고 본문',
  ...over,
});

const rule = (over: Partial<AlertRule> = {}): AlertRule =>
  ({
    id: 'r1',
    name: '규칙1',
    severity: 'critical',
    noise: { group: true },
    settings: [],
    evaluate: jest.fn(async () => [finding()]),
    ...over,
  }) as AlertRule;

/**
 * Redis 신호 저장소 대역(M2 §D7). 실제 ioredis 의미론 중 이 스펙이 의존하는 것만 재현한다 —
 * `SET NX` 단일 승자, 스냅샷 왕복, 폴백 상태 삭제 규약.
 */
class FakeSignals {
  snapshot: any = null;
  fbState: { t: number; urls: string[] } | null = null;
  fbStreak = 0;
  fbLease: string | null = null;
  invalidateThrows = false;
  invalidateCount = 0;

  writeSnapshot = jest.fn(async (s: any) => {
    this.snapshot = s;
  });
  async readSnapshot() {
    return this.snapshot;
  }
  async invalidateSnapshot() {
    this.invalidateCount++;
    if (this.invalidateThrows) throw new Error('redis-down');
    this.snapshot = null;
  }
  async acquireFallbackLease(runId: string) {
    if (this.fbLease) return false; //* SET NX — 이미 잡혀 있으면 진다
    this.fbLease = runId;
    return true;
  }
  async releaseFallbackLease(runId: string) {
    if (this.fbLease === runId) this.fbLease = null;
  }
  async bumpFallbackStreak() {
    return ++this.fbStreak;
  }
  async resetFallbackStreak() {
    this.fbStreak = 0;
  }
  async readFallbackState() {
    return this.fbState;
  }
  async writeFallbackState(s: { t: number; urls: string[] } | null) {
    this.fbState = s && s.urls.length > 0 ? s : null;
  }

  //--- M3 §D1 — 이력 구멍 표지(Redis) ---//
  historyGap: { at: number; count: number } | null = null;
  markHistoryGap = jest.fn(async (now: number = Date.now()) => {
    this.historyGap = { at: now, count: (this.historyGap?.count ?? 0) + 1 };
  });
  async readHistoryGap() {
    return this.historyGap;
  }
}

class FakeHealth {
  dbOk = true;
  async probeDb() {
    return this.dbOk
      ? { ok: true, ms: 1 }
      : { ok: false, ms: 1, error: 'ECONNREFUSED' };
  }
  async probeRedis() {
    return null;
  }
}

/**
 * `TreatProviderService` 대역(§D5). `AlertService` 는 벤더 축을 위해 이 의존을 새로 받는다 —
 * `TreatMonitorRule` 이 이미 같은 의존을 갖는 같은 모듈 안이라 새 경계가 아니다.
 */
class FakeProviderSvc {
  rows: any[] = [];
  webhook: { url: string; types: string[] } = { url: '', types: [] };
  listError: unknown = null;
  list = jest.fn(async () => {
    if (this.listError) throw this.listError;
    return { list: this.rows };
  });
  getWebhookConfig = jest.fn(async () => this.webhook);
}

function build(opts: { rules?: AlertRule[]; hostAllow?: string } = {}) {
  const repo = new FakeRepo();
  const signals = new FakeSignals();
  const health = new FakeHealth();
  const settings = new AlertSettingsService(repo as any);
  const config = {
    get: (k: string) =>
      k === 'alert.webhookHostAllow'
        ? (opts.hostAllow ?? 'chat.googleapis.com,hooks.slack.com')
        : undefined,
  };
  const providerSvc = new FakeProviderSvc();
  const svc = new AlertService(
    repo as any,
    settings,
    config as any,
    signals as any,
    health as any,
    providerSvc as any,
    opts.rules ?? [],
  );
  svc.lookup = jest.fn(async () => PUBLIC_ADDR) as any;
  return { svc, repo, signals, health, providerSvc };
}

beforeEach(() => {
  post.mockReset();
  post.mockResolvedValue({ status: 200 });
});

describe('AlertService.run — 리스 (D1-b)', () => {
  it('리스를 못 얻으면 규칙을 평가하지 않고 lastRunAt 도 바뀌지 않는다', async () => {
    const r = rule();
    const { svc, repo } = build({ rules: [r] });
    repo.lease = `${Date.now() + 60_000}:other-run`;

    const res = await svc.run();

    expect(res).toEqual({ sent: 0, recovered: 0, skipped: 'lease-not-acquired' });
    expect(r.evaluate).not.toHaveBeenCalled();
    expect(repo.settings.get(STATE_KEY.lastRunAt)).toBeUndefined();
  });

  it('만료된 리스는 다시 획득한다 (크래시 복구 경로)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.lease = `${Date.now() - 1}:dead-run`;

    await expect(svc.run()).resolves.toMatchObject({ skipped: null });
  });

  it('정상 종료 직후 재실행이 즉시 획득한다 — 조기 반납이 다음 회차를 막지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    await svc.run();
    expect(repo.lease).toBe('0'); //* 조기 반납
    await expect(svc.run()).resolves.toMatchObject({ skipped: null });
  });

  it('리스 행이 없어도 자가 생성 후 획득한다 — 시딩 누락이 영구 무음을 만들지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    (repo as any).lease = null;
    repo.ensureLeaseRow.mockImplementation(async () => {
      if ((repo as any).lease === null) (repo as any).lease = '0';
    });

    await expect(svc.run()).resolves.toMatchObject({ skipped: null });
    expect(repo.ensureLeaseRow).toHaveBeenCalled();
  });

  it('als_value 가 비숫자면 획득하되 leaseHealthy:false 로 노출한다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.lease = 'broken-value';

    await svc.run();

    expect(repo.settings.get(STATE_KEY.leaseHealthy)).toBe('0');
  });

  it('토큰 불일치면 미획득 — affectedRows 가 아니라 토큰 비교로 판정한다', async () => {
    const r = rule();
    const { svc, repo } = build({ rules: [r] });
    //* 획득 UPDATE 는 "성공"했지만 저장된 값이 타 runId 인 상태를 모사한다.
    repo.tryAcquireLease = jest.fn(async () => {
      repo.lease = `${Date.now() + 60_000}:someone-else`;
    }) as any;

    const res = await svc.run();

    expect(res.skipped).toBe('lease-not-acquired');
    expect(r.evaluate).not.toHaveBeenCalled();
  });

  it('반납은 소유권 조건부다 — 타 실행이 보유 중이면 값이 바뀌지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.lease = `${Date.now() + 60_000}:other-run`;
    const before = repo.lease;

    await svc.run();

    expect(repo.lease).toBe(before);
  });
});

describe('AlertService.run — 소유권 재확인 2회', () => {
  it('발송 직전 리스를 빼앗기면 발송도 상태 쓰기도 하지 않는다 (7-b)', async () => {
    const repoRef: { repo?: FakeRepo } = {};
    const r = rule({
      evaluate: jest.fn(async () => {
        repoRef.repo!.lease = `${Date.now() + 60_000}:thief`;
        return [finding()];
      }),
    });
    const { svc, repo } = build({ rules: [r] });
    repoRef.repo = repo;
    repo.channels = [channel()];

    const res = await svc.run();

    expect(res.skipped).toBe('lease-lost-before-send');
    expect(post).not.toHaveBeenCalled();
    expect(repo.settings.get(STATE_KEY.suppress)).toBeUndefined();
  });

  it('발송 중 리스를 빼앗기면 상태 변경을 폐기하고 새 소유자의 값을 보존한다 (9-b)', async () => {
    const repoRef: { repo?: FakeRepo } = {};
    const { svc, repo } = build({ rules: [rule()] });
    repoRef.repo = repo;
    repo.channels = [channel()];
    post.mockImplementation(async () => {
      repo.lease = `${Date.now() + 60_000}:thief`;
      repo.settings.set(STATE_KEY.suppress, JSON.stringify({ 'new-owner': 1 }));
      return { status: 200 };
    });

    const res = await svc.run();

    expect(res.skipped).toBe('lease-lost-after-send');
    expect(repo.settings.get(STATE_KEY.suppress)).toBe(JSON.stringify({ 'new-owner': 1 }));
  });

  it('상태는 전부 리스 조건부 write 로만 나간다 — setSetting 을 쓰지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    await svc.run();

    expect(repo.setSetting).not.toHaveBeenCalled();
    expect(repo.setStateConditional).toHaveBeenCalled();
    for (const call of repo.setStateConditional.mock.calls)
      expect(typeof call[2]).toBe('string'); //* runId 를 항상 실어 보낸다
  });
});

describe('AlertService.run — 다중 채널 격리 · 억제 · 복구', () => {
  it('채널 1개가 실패해도 나머지는 발송되고 run() 은 throw 하지 않는다 (M5 · M10)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_B })];
    post.mockImplementation(async (url: string) => {
      if (url === URL_A) throw new Error('boom');
      return { status: 200 };
    });

    const res = await svc.run();

    expect(res.sent).toBe(1);
    expect(post).toHaveBeenCalledTimes(2);
    expect(repo.settings.get(STATE_KEY.sendFailureCount)).toBe('1');
  });

  it('1개 채널만 성공하면 그 목적지 키만 ok:true 이고 실패분은 다음 주기에 재시도된다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_B })];
    post.mockImplementation(async (url: string) =>
      url === URL_A ? Promise.reject(new Error('boom')) : { status: 200 },
    );

    await svc.run();

    const state = repo.state();
    expect(state[`r1::${URL_A}::경고1`].ok).toBe(false);
    expect(state[`r1::${URL_B}::경고1`].ok).toBe(true);
  });

  it('억제창 안에서 재실행하면 발송이 0이다 (M4)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    await svc.run();
    post.mockClear();
    await svc.run();

    expect(post).not.toHaveBeenCalled();
  });

  it('전 채널 실패 시 키는 ok:false 로 남고 1주기 경과 후 재발송된다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];
    post.mockRejectedValue(new Error('boom'));

    await svc.run();
    expect(repo.state()[`r1::${URL_A}::경고1`].ok).toBe(false);

    //* 1주기(폴링 5분) 경과 모사
    const state = repo.state();
    state[`r1::${URL_A}::경고1`].t = Date.now() - 6 * 60 * 1000;
    repo.putState(state);
    post.mockClear();
    post.mockResolvedValue({ status: 200 });
    await svc.run();

    expect(post).toHaveBeenCalledTimes(1);
  });

  it('ok:false 기록 직후 크래시를 모사해도 1주기 뒤 재발송된다 — 영구 무음이 없다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::경고1`]: {
        t: Date.now() - 6 * 60 * 1000,
        ok: false,
        since: 0,
        url: URL_A,
        text: '경고 본문',
      },
    });

    await svc.run();

    expect(post).toHaveBeenCalledTimes(1);
  });

  it('경고가 사라지면 복구를 정확히 1회 보내고 재실행 시 0회다 (M3)', async () => {
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::경고1`]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: '경고 본문' },
    });

    const first = await svc.run();
    expect(first.recovered).toBe(1);
    expect(post).toHaveBeenCalledWith(URL_A, { text: '✅ [복구] 경고 본문' }, expect.any(Object));

    post.mockClear();
    const second = await svc.run();
    expect(second.recovered).toBe(0);
    expect(post).not.toHaveBeenCalled();
  });

  it('ok:false 목적지에는 복구를 보내지 않고 키만 조용히 삭제한다 (Santa R7)', async () => {
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::경고1`]: { t: Date.now() - 1000, ok: false, since: 0, url: URL_A, text: '경고 본문' },
    });

    const res = await svc.run();

    expect(post).not.toHaveBeenCalled();
    expect(res.recovered).toBe(0);
    expect(repo.state()[`r1::${URL_A}::경고1`]).toBeUndefined();
  });

  it('경고 지속 중 채널을 추가하면 신규 채널로만 발송된다 (D7 키 단위 · M13)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A })];
    await svc.run();

    repo.channels.push(channel({ alcId: 2, alcUrl: URL_B }));
    post.mockClear();
    await svc.run();

    expect(post).toHaveBeenCalledTimes(1);
    expect(post.mock.calls[0][0]).toBe(URL_B);
  });

  it('채널을 제거하면 그 목적지 키가 복구 1회 후 삭제된다 (D7 · M13)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_B })];
    await svc.run();

    repo.channels = [channel({ alcId: 1, alcUrl: URL_A })];
    post.mockClear();
    const res = await svc.run();

    expect(res.recovered).toBe(1);
    expect(post.mock.calls.map((c: any[]) => c[0])).toEqual([URL_B]);
    expect(repo.state()[`r1::${URL_B}::경고1`]).toBeUndefined();
  });
});

describe('AlertService.run — 단계별 격리 (D8)', () => {
  it('규칙 A 가 throw 해도 규칙 B 의 finding 은 발송된다', async () => {
    const bad = rule({
      id: 'bad',
      evaluate: jest.fn(async () => {
        throw new Error('rule blew up');
      }),
    });
    const good = rule({ id: 'good', evaluate: jest.fn(async () => [finding({ key: 'ok' })]) });
    const { svc, repo } = build({ rules: [bad, good] });
    repo.channels = [channel()];

    const res = await svc.run();

    expect(res.sent).toBe(1);
    expect(post).toHaveBeenCalledTimes(1);
  });

  it('alc_types JSON 이 깨진 채널만 건너뛰고 나머지로 발송한다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [
      channel({ alcId: 1, alcUrl: URL_A, alcTypes: '{broken' }),
      channel({ alcId: 2, alcUrl: URL_B }),
    ];

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[0])).toEqual([URL_B]);
  });

  it('run() 은 어떤 경로로도 throw 하지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];
    (repo as any).findChannels = async () => {
      throw new Error('db down');
    };

    await expect(svc.run()).resolves.toMatchObject({ sent: 0 });
  });
});

describe('AlertService.run — 미평가 규칙은 "해소"가 아니다 (허위 복구 방지)', () => {
  const live = (): Record<string, SuppressEntry> => ({
    [`r1::${URL_A}::경고1`]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: '경고 본문' },
  });

  it('규칙이 throw 하면 살아 있는 경고에 복구를 보내지 않고 상태를 보존한다', async () => {
    const bad = rule({
      evaluate: jest.fn(async () => {
        throw new Error('db down');
      }),
    });
    const { svc, repo } = build({ rules: [bad] });
    repo.channels = [channel()];
    repo.putState(live());

    const res = await svc.run();

    expect(post).not.toHaveBeenCalled();
    expect(res.recovered).toBe(0);
    expect(repo.state()[`r1::${URL_A}::경고1`]).toMatchObject({ ok: true });
  });

  it('규칙을 OFF 로 내려도 복구가 나가지 않는다 — 다시 켤 때까지 상태를 보존한다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];
    repo.settings.set('alert_rule_enabled:r1', '0');
    repo.putState(live());

    const res = await svc.run();

    expect(post).not.toHaveBeenCalled();
    expect(res.recovered).toBe(0);
    expect(repo.state()[`r1::${URL_A}::경고1`]).toMatchObject({ ok: true });
  });

  it('채널 조회 실패는 "채널 0개"와 다르다 — 복구를 보내지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.putState(live());
    (repo as any).findChannels = async () => {
      throw new Error('db down');
    };

    const res = await svc.run();

    expect(post).not.toHaveBeenCalled();
    expect(res.recovered).toBe(0);
    expect(repo.state()[`r1::${URL_A}::경고1`]).toMatchObject({ ok: true });
  });

  it('미평가 규칙의 연속 카운트는 리셋되지 않는다', async () => {
    const bad = rule({
      noise: { persistence: true, group: true },
      evaluate: jest.fn(async () => {
        throw new Error('db down');
      }),
    });
    const { svc, repo } = build({ rules: [bad] });
    repo.channels = [channel()];
    repo.settings.set(STATE_KEY.streak, JSON.stringify({ 'r1::경고1': 3 }));

    await svc.run();

    expect(JSON.parse(repo.settings.get(STATE_KEY.streak)!)).toEqual({ 'r1::경고1': 3 });
  });

  it('평가에 성공한 규칙의 복구는 그대로 나간다 — 격리가 복구를 통째로 막지 않는다', async () => {
    const bad = rule({ id: 'bad', evaluate: jest.fn(async () => { throw new Error('boom'); }) });
    const good = rule({ id: 'r1', evaluate: jest.fn(async () => []) });
    const { svc, repo } = build({ rules: [bad, good] });
    repo.channels = [channel()];
    repo.putState({
      ...live(),
      [`bad::${URL_A}::옛 경고`]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: '옛 경고' },
    });

    const res = await svc.run();

    expect(res.recovered).toBe(1);
    expect(post.mock.calls.map((c: any[]) => c[1].text)).toEqual(['✅ [복구] 경고 본문']);
    expect(repo.state()[`bad::${URL_A}::옛 경고`]).toMatchObject({ ok: true });
  });
});

describe('AlertService.run — 전체 OFF · 메시지 접두', () => {
  it('alert_enabled 가 0 이면 즉시 반환한다 (R8)', async () => {
    const r = rule();
    const { svc, repo } = build({ rules: [r] });
    repo.settings.set('alert_enabled', '0');

    const res = await svc.run();

    expect(res.skipped).toBe('disabled');
    expect(r.evaluate).not.toHaveBeenCalled();
  });

  /**
   * 환경 라벨은 사용자 결정으로 전 층에서 제거됐다(계획서 §D9). 남는 단언은 **접두가 붙지
   * 않는다**는 것 하나다 — 라벨 제거를 소스에서만 하고 이 단언을 지우면, 접두를 되살린
   * 변경이 아무 검사에도 안 걸린다.
   */
  it('메시지 본문에 환경 접두가 붙지 않는다 (§D9 라벨 제거)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    await svc.run();

    expect(post.mock.calls[0][1].text).toBe('🔴 [위험] 경고 본문');
  });
});

describe('AlertService — 목적지 전개 (D7 · D9)', () => {
  it('channelHint 매칭 채널이 있으면 일반 채널로는 가지 않는다 — 합집합이 아니라 우선순위', async () => {
    const r = rule({
      evaluate: jest.fn(async () => [finding({ channelHint: 'octover' })]),
    });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [
      channel({ alcId: 1, alcName: 'octover', alcUrl: URL_A }),
      channel({ alcId: 2, alcName: '전역', alcUrl: URL_B }),
    ];

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[0])).toEqual([URL_A]);
  });

  it('힌트 매칭이 없으면 일반 채널로 폴백한다', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ channelHint: 'nobody' })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel({ alcId: 2, alcName: '전역', alcUrl: URL_B })];

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[0])).toEqual([URL_B]);
  });

  it('alc_types 가 이 규칙 id 를 포함할 때만 대상이 된다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [
      channel({ alcId: 1, alcUrl: URL_A, alcTypes: JSON.stringify(['other']) }),
      channel({ alcId: 2, alcUrl: URL_B, alcTypes: JSON.stringify(['r1']) }),
    ];

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[0])).toEqual([URL_B]);
  });

  it('채널이 0개여도 legacyUrls 로 발송한다 (M13 성립 조건)', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ legacyUrls: [URL_A] })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [];

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[0])).toEqual([URL_A]);
  });

  it('legacyUrls 와 채널 URL 이 같으면 1회만 발송한다 (중복 제거)', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ legacyUrls: [URL_A] })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel({ alcUrl: URL_A })];

    await svc.run();

    expect(post).toHaveBeenCalledTimes(1);
  });

  it('검증 탈락 URL 은 채널이든 legacyUrls 든 발송 0이고 droppedLegacyUrlCount 로 드러난다', async () => {
    const r = rule({
      evaluate: jest.fn(async () => [finding({ legacyUrls: ['https://evil.com/x'] })]),
    });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel({ alcUrl: 'http://10.0.0.5/hook' })];

    await svc.run();

    expect(post).not.toHaveBeenCalled();
    expect(Number(repo.settings.get(STATE_KEY.droppedLegacyUrlCount))).toBe(2);
  });
});

describe('AlertService.send — 유일한 발송 지점', () => {
  it('어떤 실패에도 throw 하지 않고 ok:false 를 반환한다 (3xx · 타임아웃 · 검증 탈락)', async () => {
    const { svc } = build();

    post.mockRejectedValueOnce({ message: 'Request failed', response: { status: 302 } });
    await expect(svc.send(URL_A, 'x')).resolves.toMatchObject({ ok: false, status: 302 });

    post.mockRejectedValueOnce({ message: 'timeout of 8000ms exceeded' });
    await expect(svc.send(URL_A, 'x')).resolves.toMatchObject({ ok: false });

    await expect(svc.send('https://evil.com/x', 'x')).resolves.toEqual({
      ok: false,
      error: 'url-not-allowed',
    });
  });

  it('빈 URL 로는 axios 를 부르지 않는다', async () => {
    const { svc } = build();
    await expect(svc.send('', 'x')).resolves.toEqual({ ok: false, error: 'no-destination' });
    expect(post).not.toHaveBeenCalled();
  });

  it('모든 발송에 maxRedirects:0 과 8초 타임아웃이 걸린다 (D9 · Santa R5)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    await svc.run();

    expect(post.mock.calls.length).toBeGreaterThan(0);
    for (const call of post.mock.calls) {
      expect(call[2]).toMatchObject({ maxRedirects: 0, timeout: 8000 });
      //* axios 의 timeout 만으로는 connect 단계가 안 잡힌다(실측 8s 지정에 21s 소요).
      //*  실제 상한은 agent 의 소켓 타임아웃이다 — 지우면 목적지 하나가 회차 전체를 분 단위로 끈다.
      expect(call[2].httpsAgent?.options?.timeout).toBe(8000);
    }
  });

  it('허용 호스트가 사설 대역으로 해석되면 발송하지 않는다 (주소 축)', async () => {
    const { svc } = build();
    svc.lookup = jest.fn(async () => [{ address: '169.254.169.254', family: 4 }]) as any;

    const r = await svc.send(URL_A, 'x');

    expect(r.ok).toBe(false);
    expect(post).not.toHaveBeenCalled();
  });

  it('복구 발송도 같은 send() 를 경유한다 — send 를 막으면 복구도 0건', async () => {
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::경고1`]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: '경고 본문' },
    });
    const sendSpy = jest.spyOn(svc, 'send').mockResolvedValue({ ok: true, status: 200 });

    await svc.run();

    expect(sendSpy).toHaveBeenCalledTimes(1);
    expect(post).not.toHaveBeenCalled();
  });
});

describe('AlertService — 손상된 억제 상태 정규화', () => {
  it('엔트리가 숫자여도 ok:false 로 읽고 복구를 보내지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({ [`r1::${URL_A}::경고1`]: Date.now() - 1000 } as any);

    const res = await svc.run();

    //* 값에 url 이 없으므로 복구 대상이 아니다 — 조용히 삭제된다(S35).
    expect(post).not.toHaveBeenCalled();
    expect(res.recovered).toBe(0);
    expect(repo.state()[`r1::${URL_A}::경고1`]).toBeUndefined();
  });

  it('키에 `::` 가 여러 번 섞여 있어도 파싱하지 않으므로 그대로 다뤄진다', async () => {
    const key = `r1::${URL_A}::a::b 경고`;
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({ [key]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: 'a::b 경고' } });

    const res = await svc.run();

    expect(res.recovered).toBe(1);
    expect(post).toHaveBeenCalledWith(URL_A, { text: '✅ [복구] a::b 경고' }, expect.any(Object));
  });
});

describe('AlertService — 지속성 적용 (M8)', () => {
  it('연속 임계가 2면 1회만 잡힌 경고는 발송하지 않고 2회째에 발송한다', async () => {
    const { svc, repo } = build({
      rules: [rule({ noise: { persistence: true, group: true } })],
    });
    repo.channels = [channel()];
    repo.settings.set('alert_persistence_streak', '2');

    await svc.run();
    expect(post).not.toHaveBeenCalled();

    await svc.run();
    expect(post).toHaveBeenCalledTimes(1);
  });
});

describe('AlertService.status — 8필드 (D4-2)', () => {
  it('활성 채널 수와 관측 카운터를 기계 판독 가능하게 낸다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1 }), channel({ alcId: 2, alcEnabled: 0 })];

    await svc.run();
    const s = await svc.status();

    expect(s).toMatchObject({
      enabled: true,
      channelCount: 1,
      sendFailureCount: 0,
      droppedLegacyUrlCount: 0,
      leaseHealthy: true,
    });
    expect(typeof s.lastRunAt).toBe('number');
  });
});

//=====================================================================================//
//  M2 — warn 다이제스트 (§D5) · DB 폴백 (§D7)
//=====================================================================================//

const warnRule = (): AlertRule =>
  rule({
    id: 'w1',
    noise: { severitySplit: true, group: true },
    evaluate: jest.fn(async () => [finding({ key: 'k1', level: 'warn', text: 'warn 본문' })]),
  });

describe('AlertService.run — warn 다이제스트 (§D5)', () => {
  it('간격 미달이면 큐에 쌓기만 하고 발송하지 않는다', async () => {
    const { svc, repo } = build({ rules: [warnRule()] });
    repo.channels = [channel()];
    repo.settings.set(STATE_KEY.digestSentAt, String(Date.now()));

    const res = await svc.run();

    expect(res.sent).toBe(0);
    expect(post).not.toHaveBeenCalled();
    expect(JSON.parse(repo.settings.get(STATE_KEY.digest)!)).toHaveLength(1);
  });

  it('간격에 도달하면 목적지당 1건으로 묶어 보내고 큐를 비운다', async () => {
    const { svc, repo } = build({ rules: [warnRule()] });
    repo.channels = [channel()];
    repo.settings.set(
      STATE_KEY.digest,
      JSON.stringify([{ key: 'w1::old', text: '어제 warn', t: 1, ruleId: 'w1' }]),
    );

    const res = await svc.run();

    expect(post).toHaveBeenCalledTimes(1);
    const text = post.mock.calls[0][1].text as string;
    expect(text).toContain('🟠 [요약] 누적 경고 2종');
    expect(text).toContain('어제 warn');
    expect(text).toContain('warn 본문');
    expect(res.sent).toBe(1);
    expect(JSON.parse(repo.settings.get(STATE_KEY.digest)!)).toEqual([]);
    expect(Number(repo.settings.get(STATE_KEY.digestSentAt))).toBeGreaterThan(0);
  });

  it('같은 키는 접어서 횟수로 센다 — 상시 재발 warn 이 본문 30줄을 독차지하면 안 된다', async () => {
    //* 신호원 부재·카운터 유휴 같은 warn 은 5분마다 재발해 하루 288건이 된다. 접지 않으면
    //*  slice(-500) 이 남기는 500건이 그 한 종류로 가득 차고, 본문은 상위 30줄만 렌더하므로
    //*  같은 문장 30줄이 나가고 진짜 warn 은 밀려 사라진다. 다이제스트는 하루 1건뿐이라(M9)
    //*  그 1건이 무의미해지는 것이 곧 warn 채널 전체의 상실이다.
    const { svc, repo } = build({ rules: [warnRule()] });
    repo.channels = [channel()];
    repo.settings.set(
      STATE_KEY.digest,
      JSON.stringify([
        { key: 'w1::k1', text: 'warn 본문', t: 1, ruleId: 'w1' },
        { key: 'w1::k1', text: 'warn 본문', t: 2, ruleId: 'w1' },
        { key: 'other::x', text: '다른 warn', t: 3, ruleId: 'other' },
      ]),
    );

    await svc.run();

    const text = post.mock.calls[0][1].text as string;
    expect(text).toContain('누적 경고 2종'); //* 4건이 아니라 2종
    expect(text).toContain('(3회)'); //* 기존 2 + 이번 회차 1
    expect(text).toContain('다른 warn'); //* 밀려나지 않는다
  });

  it('접힌 횟수는 회차를 넘어 누적된다 — 매번 1로 리셋되면 접기의 절반이 무의미하다', async () => {
    //* 저장된 항목은 이미 접힌 결과이므로 `count` 를 들고 온다. 병합에서 `+1` 로 고정하면
    //*  "하루 종일 계속됐다"가 매 회차 "1회 있었다"로 보고된다.
    const { svc, repo } = build({ rules: [warnRule()] });
    repo.channels = [channel()];
    repo.settings.set(STATE_KEY.digestSentAt, String(Date.now())); //* 간격 미달 — 큐잉만
    repo.settings.set(
      STATE_KEY.digest,
      JSON.stringify([{ key: 'w1::k1', text: 'warn 본문', t: 1, ruleId: 'w1', count: 7 }]),
    );

    await svc.run();

    const queue = JSON.parse(repo.settings.get(STATE_KEY.digest)!);
    expect(queue).toHaveLength(1);
    expect(queue[0].count).toBe(8); //* 7 + 이번 회차 1
  });

  it('전 목적지 실패면 큐를 비우지 않고 다음 회차에 재시도한다', async () => {
    post.mockRejectedValue(new Error('boom'));
    const { svc, repo } = build({ rules: [warnRule()] });
    repo.channels = [channel()];

    await svc.run();

    expect(JSON.parse(repo.settings.get(STATE_KEY.digest)!)).toHaveLength(1);
    expect(repo.settings.get(STATE_KEY.digestSentAt)).toBeUndefined();
  });

  it('ruleId 없는 구 항목(M1 이 넣은 것)은 전 채널로 폴백 발송한다', async () => {
    const { svc, repo } = build({ rules: [] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_B })];
    repo.settings.set(
      STATE_KEY.digest,
      JSON.stringify([{ key: 'legacy::k', text: '구 항목', t: 1 }]),
    );

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[0]).sort()).toEqual([URL_A, URL_B]);
  });
});

describe('AlertService.run — DB 폴백 (§D7)', () => {
  const snapshot = (over: Record<string, unknown> = {}) => ({
    t: Date.now() - 1000,
    enabled: true,
    dedupeMs: 30 * 60 * 1000,
    streak: 2,
    urls: [URL_A],
    ...over,
  });

  const recoveryCalls = () =>
    post.mock.calls.filter((c: any[]) => String(c[1].text).includes('DB 연결 복구'));

  it('DB 가 정상이면 폴백을 타지 않고 last-known-good 스냅샷을 남긴다', async () => {
    const { svc, repo, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];

    await svc.run();

    expect(signals.snapshot).toMatchObject({ enabled: true, urls: [URL_A] });
  });

  it('스냅샷 streak 은 폴백 전용 노브에서 온다 — persistenceStreak(기본 1) 을 쓰면 게이트가 no-op 이다', async () => {
    //* 회귀 방지. `alert_persistence_streak` 기본값은 UI9 때문에 1 이고(§D8 R3-F1),
    //*  1 이면 `streak < snapshot.streak` 이 첫 회차부터 거짓이라 §D7 R2-F2 의 순단 방어가
    //*  **기본 배포 상태에서** 통째로 사라진다. 두 값을 다르게 두고 어느 쪽이 실렸는지 본다.
    const { svc, repo, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    repo.settings.set('alert_persistence_streak', '1');
    repo.settings.set('alert_fallback_streak', '3');

    await svc.run();

    expect(signals.snapshot.streak).toBe(3);
  });

  it('스냅샷 urls 는 검증을 통과한 활성 채널만 담는다 (D9 경계 유지)', async () => {
    const { svc, repo, signals } = build({ rules: [rule()] });
    repo.channels = [
      channel({ alcId: 1, alcUrl: URL_A }),
      channel({ alcId: 2, alcUrl: 'https://evil.com/x' }),
      channel({ alcId: 3, alcUrl: URL_B, alcEnabled: 0 }),
    ];

    await svc.run();

    expect(signals.snapshot.urls).toEqual([URL_A]);
  });

  it('프로브 1회 실패로는 발송하지 않는다 — 순단이 곧바로 오탐이 되면 안 된다(R2-F2)', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot();

    const res = await svc.run();

    expect(res.skipped).toBe('db-down-streak');
    expect(post).not.toHaveBeenCalled();
  });

  it('연속 실패가 snapshot.streak 에 도달하면 발송한다', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot();

    await svc.run();
    const res = await svc.run();

    expect(res).toMatchObject({ sent: 1, skipped: 'db-down-fallback' });
    expect(post.mock.calls[0][1].text).toBe('🔴 [위험] DB 연결 실패');
    expect(signals.fbState).toMatchObject({ urls: [URL_A] });
  });

  it('발송 후 억제창 안에서는 재발송하지 않는다 (M4)', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot();

    await svc.run();
    await svc.run();
    const res = await svc.run();

    expect(res.skipped).toBe('db-down-suppressed');
    expect(post).toHaveBeenCalledTimes(1);
  });

  it('복구는 실제로 발송한 목적지에만 1회 나간다 (UI10)', async () => {
    const { svc, repo, health, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    health.dbOk = false;
    signals.snapshot = snapshot();
    await svc.run();
    await svc.run(); //* 여기서 발송

    health.dbOk = true;
    post.mockClear();
    await svc.run();
    expect(recoveryCalls()).toHaveLength(1);
    expect(signals.fbState).toBeNull();

    post.mockClear();
    await svc.run();
    expect(recoveryCalls()).toHaveLength(0); //* 두 번째는 나가지 않는다
  });

  it('복구도 kill switch 를 지킨다 — 장애 중 알림을 끄면 복구가 되살아나지 않는다 (UI16)', async () => {
    const { svc, repo, health, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    health.dbOk = false;
    signals.snapshot = snapshot();
    await svc.run();
    await svc.run(); //* 발송됨 — fbState 에 URL_A 가 남는다

    //* 장애 도중 관리자가 알림을 껐다 → PATCH 툼스톤이 스냅샷을 지운다(§D7 1).
    repo.settings.set('alert_enabled', '0');
    signals.snapshot = null;
    health.dbOk = true;
    post.mockClear();

    await svc.run();

    expect(recoveryCalls()).toHaveLength(0);
    expect(signals.fbState).not.toBeNull(); //* 유실이 아니라 보류 — 다시 켜면 나간다
  });

  it('복구는 현재 채널 목록으로 좁혀진다 — 삭제된 채널로는 나가지 않는다', async () => {
    const { svc, repo, health, signals } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_B })];
    health.dbOk = false;
    signals.snapshot = snapshot({ urls: [URL_A, URL_B] });
    await svc.run();
    await svc.run(); //* 두 목적지 모두 발송됨

    //* 장애 도중 URL_B 채널을 삭제했다 → 다음 정상 회차의 스냅샷에는 URL_A 만 남는다.
    signals.snapshot = snapshot({ urls: [URL_A] });
    health.dbOk = true;
    post.mockClear();

    await svc.run();

    expect(recoveryCalls().map((c: any[]) => c[0])).toEqual([URL_A]);
  });

  it('미발송 상태에서는 복구도 나가지 않는다', async () => {
    const { svc, repo, health, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    health.dbOk = false;
    signals.snapshot = snapshot();
    await svc.run(); //* streak 미달 — 무발송

    health.dbOk = true;
    post.mockClear();
    await svc.run();

    expect(recoveryCalls()).toHaveLength(0);
  });

  it('스냅샷이 없으면 무발송이다 — 보낼 목적지를 알 수 없다', async () => {
    const { svc, health } = build({ rules: [rule()] });
    health.dbOk = false;

    expect((await svc.run()).skipped).toBe('db-down-no-snapshot');
    expect(post).not.toHaveBeenCalled();
  });

  it('알림 OFF 스냅샷이면 fail-closed 다 (UI16 · R2-F3)', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot({ enabled: false, streak: 1 });

    expect((await svc.run()).skipped).toBe('db-down-disabled');
    expect(post).not.toHaveBeenCalled();
  });

  it('채널이 지워진 스냅샷은 보낼 곳이 없어 무발송이다 (R2-F3)', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot({ urls: [], streak: 1 });

    expect((await svc.run()).skipped).toBe('db-down-no-destination');
    expect(post).not.toHaveBeenCalled();
  });

  it('전체 OFF 조기 반환 직전에 스냅샷 툼스톤을 남긴다 (보조 그물, §D7 2)', async () => {
    const { svc, repo, signals } = build({ rules: [rule()] });
    signals.snapshot = snapshot();
    repo.settings.set('alert_enabled', '0');

    await svc.run();

    expect(signals.snapshot).toBeNull();
  });

  it('폴백 리스를 못 잡은 회차는 상태를 읽지도 쓰지도 않는다 (R3-F4)', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot();
    signals.fbLease = 'other-run'; //* 겹친 회차가 이미 보유

    const res = await svc.run();

    expect(res.skipped).toBe('db-down-lease-not-acquired');
    expect(signals.fbStreak).toBe(0);
    expect(post).not.toHaveBeenCalled();
  });

  it('DB 복구 시 연속카운트가 0 으로 리셋된다', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    signals.snapshot = snapshot();
    await svc.run();
    expect(signals.fbStreak).toBe(1);

    health.dbOk = true;
    await svc.run();

    expect(signals.fbStreak).toBe(0);
  });
});

//=================== M3 — 자기감시 관측 계층 ===================//

describe('AlertService.run — 런너 틱 (M3 §D2)', () => {
  const tick = (repo: FakeRepo) => {
    const raw = repo.settings.get(STATE_KEY.tick);
    return raw ? JSON.parse(raw) : null;
  };

  it('전체 OFF 회차에도 틱을 찍는다 — 끈 상태에서 런너가 죽으면 차이가 0 이 되면 안 된다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.settings.set('alert_enabled', '0');

    const res = await svc.run();

    expect(res.skipped).toBe('disabled');
    //* `lastRunAt` 은 여전히 안 찍힌다 — 그 필드의 의미를 바꾸지 않는 것이 §D2 의 전제다.
    expect(repo.settings.get(STATE_KEY.lastRunAt)).toBeUndefined();
    expect(tick(repo).at).toBeGreaterThan(0);
    expect(tick(repo).samples).toBe(1);
  });

  it('리스 미획득 회차는 틱을 찍지 않는다 — 죽은 런너가 살아 있는 것처럼 보이면 안 된다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.lease = `${Date.now() + 60_000}:other-run`;

    await svc.run();

    expect(tick(repo)).toBeNull();
  });

  it('간격 링을 통째로 보관하고 상한 5개를 넘지 않는다 (R3-F1)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    for (let i = 0; i < 8; i++) await svc.run();

    const t = tick(repo);
    expect(t.samples).toBe(8);
    expect(Array.isArray(t.gaps)).toBe(true);
    //* 요약값 하나가 아니라 원본이다 — 그래야 옛 간격이 창에서 빠졌는지 알 수 있다.
    expect(t.gaps.length).toBeLessThanOrEqual(5);
  });
});

describe('AlertService.run — 스누즈 (M3 §D7)', () => {
  const snoozeFor = (repo: FakeRepo, minutes: number, ruleId?: string) =>
    repo.settings.set(
      ruleId ? `alert_snooze:${ruleId}` : 'alert_snooze',
      JSON.stringify({ until: Date.now() + minutes * 60_000, by: 1, at: Date.now() }),
    );
  const log = (repo: FakeRepo) => JSON.parse(repo.settings.get(STATE_KEY.snoozeLog) ?? '{}');
  const stats = (repo: FakeRepo) => JSON.parse(repo.settings.get(STATE_KEY.ruleStat) ?? '{}');

  it('스누즈 회차는 발송 0 이지만 평가는 계속되어 경과가 snoozeLog 에 남는다 (R1-F3)', async () => {
    const r = rule();
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    snoozeFor(repo, 30);

    const res = await svc.run();

    expect(res.sent).toBe(0);
    expect(post).not.toHaveBeenCalled();
    //* 평가 자체는 돌았다 — 그것이 "전달 차단이지 평가 중단이 아니다"의 관측 지점이다.
    expect(r.evaluate).toHaveBeenCalled();
    expect(Object.keys(log(repo))).toEqual(['r1::경고1']);
    expect(log(repo)['r1::경고1'].text).toBe('경고 본문');
    expect(stats(repo).r1.skip).toBe('snoozed');
    expect(stats(repo).r1.checkedAt).toBeGreaterThan(0);
  });

  it('스누즈가 허위 복구를 만들지 않는다 — 억제 상태를 보존한다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::경고1`]: { t: Date.now() - 1, ok: true, since: 0, url: URL_A, text: '경고 본문' },
    });
    snoozeFor(repo, 30);

    const res = await svc.run();

    expect(res.recovered).toBe(0);
    expect(post).not.toHaveBeenCalled();
    expect(Object.keys(repo.state())).toContain(`r1::${URL_A}::경고1`);
  });

  it('스누즈 중에도 streak 이 진행돼 해제 첫 회차에 지속분이 발송된다 (R2-F3)', async () => {
    const r = rule({ noise: { persistence: true } });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    repo.settings.set('alert_persistence_streak', '2');
    snoozeFor(repo, 30);

    await svc.run();
    expect(JSON.parse(repo.settings.get(STATE_KEY.streak)!)['r1::경고1']).toBe(1);
    //* 동결됐다면 여기서 1 에 멈춘다.
    await svc.run();
    expect(JSON.parse(repo.settings.get(STATE_KEY.streak)!)['r1::경고1']).toBe(2);
    expect(post).not.toHaveBeenCalled();

    repo.settings.delete('alert_snooze');
    const res = await svc.run();

    expect(res.sent).toBe(1);
  });

  it('해제 회차에 해소분은 요약 1건, 지속분은 정상 발송으로 소유권이 넘어간다', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ key: 'A', text: '문제 A' })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    snoozeFor(repo, 30);
    await svc.run();

    //* 스누즈 중 A 가 해소되고 B 가 새로 발생한다.
    (r.evaluate as jest.Mock).mockResolvedValue([finding({ key: 'B', text: '문제 B' })]);
    await svc.run();
    expect(log(repo)['r1::A'].resolvedAt).toBeGreaterThan(0);

    repo.settings.delete('alert_snooze');
    post.mockClear();
    const res = await svc.run();

    const texts = post.mock.calls.map((c: any[]) => String(c[1].text));
    expect(texts.filter((t: string) => t.includes('스누즈 중 경과'))).toHaveLength(1);
    expect(texts.find((t: string) => t.includes('스누즈 중 경과'))).toContain('문제 A');
    expect(texts.some((t: string) => t.includes('문제 B'))).toBe(true);
    //* 지속분은 `alert:state` 로 소유권이 넘어가 중복 요약이 나가지 않는다.
    expect(log(repo)['r1::B']).toBeUndefined();
    expect(res.sent).toBeGreaterThan(0);
  });

  it('요약 발송이 실패하면 snoozeLog 항목이 유지돼 다음 회차에 재시도된다 (R3-F3)', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ key: 'A', text: '문제 A' })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    snoozeFor(repo, 30);
    await svc.run();
    (r.evaluate as jest.Mock).mockResolvedValue([]);
    await svc.run();

    repo.settings.delete('alert_snooze');
    post.mockRejectedValue(new Error('webhook down'));
    await svc.run();

    expect(log(repo)['r1::A']).toBeDefined();
    expect(log(repo)['r1::A'].deliveredUrls ?? []).toEqual([]);
  });

  it('평가 실패 회차는 거짓 해소를 만들지 않는다 (R4-F3)', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ key: 'A' })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    snoozeFor(repo, 30);
    await svc.run();

    (r.evaluate as jest.Mock).mockRejectedValue(new Error('boom'));
    await svc.run();

    //* finding 0건이지만 "안 잡혔다"가 아니라 "볼 수 없었다"다.
    expect(log(repo)['r1::A'].resolvedAt).toBeUndefined();
  });

  it('해제 회차에 규칙이 throw 하면 항목을 지우지도 보내지도 않는다 (R5-F4)', async () => {
    const r = rule({ evaluate: jest.fn(async () => [finding({ key: 'A' })]) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    snoozeFor(repo, 30);
    await svc.run();

    repo.settings.delete('alert_snooze');
    (r.evaluate as jest.Mock).mockRejectedValue(new Error('boom'));
    post.mockClear();
    await svc.run();

    expect(log(repo)['r1::A']).toBeDefined();
    expect(post).not.toHaveBeenCalled();
  });

  it('전역 스누즈 회차는 대기 다이제스트도 막고 스냅샷에 snoozeUntil 을 싣는다 (R8-F4 · R8-F5)', async () => {
    const { svc, repo, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    repo.settings.set(
      STATE_KEY.digest,
      JSON.stringify([{ key: 'r1::w', text: 'warn 하나', t: Date.now() - 1, ruleId: 'r1' }]),
    );
    snoozeFor(repo, 30);

    await svc.run();

    expect(post).not.toHaveBeenCalled();
    //* 큐는 보존된다 — 해제 후 정상 간격에 나간다.
    expect(JSON.parse(repo.settings.get(STATE_KEY.digest)!)).toHaveLength(1);
    //* 툼스톤이 아니다 — 만료 시각을 실은 정상 기록이라 만료 후 폴백이 침묵하지 않는다.
    expect(signals.snapshot).not.toBeNull();
    expect(signals.snapshot.snoozeUntil).toBeGreaterThan(Date.now());
  });

  it('전역 스누즈가 DB 폴백 발송도 막는다 (R8-F4)', async () => {
    const { svc, repo, health, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    health.dbOk = false;
    signals.fbStreak = 5;
    signals.snapshot = {
      t: Date.now() - 1000,
      enabled: true,
      dedupeMs: 30 * 60 * 1000,
      streak: 2,
      urls: [URL_A],
      snoozeUntil: Date.now() + 30 * 60_000,
    };

    const res = await svc.run();

    expect(res.skipped).toBe('db-down-snoozed');
    expect(post).not.toHaveBeenCalled();
  });
});

describe('AlertService.run — 규칙 기록 · 발송 이력 (M3 §D3 · §D4)', () => {
  const stats = (repo: FakeRepo) => JSON.parse(repo.settings.get(STATE_KEY.ruleStat) ?? '{}');

  it('규칙 throw 회차에 checkedAt 이 밀리지 않고 error 가 채워진다', async () => {
    const r = rule();
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    await svc.run();
    const first = stats(repo).r1.checkedAt;
    expect(first).toBeGreaterThan(0);

    (r.evaluate as jest.Mock).mockRejectedValue(new Error('boom'));
    await svc.run();

    //* 갱신되면 화면이 "정상 점검 중, 이상 없음"으로 보인다 — 조용한 미탐이다.
    expect(stats(repo).r1.checkedAt).toBe(first);
    expect(stats(repo).r1.error).toContain('boom');
  });

  it('발송 성공이 그 규칙의 sentAt·firedAt 에 귀속된다', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    await svc.run();

    expect(stats(repo).r1.sentAt).toBeGreaterThan(0);
    expect(stats(repo).r1.firedAt).toBeGreaterThan(0);
  });

  it('다이제스트 발송이 타입별 시각을 갱신하고 이력은 물리 발송 1행만 남긴다 (R7-F2 · R8-F3)', async () => {
    const r = rule({
      severity: 'warn',
      noise: { severitySplit: true },
      evaluate: jest.fn(async () => []),
    });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel()];
    repo.settings.set(
      STATE_KEY.digest,
      JSON.stringify([
        { key: 'r1::w1', text: 'warn 1', t: Date.now() - 1, ruleId: 'r1' },
        { key: 'r1::w2', text: 'warn 2', t: Date.now() - 1, ruleId: 'r1' },
      ]),
    );

    await svc.run();

    expect(stats(repo).r1.sentAt).toBeGreaterThan(0);
    //* 규칙마다 1행이면 다이제스트 1회가 직전 실패를 창 밖으로 밀어 실패율을 표백한다.
    expect(repo.history.filter((h) => h.kind === 'digest')).toHaveLength(1);
  });

  it('이력 기록 실패가 발송 결과를 바꾸지 않고 구멍 표지를 남긴다 (R5-F2)', async () => {
    const { svc, repo, signals } = build({ rules: [rule()] });
    repo.channels = [channel()];
    repo.insertHistoryThrows = true;

    const res = await svc.run();

    expect(res.sent).toBe(1);
    expect(signals.historyGap).not.toBeNull();
  });

  /**
   * 원장은 추가 전용이라 지우는 주체가 없으면 무한히 는다(§D1). 정리는 회차 맨 끝에 얹혀
   * 돌되 **발송을 바꾸지 않는다** — 원장 청소가 알림을 막으면 주객이 전도된다(UI16).
   */
  it('보존기간 정리가 회차마다 돌고, 실패해도 발송 결과를 바꾸지 않는다 (§D1)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel()];

    const ok = await svc.run();
    expect(repo.pruneHistory).toHaveBeenCalledTimes(1);
    expect(repo.pruneHistory.mock.calls[0][0]).toBe(90); //* 기본 보존일

    repo.pruneHistoryThrows = true;
    repo.history.length = 0;
    const failed = await svc.run();

    expect(ok.skipped).toBeNull();
    expect(failed.skipped).toBeNull(); //* 정리 실패가 회차를 error 로 떨어뜨리지 않는다
  });

  it('이력에 원문 URL 이 없고 채널이 귀속된다 (R5-F1 · R4-F4)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 9, alcUrl: URL_A })];

    await svc.run();

    const row = repo.history.find((h) => h.kind === 'fire');
    expect(row.channelId).toBe(9);
    expect(JSON.stringify(repo.history)).not.toContain(URL_A);
  });

  /**
   * R5-F1 은 목적지 컬럼만 막았다. 본문에 원문 URL 이 실려 오면 그대로 `alh_text` 에 남고
   * `GET /monitor` 가 다시 돌려준다 — `POST /admin/alert/test` 는 임의 `text` 를 받으므로
   * 관리자가 연결 확인용으로 웹훅 URL 을 붙여넣는 순간 자격증명이 DB·백업·응답으로 퍼진다.
   */
  it('본문에 섞인 원문 웹훅 URL 도 이력에 남지 않는다 (R5-F1 우회 차단)', async () => {
    const { svc, repo } = build({
      rules: [rule({ evaluate: jest.fn(async () => [finding({ text: `확인용 ${URL_A} 끝` })]) })],
    });
    repo.channels = [channel({ alcId: 9, alcUrl: URL_B })];

    await svc.run();

    const row = repo.history.find((h) => h.kind === 'fire');
    expect(row.text).not.toContain(URL_A);
    expect(row.text).toContain('확인용'); //* 본문 자체는 남는다 — 통째로 지우지 않는다
    expect(JSON.stringify(repo.history)).not.toContain(URL_A);
  });

  it('스킴 대소문자·허용목록 축소로 마스킹을 빗나가지 않는다 (santa R2)', async () => {
    const upper = 'HTTPS://hooks.slack.com/services/T/B/SECRET';
    const { svc, repo } = build({
      //* 허용 접미사에서 Slack 을 뺀 상태 — 보낼 수 없게 됐다고 자격증명이 아니게 되지 않는다.
      hostAllow: 'chat.googleapis.com',
      rules: [rule({ evaluate: jest.fn(async () => [finding({ text: `점검 ${upper} 완료` })]) })],
    });
    repo.channels = [channel({ alcId: 9, alcUrl: URL_A })];

    await svc.run();

    const row = repo.history.find((h) => h.kind === 'fire');
    expect(row.text).not.toContain('SECRET');
    expect(row.text).toContain('완료'); //* 뒤따르는 한글 산문을 삼키지 않는다
  });

  it('비밀이 아닌 URL 은 이력 본문에 그대로 남는다 — 조사에 쓸모를 남긴다', async () => {
    const healthUrl = 'https://s-api.insuniverse.com/health';
    const { svc, repo } = build({
      rules: [rule({ evaluate: jest.fn(async () => [finding({ text: `점검 ${healthUrl}` })]) })],
    });
    repo.channels = [channel({ alcId: 9, alcUrl: URL_B })];

    await svc.run();

    expect(repo.history.find((h) => h.kind === 'fire').text).toContain(healthUrl);
  });

  it('같은 URL 을 가진 채널 둘은 오귀속 대신 미귀속이다 (R6-F5)', async () => {
    const { svc, repo } = build({ rules: [rule()] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_A })];

    await svc.run();

    expect(repo.history.find((h) => h.kind === 'fire').channelId).toBeNull();
  });

  it('복구 발송도 채널에 귀속된다 (R4-F4)', async () => {
    const r = rule({ evaluate: jest.fn(async () => []) });
    const { svc, repo } = build({ rules: [r] });
    repo.channels = [channel({ alcId: 9, alcUrl: URL_A })];
    repo.putState({
      [`r1::${URL_A}::경고1`]: { t: Date.now() - 1, ok: true, since: 0, url: URL_A, text: '경고 본문' },
    });

    await svc.run();

    const row = repo.history.find((h) => h.kind === 'recover');
    expect(row.channelId).toBe(9);
    expect(row.ruleId).toBe('r1');
  });

  it('DB 폴백 진입 자체가 이력 구멍 표지를 남긴다 — 전 목적지 실패여도 (R6-F4)', async () => {
    const { svc, health, signals } = build({ rules: [rule()] });
    health.dbOk = false;
    //* 리스도 못 잡은 회차 — 그래도 그 구간은 이력에 못 들어간다.
    signals.fbLease = 'other-run';

    await svc.run();

    expect(signals.historyGap).not.toBeNull();
  });
});

describe('AlertService.run — 해소 문면 (상태가 심각도를 대체한다)', () => {
  it('발화 원문의 심각도 마커를 걷어내고 지속 시간을 붙인다', async () => {
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::경고1`]: {
        t: Date.now() - 1000,
        ok: true,
        since: Date.now() - 9 * 60 * 1000,
        url: URL_A,
        text: "🔴 [위험] 'fax-status' 미실행 (기대 1분 · 유예 2배)",
      },
    });

    await svc.run();

    expect(post).toHaveBeenCalledWith(
      URL_A,
      { text: "✅ [복구] 'fax-status' 미실행 (기대 1분 · 유예 2배) · 9분 만에 해소" },
      expect.any(Object),
    );
  });

  /**
   * `since` 를 `t` 로 대신할 수 없다는 반사실. `t` 는 중복억제 창마다 갱신되므로 그것으로 재면
   * 3시간짜리 장애가 마지막 재발송 이후 경과분으로 나간다.
   */
  it('재발송으로 t 가 갱신돼도 지속 시간은 최초 발화부터 잰다', async () => {
    const start = Date.now() - 3 * 60 * 60 * 1000;
    const evaluate = jest
      .fn()
      .mockResolvedValueOnce([finding()]) //* 1회차 — 여전히 잡힌다
      .mockResolvedValueOnce([]); //* 2회차 — 해소
    const { svc, repo } = build({ rules: [rule({ evaluate })] });
    repo.channels = [channel()];
    //* 억제창(기본 30분)보다 오래된 엔트리라 1회차에 재발송되고 `t` 가 now 로 갱신된다.
    repo.putState({
      [`r1::${URL_A}::경고1`]: { t: start, ok: true, since: start, url: URL_A, text: '경고 본문' },
    });

    await svc.run();
    const after = repo.state()[`r1::${URL_A}::경고1`];
    expect(after.t).toBeGreaterThan(start); //* 재발송이 실제로 일어났다
    expect(after.since).toBe(start); //* 그래도 발화 시각은 이월된다

    post.mockClear();
    await svc.run(); //* 해소 회차

    expect(post.mock.calls[0][1].text).toBe('✅ [복구] 경고 본문 · 3시간 만에 해소');
  });

  it('한 목적지의 해소 여러 건은 한 통으로 묶인다 — 발송이 묶였으면 해소도 묶인다', async () => {
    const { svc, repo } = build({ rules: [rule({ evaluate: jest.fn(async () => []) })] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::a`]: {
        t: Date.now() - 1000,
        ok: true,
        since: 0,
        url: URL_A,
        text: "🔴 [위험] 'a' 하트비트 없음",
      },
      [`r1::${URL_A}::b`]: {
        t: Date.now() - 1000,
        ok: true,
        since: 0,
        url: URL_A,
        text: "🔴 [위험] 'b' 하트비트 없음",
      },
    });

    const res = await svc.run();

    expect(post).toHaveBeenCalledTimes(1);
    expect(post.mock.calls[0][1].text).toBe(
      [
        '✅ [복구] 운영 알림 2건 해소',
        '',
        '■ 규칙1 (2건)',
        "· 'a' 하트비트 없음",
        "· 'b' 하트비트 없음",
      ].join('\n'),
    );
    //* 통수가 아니라 해소된 경고 수를 센다 — 묶음이 카운터를 줄이면 안 된다.
    expect(res.recovered).toBe(2);
    expect(Object.keys(repo.state())).toHaveLength(0);
  });

  it('묶음을 끈 규칙의 해소는 1건 1통이다', async () => {
    const solo = rule({ noise: { group: false }, evaluate: jest.fn(async () => []) });
    const { svc, repo } = build({ rules: [solo] });
    repo.channels = [channel()];
    repo.putState({
      [`r1::${URL_A}::a`]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: '경고 A' },
      [`r1::${URL_A}::b`]: { t: Date.now() - 1000, ok: true, since: 0, url: URL_A, text: '경고 B' },
    });

    await svc.run();

    expect(post.mock.calls.map((c: any[]) => c[1].text)).toEqual([
      '✅ [복구] 경고 A',
      '✅ [복구] 경고 B',
    ]);
  });
});

/**
 * 테스트 발송 안전 계약 (§D5 · §D5b · §D5c).
 *
 * 이름은 게이트 `#13` 이 **개별 단언의 `title`** 로 찾는다 — `fullName` 이 아니라 `title` 인 이유는
 * 계약 이름을 단 `describe` 로 무관한 테스트를 감싸는 우회를 막기 위해서다.
 * 여섯째 계약 `목적지 URL 정확 일치` 는 전용 spec 에 단독으로 산다.
 */
describe('AlertService.sendTest — 안전 계약', () => {
  it('sendTest 계약: channelId 교집합 — 한 채널을 찍은 요청이 전 채널로 퍼지지 않는다', async () => {
    const { svc, repo } = build({ rules: [rule({ id: 'r1' })] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A }), channel({ alcId: 2, alcUrl: URL_B })];

    const out = await svc.sendTest({ ruleId: 'r1', channelId: 2 });

    expect(post.mock.calls.map((c: any[]) => String(c[0]))).toEqual([URL_B]);
    expect(out.results.map((r) => r.channelId)).toEqual([2]);
  });

  /**
   * §D6 재판정 — 테스트 문면이 **실제 발화 문면**이 되었으므로 구분 수단은 배너 하나다.
   * 배너가 첫 줄이 아니면 메신저 미리보기에 안 잡혀 수신자가 진짜 장애로 읽는다.
   */
  it('sendTest 계약: 첫 줄이 테스트 배너이고 그 아래가 규칙의 실제 문면이다', async () => {
    const { svc, repo } = build({
      rules: [rule({ id: 'r1', sampleText: '🔴 [위험] API 5xx 12/230건 (최근 5분)' })],
    });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A })];

    await svc.sendTest({ ruleId: 'r1' });

    const text = String(post.mock.calls[0][1].text);
    expect(text.split('\n')[0]).toBe(TEST_BANNER);
    expect(text).toContain('🔴 [위험] API 5xx 12/230건 (최근 5분)');
  });

  it('sendTest 계약: sampleText 가 없는 규칙은 연결 확인 문면으로 떨어지고 배너는 그대로다', async () => {
    const { svc, repo } = build({ rules: [rule({ id: 'r1', name: '규칙1' })] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A })];

    await svc.sendTest({ ruleId: 'r1' });

    const text = String(post.mock.calls[0][1].text);
    expect(text.split('\n')[0]).toBe(TEST_BANNER);
    expect(text).toContain('규칙1 — 채널 연결 확인');
  });

  it('sendTest 계약: 제외 사유 코드 8종 — 여덟 지점이 각각 자기 이름으로 드러난다', async () => {
    const { svc, repo, providerSvc } = build({
      rules: [rule({ id: 'r1' }), rule({ id: 'treat-monitor', name: '진료 모니터링' })],
    });
    providerSvc.rows = [{ tprKey: 'octover', tprEnabled: 1, tprWebhookUrl: '' }];

    //* 채널 차원 6종 — disabled · malformed-types · not-subscribed · empty-url · duplicate-url · url-rejected
    repo.channels = [
      channel({ alcId: 1, alcUrl: URL_A, alcEnabled: 0 }),
      channel({ alcId: 2, alcUrl: URL_B, alcTypes: '{깨진 JSON' }),
      channel({ alcId: 3, alcUrl: URL_B, alcTypes: JSON.stringify(['other']) }),
      channel({ alcId: 4, alcUrl: '   ' }),
      channel({ alcId: 5, alcUrl: URL_A }),
      channel({ alcId: 6, alcUrl: URL_A }),
      channel({ alcId: 7, alcUrl: 'https://evil.example.com/x' }),
    ];
    const a = await svc.sendTest({ ruleId: 'r1' });

    //* 힌트 선점 — 힌트 채널이 잡히면 나머지는 미구독이 아니라 우선순위 탈락이다.
    repo.channels = [
      channel({ alcId: 10, alcName: 'octover', alcUrl: URL_A }),
      channel({ alcId: 11, alcName: 'general', alcUrl: URL_B }),
    ];
    const b = await svc.sendTest({ ruleId: 'treat-monitor', channelHint: 'octover' });

    //* 요청 차원 — 붙일 채널이 없어 channelId 가 null 인 유일한 코드다.
    const c = await svc.sendTest({ ruleId: 'treat-monitor', channelHint: '없는벤더' });

    const seen = new Set(
      [...a.excluded, ...b.excluded, ...c.excluded].map((e) => e.reason),
    );
    expect([...seen].sort()).toEqual(
      [
        'disabled',
        'duplicate-url',
        'empty-url',
        'hint-preempted',
        'malformed-types',
        'not-subscribed',
        'unknown-hint',
        'url-rejected',
      ].sort(),
    );
    //* `unknown-hint` 만 채널이 없다 — 그 사실이 타입에도 응답에도 그대로 실린다.
    expect(c.excluded).toEqual([{ channelId: null, reason: 'unknown-hint' }]);
  });

  it('sendTest 계약: 미등록 ruleId 거부 — 카탈로그에 없는 종류는 400 이다', async () => {
    const { svc, repo } = build({ rules: [rule({ id: 'r1' })] });
    repo.channels = [channel({ alcId: 1, alcUrl: URL_A })];

    await expect(svc.sendTest({ ruleId: 'nope' })).rejects.toThrow('등록되지 않은');
    expect(post).not.toHaveBeenCalled();
  });

  it('sendTest 계약: unknown-hint 사전 차단 — destinationsFor 에 넘기기 전에 막는다', async () => {
    const { svc, repo, providerSvc } = build({
      rules: [rule({ id: 'treat-monitor', name: '진료 모니터링' })],
    });
    providerSvc.rows = [{ tprKey: 'octover', tprEnabled: 1, tprWebhookUrl: '' }];
    //* 사후에 걸면 힌트 분기가 못 잡고 기본 채널 선택으로 조용히 떨어져 **이 채널로 나간다**.
    repo.channels = [channel({ alcId: 1, alcName: 'general', alcUrl: URL_A })];

    const out = await svc.sendTest({ ruleId: 'treat-monitor', channelHint: '없는벤더' });

    expect(post).not.toHaveBeenCalled();
    expect(out.results).toEqual([]);
  });
});
