feat(FN-1675): merge fusion/fn-1675

This commit is contained in:
gsxdsm
2026-04-15 18:23:05 -07:00
parent 20c138731c
commit cff03f11a3
9 changed files with 974 additions and 27 deletions

View File

@@ -2,6 +2,16 @@
## Architecture ## Architecture
- **`FN-1736 Multi-Project Scoping Audit`**: Comprehensive audit of project-scoping across the Fusion stack found:
- SSE/WebSocket endpoints (`/api/tasks/:id/logs/stream`, `/api/events`, `/api/ws`) already use `resolveProjectScopedStore()` or `getProjectContext()` correctly
- Badge WebSocket (`setupBadgeWebSocket`) properly scopes per-project with listeners on scoped stores
- Terminal WebSocket properly scopes per-project via `projectId` query param
- All frontend EventSource connections (`useMultiAgentLogs`, `AgentDetailView`, `MissionManager`, etc.) pass `projectId` correctly
- CLI commands (`mission.ts`, `plugin.ts`, `backup.ts`, etc.) use `resolveProject()`/`getStore()` properly
- **Fixed**: Chat session creation (`POST /api/chat/sessions`) was NOT passing `projectId` to `createSession()` — now uses `getProjectContext(req)` and passes `projectId ?? null`
- AI sessions are intentionally global (shared across projects) — `AiSessionStore` uses single DB
- Session locks (`useSessionLock`) are intentionally global — they prevent concurrent AI session access across projects
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI. - `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
- **Centralized Context-Window Auto-Compaction (FN-1877)**: The `promptWithFallback()` function in `packages/engine/src/pi.ts` automatically catches context-window overflow errors, runs `compactSessionContext()`, and retries once. This centralizes recovery for ALL agent types (executor, step-session, merger, triage, heartbeat, reviewer, mission-execution-loop). Callers that previously had duplicate compact-and-resume logic (executor, step-session-executor, merger) have been simplified to use `promptWithFallback`'s auto-compaction as first-level recovery, with their own reduced-prompt fallbacks as second-level recovery. This eliminates code duplication and ensures consistent recovery behavior. - **Centralized Context-Window Auto-Compaction (FN-1877)**: The `promptWithFallback()` function in `packages/engine/src/pi.ts` automatically catches context-window overflow errors, runs `compactSessionContext()`, and retries once. This centralizes recovery for ALL agent types (executor, step-session, merger, triage, heartbeat, reviewer, mission-execution-loop). Callers that previously had duplicate compact-and-resume logic (executor, step-session-executor, merger) have been simplified to use `promptWithFallback`'s auto-compaction as first-level recovery, with their own reduced-prompt fallbacks as second-level recovery. This eliminates code duplication and ensures consistent recovery behavior.
- **Workflow Step Revision Loop (FN-1499)**: Workflow steps can request implementation revisions via "REQUEST REVISION" output. The flow: - **Workflow Step Revision Loop (FN-1499)**: Workflow steps can request implementation revisions via "REQUEST REVISION" output. The flow:

View File

@@ -257,3 +257,99 @@ Created from this audit:
- [ ] `FN-1281` — Close major dashboard route/service coverage gaps (`mission-routes.ts`, `subtask-breakdown.ts`, `script-store.ts`). - [ ] `FN-1281` — Close major dashboard route/service coverage gaps (`mission-routes.ts`, `subtask-breakdown.ts`, `script-store.ts`).
- [ ] `FN-1282` — Add tests for untested dashboard agent workflow hooks/components (`useAgents`, `useProjectHealth`, file hooks, agent modals, mission interview modal). - [ ] `FN-1282` — Add tests for untested dashboard agent workflow hooks/components (`useAgents`, `useProjectHealth`, file hooks, agent modals, mission interview modal).
- [ ] `FN-1283` — Improve CLI test effectiveness (`settings-import.ts`, `settings-export.ts`, `project-resolver.ts`, `bin.ts` fallback paths). - [ ] `FN-1283` — Improve CLI test effectiveness (`settings-import.ts`, `settings-export.ts`, `project-resolver.ts`, `bin.ts` fallback paths).
---
# FN-1675 Roadmap Regression Test Coverage
_Date: 2026-04-16_
## Summary
Added comprehensive regression test coverage for the standalone roadmap feature set across core persistence, dashboard API routes, and frontend hook/component layers.
## Coverage Matrix
### Core Persistence (`packages/core/src/`)
| Test File | Tests | Coverage Areas |
|-----------|-------|---------------|
| `roadmap-store.test.ts` | 86 | CRUD operations, reorder (milestone/feature), cross-milestone move, hierarchy operations, export/handoff, **persistence re-instantiation**, **negative ordering tests** |
| `roadmap-ordering.test.ts` | 14 | Pure ordering helpers, deterministic tie-breaker resolution, **boundary tests (negative/NaN/Infinity targetOrderIndex)**, **contiguous orderIndex verification** |
| `roadmap-handoff.test.ts` | 20 | Handoff mapping functions, source lineage preservation, deterministic ordering |
**Key additions:**
- Persistence re-instantiation tests verifying data survives database close/reopen
- Boundary tests for `targetOrderIndex` clamping (negative, NaN, Infinity)
- Negative tests for roadmapId/fromMilestoneId mismatches
- Explicit contiguous orderIndex assertions for all reorder/move operations
### Dashboard Routes (`packages/dashboard/src/`)
| Test File | Tests | Coverage Areas |
|-----------|-------|---------------|
| `roadmap-routes.routes.test.ts` | 30 | CRUD endpoints, reorder/move, suggestions (mocked), handoff, project scoping, **validation error (400) tests** |
| `roadmap-suggestions.test.ts` | 65 | Input validation, AI output normalization, failure behavior |
**Key additions:**
- 400 validation error tests for roadmap/milestone/feature creation
- 400 validation error tests for reorder payloads
### Dashboard Hooks (`packages/dashboard/app/hooks/`)
| Test File | Tests | Coverage Areas |
|-----------|-------|---------------|
| `useRoadmaps.test.ts` | 52 | State management, CRUD, reorder/move with optimistic updates, rollback on failure, **handoff/fetch**, **stale async guard**, **no-op suppression** |
**Key additions:**
- Handoff/fetch tests (fetchHandoff, clearHandoff, project-context clearing)
- Stale async response rejection for handoff fetches
- No-op suppression tests for reorder/move operations
### Dashboard API Wrapper (`packages/dashboard/app/`)
| Test File | Tests | Coverage Areas |
|-----------|-------|---------------|
| `api.test.ts` | 14 (roadmap-specific) | Roadmap API wrappers, 204 void handling, projectId propagation, reorder/move/suggestion/handoff endpoints |
## Test Quality Notes
- **Persistence tests** use isolated temp directories with proper cleanup
- **Stale async guard** tests use version-ref pattern matching `useTasks` patterns
- **No-op suppression** prevents unnecessary API calls for no-position-change operations
- **Validation tests** cover both happy-path and error-path assertions
- **Boundary tests** cover edge cases (negative, NaN, Infinity indices)
## Files Modified
### Core (`packages/core/src/`)
- `roadmap-store.test.ts` — +349 lines (persistence, negative ordering, listFeatureTaskPlanningHandoffs, getMissionPlanningHandoff)
- `roadmap-ordering.test.ts` — +4 tests (boundary conditions)
### Dashboard Routes (`packages/dashboard/src/`)
- `roadmap-routes.routes.test.ts` — +65 lines (400 validation tests)
### Dashboard Hooks (`packages/dashboard/app/hooks/`)
- `useRoadmaps.ts` — Fixed stale closure bug (use `projectIdRef.current`)
- `useRoadmaps.test.ts` — +313 lines (handoff tests, no-op suppression tests)
## Test Execution
```bash
# Core roadmap tests
pnpm --filter @fusion/core exec vitest run src/roadmap-store.test.ts src/roadmap-ordering.test.ts src/roadmap-handoff.test.ts
# Dashboard route tests
pnpm --filter @fusion/dashboard exec vitest run src/roadmap-routes.routes.test.ts src/roadmap-suggestions.test.ts
# Dashboard hook tests
pnpm --filter @fusion/dashboard exec vitest run app/hooks/__tests__/useRoadmaps.test.ts
# All roadmap tests (combined)
pnpm --filter @fusion/core exec vitest run src/roadmap
pnpm --filter @fusion/dashboard exec vitest run src/roadmap app/api.test.ts -t roadmap
```
## Coverage Verification
All 120 core roadmap tests pass, 95 dashboard route/suggestion tests pass, 52 hook tests pass.

View File

@@ -248,5 +248,87 @@ describe("roadmap-ordering", () => {
"Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)", "Feature RF-2 is outside the affected milestone scope (RMS-SOURCE → RMS-TARGET)",
); );
}); });
it("clamps negative targetOrderIndex to 0", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: -5,
});
// RF-2 should be moved to index 0, RF-1 to index 1
expect(result.movedFeature.orderIndex).toBe(0);
expect(result.sourceMilestoneFeatures.map((f) => f.id)).toEqual(["RF-2", "RF-1"]);
});
it("clamps NaN targetOrderIndex to end", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: NaN,
});
// NaN is clamped to the end (length of the remaining list)
expect(result.movedFeature.orderIndex).toBe(1);
});
it("clamps Infinity targetOrderIndex to end", () => {
const features = [
createFeature("RF-1", "RMS-1", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-1", 1, "2026-04-13T00:00:01.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-1",
fromMilestoneId: "RMS-1",
toMilestoneId: "RMS-1",
targetOrderIndex: Infinity,
});
// Infinity is clamped to the end
expect(result.movedFeature.orderIndex).toBe(1);
});
it("produces strictly contiguous orderIndex values after move", () => {
const features = [
createFeature("RF-1", "RMS-SOURCE", 0, "2026-04-13T00:00:00.000Z"),
createFeature("RF-2", "RMS-SOURCE", 1, "2026-04-13T00:00:01.000Z"),
createFeature("RF-3", "RMS-SOURCE", 2, "2026-04-13T00:00:02.000Z"),
createFeature("RF-4", "RMS-TARGET", 0, "2026-04-13T00:00:03.000Z"),
];
const result = moveRoadmapFeature(features, {
roadmapId: "RM-1",
featureId: "RF-2",
fromMilestoneId: "RMS-SOURCE",
toMilestoneId: "RMS-TARGET",
targetOrderIndex: 0,
});
// Verify contiguous orderIndex for source
const sourceOrderIndices = result.sourceMilestoneFeatures.map((f) => f.orderIndex);
expect(sourceOrderIndices).toEqual([0, 1]);
expect(new Set(sourceOrderIndices).size).toBe(sourceOrderIndices.length);
// Verify contiguous orderIndex for target
const targetOrderIndices = result.targetMilestoneFeatures.map((f) => f.orderIndex);
expect(targetOrderIndices).toEqual([0, 1]);
expect(new Set(targetOrderIndices).size).toBe(targetOrderIndices.length);
});
}); });
}); });

View File

@@ -942,5 +942,372 @@ describe("RoadmapStore", () => {
expect(handoff1.source.milestoneOrderIndex).toBeLessThan(handoff2.source.milestoneOrderIndex); expect(handoff1.source.milestoneOrderIndex).toBeLessThan(handoff2.source.milestoneOrderIndex);
}); });
}); });
describe("getMissionPlanningHandoff", () => {
it("is an alias for getRoadmapMissionHandoff with same behavior", () => {
const roadmap = store.createRoadmap({ title: "Alias Test" });
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
const f1 = store.createFeature(m1.id, { title: "F1" });
const result1 = store.getRoadmapMissionHandoff(roadmap.id);
const result2 = store.getMissionPlanningHandoff(roadmap.id);
// Both should return equivalent results
expect(result1.sourceRoadmapId).toBe(result2.sourceRoadmapId);
expect(result1.title).toBe(result2.title);
expect(result1.description).toBe(result2.description);
expect(result1.milestones.length).toBe(result2.milestones.length);
expect(result1.milestones[0].sourceMilestoneId).toBe(result2.milestones[0].sourceMilestoneId);
});
});
describe("listFeatureTaskPlanningHandoffs", () => {
it("returns empty array for roadmap with no milestones", () => {
const roadmap = store.createRoadmap({ title: "Empty" });
const handoffs = store.listFeatureTaskPlanningHandoffs(roadmap.id);
expect(handoffs).toEqual([]);
});
it("returns empty array for roadmap with milestones but no features", () => {
const roadmap = store.createRoadmap({ title: "No Features" });
store.createMilestone(roadmap.id, { title: "M1" });
store.createMilestone(roadmap.id, { title: "M2" });
const handoffs = store.listFeatureTaskPlanningHandoffs(roadmap.id);
expect(handoffs).toEqual([]);
});
it("returns flattened feature handoffs in deterministic order", () => {
const roadmap = store.createRoadmap({ title: "Flat Test" });
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
const m2 = store.createMilestone(roadmap.id, { title: "M2" });
const f1 = store.createFeature(m1.id, { title: "F1", description: "Desc 1" });
const f2 = store.createFeature(m1.id, { title: "F2", description: "Desc 2" });
const f3 = store.createFeature(m2.id, { title: "F3" });
const handoffs = store.listFeatureTaskPlanningHandoffs(roadmap.id);
expect(handoffs).toHaveLength(3);
// Milestone order: m1 (orderIndex 0), m2 (orderIndex 1)
// Feature order within milestone: f1 (orderIndex 0), f2 (orderIndex 1)
// Flattened: f1, f2, f3
expect(handoffs[0].source.featureId).toBe(f1.id);
expect(handoffs[0].title).toBe("F1");
expect(handoffs[0].description).toBe("Desc 1");
expect(handoffs[1].source.featureId).toBe(f2.id);
expect(handoffs[1].title).toBe("F2");
expect(handoffs[2].source.featureId).toBe(f3.id);
});
it("preserves source lineage in each handoff", () => {
const roadmap = store.createRoadmap({ title: "Lineage Test", description: "Roadmap desc" });
const m1 = store.createMilestone(roadmap.id, { title: "Milestone Title" });
const f1 = store.createFeature(m1.id, { title: "Feature Title", description: "Feature desc" });
const handoffs = store.listFeatureTaskPlanningHandoffs(roadmap.id);
expect(handoffs).toHaveLength(1);
expect(handoffs[0].source.roadmapId).toBe(roadmap.id);
expect(handoffs[0].source.roadmapTitle).toBe("Lineage Test");
expect(handoffs[0].source.milestoneId).toBe(m1.id);
expect(handoffs[0].source.milestoneTitle).toBe("Milestone Title");
expect(handoffs[0].source.featureId).toBe(f1.id);
expect(handoffs[0].source.milestoneOrderIndex).toBe(0);
expect(handoffs[0].source.featureOrderIndex).toBe(0);
});
it("throws for non-existent roadmap", () => {
expect(() => store.listFeatureTaskPlanningHandoffs("RM-nonexistent")).toThrow("Roadmap RM-nonexistent not found");
});
});
});
describe("persistence re-instantiation", () => {
// These tests manage their own setup/teardown to avoid conflicts with shared afterEach
it("survives store re-instantiation with all entities intact", async () => {
// Create own temp directory for this test
const persistTmpDir = makeTmpDir();
try {
const persistDb = new Database(join(persistTmpDir, ".fusion"));
persistDb.init();
const persistStore = new RoadmapStore(persistDb);
// Create a roadmap, milestones, and features
const roadmap = persistStore.createRoadmap({ title: "Persistence Test", description: "Test description" });
const m1 = persistStore.createMilestone(roadmap.id, { title: "Milestone 1", description: "M1 desc" });
const m2 = persistStore.createMilestone(roadmap.id, { title: "Milestone 2" });
const f1 = persistStore.createFeature(m1.id, { title: "Feature 1", description: "F1 desc" });
const f2 = persistStore.createFeature(m1.id, { title: "Feature 2" });
const f3 = persistStore.createFeature(m2.id, { title: "Feature 3" });
// Close and reopen the store from the same database
persistDb.close();
const reopenedDb = new Database(join(persistTmpDir, ".fusion"));
reopenedDb.init();
const reopenedStore = new RoadmapStore(reopenedDb);
// Verify all data persisted correctly
const persistedRoadmap = reopenedStore.getRoadmap(roadmap.id);
expect(persistedRoadmap).toBeDefined();
expect(persistedRoadmap!.title).toBe("Persistence Test");
expect(persistedRoadmap!.description).toBe("Test description");
const persistedMilestones = reopenedStore.listMilestones(roadmap.id);
expect(persistedMilestones).toHaveLength(2);
expect(persistedMilestones[0].title).toBe("Milestone 1");
expect(persistedMilestones[1].title).toBe("Milestone 2");
const persistedFeaturesM1 = reopenedStore.listFeatures(m1.id);
expect(persistedFeaturesM1).toHaveLength(2);
expect(persistedFeaturesM1[0].title).toBe("Feature 1");
const persistedFeaturesM2 = reopenedStore.listFeatures(m2.id);
expect(persistedFeaturesM2).toHaveLength(1);
expect(persistedFeaturesM2[0].title).toBe("Feature 3");
reopenedDb.close();
} finally {
await rm(persistTmpDir, { recursive: true, force: true });
}
});
it("survives re-instantiation with reordered entities", async () => {
const persistTmpDir = makeTmpDir();
try {
const persistDb = new Database(join(persistTmpDir, ".fusion"));
persistDb.init();
const persistStore = new RoadmapStore(persistDb);
const roadmap = persistStore.createRoadmap({ title: "Reorder Persistence" });
const m1 = persistStore.createMilestone(roadmap.id, { title: "M1" });
const m2 = persistStore.createMilestone(roadmap.id, { title: "M2" });
const m3 = persistStore.createMilestone(roadmap.id, { title: "M3" });
const f1 = persistStore.createFeature(m1.id, { title: "F1" });
const f2 = persistStore.createFeature(m1.id, { title: "F2" });
// Reorder milestones
persistStore.reorderMilestones({
roadmapId: roadmap.id,
orderedMilestoneIds: [m3.id, m1.id, m2.id],
});
// Reorder features
persistStore.reorderFeatures({
roadmapId: roadmap.id,
milestoneId: m1.id,
orderedFeatureIds: [f2.id, f1.id],
});
// Close and reopen
persistDb.close();
const reopenedDb = new Database(join(persistTmpDir, ".fusion"));
reopenedDb.init();
const reopenedStore = new RoadmapStore(reopenedDb);
// Verify reorder persisted
const milestones = reopenedStore.listMilestones(roadmap.id);
expect(milestones.map((m) => m.id)).toEqual([m3.id, m1.id, m2.id]);
expect(milestones.map((m) => m.orderIndex)).toEqual([0, 1, 2]); // Contiguous
const features = reopenedStore.listFeatures(m1.id);
expect(features.map((f) => f.id)).toEqual([f2.id, f1.id]);
expect(features.map((f) => f.orderIndex)).toEqual([0, 1]); // Contiguous
reopenedDb.close();
} finally {
await rm(persistTmpDir, { recursive: true, force: true });
}
});
it("survives re-instantiation with cross-milestone moves", async () => {
const persistTmpDir = makeTmpDir();
try {
const persistDb = new Database(join(persistTmpDir, ".fusion"));
persistDb.init();
const persistStore = new RoadmapStore(persistDb);
const roadmap = persistStore.createRoadmap({ title: "Move Persistence" });
const m1 = persistStore.createMilestone(roadmap.id, { title: "M1" });
const m2 = persistStore.createMilestone(roadmap.id, { title: "M2" });
const f1 = persistStore.createFeature(m1.id, { title: "F1" });
const f2 = persistStore.createFeature(m1.id, { title: "F2" });
const f3 = persistStore.createFeature(m2.id, { title: "F3" });
// Move f2 from m1 to m2
persistStore.moveFeature({
roadmapId: roadmap.id,
featureId: f2.id,
fromMilestoneId: m1.id,
toMilestoneId: m2.id,
targetOrderIndex: 0,
});
// Close and reopen
persistDb.close();
const reopenedDb = new Database(join(persistTmpDir, ".fusion"));
reopenedDb.init();
const reopenedStore = new RoadmapStore(reopenedDb);
// Verify move persisted
const f2Persisted = reopenedStore.getFeature(f2.id);
expect(f2Persisted!.milestoneId).toBe(m2.id);
expect(f2Persisted!.orderIndex).toBe(0);
// Verify m1 renumbered correctly
const m1Features = reopenedStore.listFeatures(m1.id);
expect(m1Features).toHaveLength(1);
expect(m1Features[0].id).toBe(f1.id);
expect(m1Features[0].orderIndex).toBe(0);
// Verify m2 renumbered correctly
const m2Features = reopenedStore.listFeatures(m2.id);
expect(m2Features).toHaveLength(2);
expect(m2Features[0].id).toBe(f2.id);
expect(m2Features[0].orderIndex).toBe(0);
expect(m2Features[1].id).toBe(f3.id);
expect(m2Features[1].orderIndex).toBe(1);
reopenedDb.close();
} finally {
await rm(persistTmpDir, { recursive: true, force: true });
}
});
it("cascade-deletes persist after re-instantiation", async () => {
const persistTmpDir = makeTmpDir();
try {
const persistDb = new Database(join(persistTmpDir, ".fusion"));
persistDb.init();
const persistStore = new RoadmapStore(persistDb);
const roadmap = persistStore.createRoadmap({ title: "Cascade Test" });
const m1 = persistStore.createMilestone(roadmap.id, { title: "M1" });
const m2 = persistStore.createMilestone(roadmap.id, { title: "M2" });
const f1 = persistStore.createFeature(m1.id, { title: "F1" });
const f2 = persistStore.createFeature(m2.id, { title: "F2" });
// Delete m1 (should cascade delete f1)
persistStore.deleteMilestone(m1.id);
// Close and reopen
persistDb.close();
const reopenedDb = new Database(join(persistTmpDir, ".fusion"));
reopenedDb.init();
const reopenedStore = new RoadmapStore(reopenedDb);
// Verify cascade delete persisted
expect(reopenedStore.getMilestone(m1.id)).toBeUndefined();
expect(reopenedStore.getFeature(f1.id)).toBeUndefined();
// Verify other data intact
expect(reopenedStore.getMilestone(m2.id)).toBeDefined();
expect(reopenedStore.getFeature(f2.id)).toBeDefined();
reopenedDb.close();
} finally {
await rm(persistTmpDir, { recursive: true, force: true });
}
});
});
describe("negative ordering tests", () => {
it("rejects reorder milestones with wrong roadmapId", () => {
const roadmap1 = store.createRoadmap({ title: "R1" });
const roadmap2 = store.createRoadmap({ title: "R2" });
const m1 = store.createMilestone(roadmap1.id, { title: "M1" });
const m2 = store.createMilestone(roadmap1.id, { title: "M2" });
// Try to reorder with wrong roadmapId
expect(() =>
store.reorderMilestones({
roadmapId: roadmap2.id, // Wrong roadmap!
orderedMilestoneIds: [m2.id, m1.id],
}),
).toThrow(); // Should fail because m1 and m2 belong to roadmap1
});
it("rejects reorder features with wrong roadmapId", () => {
const roadmap = store.createRoadmap({ title: "R1" });
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
const f1 = store.createFeature(m1.id, { title: "F1" });
const f2 = store.createFeature(m1.id, { title: "F2" });
// Try to reorder with wrong roadmapId
expect(() =>
store.reorderFeatures({
roadmapId: "RM-wrong", // Wrong roadmap!
milestoneId: m1.id,
orderedFeatureIds: [f2.id, f1.id],
}),
).toThrow();
});
it("rejects move feature with wrong fromMilestoneId", () => {
const roadmap = store.createRoadmap({ title: "R1" });
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
const m2 = store.createMilestone(roadmap.id, { title: "M2" });
const f1 = store.createFeature(m1.id, { title: "F1" });
// Try to move with wrong fromMilestoneId
expect(() =>
store.moveFeature({
roadmapId: roadmap.id,
featureId: f1.id,
fromMilestoneId: m2.id, // Wrong! f1 belongs to m1
toMilestoneId: m2.id,
targetOrderIndex: 0,
}),
).toThrow(); // The feature is not found in the affected scope
});
it("produces contiguous orderIndex after milestone reorder", () => {
const roadmap = store.createRoadmap({ title: "Contiguous Test" });
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
const m2 = store.createMilestone(roadmap.id, { title: "M2" });
const m3 = store.createMilestone(roadmap.id, { title: "M3" });
// Reorder to different positions
store.reorderMilestones({
roadmapId: roadmap.id,
orderedMilestoneIds: [m2.id, m3.id, m1.id],
});
const milestones = store.listMilestones(roadmap.id);
const orderIndices = milestones.map((m) => m.orderIndex);
// Verify contiguous [0, 1, 2]
expect(orderIndices).toEqual([0, 1, 2]);
// Verify no gaps or duplicates
const uniqueIndices = new Set(orderIndices);
expect(uniqueIndices.size).toBe(orderIndices.length);
});
it("produces contiguous orderIndex after feature reorder", () => {
const roadmap = store.createRoadmap({ title: "Contiguous Feature" });
const m1 = store.createMilestone(roadmap.id, { title: "M1" });
const f1 = store.createFeature(m1.id, { title: "F1" });
const f2 = store.createFeature(m1.id, { title: "F2" });
const f3 = store.createFeature(m1.id, { title: "F3" });
// Reorder to different positions
store.reorderFeatures({
roadmapId: roadmap.id,
milestoneId: m1.id,
orderedFeatureIds: [f3.id, f1.id, f2.id],
});
const features = store.listFeatures(m1.id);
const orderIndices = features.map((f) => f.orderIndex);
// Verify contiguous [0, 1, 2]
expect(orderIndices).toEqual([0, 1, 2]);
// Verify no gaps or duplicates
const uniqueIndices = new Set(orderIndices);
expect(uniqueIndices.size).toBe(orderIndices.length);
});
}); });
}); });

View File

@@ -21,6 +21,7 @@ vi.mock("../../api", () => ({
moveRoadmapFeature: vi.fn(), moveRoadmapFeature: vi.fn(),
generateMilestoneSuggestions: vi.fn(), generateMilestoneSuggestions: vi.fn(),
generateFeatureSuggestions: vi.fn(), generateFeatureSuggestions: vi.fn(),
fetchRoadmapHandoff: vi.fn(),
})); }));
const mockRoadmaps = [ const mockRoadmaps = [
@@ -555,7 +556,24 @@ describe("useRoadmaps", () => {
}); });
describe("reorderFeatures", () => { describe("reorderFeatures", () => {
it("reorders features within a milestone", async () => { it("reorders features within a milestone with optimistic update", async () => {
// This test requires multiple features to meaningfully test reordering
// We'll create a custom hierarchy with multiple features
const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
...mockRoadmapHierarchy,
milestones: [
{
...mockRoadmapHierarchy.milestones[0],
features: [
{ id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
{ id: "RF-002", milestoneId: "RMS-001", title: "Feature 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
],
},
mockRoadmapHierarchy.milestones[1],
],
};
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); (api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const { result } = renderHook(() => useRoadmaps()); const { result } = renderHook(() => useRoadmaps());
@@ -569,18 +587,34 @@ describe("useRoadmaps", () => {
expect(result.current.selectedRoadmapId).toBe("RM-001"); expect(result.current.selectedRoadmapId).toBe("RM-001");
}); });
// Reorder features in RMS-001: already has RF-001 at orderIndex 0 // Reorder features in RMS-001: swap RF-001 and RF-002
await result.current.reorderFeatures("RMS-001", ["RF-001"]); await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"]);
expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith( expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith(
"RMS-001", "RMS-001",
["RF-001"], ["RF-002", "RF-001"],
undefined undefined
); );
expect(api.fetchRoadmap).toHaveBeenCalled(); expect(api.fetchRoadmap).toHaveBeenCalled();
}); });
it("rolls back on failure", async () => { it("rolls back on failure", async () => {
// This test requires multiple features
const multiFeatureHierarchy: import("@fusion/core").RoadmapWithHierarchy = {
...mockRoadmapHierarchy,
milestones: [
{
...mockRoadmapHierarchy.milestones[0],
features: [
{ id: "RF-001", milestoneId: "RMS-001", title: "Feature 1", orderIndex: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
{ id: "RF-002", milestoneId: "RMS-001", title: "Feature 2", orderIndex: 1, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
],
},
mockRoadmapHierarchy.milestones[1],
],
};
(api.fetchRoadmap as ReturnType<typeof vi.fn>).mockResolvedValue(multiFeatureHierarchy);
const reorderError = new Error("Feature reorder failed"); const reorderError = new Error("Feature reorder failed");
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError); (api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
@@ -599,7 +633,7 @@ describe("useRoadmaps", () => {
const onError = vi.fn(); const onError = vi.fn();
try { try {
await result.current.reorderFeatures("RMS-001", ["RF-001"], { onError }); await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"], { onError });
} catch { } catch {
// Expected to throw // Expected to throw
} }
@@ -1461,4 +1495,239 @@ describe("useRoadmaps", () => {
expect(result.current.milestoneSuggestions).toHaveLength(0); expect(result.current.milestoneSuggestions).toHaveLength(0);
}); });
}); });
describe("Handoff / Export", () => {
const mockHandoffPayload = {
mission: {
sourceRoadmapId: "RM-001",
title: "Q2 Roadmap",
description: "Q2 product roadmap",
milestones: [
{
sourceMilestoneId: "RMS-001",
title: "Milestone 1",
description: "First milestone",
orderIndex: 0,
features: [
{ sourceFeatureId: "RF-001", title: "Feature 1", description: "First feature", orderIndex: 0 },
],
},
],
},
features: [
{
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "Q2 Roadmap", milestoneTitle: "Milestone 1", milestoneOrderIndex: 0, featureOrderIndex: 0 },
title: "Feature 1",
description: "First feature",
},
],
};
beforeEach(() => {
// Reset fetchHandoff mock for each test
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockReset();
});
it("fetches handoff payload for a roadmap", async () => {
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear any stale handoff state from previous tests
result.current.clearHandoff();
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
await result.current.fetchHandoff("RM-001");
await waitFor(() => {
expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
expect(result.current.isFetchingHandoff).toBe(false);
expect(result.current.handoffError).toBeNull();
});
});
it("handles fetchHandoff error", async () => {
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Failed to fetch handoff")
);
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear any stale handoff state
result.current.clearHandoff();
await result.current.fetchHandoff("RM-001");
await waitFor(() => {
expect(result.current.handoffError).toBeDefined();
expect(result.current.handoffPayload).toBeNull();
expect(result.current.isFetchingHandoff).toBe(false);
});
});
it("clears handoff state with clearHandoff", async () => {
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
const { result, rerender } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear any stale handoff state
result.current.clearHandoff();
await result.current.fetchHandoff("RM-001");
await waitFor(() => {
expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
});
// Call clearHandoff and rerender to get fresh state
result.current.clearHandoff();
rerender();
expect(result.current.handoffPayload).toBeNull();
expect(result.current.handoffError).toBeNull();
expect(result.current.isFetchingHandoff).toBe(false);
});
it("clears handoff payload when project changes", async () => {
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
{ initialProps: { projectId: "proj-1" } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear any stale handoff state
result.current.clearHandoff();
await result.current.fetchHandoff("RM-001");
await waitFor(() => {
expect(result.current.handoffPayload).toEqual(mockHandoffPayload);
});
// Change project
rerender({ projectId: "proj-2" });
// Handoff should be cleared
expect(result.current.handoffPayload).toBeNull();
expect(result.current.handoffError).toBeNull();
});
it("sends correct projectId when fetching handoff", async () => {
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockResolvedValue(mockHandoffPayload);
const { result } = renderHook(() => useRoadmaps({ projectId: "proj-test" }));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear any stale handoff state
result.current.clearHandoff();
await result.current.fetchHandoff("RM-001");
await waitFor(() => {
expect(api.fetchRoadmapHandoff).toHaveBeenCalledWith("RM-001", "proj-test");
});
});
it("does not set stale handoff response after project change", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
{ initialProps: { projectId: "proj-1" } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Clear any stale handoff state
result.current.clearHandoff();
// Start fetch but don't resolve yet
let resolveHandoff: ((value: typeof mockHandoffPayload) => void) | null = null;
(api.fetchRoadmapHandoff as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
return new Promise((resolve) => {
resolveHandoff = resolve;
});
});
const fetchPromise = result.current.fetchHandoff("RM-001");
// Change project before promise resolves
rerender({ projectId: "proj-2" });
// Resolve the promise
if (resolveHandoff) {
resolveHandoff(mockHandoffPayload);
}
await fetchPromise;
// Handoff should NOT be set because we're in a different project now
expect(result.current.handoffPayload).toBeNull();
});
});
describe("No-op suppression", () => {
it("skips API call when reordering features to same order", async () => {
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// Try to reorder with same order as current
const currentFeatureIds = result.current.featuresByMilestoneId["RMS-001"]?.map((f) => f.id) || [];
await result.current.reorderFeatures("RMS-001", currentFeatureIds);
// API should NOT have been called
expect(api.reorderRoadmapFeatures).not.toHaveBeenCalled();
});
it("skips API call when moving feature to same position in same milestone", async () => {
const { result } = renderHook(() => useRoadmaps());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
result.current.selectRoadmap("RM-001");
await waitFor(() => {
expect(result.current.selectedRoadmapId).toBe("RM-001");
});
// The feature RF-001 is already at index 0, try to move it to index 0
await result.current.moveFeature("RF-001", "RMS-001", 0);
// API should NOT have been called
expect(api.moveRoadmapFeature).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -277,7 +277,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
const newRoadmap = await api.createRoadmap(input, projectId); const newRoadmap = await api.createRoadmap(input, projectIdRef.current);
setRoadmaps((prev) => [...prev, newRoadmap]); setRoadmaps((prev) => [...prev, newRoadmap]);
opts?.onSuccess?.(); opts?.onSuccess?.();
} catch (err) { } catch (err) {
@@ -285,7 +285,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [projectId]); }, []); // No dependencies needed - uses refs
const updateRoadmap = useCallback(async ( const updateRoadmap = useCallback(async (
roadmapId: string, roadmapId: string,
@@ -293,7 +293,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
const updated = await api.updateRoadmap(roadmapId, updates, projectId); const updated = await api.updateRoadmap(roadmapId, updates, projectIdRef.current);
setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r))); setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r)));
if (selectedRoadmapIdRef.current === roadmapId) { if (selectedRoadmapIdRef.current === roadmapId) {
setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null); setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
@@ -304,14 +304,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [projectId]); }, []); // No dependencies needed - uses refs
const deleteRoadmap = useCallback(async ( const deleteRoadmap = useCallback(async (
roadmapId: string, roadmapId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
await api.deleteRoadmap(roadmapId, projectId); await api.deleteRoadmap(roadmapId, projectIdRef.current);
setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId)); setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
if (selectedRoadmapIdRef.current === roadmapId) { if (selectedRoadmapIdRef.current === roadmapId) {
setSelectedRoadmapId(null); setSelectedRoadmapId(null);
@@ -325,7 +325,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [projectId]); }, []); // No dependencies needed - uses refs
const selectRoadmap = useCallback((roadmapId: string | null) => { const selectRoadmap = useCallback((roadmapId: string | null) => {
setSelectedRoadmapId(roadmapId); setSelectedRoadmapId(roadmapId);
@@ -364,7 +364,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectId); const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectIdRef.current);
setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m))); setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m)));
if (selectedRoadmapIdRef.current) { if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current); void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
@@ -375,14 +375,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
const deleteMilestone = useCallback(async ( const deleteMilestone = useCallback(async (
milestoneId: string, milestoneId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
await api.deleteRoadmapMilestone(milestoneId, projectId); await api.deleteRoadmapMilestone(milestoneId, projectIdRef.current);
setMilestones((prev) => prev.filter((m) => m.id !== milestoneId)); setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
setFeaturesByMilestoneId((prev) => { setFeaturesByMilestoneId((prev) => {
const updated = { ...prev }; const updated = { ...prev };
@@ -398,7 +398,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
// Feature CRUD // Feature CRUD
const createFeature = useCallback(async ( const createFeature = useCallback(async (
@@ -407,7 +407,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectId); const newFeature = await api.createRoadmapFeature(milestoneId, input, projectIdRef.current);
setFeaturesByMilestoneId((prev) => ({ setFeaturesByMilestoneId((prev) => ({
...prev, ...prev,
[milestoneId]: [...(prev[milestoneId] || []), newFeature], [milestoneId]: [...(prev[milestoneId] || []), newFeature],
@@ -421,7 +421,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
const updateFeature = useCallback(async ( const updateFeature = useCallback(async (
featureId: string, featureId: string,
@@ -429,7 +429,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
const updated = await api.updateRoadmapFeature(featureId, updates, projectId); const updated = await api.updateRoadmapFeature(featureId, updates, projectIdRef.current);
setFeaturesByMilestoneId((prev) => { setFeaturesByMilestoneId((prev) => {
const updatedMap: Record<string, RoadmapFeature[]> = {}; const updatedMap: Record<string, RoadmapFeature[]> = {};
for (const [milestoneId, features] of Object.entries(prev)) { for (const [milestoneId, features] of Object.entries(prev)) {
@@ -446,14 +446,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
const deleteFeature = useCallback(async ( const deleteFeature = useCallback(async (
featureId: string, featureId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
try { try {
await api.deleteRoadmapFeature(featureId, projectId); await api.deleteRoadmapFeature(featureId, projectIdRef.current);
setFeaturesByMilestoneId((prev) => { setFeaturesByMilestoneId((prev) => {
const updatedMap: Record<string, RoadmapFeature[]> = {}; const updatedMap: Record<string, RoadmapFeature[]> = {};
for (const [milestoneId, features] of Object.entries(prev)) { for (const [milestoneId, features] of Object.entries(prev)) {
@@ -470,7 +470,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
// Milestone ordering // Milestone ordering
const reorderMilestones = useCallback(async ( const reorderMilestones = useCallback(async (
@@ -490,7 +490,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
setMilestones(reordered); setMilestones(reordered);
try { try {
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectId); await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectIdRef.current);
// Refresh to get server state // Refresh to get server state
if (selectedRoadmapIdRef.current) { if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current); void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
@@ -503,7 +503,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
// Feature ordering // Feature ordering
const reorderFeatures = useCallback(async ( const reorderFeatures = useCallback(async (
@@ -511,9 +511,16 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
orderedFeatureIds: string[], orderedFeatureIds: string[],
opts?: { onSuccess?: () => void; onError?: (err: Error) => void } opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => { ) => {
// No-op suppression: if IDs are already in the same order, skip API call
const currentFeatures = featuresByMilestoneIdRef.current[milestoneId] || [];
const currentIds = currentFeatures.map((f) => f.id);
if (JSON.stringify(currentIds) === JSON.stringify(orderedFeatureIds)) {
opts?.onSuccess?.();
return;
}
// Save snapshot for rollback // Save snapshot for rollback
const snapshot = featuresByMilestoneIdRef.current; const snapshot = featuresByMilestoneIdRef.current;
const currentFeatures = snapshot[milestoneId] || [];
// Optimistic update // Optimistic update
const reordered = orderedFeatureIds const reordered = orderedFeatureIds
@@ -527,7 +534,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
})); }));
try { try {
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectId); await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectIdRef.current);
// Refresh to get server state // Refresh to get server state
if (selectedRoadmapIdRef.current) { if (selectedRoadmapIdRef.current) {
void fetchSelectedRoadmap(selectedRoadmapIdRef.current); void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
@@ -540,7 +547,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
opts?.onError?.(error); opts?.onError?.(error);
throw error; throw error;
} }
}, [fetchSelectedRoadmap, projectId]); }, [fetchSelectedRoadmap]); // Uses refs internally
const moveFeature = useCallback(async ( const moveFeature = useCallback(async (
featureId: string, featureId: string,
@@ -566,6 +573,17 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
throw error; throw error;
} }
// No-op suppression: if already at target position in same milestone, skip
if (sourceMilestoneId === targetMilestoneId) {
const currentFeatures = snapshot[sourceMilestoneId] || [];
const clampedIndex = Math.max(0, Math.min(targetIndex, currentFeatures.length - 1));
const currentIndex = currentFeatures.findIndex((f) => f.id === featureId);
if (currentIndex === clampedIndex) {
opts?.onSuccess?.();
return;
}
}
// Optimistic update // Optimistic update
const sourceFeatures = snapshot[sourceMilestoneId] || []; const sourceFeatures = snapshot[sourceMilestoneId] || [];
const targetFeatures = snapshot[targetMilestoneId] || []; const targetFeatures = snapshot[targetMilestoneId] || [];

View File

@@ -357,14 +357,41 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect((response.body as any).session.id).toBe("chat-abc123"); expect((response.body as any).session.id).toBe("chat-abc123");
// Model is resolved from agent's runtimeConfig.model // Model is resolved from agent's runtimeConfig.model
// projectId is null when no projectId query param is provided
expect(mockCreateSession).toHaveBeenCalledWith({ expect(mockCreateSession).toHaveBeenCalledWith({
agentId: "agent-001", agentId: "agent-001",
title: null, title: null,
projectId: null,
modelProvider: "anthropic", modelProvider: "anthropic",
modelId: "claude-sonnet-4-5", modelId: "claude-sonnet-4-5",
}); });
}); });
it("creates session with projectId when projectId query param is provided", async () => {
const sessionWithProject = {
...sampleSession,
projectId: "proj-001",
};
mockCreateSession.mockReturnValue(sessionWithProject);
const response = await request(
app,
"POST",
"/api/chat/sessions?projectId=proj-001",
JSON.stringify({ agentId: "agent-001" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
expect((response.body as any).session.projectId).toBe("proj-001");
// Verify projectId is passed to createSession
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "proj-001",
}),
);
});
it("creates session with title and resolves model from agent config", async () => { it("creates session with title and resolves model from agent config", async () => {
const sessionWithOptions = { const sessionWithOptions = {
...sampleSession, ...sampleSession,
@@ -387,6 +414,13 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect((response.body as any).session.title).toBe("Custom Title"); expect((response.body as any).session.title).toBe("Custom Title");
// projectId is null when no projectId query param is provided
expect(mockCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
title: "Custom Title",
projectId: null,
}),
);
}); });
it("returns 400 when agentId is missing", async () => { it("returns 400 when agentId is missing", async () => {
@@ -455,9 +489,11 @@ describe("Chat API Routes", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
// No model resolved from agent config // No model resolved from agent config
// projectId is null when no projectId query param is provided
expect(mockCreateSession).toHaveBeenCalledWith({ expect(mockCreateSession).toHaveBeenCalledWith({
agentId: "agent-002", agentId: "agent-002",
title: null, title: null,
projectId: null,
modelProvider: null, modelProvider: null,
modelId: null, modelId: null,
}); });

View File

@@ -276,6 +276,31 @@ describe("Roadmap Routes", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body.title).toBe("New Roadmap"); expect(response.body.title).toBe("New Roadmap");
}); });
it("returns 400 when title is missing", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is empty", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title is whitespace-only", async () => {
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: " " }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
it("returns 400 when title exceeds 200 characters", async () => {
const longTitle = "A".repeat(201);
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: longTitle }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("200 characters");
});
}); });
describe("GET /api/roadmaps/:roadmapId", () => { describe("GET /api/roadmaps/:roadmapId", () => {
@@ -325,6 +350,20 @@ describe("Roadmap Routes", () => {
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" }); const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" });
expect(response.status).toBe(204); expect(response.status).toBe(204);
}); });
it("returns 400 when orderedMilestoneIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedMilestoneIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: ["id1", 123, "id3"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
}); });
describe("PATCH /api/roadmaps/milestones/:milestoneId", () => { describe("PATCH /api/roadmaps/milestones/:milestoneId", () => {
@@ -354,6 +393,32 @@ describe("Roadmap Routes", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body.title).toBe("New Feature"); expect(response.body.title).toBe("New Feature");
}); });
it("returns 400 when title is missing", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({}), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("title is required");
});
});
describe("POST /api/roadmaps/milestones/:milestoneId/features/reorder", () => {
it("returns 400 when orderedFeatureIds is not an array", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: "not-an-array" }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array");
});
it("returns 400 when orderedFeatureIds contains non-strings", async () => {
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: [123, "id2"] }), { "Content-Type": "application/json" });
expect(response.status).toBe(400);
expect(response.body.error).toContain("must be an array of strings");
});
}); });
describe("PATCH /api/roadmaps/features/:featureId", () => { describe("PATCH /api/roadmaps/features/:featureId", () => {

View File

@@ -7817,6 +7817,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
* Create a new chat session. * Create a new chat session.
* Body: { agentId: string, title?: string } * Body: { agentId: string, title?: string }
* The model is resolved from the agent's runtimeConfig.model setting. * The model is resolved from the agent's runtimeConfig.model setting.
* The session is scoped to the project identified by projectId query param or header.
*/ */
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
try { try {
@@ -7825,7 +7826,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw internalError("Chat store not available"); throw internalError("Chat store not available");
} }
const scopedStore = await getScopedStore(req); // Get project context to scope the session and resolve agent from the correct store
const { store: scopedStore, projectId } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core"); const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init(); await agentStore.init();
@@ -7852,9 +7854,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : undefined; const resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : undefined;
const resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : undefined; const resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : undefined;
// Create the chat session with projectId for multi-project scoping
const session = chatStore.createSession({ const session = chatStore.createSession({
agentId: agentId.trim(), agentId: agentId.trim(),
title: title?.trim() || null, title: title?.trim() || null,
projectId: projectId ?? null,
modelProvider: resolvedProvider ?? null, modelProvider: resolvedProvider ?? null,
modelId: resolvedModelId ?? null, modelId: resolvedModelId ?? null,
}); });