This commit is contained in:
277
packages/server/tests/offline-mode.test.ts
Normal file
277
packages/server/tests/offline-mode.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* PM-6: Offline mode tests — OfflineManager, REST routes, health check enhancement.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } 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 { OfflineManager } from '../src/local/offline-manager.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { injectWithAuth } from './test-utils.js';
|
||||
|
||||
// ── OfflineManager unit tests ───────────────────────────────────────
|
||||
|
||||
describe('OfflineManager', () => {
|
||||
let tmpDir: string;
|
||||
let eventBus: EventEmitter;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-offline-test-'));
|
||||
eventBus = new EventEmitter();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('starts with online state', () => {
|
||||
const mgr = new OfflineManager({
|
||||
dataDir: tmpDir,
|
||||
getLlmEndpoint: () => 'http://localhost:9999',
|
||||
getLlmApiKey: () => 'test-key',
|
||||
eventBus,
|
||||
});
|
||||
|
||||
const state = mgr.state;
|
||||
expect(state.offline).toBe(false);
|
||||
expect(state.since).toBeNull();
|
||||
expect(state.queuedMessages).toBe(0);
|
||||
});
|
||||
|
||||
it('queues messages and persists them', () => {
|
||||
const mgr = new OfflineManager({
|
||||
dataDir: tmpDir,
|
||||
getLlmEndpoint: () => 'http://localhost:9999',
|
||||
getLlmApiKey: () => 'test-key',
|
||||
eventBus,
|
||||
});
|
||||
|
||||
const msg1 = mgr.queueMessage('ws-1', 'Hello world');
|
||||
expect(msg1.id).toBeDefined();
|
||||
expect(msg1.workspaceId).toBe('ws-1');
|
||||
expect(msg1.message).toBe('Hello world');
|
||||
expect(msg1.timestamp).toBeDefined();
|
||||
|
||||
const msg2 = mgr.queueMessage('ws-2', 'Second message');
|
||||
|
||||
expect(mgr.state.queuedMessages).toBe(2);
|
||||
expect(mgr.getQueue()).toHaveLength(2);
|
||||
|
||||
// Verify persistence
|
||||
const queuePath = path.join(tmpDir, 'offline-queue.json');
|
||||
expect(fs.existsSync(queuePath)).toBe(true);
|
||||
const persisted = JSON.parse(fs.readFileSync(queuePath, 'utf-8'));
|
||||
expect(persisted).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('dequeues a specific message', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-offline-deq-'));
|
||||
const mgr = new OfflineManager({
|
||||
dataDir: dir,
|
||||
getLlmEndpoint: () => 'http://localhost:9999',
|
||||
getLlmApiKey: () => 'test-key',
|
||||
eventBus,
|
||||
});
|
||||
|
||||
const msg1 = mgr.queueMessage('ws-1', 'Keep this');
|
||||
const msg2 = mgr.queueMessage('ws-1', 'Remove this');
|
||||
|
||||
expect(mgr.dequeue(msg2.id)).toBe(true);
|
||||
expect(mgr.getQueue()).toHaveLength(1);
|
||||
expect(mgr.getQueue()[0].message).toBe('Keep this');
|
||||
|
||||
// Non-existent ID
|
||||
expect(mgr.dequeue('fake-id')).toBe(false);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('clears queue', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-offline-clr-'));
|
||||
const mgr = new OfflineManager({
|
||||
dataDir: dir,
|
||||
getLlmEndpoint: () => 'http://localhost:9999',
|
||||
getLlmApiKey: () => 'test-key',
|
||||
eventBus,
|
||||
});
|
||||
|
||||
mgr.queueMessage('ws-1', 'msg1');
|
||||
mgr.queueMessage('ws-1', 'msg2');
|
||||
mgr.queueMessage('ws-1', 'msg3');
|
||||
|
||||
const cleared = mgr.clearQueue();
|
||||
expect(cleared).toBe(3);
|
||||
expect(mgr.getQueue()).toHaveLength(0);
|
||||
expect(mgr.state.queuedMessages).toBe(0);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('emits notification when transitioning to offline', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-offline-emit-'));
|
||||
const bus = new EventEmitter();
|
||||
const notifications: Array<{ title: string }> = [];
|
||||
bus.on('notification', (data) => notifications.push(data));
|
||||
|
||||
// Mock fetch to always fail
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const mgr = new OfflineManager({
|
||||
dataDir: dir,
|
||||
getLlmEndpoint: () => 'http://unreachable:9999',
|
||||
getLlmApiKey: () => 'test-key',
|
||||
eventBus: bus,
|
||||
});
|
||||
|
||||
await mgr.checkHealth();
|
||||
|
||||
expect(mgr.isOffline).toBe(true);
|
||||
expect(mgr.state.since).not.toBeNull();
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].title).toBe('Offline');
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('emits back_online notification when recovering', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-offline-recov-'));
|
||||
const bus = new EventEmitter();
|
||||
const notifications: Array<{ title: string }> = [];
|
||||
bus.on('notification', (data) => notifications.push(data));
|
||||
const stateChanges: Array<{ offline: boolean }> = [];
|
||||
bus.on('offline_state_change', (data) => stateChanges.push(data));
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
// First: make it go offline
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
const mgr = new OfflineManager({
|
||||
dataDir: dir,
|
||||
getLlmEndpoint: () => 'http://localhost:9999',
|
||||
getLlmApiKey: () => 'test-key',
|
||||
eventBus: bus,
|
||||
});
|
||||
await mgr.checkHealth();
|
||||
expect(mgr.isOffline).toBe(true);
|
||||
|
||||
// Then: make it come back online
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
|
||||
await mgr.checkHealth();
|
||||
expect(mgr.isOffline).toBe(false);
|
||||
expect(notifications).toHaveLength(2);
|
||||
expect(notifications[1].title).toBe('Back online');
|
||||
expect(stateChanges).toHaveLength(2);
|
||||
expect(stateChanges[1].offline).toBe(false);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── REST route integration tests ────────────────────────────────────
|
||||
|
||||
describe('Offline REST routes', () => {
|
||||
let server: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-offline-route-'));
|
||||
|
||||
// Create personal.mind
|
||||
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('offline-test');
|
||||
frames.createIFrame(s1.gop_id, 'Test frame', 'normal');
|
||||
mind.close();
|
||||
|
||||
server = await buildLocalServer({ dataDir: tmpDir });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('GET /api/offline/status returns expected shape', async () => {
|
||||
const res = await injectWithAuth(server, { method: 'GET', url: '/api/offline/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
expect(typeof body.offline).toBe('boolean');
|
||||
expect(body).toHaveProperty('since');
|
||||
expect(typeof body.queuedMessages).toBe('number');
|
||||
expect(body).toHaveProperty('lastCheck');
|
||||
});
|
||||
|
||||
it('POST /api/offline/queue stores messages', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/offline/queue',
|
||||
payload: { workspaceId: 'test-ws', message: 'Hello from offline' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.queued).toBeDefined();
|
||||
expect(body.queued.message).toBe('Hello from offline');
|
||||
expect(body.queued.workspaceId).toBe('test-ws');
|
||||
expect(body.queued.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('POST /api/offline/queue rejects missing message', async () => {
|
||||
const res = await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/offline/queue',
|
||||
payload: { workspaceId: 'test-ws' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/offline/queue returns queued messages', async () => {
|
||||
const res = await injectWithAuth(server, { method: 'GET', url: '/api/offline/queue' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(Array.isArray(body.messages)).toBe(true);
|
||||
expect(body.messages.length).toBeGreaterThanOrEqual(1);
|
||||
expect(body.messages[0].message).toBe('Hello from offline');
|
||||
});
|
||||
|
||||
it('DELETE /api/offline/queue clears queue', async () => {
|
||||
// Queue another message first
|
||||
await injectWithAuth(server, {
|
||||
method: 'POST',
|
||||
url: '/api/offline/queue',
|
||||
payload: { workspaceId: 'ws-2', message: 'Another' },
|
||||
});
|
||||
|
||||
const res = await injectWithAuth(server, { method: 'DELETE', url: '/api/offline/queue' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.cleared).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify empty
|
||||
const check = await injectWithAuth(server, { method: 'GET', url: '/api/offline/queue' });
|
||||
const checkBody = JSON.parse(check.body);
|
||||
expect(checkBody.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('health endpoint includes offline state', async () => {
|
||||
const res = await injectWithAuth(server, { method: 'GET', url: '/health' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
// PM-6: New fields
|
||||
expect(body.offline).toBeDefined();
|
||||
expect(typeof body.offline.offline).toBe('boolean');
|
||||
expect(typeof body.offline.queuedMessages).toBe('number');
|
||||
expect(body.llm).toHaveProperty('reachable');
|
||||
expect(body.llm).toHaveProperty('lastCheck');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user