task-entry-draft-mode.test.js 5.34 KB
/**
 * task-entry-draft-mode.test.js —— POST /:companyId/tasks 的 draft:true 分支(39 号文档地基 1)
 *
 * 现有 `POST /:companyId/tasks` 建完任务无条件立刻入队执行("下达即执行")。
 * `draft:true` 时只应该落库,不应该 enqueue/start——服务的是 agent(如 ceo)自主提的新任务,
 * 不该像 task-planning 一样先经过 draft 就自动花预算。
 *
 * 隔离手法与 graph-engine.test.js / company-goal-task-routes.test.js 一致:先设
 * GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,真实的 data/graph.sqlite、data/companies/ 全程不被碰。
 */
const fs = require('fs');
const os = require('os');
const path = require('path');

const TEST_DB_PATH = '/tmp/test-task-entry-draft-mode.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
  '../../services/graph-db',
  '../../services/work-graph-db',
  '../../services/org-graph-service',
  '../../services/graph-run-sse',
  '../../routes/company',
];

const CID = 'draft-mode-test-co';

let wdb;
let orgSvc;
let server;
let base;
let origCompanyGet;

async function call(method, url, body) {
  const r = await fetch(base + url, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  return [r.status, await r.json()];
}

function ensureAgents(ids) {
  const db = wdb.getDb();
  const now = new Date().toISOString();
  const ins = db.prepare(`
    INSERT OR IGNORE INTO system_agents
      (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
    VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
  `);
  for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, now, now);
}

describe('POST /:companyId/tasks draft 模式(39 号文档地基 1)', () => {
  beforeAll(async () => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    process.env.GRAPH_RUNS_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'draft-mode-runs-'));
    for (const m of MODULES) delete require.cache[require.resolve(m)];

    wdb = require('../../services/work-graph-db');
    orgSvc = require('../../services/org-graph-service');

    const { CompanyService } = require('../../services/company-service');
    origCompanyGet = CompanyService.prototype.get;
    CompanyService.prototype.get = (companyId) =>
      (companyId === CID ? { id: CID, name: CID, status: 'running' } : null);

    // 一张最小 org graph:POST /tasks 建行前会先查「公司有没有 org graph」,没有直接 409,
    // 这一步不是本文件要测的东西,先把它铺好。
    ensureAgents(['org-planner', 'org-coder', 'org-validator']);
    expect(
      orgSvc.writeGraph(CID, {
        nodes: ['org-planner', 'org-coder', 'org-validator'],
        edges: [
          { from: 'org-planner', to: 'org-coder', condition: 'success' },
          { from: 'org-coder', to: 'org-validator', condition: 'success' },
        ],
      }).ok
    ).toBe(true);

    const express = require('express');
    const app = express();
    app.use(express.json());
    app.use('/api/company', require('../../routes/company'));

    await new Promise((resolve) => { server = app.listen(0, resolve); });
    base = `http://127.0.0.1:${server.address().port}/api/company/${CID}`;
  });

  afterAll(() => {
    if (server) server.close();
    if (origCompanyGet) require('../../services/company-service').CompanyService.prototype.get = origCompanyGet;
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } }
    try { fs.rmSync(process.env.GRAPH_RUNS_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
    delete process.env.GRAPH_DB_PATH;
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('draft:true → 201 + status draft,不落 runId,task 不入队(current_run_id 仍为空)', async () => {
    const [status, body] = await call('POST', '/tasks', {
      task: '(草稿)先别执行的一条任务',
      draft: true,
      created_by: 'ceo',
    });

    expect(status).toBe(201);
    expect(body.status).toBe('draft');
    expect(body.runId).toBeUndefined();
    expect(body.taskId).toBeTruthy();
    expect(body.hint).toContain(`/tasks/${body.taskId}/enqueue`);

    const row = wdb.getTask(body.taskId);
    expect(row.status).toBe('draft');
    expect(row.current_run_id).toBeFalsy();
    expect(row.created_by).toBe('ceo');
  });

  it('draft:true 建出的任务,之后走既有 /enqueue 端点能正常下达(不是死路)', async () => {
    const [, created] = await call('POST', '/tasks', { task: '(草稿)待下达', draft: true });
    const [status, body] = await call('POST', `/tasks/${created.taskId}/enqueue`);
    expect(status).toBe(201);
    expect(['queued', 'running'].includes(body.status)).toBe(true);

    const row = wdb.getTask(created.taskId);
    expect(['queued', 'running'].includes(row.status)).toBe(true);
  });

  it('默认行为不变:不传 draft 时仍然立刻入队/起跑(存量调用方不受影响)', async () => {
    const [status, body] = await call('POST', '/tasks', { task: '正常下达一条任务' });
    expect(status).toBe(201);
    expect(body.status).not.toBe('draft');
    expect(body.runId).toBeTruthy();
  });
});