Ctrl K
ComunidadGuías…

voice-transcription/index.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { test } from "node:test";
import assert from "node:assert/strict";
import type {
  PluginContext,
  HookContext,
  HookResult,
  IncomingMessage,
  PluginNetResponse,
} from "../types/openwa";
import { VoiceTranscriptionPlugin } from "./index.ts";

function voiceMsg(): IncomingMessage {
  return {
    id: "m1",
    from: "x@s.whatsapp.net",
    to: "y@s.whatsapp.net",
    chatId: "c1@s.whatsapp.net",
    body: "",
    type: "voice",
    timestamp: 0,
    fromMe: false,
    isGroup: false,
    media: {
      mimetype: "audio/ogg; codecs=opus",
      data: Buffer.from("AUDIO").toString("base64"),
      sizeBytes: 5,
    },
  };
}

function makeStorage() {
  const m = new Map<string, unknown>();
  return {
    get: async (k: string) => (m.has(k) ? m.get(k) : null),
    set: async (k: string, v: unknown) => void m.set(k, v),
    delete: async (k: string) => void m.delete(k),
    list: async () => [...m.keys()],
  };
}

function fakeContext(opts: {
  net: { fetch: (...a: unknown[]) => Promise<PluginNetResponse> };
  config?: Record<string, unknown>;
}) {
  let hook:
    | ((ctx: HookContext<IncomingMessage>) => Promise<HookResult>)
    | undefined;
  let priority: number | undefined;
  const ctx = {
    pluginId: "voice-transcription",
    manifest: { id: "voice-transcription" },
    config: {
      sttBaseUrl: "http://stt",
      deliveryWebhookUrl: "http://hook.local/in",
      ...opts.config,
    },
    logger: { log() {}, debug() {}, warn() {}, error() {} },
    storage: makeStorage(),
    registerHook: (
      event: string,
      handler: (c: HookContext<IncomingMessage>) => Promise<HookResult>,
      p?: number,
    ) => {
      if (event === "message:received") {
        hook = handler;
        priority = p;
      }
    },
    messages: {},
    engine: {},
    net: opts.net,
    hookManager: {},
  } as unknown as PluginContext;
  return { ctx, getHook: () => hook, getPriority: () => priority };
}

const engineCtx = (data: IncomingMessage): HookContext<IncomingMessage> => ({
  event: "message:received",
  source: "Engine",
  sessionId: "s1",
  timestamp: new Date(0),
  data,
});

test("the message:received hook returns {continue:true} without awaiting STT (non-blocking)", async () => {
  // ctx.net.fetch never resolves. If the hook awaited the STT round-trip, the call below would hang.
  const net = { fetch: () => new Promise<PluginNetResponse>(() => {}) };
  const { ctx, getHook } = fakeContext({ net });
  const plugin = new VoiceTranscriptionPlugin();
  await plugin.onEnable(ctx);
  const hook = getHook();
  assert.ok(hook, "plugin must register a message:received hook");

  const hookCall = hook(engineCtx(voiceMsg()));
  const winner = await Promise.race([
    hookCall.then((r) => ({ result: r })),
    new Promise<string>((res) => setTimeout(() => res("timeout"), 250)),
  ]);
  assert.notEqual(
    winner,
    "timeout",
    "hook blocked on STT instead of returning immediately",
  );
  assert.deepEqual((winner as { result: HookResult }).result, {
    continue: true,
  });
});

// Transformer band (PLUGIN-STANDARD.md "Co-installation"): runs after the observers, before any
// responder. Must never claim — this plugin delivers a transcript out-of-band, so the contact's
// original message still needs whatever bot would otherwise answer it.
test("registers in the transformer band and never claims", async () => {
  const net = { fetch: () => new Promise<PluginNetResponse>(() => {}) };
  const { ctx, getHook, getPriority } = fakeContext({ net });
  const plugin = new VoiceTranscriptionPlugin();
  await plugin.onEnable(ctx);
  assert.equal(getPriority(), 40);
  const result = await getHook()!(engineCtx(voiceMsg()));
  assert.equal(
    result.continue,
    true,
    "a transcriber must never take the message from a responder",
  );
});

test("does not start transcription for non-Engine sources", async () => {
  let fetched = false;
  const net = {
    fetch: async () => {
      fetched = true;
      return {
        ok: true,
        status: 200,
        statusText: "",
        headers: {},
        body: '{"text":"x"}',
      } as PluginNetResponse;
    },
  };
  const { ctx, getHook } = fakeContext({ net });
  const plugin = new VoiceTranscriptionPlugin();
  await plugin.onEnable(ctx);
  const result = await getHook()!({
    ...engineCtx(voiceMsg()),
    source: "Dashboard",
  });
  assert.deepEqual(result, { continue: true });
  await new Promise((r) => setImmediate(r)); // let any floated work run a tick
  assert.equal(fetched, false);
});

// Regression: the message hook must rebuild the coordinator when a coordinator-affecting config field
// changes (per-session override), and must NOT rebuild it when the config is unchanged (preserving the
// STT provider's circuit-breaker state across messages for the same backend).
test("coordinator rebuilds when coordinator-affecting config changes, is reused when unchanged", async () => {
  // Hold config by reference (fakeContext spreads it into a new object, hiding mutations). A custom
  // context mirrors what the host does: ctx.config is a live view of the firing session's resolved slice.
  const config: Record<string, unknown> = {
    sttBaseUrl: "http://stt-a",
    deliveryWebhookUrl: "http://hook.local/in",
  };
  let hook:
    | ((ctx: HookContext<IncomingMessage>) => Promise<HookResult>)
    | undefined;
  const net = {
    fetch: async () =>
      ({
        ok: true,
        status: 200,
        statusText: "",
        headers: {},
        body: '{"text":"x"}',
      }) as PluginNetResponse,
  };
  const ctx = {
    pluginId: "voice-transcription",
    manifest: { id: "voice-transcription" },
    config, // held by reference — mutations are visible to configSignature on the next hook fire
    logger: { log() {}, debug() {}, warn() {}, error() {} },
    storage: makeStorage(),
    registerHook: (
      _event: string,
      handler: (c: HookContext<IncomingMessage>) => Promise<HookResult>,
    ) => {
      hook = handler;
    },
    messages: {},
    engine: {},
    net,
    hookManager: {},
  } as unknown as PluginContext;
  const plugin = new VoiceTranscriptionPlugin();
  await plugin.onEnable(ctx);
  assert.ok(hook, "hook registered");
  const coordAfterEnable = (plugin as unknown as { coordinator: unknown })
    .coordinator;
  assert.ok(coordAfterEnable, "coordinator built at enable");

  // Fire a hook WITHOUT changing config → coordinator must be reused (circuit breaker preserved).
  hook!(engineCtx(voiceMsg()));
  const coordUnchanged = (plugin as unknown as { coordinator: unknown })
    .coordinator;
  assert.strictEqual(
    coordUnchanged,
    coordAfterEnable,
    "coordinator reused for unchanged config",
  );

  // Change a coordinator-affecting field (sttBaseUrl) → coordinator must rebuild on next hook fire.
  config.sttBaseUrl = "http://stt-b";
  hook!(engineCtx(voiceMsg()));
  const coordRebuilt = (plugin as unknown as { coordinator: unknown })
    .coordinator;
  assert.notStrictEqual(
    coordRebuilt,
    coordAfterEnable,
    "coordinator rebuilt for changed config",
  );
});

// The host reports a plugin that implements no health check as HEALTHY, so every fail-open condition
// this plugin has (an open circuit breaker, a backend URL the host will refuse, no delivery configured
// at all) showed a green tile while nothing was being transcribed.
function healthCtx(config: Record<string, unknown>): PluginContext {
  return {
    config,
    logger: { log() {}, debug() {}, warn() {}, error() {} },
    registerHook() {},
    net: { fetch: async () => ({ ok: true, status: 200, headers: {}, body: "{}" }) },
    storage: { get: async () => null, set: async () => {}, delete: async () => {}, list: async () => [] },
    messages: { sendText: async () => ({ messageId: "x", timestamp: 0 }), reply: async () => ({ messageId: "x", timestamp: 0 }) },
    conversations: { send: async () => ({}) },
  } as unknown as PluginContext;
}

test("healthCheck reports the states that were previously fail-open warn lines", async () => {
  const fresh = new VoiceTranscriptionPlugin();
  assert.equal((await fresh.healthCheck()).healthy, false, "not enabled is not healthy");

  const ok = new VoiceTranscriptionPlugin();
  await ok.onEnable(healthCtx({ sttBaseUrl: "https://stt.example.com", deliveryWebhookUrl: "https://hook.example.com" }));
  assert.equal((await ok.healthCheck()).healthy, true);

  for (const [label, config] of [
    ["sttBaseUrl is not a URL", { sttBaseUrl: "stt.example.com", deliveryWebhookUrl: "https://h.example.com" }],
    ["sttBaseUrl carries credentials", { sttBaseUrl: "https://u:p@stt.example.com", deliveryWebhookUrl: "https://h.example.com" }],
    ["sttBaseUrl carries query string", { sttBaseUrl: "https://stt.example.com?x=1", deliveryWebhookUrl: "https://h.example.com" }],
    ["delivery webhook unusable", { sttBaseUrl: "https://stt.example.com", deliveryWebhookUrl: "ftp://h.example.com" }],
    ["no delivery configured", { sttBaseUrl: "https://stt.example.com" }],
  ] as [string, Record<string, unknown>][]) {
    const p = new VoiceTranscriptionPlugin();
    await p.onEnable(healthCtx(config));
    const h = await p.healthCheck();
    assert.equal(h.healthy, false, `${label}: must be reported unhealthy`);
    assert.ok(h.message, `${label}: must say why`);
    // A credential in the URL must never be echoed into the health message, which the dashboard renders.
    assert.ok(!/u:p@/.test(h.message ?? ""), `${label}: must not echo the URL`);
  }
});

test("deliveryTimeoutMs is clamped to the range the manifest advertises", async () => {
  // The host never validates configSchema bounds. Unclamped, the advertised maximum expired together
  // with the host's 30s capability budget so a slow receiver surfaced as a capability timeout, and 0 or
  // below made plugin-net abort every delivery after 1ms.
  const read = async (deliveryTimeoutMs: unknown) => {
    const p = new VoiceTranscriptionPlugin();
    const cfg: Record<string, unknown> = { sttBaseUrl: "https://stt.example.com", deliveryWebhookUrl: "https://h.example.com" };
    if (deliveryTimeoutMs !== undefined) cfg.deliveryTimeoutMs = deliveryTimeoutMs;
    await p.onEnable(healthCtx(cfg));
    return JSON.parse((p as unknown as { configSignature(c: Record<string, unknown>): string }).configSignature(cfg))[7] as number;
  };
  assert.equal(await read(undefined), 5000, "default");
  assert.equal(await read(0), 1000, "zero would abort every delivery after 1ms");
  assert.equal(await read(-1), 1000, "negative likewise");
  assert.equal(await read(30000), 25000, "the advertised maximum is clamped below the host budget");
  assert.equal(await read(5000), 5000, "a sane value is untouched");
});