From a6b8397dd81b505ba8ac8ae62fcdd5a3e74f1501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:34:51 +0200 Subject: [PATCH] test: cron schedule + webhook URL + canvas layout coverage (#73) - calculateNextRun: field-count, garbage, bad-zone errors; CRON_TZ actually shifts the instant (UTC vs Asia/Tokyo) - validWebhookURL: http/https only, host required, whitespace trimmed - canvas layout math extracted to canvas/layout.ts (component files may only export components) and covered: rectsOverlap gap zone, resolveFreeSpot grid-snap/collision/bounds-fallback, groupSlot fit/append/grow/ignore-dragged, buildMetadataFromFlow serialization Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/backend/internal/api/cron_test.go | 66 +++++++ app/backend/internal/api/webhooks_test.go | 30 +++ .../workspace/canvas/ProjectCanvas.tsx | 173 ++---------------- .../workspace/canvas/canvas-layout.test.ts | 142 ++++++++++++++ .../src/features/workspace/canvas/layout.ts | 156 ++++++++++++++++ 5 files changed, 408 insertions(+), 159 deletions(-) create mode 100644 app/backend/internal/api/cron_test.go create mode 100644 app/frontend/src/features/workspace/canvas/canvas-layout.test.ts create mode 100644 app/frontend/src/features/workspace/canvas/layout.ts diff --git a/app/backend/internal/api/cron_test.go b/app/backend/internal/api/cron_test.go new file mode 100644 index 0000000..7bb3b88 --- /dev/null +++ b/app/backend/internal/api/cron_test.go @@ -0,0 +1,66 @@ +package api + +import ( + "strings" + "testing" + "time" +) + +func TestCalculateNextRun(t *testing.T) { + cases := []struct { + name string + schedule string + timezone string + wantErr bool + }{ + {"every minute", "* * * * *", "", false}, + {"daily at 4am", "0 4 * * *", "UTC", false}, + {"weekday run", "0 9 * * 1-5", "Europe/Prague", false}, + {"whitespace tolerated", " */15 * * * * ", "", false}, + {"empty schedule", "", "", true}, + {"bad field count", "* * *", "", true}, + {"garbage", "not a cron", "", true}, + {"bad timezone", "0 4 * * *", "Mars/Olympus", true}, + } + now := time.Now() + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + next, err := calculateNextRun(tc.schedule, tc.timezone) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got next=%v", next) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if next == nil { + t.Fatal("expected non-nil next run") + } + if !next.After(now) { + t.Errorf("next run %v is not after %v", next, now) + } + }) + } +} + +func TestCalculateNextRunTimezone(t *testing.T) { + // CRON_TZ must change the computed instant: 04:00 in a fixed-offset zone + // lands at a different UTC hour than 04:00 UTC. + utcNext, err := calculateNextRun("0 4 * * *", "UTC") + if err != nil { + t.Fatalf("utc: %v", err) + } + tokyoNext, err := calculateNextRun("0 4 * * *", "Asia/Tokyo") + if err != nil { + t.Fatalf("tokyo: %v", err) + } + if utcNext.Equal(*tokyoNext) { + t.Fatalf("timezone had no effect: both %v", utcNext) + } + if !strings.EqualFold(utcNext.Location().String(), "Local") { + // UTC-scheduled runs still land in server-local time representation. + t.Logf("utc next: %v / tokyo next: %v", utcNext, tokyoNext) + } +} diff --git a/app/backend/internal/api/webhooks_test.go b/app/backend/internal/api/webhooks_test.go index 90a0a81..4046a70 100644 --- a/app/backend/internal/api/webhooks_test.go +++ b/app/backend/internal/api/webhooks_test.go @@ -31,3 +31,33 @@ func TestWebhookEventMatches(t *testing.T) { } } } + +func TestValidWebhookURL(t *testing.T) { + cases := []struct { + name string + url string + wantErr bool + }{ + {"https", "https://hooks.example.com/wh/abc", false}, + {"http", "http://internal.lan:8080/notify", false}, + {"whitespace trimmed", " https://example.com/hook ", false}, + {"with query", "https://example.com/hook?token=x", false}, + {"ftp rejected", "ftp://files.example.com/x", true}, + {"no scheme", "example.com/hook", true}, + {"bare scheme", "https://", true}, + {"empty", "", true}, + {"javascript rejected", "javascript:alert(1)", true}, + {"host empty", "http:///path-only", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validWebhookURL(tc.url) + if tc.wantErr && err == nil { + t.Errorf("validWebhookURL(%q) = nil, want error", tc.url) + } + if !tc.wantErr && err != nil { + t.Errorf("validWebhookURL(%q) = %v, want nil", tc.url, err) + } + }) + } +} diff --git a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx index bbfd913..9b93377 100644 --- a/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx +++ b/app/frontend/src/features/workspace/canvas/ProjectCanvas.tsx @@ -25,14 +25,22 @@ import { } from '@/lib/api-client'; import { useToast } from '@/shared/hooks/use-toast'; import { inferAutoConnections, type ServiceVariable } from '../auto-connections'; -import { - DEFAULT_VIEWPORT, - type CanvasGroup, - type CanvasNodeLayout, - type ProjectCanvasMetadata, -} from '../model'; +import { DEFAULT_VIEWPORT, type ProjectCanvasMetadata } from '../model'; import { hasCanvasMetadata, loadCanvasMetadata, saveCanvasMetadata } from '../storage'; import { computeLayeredLayout, layoutFromVariables, NODE_HEIGHT, NODE_WIDTH } from './auto-layout'; +import { + buildMetadataFromFlow, + GROUP_DEFAULT_HEIGHT, + GROUP_DEFAULT_WIDTH, + GROUP_PAD_BOTTOM, + GROUP_PAD_TOP, + GROUP_PAD_X, + GRID_STEP, + groupSlot, + resolveFreeSpot, + type CanvasNode, + type Rect, +} from './layout'; import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes'; import { ServiceInspector, type InspectorGroup } from './inspector'; import { serviceAccent } from './service-visuals'; @@ -65,11 +73,7 @@ type CanvasProps = { type ServiceCanvasNode = Node; type GroupCanvasNode = Node; -type CanvasNode = ServiceCanvasNode | GroupCanvasNode; type CanvasEdge = Edge<{ reasons: string[] }>; - -const GROUP_DEFAULT_WIDTH = 340; -const GROUP_DEFAULT_HEIGHT = 230; const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']); const nodeTypes: NodeTypes = { @@ -176,155 +180,6 @@ function toFlowEdges( }); } -const GROUP_PAD_X = 28; -const GROUP_PAD_TOP = 56; -const GROUP_PAD_BOTTOM = 24; -const GROUP_ROW_GAP = 16; -const GRID_STEP = 26; -const NODE_GAP = 14; - -type Rect = { x: number; y: number; w: number; h: number }; - -function rectsOverlap(a: Rect, b: Rect): boolean { - return ( - a.x < b.x + b.w + NODE_GAP && - a.x + a.w + NODE_GAP > b.x && - a.y < b.y + b.h + NODE_GAP && - a.y + a.h + NODE_GAP > b.y - ); -} - -// Nearest grid-aligned position where a node fits without touching any -// occupied rect. Spiral-searches outward on the canvas lattice; falls back to -// the given position when the neighbourhood is fully boxed in. -function resolveFreeSpot( - desired: { x: number; y: number }, - occupied: Rect[], - fallback: { x: number; y: number }, - bounds?: Rect, -): { x: number; y: number } { - const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP; - const base = { x: snap(desired.x), y: snap(desired.y) }; - const fits = (x: number, y: number) => { - if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) { - return false; - } - return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect)); - }; - - if (fits(base.x, base.y)) { - return base; - } - - for (let ring = 1; ring <= 12; ring++) { - for (let dx = -ring; dx <= ring; dx++) { - for (let dy = -ring; dy <= ring; dy++) { - if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) { - continue; - } - const x = base.x + dx * GRID_STEP; - const y = base.y + dy * GRID_STEP; - if (fits(x, y)) { - return { x, y }; - } - } - } - } - - return fallback; -} - -// Where an incoming service lands inside a group frame. If the point already -// fits inside the padded area, honour it. Otherwise append beneath the lowest -// member and grow the frame just enough — keeps groups compact instead of -// ballooning the frame to wherever the node used to sit. -function groupSlot( - nodes: CanvasNode[], - groupId: string, - group: CanvasNode, - desiredX: number, - desiredY: number, - ignoreServiceId?: string, -): { x: number; y: number; width: number; height: number } { - const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH; - const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT; - - const members = nodes.filter( - (node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId, - ); - const memberRects: Rect[] = members.map((node) => ({ - x: node.position.x, - y: node.position.y, - w: NODE_WIDTH, - h: NODE_HEIGHT, - })); - const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP; - const snappedX = snap(desiredX); - const snappedY = snap(desiredY); - const snappedFits = - snappedX >= GROUP_PAD_X && - snappedY >= GROUP_PAD_TOP && - snappedX + NODE_WIDTH <= width - GROUP_PAD_X && - snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM; - const collides = memberRects.some((rect) => - rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect), - ); - - if (snappedFits && !collides) { - return { x: snappedX, y: snappedY, width, height }; - } - - let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP; - for (const node of nodes) { - if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) { - bottom = Math.max(bottom, node.position.y + NODE_HEIGHT); - } - } - - const x = GROUP_PAD_X; - const y = bottom + GROUP_ROW_GAP; - return { - x, - y, - width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X), - height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM), - }; -} - -function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata { - const groups: CanvasGroup[] = []; - const layouts: CanvasNodeLayout[] = []; - - for (const node of nodes) { - if (node.type === 'groupNode') { - const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH; - const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT; - groups.push({ - id: node.id, - title: node.data.title, - position: node.position, - width, - height, - }); - continue; - } - - if (node.type === 'serviceNode') { - layouts.push({ - serviceId: node.id, - position: node.position, - groupId: node.parentId, - }); - } - } - - return { - groups, - nodes: layouts, - edges: [], - viewport, - }; -} function ZoomControls({ onTidy }: { onTidy: () => void }) { const { zoom } = useViewport(); diff --git a/app/frontend/src/features/workspace/canvas/canvas-layout.test.ts b/app/frontend/src/features/workspace/canvas/canvas-layout.test.ts new file mode 100644 index 0000000..25fc069 --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/canvas-layout.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMetadataFromFlow, + groupSlot, + rectsOverlap, + resolveFreeSpot, +} from './layout'; +import { NODE_HEIGHT, NODE_WIDTH } from './auto-layout'; +import type { GroupNodeType, ServiceNodeType } from './nodes'; + +const GRID_STEP = 26; +const NODE_GAP = 14; + +function serviceNode( + id: string, + position: { x: number; y: number }, + parentId?: string, +): ServiceNodeType { + return { id, type: 'serviceNode', position, parentId } as ServiceNodeType; +} + +function groupNode( + id: string, + width = 340, + height = 230, +): GroupNodeType { + return { + id, + type: 'groupNode', + position: { x: 0, y: 0 }, + style: { width, height }, + data: { title: `Group ${id}` }, + } as GroupNodeType; +} + +describe('rectsOverlap', () => { + const a = { x: 0, y: 0, w: 100, h: 100 }; + + it('detects intersection', () => { + expect(rectsOverlap(a, { x: 50, y: 50, w: 100, h: 100 })).toBe(true); + expect(rectsOverlap(a, { x: -50, y: -50, w: 100, h: 100 })).toBe(true); + }); + + it('returns false for fully disjoint rects', () => { + expect(rectsOverlap(a, { x: 500, y: 500, w: 100, h: 100 })).toBe(false); + }); + + it('treats the node gap as a collision zone', () => { + // b sits just inside NODE_GAP to the right of a. + expect(rectsOverlap(a, { x: 100 + NODE_GAP - 1, y: 0, w: 50, h: 50 })).toBe(true); + // Beyond the gap there is no overlap. + expect(rectsOverlap(a, { x: 100 + NODE_GAP + 1, y: 0, w: 50, h: 50 })).toBe(false); + }); +}); + +describe('resolveFreeSpot', () => { + it('snaps the desired position to the grid when free', () => { + const spot = resolveFreeSpot({ x: 40, y: 70 }, [], { x: 0, y: 0 }); + expect(spot).toEqual({ x: 52, y: 78 }); + expect(spot.x % GRID_STEP).toBe(0); + expect(spot.y % GRID_STEP).toBe(0); + }); + + it('finds a neighbouring free grid point when occupied', () => { + const occupied = [{ x: 0, y: 0, w: NODE_WIDTH, h: NODE_HEIGHT }]; + const spot = resolveFreeSpot({ x: 0, y: 0 }, occupied, { x: -1, y: -1 }); + expect(spot).not.toEqual({ x: -1, y: -1 }); + expect(Number.isInteger(spot.x / GRID_STEP)).toBe(true); + expect(Number.isInteger(spot.y / GRID_STEP)).toBe(true); + expect( + rectsOverlap({ x: spot.x, y: spot.y, w: NODE_WIDTH, h: NODE_HEIGHT }, occupied[0]), + ).toBe(false); + }); + + it('falls back when bounds reject every candidate', () => { + // Node is wider than the bounds, so nothing can fit. + const bounds = { x: 0, y: 0, w: 10, h: 10 }; + const fallback = { x: 999, y: 888 }; + expect(resolveFreeSpot({ x: 0, y: 0 }, [], fallback, bounds)).toEqual(fallback); + }); +}); + +describe('groupSlot', () => { + it('honours a point that fits inside the padded frame', () => { + const group = groupNode('g1'); + const slot = groupSlot([group], 'g1', group, 40, 70); + expect(slot).toEqual({ x: 52, y: 78, width: 340, height: 230 }); + }); + + it('appends below the lowest member and grows the frame on collision', () => { + const group = groupNode('g1'); + const member = serviceNode('s1', { x: 52, y: 78 }, 'g1'); + const slot = groupSlot([group, member], 'g1', group, 40, 70); + // bottom = 78 + 104 = 182; y = 182 + 16 = 198; grows height to 326. + expect(slot.x).toBe(28); + expect(slot.y).toBe(198); + expect(slot.height).toBe(326); + expect(slot.width).toBe(340); + }); + + it('appends under the padding when the desired point is outside', () => { + const group = groupNode('g1'); + const slot = groupSlot([group], 'g1', group, 5000, 5000); + expect(slot).toEqual({ x: 28, y: 56, width: 340, height: 230 }); + }); + + it('ignores the dragged node when checking collisions', () => { + const group = groupNode('g1'); + const dragged = serviceNode('s1', { x: 52, y: 78 }, 'g1'); + const slot = groupSlot([group, dragged], 'g1', group, 40, 70, 's1'); + expect(slot).toEqual({ x: 52, y: 78, width: 340, height: 230 }); + }); +}); + +describe('buildMetadataFromFlow', () => { + it('serializes groups and service layouts', () => { + const nodes = [ + { ...groupNode('g1', 400, 300), position: { x: 10, y: 20 } }, + serviceNode('s1', { x: 5, y: 6 }, 'g1'), + serviceNode('s2', { x: 100, y: 200 }), + ]; + const viewport = { x: 1, y: 2, zoom: 0.5 }; + const meta = buildMetadataFromFlow(nodes, viewport); + + expect(meta.groups).toEqual([ + { id: 'g1', title: 'Group g1', position: { x: 10, y: 20 }, width: 400, height: 300 }, + ]); + expect(meta.nodes).toEqual([ + { serviceId: 's1', position: { x: 5, y: 6 }, groupId: 'g1' }, + { serviceId: 's2', position: { x: 100, y: 200 }, groupId: undefined }, + ]); + expect(meta.edges).toEqual([]); + expect(meta.viewport).toEqual(viewport); + }); + + it('uses default group dimensions when style is missing', () => { + const bare = { id: 'g2', type: 'groupNode', position: { x: 0, y: 0 }, data: { title: 'bare' } } as GroupNodeType; + const meta = buildMetadataFromFlow([bare], { x: 0, y: 0, zoom: 1 }); + expect(meta.groups[0].width).toBe(340); + expect(meta.groups[0].height).toBe(230); + }); +}); diff --git a/app/frontend/src/features/workspace/canvas/layout.ts b/app/frontend/src/features/workspace/canvas/layout.ts new file mode 100644 index 0000000..ed69649 --- /dev/null +++ b/app/frontend/src/features/workspace/canvas/layout.ts @@ -0,0 +1,156 @@ +import type { CanvasGroup, CanvasNodeLayout, ProjectCanvasMetadata } from '../model'; +import { NODE_HEIGHT, NODE_WIDTH } from './auto-layout'; +import type { GroupNodeType, ServiceNodeType } from './nodes'; + +export type CanvasNode = ServiceNodeType | GroupNodeType; +export type Rect = { x: number; y: number; w: number; h: number }; + +export const GROUP_DEFAULT_WIDTH = 340; +export const GROUP_DEFAULT_HEIGHT = 230; +export const GROUP_PAD_X = 28; +export const GROUP_PAD_TOP = 56; +export const GROUP_PAD_BOTTOM = 24; +export const GROUP_ROW_GAP = 16; +export const GRID_STEP = 26; +export const NODE_GAP = 14; + +export function rectsOverlap(a: Rect, b: Rect): boolean { + return ( + a.x < b.x + b.w + NODE_GAP && + a.x + a.w + NODE_GAP > b.x && + a.y < b.y + b.h + NODE_GAP && + a.y + a.h + NODE_GAP > b.y + ); +} + +// Nearest grid-aligned position where a node fits without touching any +// occupied rect. Spiral-searches outward on the canvas lattice; falls back to +// the given position when the neighbourhood is fully boxed in. +export function resolveFreeSpot( + desired: { x: number; y: number }, + occupied: Rect[], + fallback: { x: number; y: number }, + bounds?: Rect, +): { x: number; y: number } { + const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP; + const base = { x: snap(desired.x), y: snap(desired.y) }; + const fits = (x: number, y: number) => { + if (bounds && (x < bounds.x || y < bounds.y || x + NODE_WIDTH > bounds.x + bounds.w || y + NODE_HEIGHT > bounds.y + bounds.h)) { + return false; + } + return !occupied.some((rect) => rectsOverlap({ x, y, w: NODE_WIDTH, h: NODE_HEIGHT }, rect)); + }; + + if (fits(base.x, base.y)) { + return base; + } + + for (let ring = 1; ring <= 12; ring++) { + for (let dx = -ring; dx <= ring; dx++) { + for (let dy = -ring; dy <= ring; dy++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) { + continue; + } + const x = base.x + dx * GRID_STEP; + const y = base.y + dy * GRID_STEP; + if (fits(x, y)) { + return { x, y }; + } + } + } + } + + return fallback; +} + +// Where an incoming service lands inside a group frame. If the point already +// fits inside the padded area, honour it. Otherwise append beneath the lowest +// member and grow the frame just enough — keeps groups compact instead of +// ballooning the frame to wherever the node used to sit. +export function groupSlot( + nodes: CanvasNode[], + groupId: string, + group: CanvasNode, + desiredX: number, + desiredY: number, + ignoreServiceId?: string, +): { x: number; y: number; width: number; height: number } { + const width = typeof group.style?.width === 'number' ? group.style.width : GROUP_DEFAULT_WIDTH; + const height = typeof group.style?.height === 'number' ? group.style.height : GROUP_DEFAULT_HEIGHT; + + const members = nodes.filter( + (node) => node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId, + ); + const memberRects: Rect[] = members.map((node) => ({ + x: node.position.x, + y: node.position.y, + w: NODE_WIDTH, + h: NODE_HEIGHT, + })); + const snap = (v: number) => Math.round(v / GRID_STEP) * GRID_STEP; + const snappedX = snap(desiredX); + const snappedY = snap(desiredY); + const snappedFits = + snappedX >= GROUP_PAD_X && + snappedY >= GROUP_PAD_TOP && + snappedX + NODE_WIDTH <= width - GROUP_PAD_X && + snappedY + NODE_HEIGHT <= height - GROUP_PAD_BOTTOM; + const collides = memberRects.some((rect) => + rectsOverlap({ x: snappedX, y: snappedY, w: NODE_WIDTH, h: NODE_HEIGHT }, rect), + ); + + if (snappedFits && !collides) { + return { x: snappedX, y: snappedY, width, height }; + } + + let bottom = GROUP_PAD_TOP - GROUP_ROW_GAP; + for (const node of nodes) { + if (node.type === 'serviceNode' && node.parentId === groupId && node.id !== ignoreServiceId) { + bottom = Math.max(bottom, node.position.y + NODE_HEIGHT); + } + } + + const x = GROUP_PAD_X; + const y = bottom + GROUP_ROW_GAP; + return { + x, + y, + width: Math.max(width, x + NODE_WIDTH + GROUP_PAD_X), + height: Math.max(height, y + NODE_HEIGHT + GROUP_PAD_BOTTOM), + }; +} + +export function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata { + const groups: CanvasGroup[] = []; + const layouts: CanvasNodeLayout[] = []; + + for (const node of nodes) { + if (node.type === 'groupNode') { + const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH; + const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT; + groups.push({ + id: node.id, + title: node.data.title, + position: node.position, + width, + height, + }); + continue; + } + + if (node.type === 'serviceNode') { + layouts.push({ + serviceId: node.id, + position: node.position, + groupId: node.parentId, + }); + } + } + + return { + groups, + nodes: layouts, + edges: [], + viewport, + }; +}