Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e480efd43a | |||
| 0ae8887f2a | |||
| d3d5a71d09 | |||
| 9529fc9eb7 |
@@ -332,6 +332,105 @@ describe("handleCanvasEvent – WORKSPACE_PROVISIONING", () => {
|
||||
const bPos = lastNodes.find((n) => n.id === "ws-b")!.position;
|
||||
expect(bPos).toEqual({ x: 420, y: 100 }); // idx 1 = (100 + 320, 100)
|
||||
});
|
||||
|
||||
it("uses finalX/finalY from payload when parentId is set and parent exists in store", () => {
|
||||
// Org-import child lands with explicit coords — these are server-computed
|
||||
// parent-relative positions. The handler must trust them verbatim.
|
||||
const parent = makeNode("parent-root", { name: "Root" });
|
||||
const { get, set } = makeStore([parent]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "WORKSPACE_PROVISIONING",
|
||||
workspace_id: "child-org",
|
||||
payload: {
|
||||
name: "Org Child",
|
||||
tier: 2,
|
||||
parent_id: "parent-root",
|
||||
x: 500,
|
||||
y: 300,
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
|
||||
expect(newNodes).toHaveLength(2);
|
||||
const child = newNodes.find((n) => n.id === "child-org")!;
|
||||
|
||||
// Must use the server-provided coords, not grid
|
||||
expect(child.position).toEqual({ x: 500, y: 300 });
|
||||
// Must bind parentId so RF renders it nested inside the parent card
|
||||
expect(child.parentId).toBe("parent-root");
|
||||
expect(child.data.parentId).toBe("parent-root");
|
||||
expect(child.data.name).toBe("Org Child");
|
||||
expect(child.data.status).toBe("provisioning");
|
||||
});
|
||||
|
||||
it("uses grid position when parentId is set but parent is NOT in store yet", () => {
|
||||
// Rare WS-reorder: child event arrives before parent's PROVISIONING event.
|
||||
// Must not crash — uses grid slot as fallback. Parent will reparent
|
||||
// the child when it lands.
|
||||
const { get, set } = makeStore([]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "WORKSPACE_PROVISIONING",
|
||||
workspace_id: "orphan-child",
|
||||
payload: {
|
||||
name: "Orphan",
|
||||
parent_id: "unknown-parent",
|
||||
x: 999,
|
||||
y: 888,
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
|
||||
const child = newNodes.find((n) => n.id === "orphan-child")!;
|
||||
|
||||
// Must NOT use finalX/finalY — parent isn't in store so grid slot is used
|
||||
expect(child.position).not.toEqual({ x: 999, y: 888 });
|
||||
// Grid slot for idx 0: (100, 100)
|
||||
expect(child.position).toEqual({ x: 100, y: 100 });
|
||||
// parentId is NOT set on the node when parent is unknown:
|
||||
// the node will be reparented when the parent eventually lands
|
||||
expect(child.data.parentId).not.toBe("unknown-parent");
|
||||
});
|
||||
|
||||
it("no-op cascade: parent in store but no finalX/Y → grid position, no parentId", () => {
|
||||
// Parent exists but payload has no x/y → must not crash, uses grid slot.
|
||||
// parentId is NOT set because we don't have parent-relative coords.
|
||||
const parent = makeNode("parent-exists");
|
||||
const { get, set } = makeStore([parent]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "WORKSPACE_PROVISIONING",
|
||||
workspace_id: "child-no-coords",
|
||||
payload: {
|
||||
name: "No Coords",
|
||||
parent_id: "parent-exists",
|
||||
// no x or y
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
|
||||
const child = newNodes.find((n) => n.id === "child-no-coords")!;
|
||||
|
||||
// Grid slot for idx 0: (100, 100)
|
||||
expect(child.position).toEqual({ x: 100, y: 100 });
|
||||
// parentId stays null (not undefined) when no finalX/Y — server has no
|
||||
// position for this node, and the handler initialises parentId=null
|
||||
expect(child.parentId).toBeUndefined();
|
||||
expect(child.data.parentId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -848,6 +848,374 @@ describe("hydrationError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- growParentsToFitChildren ----------
|
||||
//
|
||||
// growParentsToFitChildren walks every parent node and expands its width/height
|
||||
// so all children fit inside with padding. Collapsed parents are skipped (grow-
|
||||
// only, never shrink). Returns the same array reference when no changes are
|
||||
// needed, a new array when at least one parent grew.
|
||||
//
|
||||
// Constants (from canvas-topology.ts):
|
||||
// CHILD_DEFAULT_WIDTH = 240
|
||||
// CHILD_DEFAULT_HEIGHT = 130
|
||||
// PARENT_SIDE_PADDING = 16
|
||||
// PARENT_BOTTOM_PADDING = 16
|
||||
//
|
||||
// For a child at (childX, childY) with size (childW, childH):
|
||||
// requiredParentW = childX + childW + PARENT_SIDE_PADDING
|
||||
// requiredParentH = childY + childH + PARENT_BOTTOM_PADDING
|
||||
//
|
||||
// Coverage targets:
|
||||
// - Node with no parentId → skipped entirely (returns same node)
|
||||
// - Parent with no children → skipped (kids.length === 0 → returns n)
|
||||
// - Collapsed parent → skipped even when children overflow
|
||||
// - Child fits within existing parent → no-op (requiredW <= currentW && requiredH <= currentH)
|
||||
// - Child overflows parent width → grows width only
|
||||
// - Child overflows parent height → grows height only
|
||||
// - Child overflows both → grows both
|
||||
// - Missing measured.width (falls back to width, then CHILD_DEFAULT_WIDTH)
|
||||
// - Missing measured.height (falls back to height, then CHILD_DEFAULT_HEIGHT)
|
||||
// - Missing parent measured.width (falls back to width, then 0)
|
||||
// - Missing parent measured.height (falls back to height, then 0)
|
||||
// - No change at all → returns same array reference (changed=false path)
|
||||
|
||||
describe("growParentsToFitChildren", () => {
|
||||
it("skips nodes with no parentId (standalone roots)", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "root",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Root", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 200, height: 150 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same array reference (no change needed)
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("skips parent with no children (orphan parentId)", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "orphan",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "nonexistent",
|
||||
data: { name: "Orphan", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 100, height: 100 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same array reference (parentId exists but no children reference it)
|
||||
expect(after).toBe(before);
|
||||
expect(after[0].measured).toEqual({ width: 100, height: 100 });
|
||||
});
|
||||
|
||||
it("skips collapsed parents even when children overflow", () => {
|
||||
// Child at (500, 400) → requires parent 500+240+16=756w, 400+130+16=546h
|
||||
// Parent is collapsed AND tiny — must NOT grow
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: true, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 200, height: 150 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 500, y: 400 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same reference (collapsed → skipped entirely)
|
||||
expect(after).toBe(before);
|
||||
const parent = after.find((n) => n.id === "parent")!;
|
||||
expect(parent.measured).toEqual({ width: 200, height: 150 });
|
||||
});
|
||||
|
||||
it("no-op when child fits within existing parent size", () => {
|
||||
// Child at (0,0) 240x130 → requires 0+240+16=256w, 0+130+16=146h
|
||||
// Parent is exactly 256×146 → fits perfectly
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same array reference (no change needed)
|
||||
expect(after).toBe(before);
|
||||
const parent = after.find((n) => n.id === "parent")!;
|
||||
expect(parent.measured).toEqual({ width: 256, height: 146 });
|
||||
});
|
||||
|
||||
it("grows parent width only when child overflows width but not height", () => {
|
||||
// Child at (100, 0) 240x130 → requires 100+240+16=356w, 0+130+16=146h
|
||||
// Parent is 256×146 → fits height, overflows width → grows to 356×146
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 100, y: 0 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(356); // 100+240+16
|
||||
expect(parent.height).toBe(146); // unchanged
|
||||
});
|
||||
|
||||
it("grows parent height only when child overflows height but not width", () => {
|
||||
// Child at (0, 50) 240x130 → requires 0+240+16=256w, 50+130+16=196h
|
||||
// Parent is 256×146 → fits width, overflows height → grows to 256×196
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 50 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(256); // unchanged
|
||||
expect(parent.height).toBe(196); // 50+130+16
|
||||
});
|
||||
|
||||
it("grows parent in both dimensions when child overflows both", () => {
|
||||
// Child at (200, 100) 240x130 → requires 200+240+16=456w, 100+130+16=246h
|
||||
// Parent is 256×146 → grows to 456×246
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 200, y: 100 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(456); // 200+240+16
|
||||
expect(parent.height).toBe(246); // 100+130+16
|
||||
});
|
||||
|
||||
it("uses CHILD_DEFAULT_WIDTH/HEIGHT when child has no measured or explicit dimensions", () => {
|
||||
// Child with NO measured, NO width/height → falls back to 240×130 defaults
|
||||
// Child at (500, 200) → requires 500+240+16=756w, 200+130+16=346h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 100, height: 100 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 500, y: 200 },
|
||||
parentId: "parent",
|
||||
// No measured, no width/height → uses CHILD_DEFAULT_WIDTH=240, CHILD_DEFAULT_HEIGHT=130
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(756); // 500+240+16
|
||||
expect(parent.height).toBe(346); // 200+130+16
|
||||
});
|
||||
|
||||
it("uses explicit width/height when measured is absent on child", () => {
|
||||
// Child has width/height but NOT measured
|
||||
// Child at (300, 50) with explicit 200×100 → requires 300+200+16=516w, 50+100+16=166h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 200, height: 100 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 300, y: 50 },
|
||||
parentId: "parent",
|
||||
width: 200,
|
||||
height: 100,
|
||||
// No measured → falls back to width=200, height=100
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(516); // 300+200+16
|
||||
expect(parent.height).toBe(166); // 50+100+16
|
||||
});
|
||||
|
||||
it("uses measured when present (takes precedence over explicit width/height)", () => {
|
||||
// Child has both measured AND explicit width/height — measured should win
|
||||
// Child at (0,0) measured=240×130 explicit=100×50 → uses measured
|
||||
// Required: 0+240+16=256w, 0+130+16=146h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 }, // fits exactly
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "parent",
|
||||
width: 100, // ignored (measured present)
|
||||
height: 50, // ignored
|
||||
measured: { width: 240, height: 130 },
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same reference (measured fits exactly)
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("multiple children: grows to fit the furthest child in each dimension", () => {
|
||||
// Child 1 at (0, 0) 240×130 → maxRight=240, maxBottom=130
|
||||
// Child 2 at (300, 200) 240×130 → maxRight=540, maxBottom=330
|
||||
// Required: 540+16=556w, 330+16=346h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 100, height: 100 },
|
||||
},
|
||||
{
|
||||
id: "child1",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child1", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
{
|
||||
id: "child2",
|
||||
type: "workspaceNode",
|
||||
position: { x: 300, y: 200 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child2", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(556); // max(0+240, 300+240)+16
|
||||
expect(parent.height).toBe(346); // max(0+130, 200+130)+16
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- ACTIVITY_LOGGED event ----------
|
||||
|
||||
describe("ACTIVITY_LOGGED event", () => {
|
||||
|
||||
@@ -14,18 +14,16 @@ import (
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ActivityHandler struct {
|
||||
broadcaster *events.Broadcaster
|
||||
notifier *push.Notifier
|
||||
}
|
||||
|
||||
func NewActivityHandler(b *events.Broadcaster, notifier *push.Notifier) *ActivityHandler {
|
||||
return &ActivityHandler{broadcaster: b, notifier: notifier}
|
||||
func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
|
||||
return &ActivityHandler{broadcaster: b}
|
||||
}
|
||||
|
||||
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
|
||||
@@ -478,7 +476,7 @@ func (h *ActivityHandler) Notify(c *gin.Context) {
|
||||
for _, a := range body.Attachments {
|
||||
attachments = append(attachments, AgentMessageAttachment(a))
|
||||
}
|
||||
writer := NewAgentMessageWriter(db.DB, h.broadcaster, h.notifier)
|
||||
writer := NewAgentMessageWriter(db.DB, h.broadcaster)
|
||||
if err := writer.Send(c.Request.Context(), workspaceID, body.Message, attachments); err != nil {
|
||||
if errors.Is(err, ErrWorkspaceNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestActivityHandler_SinceID_ReturnsNewerASC(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -69,7 +69,7 @@ func TestActivityHandler_SinceID_CursorNotFound_410(t *testing.T) {
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -101,7 +101,7 @@ func TestActivityHandler_SinceID_CrossWorkspaceCursor_410(t *testing.T) {
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -137,7 +137,7 @@ func TestActivityHandler_SinceID_CombinedWithSinceSecs(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestActivityHandler_SinceSecs_Accepted(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -70,7 +70,7 @@ func TestActivityHandler_SinceSecs_ClampedAt30Days(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -106,7 +106,7 @@ func TestActivityHandler_SinceSecs_InvalidRejected(t *testing.T) {
|
||||
// No DB call expected; bad input must be caught before the query.
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -142,7 +142,7 @@ func TestActivityHandler_SinceSecs_Omitted(t *testing.T) {
|
||||
WillReturnRows(newActivityRows())
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"kind", "id", "workspace_id", "label", "content", "method", "status", "request_body", "response_body", "created_at",
|
||||
@@ -68,7 +68,7 @@ func TestSessionSearchReturnsActivityAndMemory(t *testing.T) {
|
||||
func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
// Expect query with "source_id IS NULL"
|
||||
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NULL`).
|
||||
@@ -97,7 +97,7 @@ func TestActivityList_SourceCanvas(t *testing.T) {
|
||||
func TestActivityList_SourceAgent(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
// Expect query with "source_id IS NOT NULL"
|
||||
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND source_id IS NOT NULL`).
|
||||
@@ -126,7 +126,7 @@ func TestActivityList_SourceAgent(t *testing.T) {
|
||||
func TestActivityList_SourceInvalid(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -142,7 +142,7 @@ func TestActivityList_SourceInvalid(t *testing.T) {
|
||||
func TestActivityList_SourceWithType(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
// Both type and source filters
|
||||
mock.ExpectQuery(`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NULL`).
|
||||
@@ -181,7 +181,7 @@ const testPeerUUID = "11111111-2222-3333-4444-555555555555"
|
||||
func TestActivityList_PeerIDFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
// peer_id binds twice in the query (source_id OR target_id) but is
|
||||
// added to args once — sqlmock matches positional args, so the
|
||||
@@ -220,7 +220,7 @@ func TestActivityList_PeerIDComposesWithType(t *testing.T) {
|
||||
// of the builder can't silently rearrange placeholders.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery(
|
||||
`SELECT .+ FROM activity_logs WHERE workspace_id = .+ AND activity_type = .+ AND source_id IS NOT NULL AND \(source_id = .+ OR target_id = .+\)`,
|
||||
@@ -258,7 +258,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
|
||||
// otherwise interpolate the value into the URL or another query.
|
||||
gin.SetMode(gin.TestMode)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
for _, bad := range []string{
|
||||
"not-a-uuid",
|
||||
@@ -292,7 +292,7 @@ func TestActivityList_PeerIDRejectsNonUUID(t *testing.T) {
|
||||
func TestActivityList_BeforeTSFilter(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
|
||||
mock.ExpectQuery(
|
||||
@@ -328,7 +328,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
|
||||
// can't silently drop one filter or reorder placeholders.
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
cutoff, _ := time.Parse(time.RFC3339, "2026-05-01T00:00:00Z")
|
||||
mock.ExpectQuery(
|
||||
@@ -363,7 +363,7 @@ func TestActivityList_BeforeTSComposesWithPeerID(t *testing.T) {
|
||||
func TestActivityList_BeforeTSRejectsInvalidFormat(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
for _, bad := range []string{
|
||||
"yesterday",
|
||||
@@ -400,7 +400,7 @@ func TestActivityReport_AcceptsMemoryWriteType(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -426,7 +426,7 @@ func TestActivityReport_RejectsUnknownType(t *testing.T) {
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -478,7 +478,7 @@ func TestNotify_PersistsToActivityLogsForReloadRecovery(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -527,7 +527,7 @@ func TestNotify_WithAttachments_PersistsFilePartsForReload(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -593,7 +593,7 @@ func TestNotify_RejectsAttachmentWithEmptyURIOrName(t *testing.T) {
|
||||
// only if the handler unexpectedly queries.
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -647,7 +647,7 @@ func TestNotify_DBFailure_StillBroadcastsAnd200(t *testing.T) {
|
||||
WillReturnError(fmt.Errorf("simulated db hiccup"))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/textutil"
|
||||
)
|
||||
|
||||
@@ -82,14 +81,12 @@ type AgentMessageAttachment struct {
|
||||
type AgentMessageWriter struct {
|
||||
db *sql.DB
|
||||
broadcaster events.EventEmitter
|
||||
notifier *push.Notifier
|
||||
}
|
||||
|
||||
// NewAgentMessageWriter binds the writer to the platform's DB pool +
|
||||
// WebSocket broadcaster. notifier may be nil if push notifications are
|
||||
// not configured.
|
||||
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter, notifier *push.Notifier) *AgentMessageWriter {
|
||||
return &AgentMessageWriter{db: db, broadcaster: broadcaster, notifier: notifier}
|
||||
// WebSocket broadcaster.
|
||||
func NewAgentMessageWriter(db *sql.DB, broadcaster events.EventEmitter) *AgentMessageWriter {
|
||||
return &AgentMessageWriter{db: db, broadcaster: broadcaster}
|
||||
}
|
||||
|
||||
// Send delivers a single agent → user message. Look up + broadcast +
|
||||
@@ -144,12 +141,7 @@ func (w *AgentMessageWriter) Send(
|
||||
}
|
||||
w.broadcaster.BroadcastOnly(workspaceID, string(events.EventAgentMessage), broadcastPayload)
|
||||
|
||||
// 3. Send push notifications to mobile devices.
|
||||
if w.notifier != nil {
|
||||
w.notifier.NotifyAgentMessage(ctx, workspaceID, wsName, message)
|
||||
}
|
||||
|
||||
// 4. Persist for chat-history hydration. response_body shape MUST stay
|
||||
// 3. Persist for chat-history hydration. response_body shape MUST stay
|
||||
// in sync with extractResponseText + extractFilesFromTask in
|
||||
// canvas/src/components/tabs/chat/historyHydration.ts:
|
||||
// - extractResponseText reads body.result (string) → renders text
|
||||
|
||||
@@ -86,7 +86,7 @@ func (c *capturingEmitter) RecordAndBroadcast(_ context.Context, eventType strin
|
||||
// path: workspace lookup, broadcast, INSERT, return nil.
|
||||
func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-1").
|
||||
@@ -114,7 +114,7 @@ func TestAgentMessageWriter_Send_Success_NoAttachments(t *testing.T) {
|
||||
// Drift here = chips disappear on chat reload.
|
||||
func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-att").
|
||||
@@ -171,7 +171,7 @@ func TestAgentMessageWriter_Send_Success_WithAttachments(t *testing.T) {
|
||||
func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter, nil)
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-missing").
|
||||
@@ -200,7 +200,7 @@ func TestAgentMessageWriter_Send_WorkspaceNotFound(t *testing.T) {
|
||||
// broadcast.
|
||||
func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-dbfail").
|
||||
@@ -221,7 +221,7 @@ func TestAgentMessageWriter_Send_DBInsertFailureStillReturnsNil(t *testing.T) {
|
||||
// table doesn't carry multi-KB summaries that bloat list queries.
|
||||
func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-trunc").
|
||||
@@ -261,7 +261,7 @@ func TestAgentMessageWriter_Send_PreviewTruncation(t *testing.T) {
|
||||
func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter, nil)
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-bc").
|
||||
@@ -312,7 +312,7 @@ func TestAgentMessageWriter_Send_BroadcastsAgentMessageEvent(t *testing.T) {
|
||||
// real incidents in alerting.
|
||||
func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
transientErr := errors.New("connection refused")
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
@@ -344,7 +344,7 @@ func TestAgentMessageWriter_Send_DBErrorOnLookupReturnsWrapped(t *testing.T) {
|
||||
// coverage. Now it does.
|
||||
func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster(), nil)
|
||||
w := NewAgentMessageWriter(db.DB, newTestBroadcaster())
|
||||
|
||||
// 200-rune CJK message — exceeds the 80-rune cap, would have hit
|
||||
// the byte-slice bug.
|
||||
@@ -393,7 +393,7 @@ func TestAgentMessageWriter_Send_NonASCIIMessagePersists(t *testing.T) {
|
||||
func TestAgentMessageWriter_Send_OmitsAttachmentsKeyWhenEmpty(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
emitter := &capturingEmitter{}
|
||||
w := NewAgentMessageWriter(db.DB, emitter, nil)
|
||||
w := NewAgentMessageWriter(db.DB, emitter)
|
||||
|
||||
mock.ExpectQuery("SELECT name, talk_to_user_enabled FROM workspaces").
|
||||
WithArgs("ws-noatt").
|
||||
|
||||
@@ -631,7 +631,7 @@ func TestActivityHandler_List(t *testing.T) {
|
||||
WillReturnRows(rows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -680,7 +680,7 @@ func TestActivityHandler_ListByType(t *testing.T) {
|
||||
WillReturnRows(rows)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -708,7 +708,7 @@ func TestActivityHandler_Report(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
// Expect the INSERT into activity_logs
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
@@ -737,7 +737,7 @@ func TestActivityHandler_Report_InvalidType(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -965,7 +965,7 @@ func TestActivityHandler_ListEmpty(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -999,7 +999,7 @@ func TestActivityHandler_ListCustomLimit(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1032,7 +1032,7 @@ func TestActivityHandler_ListMaxLimit(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1060,7 +1060,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -1091,7 +1091,7 @@ func TestActivityHandler_ReportAllValidTypes(t *testing.T) {
|
||||
func TestActivityHandler_ReportMissingBody(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1165,7 +1165,7 @@ func TestActivityHandler_Report_SourceIDSpoofRejected(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1188,7 +1188,7 @@ func TestActivityHandler_Report_MatchingSourceIDAccepted(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@@ -1218,7 +1218,7 @@ func TestActivityHandler_Report_SourceIDLogInjection(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewActivityHandler(broadcaster, nil)
|
||||
handler := NewActivityHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -85,7 +84,6 @@ type mcpTool struct {
|
||||
type MCPHandler struct {
|
||||
database *sql.DB
|
||||
broadcaster *events.Broadcaster
|
||||
notifier *push.Notifier
|
||||
|
||||
// memv2 is the v2 memory plugin wiring (RFC #2728). nil-safe:
|
||||
// every v2 tool calls memoryV2Available() first and returns a
|
||||
@@ -96,9 +94,8 @@ type MCPHandler struct {
|
||||
|
||||
// NewMCPHandler wires the handler to db and broadcaster.
|
||||
// Pass db.DB and the platform broadcaster at router-setup time.
|
||||
// notifier may be nil if push notifications are not configured.
|
||||
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster, notifier *push.Notifier) *MCPHandler {
|
||||
return &MCPHandler{database: database, broadcaster: broadcaster, notifier: notifier}
|
||||
func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandler {
|
||||
return &MCPHandler{database: database, broadcaster: broadcaster}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
func newMCPHandler(t *testing.T) (*MCPHandler, sqlmock.Sqlmock) {
|
||||
t.Helper()
|
||||
mock := setupTestDB(t)
|
||||
h := NewMCPHandler(db.DB, newTestBroadcaster(), nil)
|
||||
h := NewMCPHandler(db.DB, newTestBroadcaster())
|
||||
return h, mock
|
||||
}
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
|
||||
// (the tool args don't accept them); pass nil. If a future tool
|
||||
// schema adds an attachments arg, build []AgentMessageAttachment
|
||||
// and pass through.
|
||||
writer := NewAgentMessageWriter(h.database, h.broadcaster, h.notifier)
|
||||
writer := NewAgentMessageWriter(h.database, h.broadcaster)
|
||||
if err := writer.Send(ctx, workspaceID, message, nil); err != nil {
|
||||
if errors.Is(err, ErrWorkspaceNotFound) {
|
||||
return "", fmt.Errorf("workspace not found")
|
||||
|
||||
@@ -19,15 +19,23 @@ import (
|
||||
|
||||
// allowedRoots are the container paths that the Files API can browse.
|
||||
var allowedRoots = map[string]bool{
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/agent-home": true, // Phase 1 stub (RFC internal#425); full implementation to follow
|
||||
}
|
||||
|
||||
// maxUploadFiles limits the number of files in a single import/replace.
|
||||
const maxUploadFiles = 200
|
||||
|
||||
// isAgentHomeStubRequest returns true when the rootPath is /agent-home,
|
||||
// which is a Phase 1 stub (RFC internal#425). Canvas designs against the
|
||||
// shape; the full implementation will follow in a later phase.
|
||||
func isAgentHomeStubRequest(rootPath string) bool {
|
||||
return rootPath == "/agent-home"
|
||||
}
|
||||
|
||||
type TemplatesHandler struct {
|
||||
configsDir string
|
||||
docker *client.Client
|
||||
@@ -218,6 +226,11 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
// ?path= — subdirectory to list (relative to root, default: "")
|
||||
// ?depth= — max depth to recurse (default: 1, max: 5)
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
@@ -382,6 +395,11 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
@@ -495,6 +513,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
@@ -572,6 +595,11 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
|
||||
@@ -207,7 +207,7 @@ func setupSwapEnv(t *testing.T) (*handlers.MCPHandler, *flatPlugin, sqlmock.Sqlm
|
||||
resolver := namespace.New(db)
|
||||
|
||||
// MCPHandler needs a real *sql.DB; pass the sqlmock-backed one.
|
||||
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
|
||||
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
|
||||
return h, plugin, mock
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ func TestE2E_PluginUnreachable_AgentSeesClearError(t *testing.T) {
|
||||
db, _, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
resolver := namespace.New(db)
|
||||
h := handlers.NewMCPHandler(db, nil, nil).WithMemoryV2(cl, resolver)
|
||||
h := handlers.NewMCPHandler(db, nil).WithMemoryV2(cl, resolver)
|
||||
|
||||
_, err := h.Dispatch(context.Background(), "root-1", "commit_memory_v2", map[string]interface{}{
|
||||
"content": "x",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP endpoints for push-token management.
|
||||
type Handler struct {
|
||||
repo *Repo
|
||||
}
|
||||
|
||||
// NewHandler creates a push-token HTTP handler.
|
||||
func NewHandler(repo *Repo) *Handler {
|
||||
return &Handler{repo: repo}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts push-token routes on the given router group.
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.POST("/push-tokens", h.Create)
|
||||
rg.DELETE("/push-tokens", h.Delete)
|
||||
}
|
||||
|
||||
// Create handles POST /push-tokens.
|
||||
// Body: { "token": "ExponentPushToken[xxx]", "platform": "ios" | "android" }
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required,oneof=ios android"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.SaveToken(c.Request.Context(), workspaceID, body.Token, body.Platform); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /push-tokens.
|
||||
// Body: { "token": "ExponentPushToken[xxx]" }
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if _, err := uuid.Parse(workspaceID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteToken(c.Request.Context(), workspaceID, body.Token); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Notifier sends push notifications for agent messages.
|
||||
type Notifier struct {
|
||||
repo *Repo
|
||||
sender *Sender
|
||||
}
|
||||
|
||||
// NewNotifier creates a Notifier.
|
||||
func NewNotifier(db *sql.DB, sender *Sender) *Notifier {
|
||||
return &Notifier{
|
||||
repo: NewRepo(db),
|
||||
sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyAgentMessage sends a push notification to all registered devices for a
|
||||
// workspace when an agent sends a message. It runs asynchronously (fire-and-
|
||||
// forget) so the caller's WebSocket broadcast is never blocked.
|
||||
func (n *Notifier) NotifyAgentMessage(ctx context.Context, workspaceID, workspaceName, message string) {
|
||||
if n == nil || n.sender == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture values for the goroutine.
|
||||
wsID := workspaceID
|
||||
wsName := workspaceName
|
||||
msg := message
|
||||
|
||||
go func() {
|
||||
// Use a fresh context with timeout so a slow Expo API doesn't
|
||||
// leak the caller's context deadline.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := n.repo.GetTokens(ctx, wsID)
|
||||
if err != nil {
|
||||
log.Printf("push: failed to get tokens for workspace %s: %v", wsID, err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Expo accepts batches of up to ~100 messages; we cap lower to stay
|
||||
// well under the limit.
|
||||
const batchSize = 50
|
||||
for i := 0; i < len(tokens); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(tokens) {
|
||||
end = len(tokens)
|
||||
}
|
||||
|
||||
batch := tokens[i:end]
|
||||
messages := make([]Message, 0, len(batch))
|
||||
for _, t := range batch {
|
||||
messages = append(messages, Message{
|
||||
To: t.Token,
|
||||
Title: wsName,
|
||||
Body: truncate(msg, 100),
|
||||
Data: map[string]string{
|
||||
"type": "agent_message",
|
||||
"workspaceId": wsID,
|
||||
"workspaceSlug": "", // populated by caller if available
|
||||
},
|
||||
Sound: "default",
|
||||
Priority: "high",
|
||||
})
|
||||
}
|
||||
|
||||
results, err := n.sender.Send(ctx, messages)
|
||||
if err != nil {
|
||||
log.Printf("push: send failed for workspace %s: %v", wsID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove invalid tokens.
|
||||
for j, r := range results {
|
||||
if ShouldRemoveToken(r) {
|
||||
if delErr := n.repo.DeleteToken(ctx, wsID, batch[j].Token); delErr != nil {
|
||||
log.Printf("push: failed to delete invalid token for workspace %s: %v", wsID, delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSenderSend(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
expoResponse := map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{"status": "ok", "id": "abc123"},
|
||||
{"status": "error", "message": "Invalid token", "details": map[string]string{"error": "DeviceNotRegistered"}},
|
||||
},
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
var msgs []Message
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&msgs))
|
||||
assert.Len(t, msgs, 2)
|
||||
assert.Equal(t, "ExponentPushToken[test1]", msgs[0].To)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(expoResponse)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = server.URL
|
||||
|
||||
results, err := sender.Send(context.Background(), []Message{
|
||||
{To: "ExponentPushToken[test1]", Title: "Test", Body: "Hello"},
|
||||
{To: "ExponentPushToken[test2]", Title: "Test", Body: "World"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "ok", results[0].Status)
|
||||
assert.Equal(t, "error", results[1].Status)
|
||||
assert.True(t, ShouldRemoveToken(results[1]))
|
||||
}
|
||||
|
||||
func TestSenderSendEmpty(t *testing.T) {
|
||||
sender := NewSender("")
|
||||
results, err := sender.Send(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, results)
|
||||
}
|
||||
|
||||
func TestHandlerCreate_InvalidWorkspaceID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(NewRepo(nil))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/not-a-uuid/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerCreate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("INSERT INTO push_tokens").
|
||||
WithArgs("11111111-1111-1111-1111-111111111111", "ExponentPushToken[abc]", "ios").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
repo := NewRepo(db)
|
||||
handler := NewHandler(repo)
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestHandlerCreateInvalidPlatform(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, _, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
handler := NewHandler(NewRepo(db))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"windows"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerDelete_BindingError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(NewRepo(nil))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{}` // missing required "token" field
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerDelete_InvalidWorkspaceID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewHandler(NewRepo(nil))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[del]"}`
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/not-a-uuid/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestHandlerDelete(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("DELETE FROM push_tokens").
|
||||
WithArgs("22222222-2222-2222-2222-222222222222", "ExponentPushToken[del]").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
repo := NewRepo(db)
|
||||
handler := NewHandler(repo)
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[del]"}`
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/22222222-2222-2222-2222-222222222222/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestHandlerCreate_DBSaveError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("INSERT INTO push_tokens").
|
||||
WithArgs("11111111-1111-1111-1111-111111111111", "ExponentPushToken[abc]", "ios").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewHandler(NewRepo(db))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[abc]","platform":"ios"}`
|
||||
req, _ := http.NewRequest("POST", "/workspaces/11111111-1111-1111-1111-111111111111/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestHandlerDelete_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("DELETE FROM push_tokens").
|
||||
WithArgs("22222222-2222-2222-2222-222222222222", "ExponentPushToken[del]").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewHandler(NewRepo(db))
|
||||
|
||||
router := gin.New()
|
||||
group := router.Group("/workspaces/:id")
|
||||
handler.RegisterRoutes(group)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := `{"token":"ExponentPushToken[del]"}`
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/22222222-2222-2222-2222-222222222222/push-tokens", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestSenderSend_HTTPError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Server that hijacks the connection and closes it before sending a response,
|
||||
// causing the HTTP client to receive a connection-closed error.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Drain request body so client send completes.
|
||||
io.Copy(io.Discard, r.Body)
|
||||
// Hijack and immediately close — no response written.
|
||||
conn, _, _ := w.(http.Hijacker).Hijack()
|
||||
conn.Close()
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = server.URL
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := sender.Send(ctx, []Message{
|
||||
{To: "ExponentPushToken[test]", Title: "T", Body: "H"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.True(t, strings.Contains(err.Error(), "post:") || strings.Contains(err.Error(), "context"))
|
||||
}
|
||||
|
||||
func TestSenderSend_Non200Response(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"error":"rate limited"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = server.URL
|
||||
|
||||
_, err := sender.Send(context.Background(), []Message{
|
||||
{To: "ExponentPushToken[test]", Title: "T", Body: "H"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "expo returned 503")
|
||||
}
|
||||
|
||||
func TestNotifierNotifyAgentMessage_NilGuard(t *testing.T) {
|
||||
// Must not panic when sender is nil.
|
||||
n := NewNotifier(nil, nil)
|
||||
// Should return immediately (nil check passes without panic).
|
||||
n.NotifyAgentMessage(context.Background(), "ws-1", "Test", "Hello world")
|
||||
}
|
||||
|
||||
func TestNotifierNotifyAgentMessage_ZeroTokens(t *testing.T) {
|
||||
// Verify that NotifyAgentMessage does NOT panic when there are zero registered
|
||||
// tokens — it should return early without calling sender.Send().
|
||||
// Note: the fire-and-forget goroutine inside NotifyAgentMessage is not
|
||||
// directly verifiable here without modifying production code; the key assertion
|
||||
// is that no panic occurs and the method returns cleanly.
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token", "platform", "created_at"}))
|
||||
|
||||
sender := NewSender("")
|
||||
sender.apiURL = "http://127.0.0.1:1" // unreachable — would error if Send is called
|
||||
|
||||
n := NewNotifier(db, sender)
|
||||
n.NotifyAgentMessage(context.Background(), "ws-1", "Test", "Hello")
|
||||
|
||||
// Give goroutine time to run GetTokens and exit early before closing DB.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestRepoGetTokens_DBError(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
repo := NewRepo(db)
|
||||
_, err = repo.GetTokens(context.Background(), "ws-1")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestRepoGetTokens_ScanError(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Return fewer columns than struct has — causes scan error.
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token"}). // missing platform, created_at
|
||||
AddRow("1", "ws-1", "ExponentPushToken[a]"))
|
||||
|
||||
repo := NewRepo(db)
|
||||
_, err = repo.GetTokens(context.Background(), "ws-1")
|
||||
require.Error(t, err) // scan error
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestRepoSaveToken_Error(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("INSERT INTO push_tokens").
|
||||
WithArgs("ws-1", "ExponentPushToken[xyz]", "android").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
repo := NewRepo(db)
|
||||
err = repo.SaveToken(context.Background(), "ws-1", "ExponentPushToken[xyz]", "android")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestRepoDeleteToken_Error(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectExec("DELETE FROM push_tokens").
|
||||
WithArgs("ws-1", "ExponentPushToken[xyz]").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
repo := NewRepo(db)
|
||||
err = repo.DeleteToken(context.Background(), "ws-1", "ExponentPushToken[xyz]")
|
||||
require.Error(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"short string unchanged", "hello", 10, "hello"},
|
||||
{"exact length unchanged", "hello", 5, "hello"},
|
||||
{"long string truncated", "hello world", 5, "hello…"},
|
||||
{"empty string", "", 5, ""},
|
||||
{"single char at max", "a", 1, "a"},
|
||||
{"multi-byte truncation adds ellipsis", "こんにちは世界", 5, ""},
|
||||
{"truncate with ellipsis ends with ellipsis", "hello world", 5, "hello…"},
|
||||
{"truncate at 1 char", "hello", 1, "h…"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := truncate(tc.s, tc.max)
|
||||
if tc.want == "" {
|
||||
// Multi-byte / edge cases: verify no expansion beyond max+3.
|
||||
assert.True(t, len(got) <= tc.max+3)
|
||||
} else {
|
||||
assert.Equal(t, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoGetTokens(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery("SELECT id, workspace_id, token, platform, created_at FROM push_tokens").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "token", "platform", "created_at"}).
|
||||
AddRow("1", "ws-1", "ExponentPushToken[a]", "ios", "2026-01-01T00:00:00Z").
|
||||
AddRow("2", "ws-1", "ExponentPushToken[b]", "android", "2026-01-01T00:00:00Z"))
|
||||
|
||||
repo := NewRepo(db)
|
||||
tokens, err := repo.GetTokens(context.Background(), "ws-1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tokens, 2)
|
||||
assert.Equal(t, "ExponentPushToken[a]", tokens[0].Token)
|
||||
assert.Equal(t, "ios", tokens[0].Platform)
|
||||
assert.Equal(t, "ExponentPushToken[b]", tokens[1].Token)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Token is one registered push token for a workspace.
|
||||
type Token struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
Token string
|
||||
Platform string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Repo reads and writes push tokens in Postgres.
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewRepo creates a token repository backed by db.
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
// SaveToken registers a push token for a workspace. If the same token already
|
||||
// exists for the workspace, it updates the timestamp.
|
||||
func (r *Repo) SaveToken(ctx context.Context, workspaceID, token, platform string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO push_tokens (workspace_id, token, platform)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (workspace_id, token) DO UPDATE
|
||||
SET updated_at = now()
|
||||
`, workspaceID, token, platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_tokens: save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteToken removes a push token. Returns nil even if the token did not exist.
|
||||
func (r *Repo) DeleteToken(ctx context.Context, workspaceID, token string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM push_tokens
|
||||
WHERE workspace_id = $1 AND token = $2
|
||||
`, workspaceID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push_tokens: delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokens returns all active push tokens for a workspace.
|
||||
func (r *Repo) GetTokens(ctx context.Context, workspaceID string) ([]Token, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, token, platform, created_at
|
||||
FROM push_tokens
|
||||
WHERE workspace_id = $1
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push_tokens: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []Token
|
||||
for rows.Next() {
|
||||
var t Token
|
||||
if err := rows.Scan(&t.ID, &t.WorkspaceID, &t.Token, &t.Platform, &t.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("push_tokens: scan: %w", err)
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
return tokens, rows.Err()
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const expoPushAPI = "https://exp.host/--/api/v2/push/send"
|
||||
|
||||
// Message is one Expo push notification.
|
||||
type Message struct {
|
||||
To string `json:"to"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Data map[string]string `json:"data,omitempty"`
|
||||
Sound string `json:"sound,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// Sender delivers push notifications via the Expo Push Service.
|
||||
type Sender struct {
|
||||
apiURL string
|
||||
httpClient *http.Client
|
||||
expoToken string // optional Expo access token for authenticated requests
|
||||
}
|
||||
|
||||
// NewSender creates a Sender. expoToken may be empty for unauthenticated
|
||||
// requests (sufficient for most use cases).
|
||||
func NewSender(expoToken string) *Sender {
|
||||
return &Sender{
|
||||
apiURL: expoPushAPI,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
expoToken: expoToken,
|
||||
}
|
||||
}
|
||||
|
||||
// SendResult is the per-recipient status from Expo.
|
||||
type SendResult struct {
|
||||
Status string `json:"status"`
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Details struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// expoResponse is the wrapper shape returned by the Expo API.
|
||||
type expoResponse struct {
|
||||
Data []SendResult `json:"data"`
|
||||
}
|
||||
|
||||
// Send fires a batch of push messages. It returns a slice of results in the
|
||||
// same order as the input, plus an error only when the HTTP call itself fails.
|
||||
// Callers should inspect each result's Status field for per-message errors
|
||||
// (e.g. "DeviceNotRegistered" → token should be deleted).
|
||||
func (s *Sender) Send(ctx context.Context, messages []Message) ([]SendResult, error) {
|
||||
if len(messages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: marshal: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
if s.expoToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.expoToken)
|
||||
}
|
||||
|
||||
res, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: post: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("push: expo returned %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var resp expoResponse
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
return nil, fmt.Errorf("push: decode: %w", err)
|
||||
}
|
||||
return resp.Data, nil
|
||||
}
|
||||
|
||||
// ShouldRemoveToken reports whether a SendResult indicates the token is no
|
||||
// longer valid and should be deleted from the database.
|
||||
func ShouldRemoveToken(r SendResult) bool {
|
||||
return r.Status == "error" && r.Details.Error == "DeviceNotRegistered"
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/pendinguploads"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/push"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -328,25 +327,13 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
|
||||
// Remaining auth-gated workspace sub-routes — appended to wsAuth group declared above.
|
||||
{
|
||||
// Push notifications (mobile)
|
||||
var pushNotifier *push.Notifier
|
||||
if expoToken := os.Getenv("EXPO_ACCESS_TOKEN"); expoToken != "" {
|
||||
pushNotifier = push.NewNotifier(db.DB, push.NewSender(expoToken))
|
||||
}
|
||||
|
||||
// Activity Logs
|
||||
acth := handlers.NewActivityHandler(broadcaster, pushNotifier)
|
||||
acth := handlers.NewActivityHandler(broadcaster)
|
||||
wsAuth.GET("/activity", acth.List)
|
||||
wsAuth.GET("/session-search", acth.SessionSearch)
|
||||
wsAuth.POST("/activity", acth.Report)
|
||||
wsAuth.POST("/notify", acth.Notify)
|
||||
|
||||
// Push token registration (mobile)
|
||||
if pushNotifier != nil {
|
||||
pushH := push.NewHandler(push.NewRepo(db.DB))
|
||||
pushH.RegisterRoutes(wsAuth)
|
||||
}
|
||||
|
||||
// Chat history — RFC #2945 PR-C (issue #3017) + PR-D (issue
|
||||
// #3026). Server-side rendering of activity_logs rows into
|
||||
// the canonical ChatMessage shape; storage is plugin-shaped
|
||||
@@ -450,7 +437,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// opencode session cannot saturate the platform.
|
||||
// C3: commit_memory/recall_memory with scope=GLOBAL → permission error;
|
||||
// send_message_to_user excluded unless MOLECULE_MCP_ALLOW_SEND_MESSAGE=true.
|
||||
mcpH := handlers.NewMCPHandler(db.DB, broadcaster, pushNotifier)
|
||||
mcpH := handlers.NewMCPHandler(db.DB, broadcaster)
|
||||
if memBundle != nil {
|
||||
mcpH.WithMemoryV2(memBundle.Plugin, memBundle.Resolver)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package secrets provides the canonical SSOT for credential-shaped
|
||||
// regex patterns used by:
|
||||
//
|
||||
// - the CI `Secret scan` workflow (.gitea/workflows/secret-scan.yml)
|
||||
// - the runtime's bundled pre-commit hook
|
||||
// (molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh)
|
||||
// - the upcoming Phase 2b docker-exec Files API backend, which has
|
||||
// to refuse to surface files whose path OR content matches a
|
||||
// credential shape (RFC internal#425, Hongming 2026-05-15)
|
||||
//
|
||||
// Before this package, the same regex set lived as duplicate bash
|
||||
// arrays in two unrelated repos; adding a pattern required editing
|
||||
// both, and pattern drift was caught only via secret-scan workflow
|
||||
// failures on PRs that had unrelated changes (#2090-class incident
|
||||
// vector). Centralising in Go makes the Files API the SSOT, with the
|
||||
// YAML + bash arrays generated/asserted from this package so drift
|
||||
// is detected at CI time, not at exfiltration time.
|
||||
//
|
||||
// This file is Phase 2a of the internal#425 RFC. Phase 2b will import
|
||||
// `Patterns` from `template_files_docker_exec.go` to gate
|
||||
// `listFilesViaDockerExec` / `readFileViaDockerExec` against
|
||||
// secret-shaped paths AND content. Until 2b lands, the package has
|
||||
// one consumer: this package's own unit tests, which pin the regex
|
||||
// strings so a refactor that drops or weakens one is caught here.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Pattern is one named credential shape — a human label plus the
|
||||
// compiled regex. The label appears in CI error output ("matched:
|
||||
// github-pat") so an operator can identify the family without seeing
|
||||
// the actual matched bytes (echoing the bytes widens the blast radius
|
||||
// per the secret-scan workflow's recovery prose).
|
||||
type Pattern struct {
|
||||
// Name is a short kebab-case identifier (e.g. "github-pat",
|
||||
// "anthropic-api-key"). Stable across versions — consumers may
|
||||
// switch on it.
|
||||
Name string
|
||||
// Description is a one-line human-readable explanation of what
|
||||
// the pattern matches. Used in CI error messages and the Files
|
||||
// API "<denied: secret-shape>" placeholder rationale.
|
||||
Description string
|
||||
// regexSource is the regex literal in Go-RE2 syntax. Stored as a
|
||||
// string so the slice declaration below stays readable; compiled
|
||||
// once via sync.Once into a *regexp.Regexp.
|
||||
regexSource string
|
||||
}
|
||||
|
||||
// Patterns is the canonical credential-shape regex set.
|
||||
//
|
||||
// Adding a pattern here:
|
||||
//
|
||||
// 1. Add a new Pattern{} entry below with a kebab-case Name, a
|
||||
// one-line Description, and the regex literal. Anchor on a
|
||||
// low-false-positive prefix.
|
||||
// 2. Add a positive + negative test case in patterns_test.go.
|
||||
// 3. Mirror the regex string into:
|
||||
// a. .gitea/workflows/secret-scan.yml SECRET_PATTERNS array
|
||||
// b. molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
|
||||
// (or wait for the codegen target that consumes this slice — TBD
|
||||
// follow-up; tracked in the Phase 2a PR description.)
|
||||
//
|
||||
// The order is: alphabetical within each provider family, families
|
||||
// grouped by ecosystem (GitHub family, AI-provider family, chat
|
||||
// family, cloud family). Keep this stable so diffs are reviewable.
|
||||
var Patterns = []Pattern{
|
||||
// --- GitHub token family ---
|
||||
{
|
||||
Name: "github-pat-classic",
|
||||
Description: "GitHub personal access token (classic)",
|
||||
regexSource: `ghp_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-app-installation-token",
|
||||
Description: "GitHub App installation token (#2090 vector)",
|
||||
regexSource: `ghs_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-user-to-server",
|
||||
Description: "GitHub OAuth user-to-server token",
|
||||
regexSource: `gho_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-user",
|
||||
Description: "GitHub OAuth user token",
|
||||
regexSource: `ghu_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-refresh",
|
||||
Description: "GitHub OAuth refresh token",
|
||||
regexSource: `ghr_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-pat-fine-grained",
|
||||
Description: "GitHub fine-grained personal access token",
|
||||
regexSource: `github_pat_[A-Za-z0-9_]{82,}`,
|
||||
},
|
||||
|
||||
// --- AI-provider API key family ---
|
||||
{
|
||||
Name: "anthropic-api-key",
|
||||
Description: "Anthropic API key",
|
||||
regexSource: `sk-ant-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "openai-project-key",
|
||||
Description: "OpenAI project API key",
|
||||
regexSource: `sk-proj-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "openai-service-account-key",
|
||||
Description: "OpenAI service-account API key",
|
||||
regexSource: `sk-svcacct-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "minimax-api-key",
|
||||
Description: "MiniMax API key (F1088 vector)",
|
||||
regexSource: `sk-cp-[A-Za-z0-9_-]{60,}`,
|
||||
},
|
||||
|
||||
// --- Chat-platform token family ---
|
||||
{
|
||||
Name: "slack-token",
|
||||
Description: "Slack token (xoxb/xoxa/xoxp/xoxr/xoxs)",
|
||||
regexSource: `xox[baprs]-[A-Za-z0-9-]{20,}`,
|
||||
},
|
||||
|
||||
// --- Cloud-provider credential family ---
|
||||
{
|
||||
Name: "aws-access-key-id",
|
||||
Description: "AWS access key ID",
|
||||
regexSource: `AKIA[0-9A-Z]{16}`,
|
||||
},
|
||||
{
|
||||
Name: "aws-sts-temp-access-key-id",
|
||||
Description: "AWS STS temporary access key ID",
|
||||
regexSource: `ASIA[0-9A-Z]{16}`,
|
||||
},
|
||||
}
|
||||
|
||||
// compiledOnce protects the lazy build of compiledPatterns. We compile
|
||||
// lazily so package init is cheap; callers pay only on first match
|
||||
// (typically once per workspace-server boot).
|
||||
var (
|
||||
compiledOnce sync.Once
|
||||
compiledPatterns []*compiledPattern
|
||||
compileErr error
|
||||
)
|
||||
|
||||
type compiledPattern struct {
|
||||
Name string
|
||||
Description string
|
||||
Re *regexp.Regexp
|
||||
}
|
||||
|
||||
// compileAll compiles every Pattern.regexSource into a *regexp.Regexp.
|
||||
// Called once via compiledOnce. Any compile failure here is a build
|
||||
// bug (the unit tests assert each regex compiles) — surfacing via
|
||||
// returned error so callers don't panic in request handling.
|
||||
func compileAll() {
|
||||
out := make([]*compiledPattern, 0, len(Patterns))
|
||||
for _, p := range Patterns {
|
||||
re, err := regexp.Compile(p.regexSource)
|
||||
if err != nil {
|
||||
compileErr = fmt.Errorf("secrets: pattern %q failed to compile: %w", p.Name, err)
|
||||
return
|
||||
}
|
||||
out = append(out, &compiledPattern{Name: p.Name, Description: p.Description, Re: re})
|
||||
}
|
||||
compiledPatterns = out
|
||||
}
|
||||
|
||||
// ScanBytes returns a non-nil Match if any pattern matches anywhere
|
||||
// inside b. Returns (nil, nil) on no match. Returns (nil, err) only
|
||||
// if a regex in the package fails to compile — that's a build bug,
|
||||
// not a runtime data issue.
|
||||
//
|
||||
// Match contains the pattern Name + Description so the caller can
|
||||
// emit a path-or-content-denial rationale WITHOUT round-tripping the
|
||||
// matched bytes (which would defeat the purpose). The matched bytes
|
||||
// stay inside this function.
|
||||
//
|
||||
// The Files API Phase 2b backend will call ScanBytes on:
|
||||
//
|
||||
// - the absolute path string (catches a file literally named
|
||||
// `ghs_abc.txt`)
|
||||
// - the file content (catches a credential pasted into a workspace
|
||||
// file by an agent or user — the Files API refuses to surface it
|
||||
// and the canvas renders "<denied: secret-shape>")
|
||||
//
|
||||
// Ordering: patterns are tried in declaration order. First match
|
||||
// wins. This means narrower patterns (e.g. `sk-svcacct-…`) should
|
||||
// appear in `Patterns` before broader ones (`sk-…`) — today there's
|
||||
// no overlap, so order is descriptive only.
|
||||
func ScanBytes(b []byte) (*Match, error) {
|
||||
compiledOnce.Do(compileAll)
|
||||
if compileErr != nil {
|
||||
return nil, compileErr
|
||||
}
|
||||
for _, cp := range compiledPatterns {
|
||||
if cp.Re.Match(b) {
|
||||
return &Match{Name: cp.Name, Description: cp.Description}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ScanString is the string-input convenience wrapper around ScanBytes.
|
||||
// Identical semantics — the body never copies, []byte(s) is a
|
||||
// zero-copy reinterpret for the regex matcher.
|
||||
func ScanString(s string) (*Match, error) {
|
||||
return ScanBytes([]byte(s))
|
||||
}
|
||||
|
||||
// Match describes which pattern caught a value. Deliberately does
|
||||
// NOT include the matched substring — callers must not echo it.
|
||||
type Match struct {
|
||||
// Name is the pattern's kebab-case identifier (e.g. "github-pat-classic").
|
||||
Name string
|
||||
// Description is the human-readable line for UI / log surfaces.
|
||||
Description string
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEveryPatternCompiles pins that every Pattern.regexSource is a
|
||||
// valid Go-RE2 expression. Without this, a bad regex would silently
|
||||
// disable ScanBytes for everything after it (the lazy compile would
|
||||
// set compileErr and ScanBytes would return that error every call).
|
||||
func TestEveryPatternCompiles(t *testing.T) {
|
||||
for _, p := range Patterns {
|
||||
if p.Name == "" {
|
||||
t.Errorf("pattern with empty Name: regex=%q", p.regexSource)
|
||||
}
|
||||
if p.Description == "" {
|
||||
t.Errorf("pattern %q has empty Description", p.Name)
|
||||
}
|
||||
}
|
||||
// Force compile + check error.
|
||||
if _, err := ScanBytes([]byte("placeholder")); err != nil {
|
||||
t.Fatalf("ScanBytes init failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoDuplicateNames — a duplicate pattern Name would make the
|
||||
// "first match wins" semantics surprising to readers and any caller
|
||||
// switching on Match.Name (none today but adding the guard is cheap).
|
||||
func TestNoDuplicateNames(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
if seen[p.Name] {
|
||||
t.Errorf("duplicate pattern Name: %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownPatternsAllPresent — pins which specific Name values are
|
||||
// expected. A future refactor that renames or removes one without
|
||||
// updating consumers (CI workflow, runtime pre-commit hook, Files
|
||||
// API Phase 2b backend) would silently widen the leak surface.
|
||||
// Failing here forces the rename to be intentional.
|
||||
func TestKnownPatternsAllPresent(t *testing.T) {
|
||||
expected := []string{
|
||||
"github-pat-classic",
|
||||
"github-app-installation-token",
|
||||
"github-oauth-user-to-server",
|
||||
"github-oauth-user",
|
||||
"github-oauth-refresh",
|
||||
"github-pat-fine-grained",
|
||||
"anthropic-api-key",
|
||||
"openai-project-key",
|
||||
"openai-service-account-key",
|
||||
"minimax-api-key",
|
||||
"slack-token",
|
||||
"aws-access-key-id",
|
||||
"aws-sts-temp-access-key-id",
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
got[p.Name] = true
|
||||
}
|
||||
for _, want := range expected {
|
||||
if !got[want] {
|
||||
t.Errorf("expected pattern %q missing from Patterns slice", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPositiveMatches — for each pattern, supply a representative
|
||||
// shape and assert ScanBytes returns a Match with the right Name.
|
||||
// These are TEST FIXTURES, not real credentials — each is the
|
||||
// pattern's prefix + a long-enough trailing run of placeholder chars.
|
||||
// `EXAMPLE` is sprinkled in to make grep-finds in CI logs obviously
|
||||
// fake to a human reader (matches saved memory
|
||||
// feedback_assert_exact_not_substring: tighten by Name not body).
|
||||
func TestPositiveMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
fixture string
|
||||
expectedName string
|
||||
}{
|
||||
{"ghp_EXAMPLE111122223333444455556666777788889999", "github-pat-classic"},
|
||||
{"ghs_EXAMPLE111122223333444455556666777788889999", "github-app-installation-token"},
|
||||
{"gho_EXAMPLE111122223333444455556666777788889999", "github-oauth-user-to-server"},
|
||||
{"ghu_EXAMPLE111122223333444455556666777788889999", "github-oauth-user"},
|
||||
{"ghr_EXAMPLE111122223333444455556666777788889999", "github-oauth-refresh"},
|
||||
{"github_pat_EXAMPLE" + strings.Repeat("1", 80), "github-pat-fine-grained"},
|
||||
{"sk-ant-EXAMPLE" + strings.Repeat("1", 40), "anthropic-api-key"},
|
||||
{"sk-proj-EXAMPLE" + strings.Repeat("1", 40), "openai-project-key"},
|
||||
{"sk-svcacct-EXAMPLE" + strings.Repeat("1", 40), "openai-service-account-key"},
|
||||
{"sk-cp-EXAMPLE" + strings.Repeat("1", 60), "minimax-api-key"},
|
||||
{"xoxb-" + strings.Repeat("a", 25), "slack-token"},
|
||||
{"xoxa-" + strings.Repeat("a", 25), "slack-token"},
|
||||
// AWS regex requires [0-9A-Z]{16} — uppercase + digits only.
|
||||
{"AKIA1234567890ABCDEF", "aws-access-key-id"},
|
||||
{"ASIA1234567890ABCDEF", "aws-sts-temp-access-key-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expectedName, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(tc.fixture))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", tc.fixture, err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatalf("ScanBytes(%q) returned no match — expected %q", tc.fixture, tc.expectedName)
|
||||
}
|
||||
if m.Name != tc.expectedName {
|
||||
t.Errorf("ScanBytes(%q) matched %q; expected %q", tc.fixture, m.Name, tc.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegativeShapes — strings that look credential-adjacent but
|
||||
// shouldn't match (too short, wrong prefix, missing trailing bytes).
|
||||
// Failing here means a pattern is too loose, which would generate
|
||||
// false-positive denial in Files API and false-positive workflow
|
||||
// failures in CI.
|
||||
func TestNegativeShapes(t *testing.T) {
|
||||
cases := []string{
|
||||
// Too-short variants — anchored on the length suffix.
|
||||
"ghp_tooshort",
|
||||
"ghs_alsoshort1234",
|
||||
"github_pat_short",
|
||||
"sk-ant-short",
|
||||
"sk-cp-not-enough-bytes-here",
|
||||
// Looks like one of the prefixes but isn't (different letter).
|
||||
"gha_EXAMPLE_thirty_six_or_more_chars_here_xxx",
|
||||
// Slack family — wrong letter after xox.
|
||||
"xoxz-aaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
// AWS-shaped but wrong length suffix.
|
||||
"AKIATOOSHORT",
|
||||
// Empty / whitespace.
|
||||
"",
|
||||
" ",
|
||||
// Plain prose mentioning the prefix as part of a longer word.
|
||||
"see also `ghp_HOWTO.md` in the repo",
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", c, err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("ScanBytes(%q) unexpectedly matched %q", c, m.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanString_NoOp — sanity-check ScanString is the zero-copy
|
||||
// wrapper around ScanBytes. Without this, a future refactor that
|
||||
// makes ScanString do its own thing (e.g. accidentally normalises
|
||||
// case) would diverge silently.
|
||||
func TestScanString_NoOp(t *testing.T) {
|
||||
in := "ghp_EXAMPLE111122223333444455556666777788889999"
|
||||
m1, err1 := ScanBytes([]byte(in))
|
||||
if err1 != nil {
|
||||
t.Fatalf("ScanBytes errored: %v", err1)
|
||||
}
|
||||
m2, err2 := ScanString(in)
|
||||
if err2 != nil {
|
||||
t.Fatalf("ScanString errored: %v", err2)
|
||||
}
|
||||
if m1 == nil || m2 == nil {
|
||||
t.Fatalf("expected matches; got bytes=%+v string=%+v", m1, m2)
|
||||
}
|
||||
if m1.Name != m2.Name {
|
||||
t.Errorf("ScanString and ScanBytes returned different Names: %q vs %q", m1.Name, m2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatch_NoRoundtrip — assert the Match struct does NOT include
|
||||
// the matched substring as a field. Adding such a field would
|
||||
// regress the "matched bytes never leave ScanBytes" invariant that
|
||||
// makes this package safe to call from log/UI surfaces. This is a
|
||||
// reflection-light contract test — checks the field names statically.
|
||||
func TestMatch_NoRoundtrip(t *testing.T) {
|
||||
var m Match
|
||||
// If someone adds a `Matched string` (or similar) field, this
|
||||
// test reads as the canonical place to update + reconsider.
|
||||
_ = m.Name
|
||||
_ = m.Description
|
||||
// The two-field shape is part of the public contract; new fields
|
||||
// require deliberation about whether they leak the secret value.
|
||||
}
|
||||
|
||||
// TestCompileError verifies that compileAll() sets compileErr and
|
||||
// leaves compiledPatterns nil when a Pattern.regexSource is an invalid
|
||||
// Go-RE2 expression. The unbalanced-paren pattern is a real compile
|
||||
// error that regexp.Compile() rejects.
|
||||
//
|
||||
// We reset compiledOnce between attempts by re-assigning the package
|
||||
// variable directly so each test run starts from a clean slate.
|
||||
func TestCompileError(t *testing.T) {
|
||||
// Reset the sync.Once and error state so this test is fully
|
||||
// deterministic regardless of test execution order.
|
||||
compiledOnce = sync.Once{}
|
||||
compiledPatterns = nil
|
||||
compileErr = nil
|
||||
|
||||
// Swap in an invalid pattern for the duration of this test.
|
||||
// The shadow prevents modifying the global Patterns slice.
|
||||
orig := Patterns
|
||||
Patterns = []Pattern{{Name: "bad", Description: "invalid", regexSource: "(unbalanced"}}
|
||||
compileAll()
|
||||
Patterns = orig
|
||||
|
||||
if compileErr == nil {
|
||||
t.Fatal("compileAll() with invalid regex did not set compileErr")
|
||||
}
|
||||
if compiledPatterns != nil {
|
||||
t.Errorf("compiledPatterns should be nil on compile error; got %d entries", len(compiledPatterns))
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanBytes_CompileErr verifies that ScanBytes returns the
|
||||
// compileErr error (not nil, not a Match) when the lazy compilation
|
||||
// previously failed. This is the "compile failed" branch of the
|
||||
// ScanBytes function, distinct from the "compiled ok, no match" branch
|
||||
// (nil, nil) and the "compiled ok, match" branch (Match, nil).
|
||||
//
|
||||
// compiledOnce and compileErr must already be set from a prior failed
|
||||
// compile attempt. We reset compiledOnce and deliberately trigger a
|
||||
// compile failure first so this test is self-contained.
|
||||
func TestScanBytes_CompileErr(t *testing.T) {
|
||||
// Force a compile failure so compileErr is populated.
|
||||
compiledOnce = sync.Once{}
|
||||
compiledPatterns = nil
|
||||
compileErr = nil
|
||||
|
||||
orig := Patterns
|
||||
Patterns = []Pattern{{Name: "bad2", Description: "bad2", regexSource: "[unclosed"}}
|
||||
compileAll()
|
||||
Patterns = orig
|
||||
|
||||
if compileErr == nil {
|
||||
t.Fatal("precondition failed: compileErr must be set before TestScanBytes_CompileErr")
|
||||
}
|
||||
|
||||
// ScanBytes should propagate compileErr, not return a match.
|
||||
m, err := ScanBytes([]byte("some content"))
|
||||
if err == nil {
|
||||
t.Fatal("ScanBytes returned nil error when compileErr is set")
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("ScanBytes should return nil Match on compile error; got %+v", m)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS push_tokens;
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE push_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(workspace_id, token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_tokens_workspace ON push_tokens(workspace_id);
|
||||
Reference in New Issue
Block a user