feat(FN-1675): merge fusion/fn-1675
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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] || [];
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user