Newer
Older
WeComCompanyPlugin / tests / outbound.test.js
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert';
import { AsyncLocalStorage } from 'node:async_hooks';

/**
 * Unit tests for outbound message delivery with three-layer fallback
 *
 * Test coverage:
 * 1. Layer 1: Active stream delivery
 * 2. Layer 2: response_url fallback
 * 3. Layer 3: Warning log when no channel available
 */

describe('outbound.sendText - three-layer fallback', () => {
  // Mock dependencies
  let streamManager;
  let responseUrls;
  let streamContext;
  let fetchMock;
  let mockStreams;

  beforeEach(() => {
    // Reset mocks
    mockStreams = new Map();
    streamManager = {
      hasStream: (id) => mockStreams.has(id),
      getStream: (id) => mockStreams.get(id),
      replaceIfPlaceholder: () => {},
    };
    responseUrls = new Map();
    fetchMock = global.fetch;
    global.fetch = async (url, options) => ({ ok: true });
  });

  afterEach(() => {
    global.fetch = fetchMock;
    mockStreams.clear();
    responseUrls.clear();
  });

  it('Layer 1: should deliver via active stream when available', async () => {
    // Setup: Active stream exists
    const streamId = 'stream_test_123';
    const userId = 'user_abc';
    mockStreams.set(streamId, { finished: false, content: 'thinking...' });

    // Simulate streamContext having streamId
    streamContext = new AsyncLocalStorage();
    streamContext.run({ streamId }, async () => {
      // Simulate outbound.sendText
      const ctx = streamContext.getStore();
      const activeStreamId = ctx?.streamId;

      assert.strictEqual(activeStreamId, streamId);
      assert.strictEqual(streamManager.hasStream(streamId), true);
    });
  });

  it('Layer 2: should use response_url fallback when stream closed', async () => {
    // Setup: Stream is closed, but response_url is available
    const streamId = 'stream_test_123';
    const userId = 'user_abc';
    const testUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test';

    // Stream is finished
    mockStreams.set(streamId, { finished: true, content: 'done' });

    // response_url saved
    responseUrls.set(userId, {
      url: testUrl,
      expiresAt: Date.now() + 60 * 60 * 1000,
      used: false,
    });

    // Simulate fallback logic
    const stream = streamManager.getStream(streamId);
    const canUseStream = stream && !stream.finished;
    assert.strictEqual(canUseStream, false);

    const saved = responseUrls.get(userId);
    const canUseFallback = saved && !saved.used && Date.now() < saved.expiresAt;
    assert.strictEqual(canUseFallback, true);

    // Simulate fetch call
    if (canUseFallback) {
      const response = await fetch(saved.url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ msgtype: 'text', text: { content: 'test' } }),
      });
      assert.strictEqual(response.ok, true);
    }
  });

  it('Layer 2: should not use response_url if already used', async () => {
    // Setup: response_url was already used
    const userId = 'user_abc';
    const testUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test';

    responseUrls.set(userId, {
      url: testUrl,
      expiresAt: Date.now() + 60 * 60 * 1000,
      used: true, // Already used
    });

    const saved = responseUrls.get(userId);
    const canUseFallback = saved && !saved.used && Date.now() < saved.expiresAt;
    assert.strictEqual(canUseFallback, false);
  });

  it('Layer 2: should not use response_url if expired', async () => {
    // Setup: response_url has expired
    const userId = 'user_abc';
    const testUrl = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test';

    responseUrls.set(userId, {
      url: testUrl,
      expiresAt: Date.now() - 1000, // Expired 1 second ago
      used: false,
    });

    const saved = responseUrls.get(userId);
    const canUseFallback = saved && !saved.used && Date.now() < saved.expiresAt;
    assert.strictEqual(canUseFallback, false);
  });

  it('Layer 3: should log warning when no delivery channel available', async () => {
    // Setup: No active stream, no response_url
    const userId = 'user_abc';

    const stream = streamManager.getStream('nonexistent');
    const saved = responseUrls.get(userId);

    const canUseStream = !!(stream && !stream.finished);
    const canUseFallback = !!(saved && !saved.used && Date.now() < saved.expiresAt);

    assert.strictEqual(canUseStream, false);
    assert.strictEqual(canUseFallback, false);

    // In real code, this would log: logger.warn("WeCom outbound: no delivery channel available...")
  });
});

describe('stream refresh handler - delayed close logic', () => {
  let streamMeta;
  let mockStreams;

  beforeEach(() => {
    streamMeta = new Map();
    mockStreams = new Map();
  });

  it('should close stream when main response done + idle for 10s', () => {
    const streamId = 'stream_test_123';
    const now = Date.now();

    // Setup: Main response done, stream idle for 11s
    streamMeta.set(streamId, {
      mainResponseDone: true,
      doneAt: now - 11000,
    });

    mockStreams.set(streamId, {
      finished: false,
      updatedAt: now - 11000,
      content: 'done',
    });

    // Simulate refresh handler logic
    const stream = mockStreams.get(streamId);
    const meta = streamMeta.get(streamId);
    const idleMs = now - stream.updatedAt;

    const shouldClose = meta?.mainResponseDone && !stream.finished && idleMs > 10000;
    assert.strictEqual(shouldClose, true);
  });

  it('should NOT close stream when idle time < 10s', () => {
    const streamId = 'stream_test_123';
    const now = Date.now();

    // Setup: Main response done, but only idle for 5s
    streamMeta.set(streamId, {
      mainResponseDone: true,
      doneAt: now - 5000,
    });

    mockStreams.set(streamId, {
      finished: false,
      updatedAt: now - 5000,
      content: 'done',
    });

    // Simulate refresh handler logic
    const stream = mockStreams.get(streamId);
    const meta = streamMeta.get(streamId);
    const idleMs = now - stream.updatedAt;

    const shouldClose = meta?.mainResponseDone && !stream.finished && idleMs > 10000;
    assert.strictEqual(shouldClose, false);
  });

  it('should NOT close stream when main response not done', () => {
    const streamId = 'stream_test_123';
    const now = Date.now();

    // Setup: Stream idle for 11s, but main response not done
    streamMeta.set(streamId, {
      mainResponseDone: false,
      doneAt: now - 11000,
    });

    mockStreams.set(streamId, {
      finished: false,
      updatedAt: now - 11000,
      content: 'processing...',
    });

    // Simulate refresh handler logic
    const stream = mockStreams.get(streamId);
    const meta = streamMeta.get(streamId);

    const shouldClose = meta?.mainResponseDone && !stream.finished;
    assert.strictEqual(shouldClose, false);
  });

  it('should NOT close stream when already finished', () => {
    const streamId = 'stream_test_123';
    const now = Date.now();

    // Setup: Stream already finished
    streamMeta.set(streamId, {
      mainResponseDone: true,
      doneAt: now - 15000,
    });

    mockStreams.set(streamId, {
      finished: true,
      updatedAt: now - 11000,
      content: 'done',
    });

    // Simulate refresh handler logic
    const stream = mockStreams.get(streamId);
    const meta = streamMeta.get(streamId);

    const shouldClose = meta?.mainResponseDone && !stream.finished;
    assert.strictEqual(shouldClose, false);
  });
});

describe('safety net - emergency stream cleanup', () => {
  let mockStreams;
  let streamMeta;

  beforeEach(() => {
    mockStreams = new Map();
    streamMeta = new Map();
  });

  it('should close idle stream after 30s safety timeout', () => {
    const streamId = 'stream_test_123';
    const now = Date.now();

    // Setup: Stream idle for 31s (exceeds safety net timeout)
    streamMeta.set(streamId, {
      mainResponseDone: true,
      doneAt: now - 31000,
    });

    mockStreams.set(streamId, {
      finished: false,
      updatedAt: now - 31000,
      content: 'done',
    });

    // Simulate safety net logic
    const stream = mockStreams.get(streamId);
    const idleMs = now - stream.updatedAt;

    const shouldForceClose = stream && !stream.finished && idleMs > 30000;
    assert.strictEqual(shouldForceClose, true);
  });

  it('should NOT close stream with recent activity', () => {
    const streamId = 'stream_test_123';
    const now = Date.now();

    // Setup: Stream updated 5s ago
    streamMeta.set(streamId, {
      mainResponseDone: true,
      doneAt: now - 5000,
    });

    mockStreams.set(streamId, {
      finished: false,
      updatedAt: now - 5000,
      content: 'done',
    });

    // Simulate safety net logic
    const stream = mockStreams.get(streamId);
    const idleMs = now - stream.updatedAt;

    const shouldForceClose = stream && !stream.finished && idleMs > 30000;
    assert.strictEqual(shouldForceClose, false);
  });
});