import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createFolder, createHost, deleteNode, getConnectionTree, getHost, isDescendantOf, listHosts, moveNode, renameFolder, updateHost, } from '../src/main/db/repositories/connections' import type { HostInput } from '../src/shared/ipc-schemas' import { createTempDb, type TempDb } from './helpers' let temp: TempDb beforeEach(() => { temp = createTempDb() }) afterEach(() => { temp.cleanup() }) function hostInput(overrides: Partial = {}): HostInput { return { parentId: null, label: 'web-01', hostname: '10.0.1.5', port: 22, username: 'root', authType: 'password', keyPath: null, color: null, ...overrides, } } function addHost(parentId: number | null, label: string): number { return createHost(temp.db, hostInput({ parentId, label })) } /** Labels of a layer, in tree order, for compact assertions. */ function layerNames(nodes: ReturnType): string[] { return nodes.map((n) => (n.kind === 'folder' ? n.name : n.label)) } describe('creating nodes', () => { it('creates top-level folders and nests them', () => { const prod = createFolder(temp.db, null, 'prod') const web = createFolder(temp.db, prod, 'web') const tree = getConnectionTree(temp.db) expect(layerNames(tree)).toEqual(['prod']) const root = tree[0] expect(root.kind).toBe('folder') if (root.kind === 'folder') { expect(root.id).toBe(prod) expect(layerNames(root.children)).toEqual(['web']) expect(root.children[0].id).toBe(web) } }) it('gives folders and hosts ids from one shared sequence', () => { // The point of the interleaved tree: a folder and a host can never collide // on an id, so a node id is a complete identity. const folder = createFolder(temp.db, null, 'f') const host = addHost(null, 'h') expect(folder).not.toBe(host) expect(new Set([folder, host]).size).toBe(2) }) it('stores host attributes', () => { const id = createHost( temp.db, hostInput({ label: 'db', hostname: '10.0.2.10', port: 5432, username: 'postgres' }), ) const host = getHost(temp.db, id) expect(host).toMatchObject({ label: 'db', hostname: '10.0.2.10', port: 5432, username: 'postgres', parentId: null, }) }) it('appends new nodes to the end of their layer', () => { const first = addHost(null, 'first') const second = addHost(null, 'second') expect(layerNames(getConnectionTree(temp.db))).toEqual(['first', 'second']) expect(getHost(temp.db, second)?.sortOrder).toBeGreaterThan( getHost(temp.db, first)?.sortOrder ?? 0, ) }) }) describe('interleaving folders and hosts', () => { it('renders them in one order rather than grouping by kind', () => { // The whole point of the schema change. Before it, every folder came before // every host, because the two sort_order sequences were not comparable. const folder = createFolder(temp.db, null, 'folder') const host = addHost(null, 'host') // Drop the host ahead of the folder. moveNode(temp.db, host, null, null, folder) expect(layerNames(getConnectionTree(temp.db))).toEqual(['host', 'folder']) }) it('allows a host between two folders', () => { // Not expressible at all before the interleaving — the two sequences had no // common scale. const a = createFolder(temp.db, null, 'a') const b = createFolder(temp.db, null, 'b') const host = addHost(null, 'middle') moveNode(temp.db, host, null, a, b) expect(layerNames(getConnectionTree(temp.db))).toEqual(['a', 'middle', 'b']) }) it('allows a folder between two hosts', () => { const h1 = addHost(null, 'h1') const h2 = addHost(null, 'h2') const folder = createFolder(temp.db, null, 'middle') moveNode(temp.db, folder, null, h1, h2) expect(layerNames(getConnectionTree(temp.db))).toEqual(['h1', 'middle', 'h2']) }) it('keeps a layer strictly ordered through many midpoint inserts', () => { const anchorLeft = createFolder(temp.db, null, 'left') const anchorRight = createFolder(temp.db, null, 'right') // Each insert goes into the gap immediately right of the previous one, so // the interval halves every time — the precision-exhaustion scenario the // REAL ordering trades for never renumbering siblings. let previous = anchorLeft for (let i = 0; i < 30; i++) { const id = addHost(null, `mid-${i}`) moveNode(temp.db, id, null, previous, anchorRight) previous = id } const orders = getConnectionTree(temp.db).map((n) => n.sortOrder) expect(orders).toHaveLength(32) expect(new Set(orders).size).toBe(orders.length) }) }) describe('moving nodes', () => { it('reparents a host into a folder', () => { const folder = createFolder(temp.db, null, 'group') const host = addHost(null, 'roamer') moveNode(temp.db, host, folder) const tree = getConnectionTree(temp.db) expect(tree).toHaveLength(1) if (tree[0].kind === 'folder') { expect(layerNames(tree[0].children)).toEqual(['roamer']) } }) it('moves a node back out to the root layer', () => { const folder = createFolder(temp.db, null, 'group') const host = createHost(temp.db, hostInput({ parentId: folder, label: 'inside' })) moveNode(temp.db, host, null) expect(layerNames(getConnectionTree(temp.db))).toEqual(['group', 'inside']) }) it('refuses to make a node its own parent', () => { const folder = createFolder(temp.db, null, 'f') expect(() => moveNode(temp.db, folder, folder)).toThrow(/own parent/) }) it('refuses to move a folder into its own descendant', () => { // Without this the subtree detaches from every root and disappears from the // UI while its rows remain. const a = createFolder(temp.db, null, 'a') const b = createFolder(temp.db, a, 'b') const c = createFolder(temp.db, b, 'c') expect(() => moveNode(temp.db, a, b)).toThrow(/descendant/) expect(() => moveNode(temp.db, a, c)).toThrow(/descendant/) }) it('identifies descendants correctly', () => { const a = createFolder(temp.db, null, 'a') const b = createFolder(temp.db, a, 'b') const unrelated = createFolder(temp.db, null, 'unrelated') expect(isDescendantOf(temp.db, b, a)).toBe(true) expect(isDescendantOf(temp.db, a, b)).toBe(false) expect(isDescendantOf(temp.db, unrelated, a)).toBe(false) }) }) describe('updating and deleting', () => { it('renames a folder', () => { const id = createFolder(temp.db, null, 'old') renameFolder(temp.db, id, 'new') expect(layerNames(getConnectionTree(temp.db))).toEqual(['new']) }) it('updates a host without moving it', () => { const folder = createFolder(temp.db, null, 'group') const host = createHost(temp.db, hostInput({ parentId: folder, label: 'before' })) const sortBefore = getHost(temp.db, host)?.sortOrder updateHost(temp.db, host, hostInput({ parentId: folder, label: 'after', port: 2222 })) const record = getHost(temp.db, host) expect(record?.label).toBe('after') expect(record?.port).toBe(2222) // An edit must not disturb the node's position in the tree. expect(record?.sortOrder).toBe(sortBefore) }) it('deletes a folder along with everything beneath it', () => { const a = createFolder(temp.db, null, 'a') const b = createFolder(temp.db, a, 'b') createHost(temp.db, hostInput({ parentId: b, label: 'deep' })) addHost(null, 'unrelated') deleteNode(temp.db, a) expect(layerNames(getConnectionTree(temp.db))).toEqual(['unrelated']) expect(listHosts(temp.db)).toHaveLength(1) }) it('deletes a host on its own', () => { const folder = createFolder(temp.db, null, 'group') const host = createHost(temp.db, hostInput({ parentId: folder, label: 'gone' })) deleteNode(temp.db, host) const tree = getConnectionTree(temp.db) expect(tree).toHaveLength(1) if (tree[0].kind === 'folder') { expect(tree[0].children).toHaveLength(0) } }) }) /** * SQLite reports an UPDATE or DELETE that matched no rows as a successful * operation. Left unchecked, that turns every one of these into a silent * no-op — and the UI, which trusts the promise, reports success for a change * that never happened. */ describe('guards against missing rows', () => { const MISSING = 999_999 it('renameFolder throws for an unknown id', () => { expect(() => renameFolder(temp.db, MISSING, 'x')).toThrow(/not found/) }) it('moveNode throws for an unknown id', () => { expect(() => moveNode(temp.db, MISSING, null)).toThrow(/not found/) }) it('deleteNode throws for an unknown id', () => { expect(() => deleteNode(temp.db, MISSING)).toThrow(/not found/) }) it('updateHost throws for an unknown id', () => { expect(() => updateHost(temp.db, MISSING, hostInput())).toThrow(/not found/) }) it('updateHost throws when handed a folder id', () => { // With one id space, a folder id is a syntactically valid node id — the // separate `hosts` table is what makes it fail rather than silently // editing nothing. const folder = createFolder(temp.db, null, 'not-a-host') expect(() => updateHost(temp.db, folder, hostInput())).toThrow(/not found/) }) }) describe('reading', () => { it('returns an empty tree for an empty inventory', () => { expect(getConnectionTree(temp.db)).toEqual([]) }) it('returns null for a folder id passed to getHost', () => { const folder = createFolder(temp.db, null, 'f') expect(getHost(temp.db, folder)).toBeNull() }) it('lists every host with its parent', () => { const folder = createFolder(temp.db, null, 'group') createHost(temp.db, hostInput({ parentId: folder, label: 'nested' })) addHost(null, 'rooted') const hosts = listHosts(temp.db) expect(hosts).toHaveLength(2) expect(hosts.find((h) => h.label === 'nested')?.parentId).toBe(folder) expect(hosts.find((h) => h.label === 'rooted')?.parentId).toBeNull() }) })