mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
Tab reordering (drag-and-drop + Ctrl+Shift+{/}) persisted only to
localStorage (codeman-session-order), so each device kept its own private
order. Add server-side persistence so the order follows the user across
devices, live. Takes the issue's recommended default (a): one global order,
server authoritative, localStorage as offline fallback.
- session-order.ts (new, pure + unit-tested): normalizeSessionOrder (coerce
to string[], drop empty/non-string, dedup) and mergeSessionOrder (the
pushing device's order wins; ids the device hadn't loaded fall to the end
in their existing relative order, never dropped — graceful for
closed/remote/parked sessions absent on that device).
- AppState.sessionOrder?: string[]; StateStore get/setSessionOrder + the field
added to buildPartialJson() (the incremental serializer whitelists fields,
so without this the value never reached disk / survived a restart).
- PUT /api/session-order (session-routes): parse -> merge -> persist ->
broadcast session:orderChanged; getLightState() init snapshot now carries
sessionOrder so a fresh load/reconnect restores it.
- SSE event session:orderChanged registered in sse-events.ts + constants.js.
- app.js: handleInit seeds localStorage from the server snapshot before
syncSessionOrder(); saveSessionOrder() also PUTs to the server (debounced
400ms, covers drag + both keyboard moves); _onSessionOrderChanged adopts a
remote order and re-renders (no-op-guarded to avoid echo flicker).
Verified (orchestrator re-ran all gates): tsc 0, lint 0, frontend-syntax +
prettier clean, build ok; session-order + session-order-routes + state-store
56/56. Functional round-trip on an isolated beta: PUT {a,b,c} -> status
snapshot reflects it; merge PUT {c,a} vs {a,b,c} -> {c,a,b} (b preserved at
end); malformed payload rejected with a clean 400; sessionOrder persisted to
state.json and survived a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 79415f2fdfbdf3fbe362a063534e7f84c553eefb)
130 lines
4.8 KiB
TypeScript
130 lines
4.8 KiB
TypeScript
/**
|
|
* @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131).
|
|
*
|
|
* Uses app.inject() — no real HTTP ports needed.
|
|
* Asserts the uniform envelope contract:
|
|
* SUCCESS -> 2xx, { success: true, data: { order } }
|
|
* ERROR -> 4xx/5xx, { success: false, error, errorCode }
|
|
* and that the order is persisted to the (mock) StateStore + broadcast over SSE.
|
|
*/
|
|
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';
|
|
|
|
// registerSessionRoutes pulls in session.js which can shell out; stub the bits
|
|
// that would touch the OS at import/registration time. None are needed by the
|
|
// session-order handler itself, but the module imports them.
|
|
vi.mock('node:child_process', async (orig) => {
|
|
const actual = await orig<typeof import('node:child_process')>();
|
|
return { ...actual, execFile: vi.fn(), spawn: vi.fn() };
|
|
});
|
|
|
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
|
|
|
interface LocalHarness {
|
|
app: FastifyInstance;
|
|
ctx: MockRouteContext;
|
|
}
|
|
|
|
async function buildHarness(): Promise<LocalHarness> {
|
|
const app = Fastify({ logger: false });
|
|
await app.register(fastifyCookie);
|
|
|
|
const ctx = createMockRouteContext();
|
|
registerSessionRoutes(app, ctx as unknown as Parameters<typeof registerSessionRoutes>[1]);
|
|
|
|
// Mirror production's uniform-envelope preSerialization hook (server.ts).
|
|
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('PUT /api/session-order', () => {
|
|
let harness: LocalHarness;
|
|
|
|
beforeEach(async () => {
|
|
harness = await buildHarness();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await harness.app.close();
|
|
});
|
|
|
|
it('persists the order and returns it in the envelope', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/session-order',
|
|
payload: { order: ['a', 'b', 'c'] },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } });
|
|
// Persisted to the store.
|
|
expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']);
|
|
expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']);
|
|
// Broadcast over SSE.
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] });
|
|
});
|
|
|
|
it('preserves a server-only id (unknown to the pushing device) at the end', async () => {
|
|
// Seed the store with an order containing a server-only id "z".
|
|
harness.ctx.store.setSessionOrder(['a', 'z', 'b']);
|
|
(harness.ctx.broadcast as ReturnType<typeof vi.fn>).mockClear();
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/session-order',
|
|
payload: { order: ['b', 'a'] },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
// Incoming order wins, server-only "z" falls to the end.
|
|
expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } });
|
|
expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']);
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] });
|
|
});
|
|
|
|
it('normalizes junk input (dedup + drop empties) before persisting', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/session-order',
|
|
payload: { order: ['a', 'a', '', 'b'] },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } });
|
|
});
|
|
|
|
it('rejects a non-array order with a 4xx envelope', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PUT',
|
|
url: '/api/session-order',
|
|
payload: { order: 'nope' },
|
|
});
|
|
|
|
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
|
const body = res.json();
|
|
expect(body.success).toBe(false);
|
|
expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled();
|
|
});
|
|
});
|