258 lines
9.6 KiB
TypeScript
258 lines
9.6 KiB
TypeScript
/**
|
|
* Harvest Runs Routes Tests (M-08 — resumable harvest)
|
|
*
|
|
* Covers:
|
|
* POST /api/harvest/commit — now creates a harvest_runs row + caches the
|
|
* input payload + completes on success (deleting the cache).
|
|
* GET /api/harvest/runs — lists recent runs.
|
|
* GET /api/harvest/runs/latest-interrupted — single latest resume candidate.
|
|
* POST /api/harvest/runs/:id/abandon — marks abandoned + deletes cache.
|
|
* POST /api/harvest/commit with resumeFromRun — replays cached input.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { MindDB, SessionStore, FrameStore, HarvestRunStore } from '@waggle/core';
|
|
import { buildLocalServer } from '../../src/local/index.js';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { injectWithAuth } from '../test-utils.js';
|
|
|
|
const CHATGPT_EXPORT = [
|
|
{
|
|
title: 'A small chat',
|
|
create_time: 1700000000,
|
|
mapping: {
|
|
node1: {
|
|
message: {
|
|
author: { role: 'user' },
|
|
content: { parts: ['My preferred editor is VSCode with vim bindings.'] },
|
|
create_time: 1700000001,
|
|
},
|
|
},
|
|
node2: {
|
|
message: {
|
|
author: { role: 'assistant' },
|
|
content: { parts: ['Got it — VSCode with vim is a solid setup.'] },
|
|
create_time: 1700000002,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
describe('Harvest Run Routes (M-08)', () => {
|
|
let server: FastifyInstance;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-harvest-runs-test-'));
|
|
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const sessions = new SessionStore(mind);
|
|
const frames = new FrameStore(mind);
|
|
const s1 = sessions.create('run-test-seed');
|
|
frames.createIFrame(s1.gop_id, 'seed frame', 'normal');
|
|
mind.close();
|
|
|
|
server = await buildLocalServer({ dataDir: tmpDir });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('POST /api/harvest/commit + run lifecycle', () => {
|
|
it('creates a completed run + deletes the cache on success', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/harvest/commit',
|
|
payload: { data: CHATGPT_EXPORT, source: 'chatgpt' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(typeof body.runId).toBe('number');
|
|
expect(body.saved).toBeGreaterThan(0);
|
|
|
|
// Confirm the run was marked completed + cache file was removed.
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const runs = new HarvestRunStore(mind);
|
|
const run = runs.getById(body.runId);
|
|
expect(run?.status).toBe('completed');
|
|
expect(run?.inputCachePath).toBeTruthy();
|
|
if (run?.inputCachePath) {
|
|
expect(fs.existsSync(run.inputCachePath)).toBe(false);
|
|
}
|
|
mind.close();
|
|
});
|
|
});
|
|
|
|
describe('GET /api/harvest/runs', () => {
|
|
it('returns recent runs newest-first', async () => {
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(Array.isArray(body.runs)).toBe(true);
|
|
expect(body.runs.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/harvest/runs/latest-interrupted', () => {
|
|
it('returns null when no runs are interrupted (all terminal)', async () => {
|
|
// After the previous test the only run is completed.
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs/latest-interrupted' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.run).toBeNull();
|
|
});
|
|
|
|
it('surfaces a hand-seeded interrupted run with a surviving cache', async () => {
|
|
// Craft a 'running' row directly in the DB to simulate client disconnect
|
|
// mid-flight. The route requires the cache file to actually exist, so
|
|
// we also write one.
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const runs = new HarvestRunStore(mind);
|
|
const cacheDir = path.join(tmpDir, 'harvest-cache');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, 'seeded.json');
|
|
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
|
|
runs.start('chatgpt', 10, cacheFile);
|
|
mind.close();
|
|
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs/latest-interrupted' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.run).not.toBeNull();
|
|
expect(body.run.status).toBe('running');
|
|
expect(body.run.source).toBe('chatgpt');
|
|
});
|
|
|
|
it('returns null when the row exists but the cache file is gone', async () => {
|
|
// Delete the seeded cache and refetch.
|
|
const cacheFile = path.join(tmpDir, 'harvest-cache', 'seeded.json');
|
|
if (fs.existsSync(cacheFile)) fs.unlinkSync(cacheFile);
|
|
|
|
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs/latest-interrupted' });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.run).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/harvest/runs/:id/abandon', () => {
|
|
it('marks a running run abandoned + deletes its cache', async () => {
|
|
// Seed another interrupted run with a real cache file.
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const runs = new HarvestRunStore(mind);
|
|
const cacheDir = path.join(tmpDir, 'harvest-cache');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, 'abandon-test.json');
|
|
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
|
|
const run = runs.start('chatgpt', 10, cacheFile);
|
|
mind.close();
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: `/api/harvest/runs/${run.id}/abandon`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.run.status).toBe('abandoned');
|
|
expect(fs.existsSync(cacheFile)).toBe(false);
|
|
});
|
|
|
|
it('returns 404 for a non-existent run id', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/harvest/runs/999999/abandon',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/harvest/commit { resumeFromRun }', () => {
|
|
it('replays the cached input (dedup produces a no-op count)', async () => {
|
|
// Seed an interrupted run backed by CHATGPT_EXPORT as the cache.
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const runs = new HarvestRunStore(mind);
|
|
const cacheDir = path.join(tmpDir, 'harvest-cache');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, 'resume-test.json');
|
|
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
|
|
const seed = runs.start('chatgpt', 1, cacheFile);
|
|
mind.close();
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/harvest/commit',
|
|
payload: { resumeFromRun: seed.id },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.runId).toBe(seed.id);
|
|
// FrameStore dedup returns the existing frame from the original commit,
|
|
// so the saved counter is bumped but no new rows are created.
|
|
expect(body.saved).toBeGreaterThan(0);
|
|
|
|
// Run is now completed + cache deleted.
|
|
const mind2 = new MindDB(personalPath);
|
|
const runs2 = new HarvestRunStore(mind2);
|
|
expect(runs2.getById(seed.id)?.status).toBe('completed');
|
|
expect(fs.existsSync(cacheFile)).toBe(false);
|
|
mind2.close();
|
|
});
|
|
|
|
it('returns 404 for an unknown resumeFromRun id', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/harvest/commit',
|
|
payload: { resumeFromRun: 999999 },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns 410 when the cache file has been deleted', async () => {
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const runs = new HarvestRunStore(mind);
|
|
// Cache path that points nowhere.
|
|
const stale = runs.start('chatgpt', 5, path.join(tmpDir, 'harvest-cache', 'nope.json'));
|
|
mind.close();
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/harvest/commit',
|
|
payload: { resumeFromRun: stale.id },
|
|
});
|
|
expect(res.statusCode).toBe(410);
|
|
});
|
|
|
|
it('returns 409 when the run is already completed', async () => {
|
|
const personalPath = path.join(tmpDir, 'personal.mind');
|
|
const mind = new MindDB(personalPath);
|
|
const runs = new HarvestRunStore(mind);
|
|
const cacheDir = path.join(tmpDir, 'harvest-cache');
|
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
const cacheFile = path.join(cacheDir, '409-test.json');
|
|
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
|
|
const run = runs.start('chatgpt', 1, cacheFile);
|
|
runs.complete(run.id, 1);
|
|
mind.close();
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/harvest/commit',
|
|
payload: { resumeFromRun: run.id },
|
|
});
|
|
expect(res.statusCode).toBe(409);
|
|
});
|
|
});
|
|
});
|