Files
Codeman/test/routes/session-pin-routes.test.ts
T
Codeman maintainer 683544a22e Merge master into PR #157 (session manager polish)
Resolutions (sse-events.ts / constants.js / app.js): unions of the docker/
multi-user event registrations from master with the session-order/pin events
from this branch.

Additions on top of the merge:
- POST /api/sessions/:id/pin now falls back to the persisted store record when
  no live session exists: COD-142 deliberately preserves pinned records after
  kill (and cleanupStaleSessions skips them), so without this a pinned-then-
  killed session could never be unpinned. Owner-scoped in multi-user mode.
- SessionOrderUpdateSchema bounds (id <= 100 chars, <= 500 entries) so a buggy
  client can't persist megabytes into state.json; empty strings still flow to
  normalizeSessionOrder which drops them.
- Route tests for the persisted-record pin fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:31:44 +02:00

170 lines
6.1 KiB
TypeScript

/**
* @fileoverview Route tests for POST /api/sessions/:id/pin (COD-139).
*
* Pinning floats a session to the top of the unified session list. The route
* sets the session's pin flag, persists it, and broadcasts session:pinned.
* Uses app.inject() with the production-mirroring envelope harness.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createEnvelopeHarness(): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/pin', () => {
let harness: LocalHarness;
beforeEach(async () => {
harness = await createEnvelopeHarness();
});
afterEach(async () => {
await harness.app.close();
});
it('pins a session: sets state, persists, and broadcasts session:pinned', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.pinned).toBe(true);
expect(typeof body.data.pinnedAt).toBe('number');
expect(harness.ctx._session.pinned).toBe(true);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
expect(broadcastCalls).toContain('session:pinned');
});
it('unpins a session and clears pinnedAt', async () => {
await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: false },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(false);
expect(body.data.pinnedAt).toBeUndefined();
expect(harness.ctx._session.pinned).toBe(false);
});
it('is idempotent for an explicit pinned value', async () => {
for (let i = 0; i < 3; i++) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.pinned).toBe(true);
}
expect(harness.ctx._session.pinned).toBe(true);
});
it('returns 404 for an unknown session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/does-not-exist/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('rejects a missing/invalid body with 400', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/pin',
payload: { pinned: 'yes' },
});
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
});
// COD-142 keeps a pinned session's persisted record after kill, so pin toggles
// must work WITHOUT a live Session — otherwise a pinned-then-killed record could
// never be unpinned (cleanupStaleSessions deliberately skips pinned records).
it('unpins a persisted-only (killed but pinned) record via the store fallback', async () => {
const persisted = { id: 'dead-1', name: 'Dead', status: 'stopped', pinned: true, pinnedAt: 123 };
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/dead-1/pin',
payload: { pinned: false },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(false);
expect(body.data.pinnedAt).toBeUndefined();
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
'dead-1',
expect.objectContaining({ pinned: undefined, pinnedAt: undefined })
);
const broadcastCalls = harness.ctx.broadcast.mock.calls.map((c) => c[0]);
expect(broadcastCalls).toContain('session:pinned');
});
it('re-pins a persisted-only record via the store fallback', async () => {
const persisted = { id: 'dead-2', name: 'Dead2', status: 'stopped' };
(harness.ctx.store.getSession as ReturnType<typeof vi.fn>).mockReturnValue(persisted);
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/dead-2/pin',
payload: { pinned: true },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.pinned).toBe(true);
expect(typeof body.data.pinnedAt).toBe('number');
expect(harness.ctx.store.setSession).toHaveBeenCalledWith(
'dead-2',
expect.objectContaining({ pinned: true, pinnedAt: expect.any(Number) })
);
});
});