This commit is contained in:
177
packages/server/tests/local/harvest-identity.test.ts
Normal file
177
packages/server/tests/local/harvest-identity.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Harvest Identity Extraction Tests (M-09)
|
||||
*
|
||||
* Covers:
|
||||
* POST /api/harvest/extract-identity — happy-path guards that don't need
|
||||
* an Anthropic key. LLM-mocked happy path is deferred to a follow-up
|
||||
* because the internal proxy call is opaque from this layer.
|
||||
* PUT /api/profile with identitySuggestions — the surface the client uses
|
||||
* to accept/dismiss suggestions (reuses the existing route).
|
||||
*/
|
||||
|
||||
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 } from '@waggle/core';
|
||||
import { buildLocalServer } from '../../src/local/index.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { injectWithAuth } from '../test-utils.js';
|
||||
|
||||
describe('Harvest Identity Routes (M-09)', () => {
|
||||
let server: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-harvest-identity-test-'));
|
||||
|
||||
const personalPath = path.join(tmpDir, 'personal.mind');
|
||||
const mind = new MindDB(personalPath);
|
||||
const sessions = new SessionStore(mind);
|
||||
const frames = new FrameStore(mind);
|
||||
|
||||
// Seed the 'harvest' GOP the way the commit route does, so the
|
||||
// extract-identity route has something to read.
|
||||
sessions.ensure('harvest', 'harvest', 'Imported memory from external sources');
|
||||
frames.createIFrame('harvest', '[Harvest:claude] About me\n\nUser is a senior partner at Egzakta Advisory, working in strategy consulting.', 'normal', 'import');
|
||||
frames.createIFrame('harvest', '[Harvest:chatgpt] Project kickoff\n\nMarko Markovic from Egzakta mentioned leading the Waggle OS project.', 'normal', 'import');
|
||||
|
||||
mind.close();
|
||||
server = await buildLocalServer({ dataDir: tmpDir });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('GET /api/profile', () => {
|
||||
it('returns empty identitySuggestions by default', async () => {
|
||||
const res = await injectWithAuth(server, { method: 'GET', url: '/api/profile' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(Array.isArray(body.identitySuggestions)).toBe(true);
|
||||
expect(body.identitySuggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/profile with identitySuggestions', () => {
|
||||
it('persists a suggestion list', async () => {
|
||||
const suggestions = [
|
||||
{
|
||||
field: 'name',
|
||||
value: 'Marko Markovic',
|
||||
confidence: 0.92,
|
||||
sourceHint: 'mentioned in 2 harvested frames',
|
||||
extractedAt: '2026-04-20T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
const putRes = await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/profile',
|
||||
payload: { identitySuggestions: suggestions },
|
||||
});
|
||||
expect(putRes.statusCode).toBe(200);
|
||||
|
||||
const getRes = await injectWithAuth(server, { method: 'GET', url: '/api/profile' });
|
||||
const body = getRes.json();
|
||||
expect(body.identitySuggestions).toHaveLength(1);
|
||||
expect(body.identitySuggestions[0].field).toBe('name');
|
||||
expect(body.identitySuggestions[0].value).toBe('Marko Markovic');
|
||||
});
|
||||
|
||||
it('shrinks the list on accept (field set + suggestion removed in one PUT)', async () => {
|
||||
// Seed two suggestions, then accept 'role' (populates field, removes row).
|
||||
await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/profile',
|
||||
payload: {
|
||||
identitySuggestions: [
|
||||
{ field: 'role', value: 'Partner', confidence: 0.88, sourceHint: 's1', extractedAt: '2026-04-20T00:00:00.000Z' },
|
||||
{ field: 'company', value: 'Egzakta Advisory', confidence: 0.95, sourceHint: 's2', extractedAt: '2026-04-20T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/profile',
|
||||
payload: {
|
||||
role: 'Partner',
|
||||
identitySuggestions: [
|
||||
{ field: 'company', value: 'Egzakta Advisory', confidence: 0.95, sourceHint: 's2', extractedAt: '2026-04-20T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const getRes = await injectWithAuth(server, { method: 'GET', url: '/api/profile' });
|
||||
const body = getRes.json();
|
||||
expect(body.role).toBe('Partner');
|
||||
expect(body.identitySuggestions).toHaveLength(1);
|
||||
expect(body.identitySuggestions[0].field).toBe('company');
|
||||
});
|
||||
|
||||
it('shrinks the list on dismiss (suggestion removed, field untouched)', async () => {
|
||||
// Seed, then dismiss 'industry' — name should NOT be set.
|
||||
await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/profile',
|
||||
payload: {
|
||||
identitySuggestions: [
|
||||
{ field: 'industry', value: 'Consulting', confidence: 0.7, sourceHint: 's', extractedAt: '2026-04-20T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const getBefore = await injectWithAuth(server, { method: 'GET', url: '/api/profile' });
|
||||
const prevIndustry = getBefore.json().industry;
|
||||
|
||||
await injectWithAuth(server, {
|
||||
method: 'PUT',
|
||||
url: '/api/profile',
|
||||
payload: { identitySuggestions: [] },
|
||||
});
|
||||
|
||||
const getAfter = await injectWithAuth(server, { method: 'GET', url: '/api/profile' });
|
||||
const body = getAfter.json();
|
||||
expect(body.identitySuggestions).toHaveLength(0);
|
||||
expect(body.industry).toBe(prevIndustry); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/harvest/extract-identity', () => {
|
||||
it('returns the current suggestion list (possibly empty) when no Anthropic key is configured', async () => {
|
||||
// In the test server no vault entries are seeded → the route short-
|
||||
// circuits with `note: 'no_anthropic_key'` and the currently stored
|
||||
// suggestions pass through unchanged.
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/harvest/extract-identity',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(Array.isArray(body.suggestions)).toBe(true);
|
||||
// We're agnostic about the suggestion count here — prior tests may have
|
||||
// seeded entries. The important invariants are the shape and the note.
|
||||
expect(body.note).toBe('no_anthropic_key');
|
||||
});
|
||||
|
||||
it('returns suggestions even after the frames are all wiped (degrades gracefully)', async () => {
|
||||
// Clear the harvest session's frames to exercise the "no frames" path.
|
||||
// The route returns the persisted suggestions from the profile in this
|
||||
// case — it does NOT wipe them just because the frames are gone.
|
||||
const personalPath = path.join(tmpDir, 'personal.mind');
|
||||
const mind = new MindDB(personalPath);
|
||||
const raw = mind.getDatabase();
|
||||
raw.prepare(`DELETE FROM memory_frames WHERE gop_id = 'harvest'`).run();
|
||||
mind.close();
|
||||
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/harvest/extract-identity',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(Array.isArray(body.suggestions)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user