feat(FN-1675): merge fusion/fn-1675
This commit is contained in:
@@ -248,5 +248,87 @@ describe("roadmap-ordering", () => {
|
||||
"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);
|
||||
});
|
||||
});
|
||||
|
||||
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(),
|
||||
generateMilestoneSuggestions: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn(),
|
||||
fetchRoadmapHandoff: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockRoadmaps = [
|
||||
@@ -555,7 +556,24 @@ describe("useRoadmaps", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
@@ -569,18 +587,34 @@ describe("useRoadmaps", () => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Reorder features in RMS-001: already has RF-001 at orderIndex 0
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-001"]);
|
||||
// Reorder features in RMS-001: swap RF-001 and RF-002
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"]);
|
||||
|
||||
expect(api.reorderRoadmapFeatures).toHaveBeenCalledWith(
|
||||
"RMS-001",
|
||||
["RF-001"],
|
||||
["RF-002", "RF-001"],
|
||||
undefined
|
||||
);
|
||||
expect(api.fetchRoadmap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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");
|
||||
(api.reorderRoadmapFeatures as ReturnType<typeof vi.fn>).mockRejectedValue(reorderError);
|
||||
|
||||
@@ -599,7 +633,7 @@ describe("useRoadmaps", () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
try {
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-001"], { onError });
|
||||
await result.current.reorderFeatures("RMS-001", ["RF-002", "RF-001"], { onError });
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
@@ -1461,4 +1495,239 @@ describe("useRoadmaps", () => {
|
||||
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 }
|
||||
) => {
|
||||
try {
|
||||
const newRoadmap = await api.createRoadmap(input, projectId);
|
||||
const newRoadmap = await api.createRoadmap(input, projectIdRef.current);
|
||||
setRoadmaps((prev) => [...prev, newRoadmap]);
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
@@ -285,7 +285,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [projectId]);
|
||||
}, []); // No dependencies needed - uses refs
|
||||
|
||||
const updateRoadmap = useCallback(async (
|
||||
roadmapId: string,
|
||||
@@ -293,7 +293,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
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)));
|
||||
if (selectedRoadmapIdRef.current === roadmapId) {
|
||||
setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
|
||||
@@ -304,14 +304,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [projectId]);
|
||||
}, []); // No dependencies needed - uses refs
|
||||
|
||||
const deleteRoadmap = useCallback(async (
|
||||
roadmapId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
await api.deleteRoadmap(roadmapId, projectId);
|
||||
await api.deleteRoadmap(roadmapId, projectIdRef.current);
|
||||
setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
|
||||
if (selectedRoadmapIdRef.current === roadmapId) {
|
||||
setSelectedRoadmapId(null);
|
||||
@@ -325,7 +325,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [projectId]);
|
||||
}, []); // No dependencies needed - uses refs
|
||||
|
||||
const selectRoadmap = useCallback((roadmapId: string | null) => {
|
||||
setSelectedRoadmapId(roadmapId);
|
||||
@@ -364,7 +364,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
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)));
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
@@ -375,14 +375,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const deleteMilestone = useCallback(async (
|
||||
milestoneId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
await api.deleteRoadmapMilestone(milestoneId, projectId);
|
||||
await api.deleteRoadmapMilestone(milestoneId, projectIdRef.current);
|
||||
setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
|
||||
setFeaturesByMilestoneId((prev) => {
|
||||
const updated = { ...prev };
|
||||
@@ -398,7 +398,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
// Feature CRUD
|
||||
const createFeature = useCallback(async (
|
||||
@@ -407,7 +407,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectId);
|
||||
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectIdRef.current);
|
||||
setFeaturesByMilestoneId((prev) => ({
|
||||
...prev,
|
||||
[milestoneId]: [...(prev[milestoneId] || []), newFeature],
|
||||
@@ -421,7 +421,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const updateFeature = useCallback(async (
|
||||
featureId: string,
|
||||
@@ -429,7 +429,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
const updated = await api.updateRoadmapFeature(featureId, updates, projectId);
|
||||
const updated = await api.updateRoadmapFeature(featureId, updates, projectIdRef.current);
|
||||
setFeaturesByMilestoneId((prev) => {
|
||||
const updatedMap: Record<string, RoadmapFeature[]> = {};
|
||||
for (const [milestoneId, features] of Object.entries(prev)) {
|
||||
@@ -446,14 +446,14 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const deleteFeature = useCallback(async (
|
||||
featureId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
try {
|
||||
await api.deleteRoadmapFeature(featureId, projectId);
|
||||
await api.deleteRoadmapFeature(featureId, projectIdRef.current);
|
||||
setFeaturesByMilestoneId((prev) => {
|
||||
const updatedMap: Record<string, RoadmapFeature[]> = {};
|
||||
for (const [milestoneId, features] of Object.entries(prev)) {
|
||||
@@ -470,7 +470,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
// Milestone ordering
|
||||
const reorderMilestones = useCallback(async (
|
||||
@@ -490,7 +490,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
setMilestones(reordered);
|
||||
|
||||
try {
|
||||
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectId);
|
||||
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectIdRef.current);
|
||||
// Refresh to get server state
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
@@ -503,7 +503,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
// Feature ordering
|
||||
const reorderFeatures = useCallback(async (
|
||||
@@ -511,9 +511,16 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
orderedFeatureIds: string[],
|
||||
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
|
||||
const snapshot = featuresByMilestoneIdRef.current;
|
||||
const currentFeatures = snapshot[milestoneId] || [];
|
||||
|
||||
// Optimistic update
|
||||
const reordered = orderedFeatureIds
|
||||
@@ -527,7 +534,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
}));
|
||||
|
||||
try {
|
||||
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectId);
|
||||
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectIdRef.current);
|
||||
// Refresh to get server state
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
@@ -540,7 +547,7 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap, projectId]);
|
||||
}, [fetchSelectedRoadmap]); // Uses refs internally
|
||||
|
||||
const moveFeature = useCallback(async (
|
||||
featureId: string,
|
||||
@@ -566,6 +573,17 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
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
|
||||
const sourceFeatures = snapshot[sourceMilestoneId] || [];
|
||||
const targetFeatures = snapshot[targetMilestoneId] || [];
|
||||
|
||||
@@ -357,14 +357,41 @@ describe("Chat API Routes", () => {
|
||||
expect(response.status).toBe(201);
|
||||
expect((response.body as any).session.id).toBe("chat-abc123");
|
||||
// Model is resolved from agent's runtimeConfig.model
|
||||
// projectId is null when no projectId query param is provided
|
||||
expect(mockCreateSession).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
title: null,
|
||||
projectId: null,
|
||||
modelProvider: "anthropic",
|
||||
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 () => {
|
||||
const sessionWithOptions = {
|
||||
...sampleSession,
|
||||
@@ -387,6 +414,13 @@ describe("Chat API Routes", () => {
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
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 () => {
|
||||
@@ -455,9 +489,11 @@ describe("Chat API Routes", () => {
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
// No model resolved from agent config
|
||||
// projectId is null when no projectId query param is provided
|
||||
expect(mockCreateSession).toHaveBeenCalledWith({
|
||||
agentId: "agent-002",
|
||||
title: null,
|
||||
projectId: null,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
});
|
||||
|
||||
@@ -276,6 +276,31 @@ describe("Roadmap Routes", () => {
|
||||
expect(response.status).toBe(201);
|
||||
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", () => {
|
||||
@@ -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" });
|
||||
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", () => {
|
||||
@@ -354,6 +393,32 @@ describe("Roadmap Routes", () => {
|
||||
expect(response.status).toBe(201);
|
||||
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", () => {
|
||||
|
||||
@@ -7817,6 +7817,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
* Create a new chat session.
|
||||
* Body: { agentId: string, title?: string }
|
||||
* 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) => {
|
||||
try {
|
||||
@@ -7825,7 +7826,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
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 = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
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 resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : undefined;
|
||||
|
||||
// Create the chat session with projectId for multi-project scoping
|
||||
const session = chatStore.createSession({
|
||||
agentId: agentId.trim(),
|
||||
title: title?.trim() || null,
|
||||
projectId: projectId ?? null,
|
||||
modelProvider: resolvedProvider ?? null,
|
||||
modelId: resolvedModelId ?? null,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user