validation.test.ts
1 /** 2 * Tests for workspace validation utilities 3 */ 4 5 import { describe, it, expect } from 'vitest'; 6 import { 7 validateWorkspace, 8 validateTab, 9 validateHierarchy, 10 canAddChild, 11 } from '../../../src/lib/workspace/validation'; 12 import type { 13 ParentWorkspace, 14 ChildWorkspace, 15 StandaloneWorkspace, 16 SavedTab, 17 } from '../../../src/lib/types'; 18 19 // Helper to create valid metadata 20 const createMetadata = () => ({ 21 created: new Date().toISOString(), 22 lastModified: new Date().toISOString(), 23 lastAccessed: new Date().toISOString(), 24 switchCount: 0, 25 totalTimeActive: 0, 26 }); 27 28 describe('validateWorkspace', () => { 29 it('validates a valid parent workspace', () => { 30 const parent: ParentWorkspace = { 31 id: 'parent-1', 32 name: 'Test Parent', 33 type: 'parent', 34 parentId: null, 35 isTranscendent: false, 36 children: [], 37 metadata: createMetadata(), 38 }; 39 40 const result = validateWorkspace(parent); 41 expect(result.valid).toBe(true); 42 expect(result.errors).toHaveLength(0); 43 }); 44 45 it('validates a valid child workspace', () => { 46 const child: ChildWorkspace = { 47 id: 'child-1', 48 name: 'Test Child', 49 type: 'child', 50 parentId: 'parent-1', 51 isTranscendent: false, 52 tabs: [], 53 metadata: createMetadata(), 54 }; 55 56 const result = validateWorkspace(child); 57 expect(result.valid).toBe(true); 58 expect(result.errors).toHaveLength(0); 59 }); 60 61 it('validates a valid standalone workspace', () => { 62 const standalone: StandaloneWorkspace = { 63 id: 'standalone-1', 64 name: 'Test Standalone', 65 type: 'standalone', 66 parentId: null, 67 isTranscendent: false, 68 tabs: [], 69 metadata: createMetadata(), 70 }; 71 72 const result = validateWorkspace(standalone); 73 expect(result.valid).toBe(true); 74 expect(result.errors).toHaveLength(0); 75 }); 76 77 it('rejects workspace without ID', () => { 78 const workspace = { 79 id: '', 80 name: 'No ID', 81 type: 'standalone', 82 parentId: null, 83 isTranscendent: false, 84 tabs: [], 85 metadata: createMetadata(), 86 } as StandaloneWorkspace; 87 88 const result = validateWorkspace(workspace); 89 expect(result.valid).toBe(false); 90 expect(result.errors.some((e) => e.field === 'id')).toBe(true); 91 }); 92 93 it('rejects workspace without name', () => { 94 const workspace = { 95 id: 'test-1', 96 name: '', 97 type: 'standalone', 98 parentId: null, 99 isTranscendent: false, 100 tabs: [], 101 metadata: createMetadata(), 102 } as StandaloneWorkspace; 103 104 const result = validateWorkspace(workspace); 105 expect(result.valid).toBe(false); 106 expect(result.errors.some((e) => e.field === 'name')).toBe(true); 107 }); 108 109 it('rejects parent with non-null parentId', () => { 110 const workspace = { 111 id: 'parent-1', 112 name: 'Bad Parent', 113 type: 'parent', 114 parentId: 'some-id', // Should be null 115 isTranscendent: false, 116 children: [], 117 metadata: createMetadata(), 118 } as unknown as ParentWorkspace; 119 120 const result = validateWorkspace(workspace); 121 expect(result.valid).toBe(false); 122 expect(result.errors.some((e) => e.message.includes('single-level'))).toBe(true); 123 }); 124 125 it('rejects child without parentId', () => { 126 const workspace = { 127 id: 'child-1', 128 name: 'Orphan Child', 129 type: 'child', 130 parentId: '', 131 isTranscendent: false, 132 tabs: [], 133 metadata: createMetadata(), 134 } as ChildWorkspace; 135 136 const result = validateWorkspace(workspace); 137 expect(result.valid).toBe(false); 138 expect(result.errors.some((e) => e.field === 'parentId')).toBe(true); 139 }); 140 }); 141 142 describe('validateTab', () => { 143 it('validates a valid tab', () => { 144 const tab: SavedTab = { 145 url: 'https://example.com', 146 title: 'Example', 147 pinned: false, 148 }; 149 150 const result = validateTab(tab); 151 expect(result.valid).toBe(true); 152 }); 153 154 it('rejects tab without URL', () => { 155 const tab = { 156 url: '', 157 title: 'No URL', 158 pinned: false, 159 } as SavedTab; 160 161 const result = validateTab(tab); 162 expect(result.valid).toBe(false); 163 expect(result.errors.some((e) => e.field === 'url')).toBe(true); 164 }); 165 }); 166 167 describe('validateHierarchy', () => { 168 it('validates a valid hierarchy', () => { 169 const parent: ParentWorkspace = { 170 id: 'parent-1', 171 name: 'Parent', 172 type: 'parent', 173 parentId: null, 174 isTranscendent: false, 175 children: ['child-1', 'child-2'], 176 metadata: createMetadata(), 177 }; 178 179 const child1: ChildWorkspace = { 180 id: 'child-1', 181 name: 'Child 1', 182 type: 'child', 183 parentId: 'parent-1', 184 isTranscendent: false, 185 tabs: [], 186 metadata: createMetadata(), 187 }; 188 189 const child2: ChildWorkspace = { 190 id: 'child-2', 191 name: 'Child 2', 192 type: 'child', 193 parentId: 'parent-1', 194 isTranscendent: false, 195 tabs: [], 196 metadata: createMetadata(), 197 }; 198 199 const result = validateHierarchy([parent, child1, child2]); 200 expect(result.valid).toBe(true); 201 expect(result.errors).toHaveLength(0); 202 }); 203 204 it('detects duplicate IDs', () => { 205 const ws1: StandaloneWorkspace = { 206 id: 'duplicate-id', 207 name: 'First', 208 type: 'standalone', 209 parentId: null, 210 isTranscendent: false, 211 tabs: [], 212 metadata: createMetadata(), 213 }; 214 215 const ws2: StandaloneWorkspace = { 216 id: 'duplicate-id', 217 name: 'Second', 218 type: 'standalone', 219 parentId: null, 220 isTranscendent: false, 221 tabs: [], 222 metadata: createMetadata(), 223 }; 224 225 const result = validateHierarchy([ws1, ws2]); 226 expect(result.valid).toBe(false); 227 expect(result.errors.some((e) => e.message.includes('Duplicate'))).toBe(true); 228 }); 229 230 it('detects orphaned children', () => { 231 const child: ChildWorkspace = { 232 id: 'orphan-child', 233 name: 'Orphan', 234 type: 'child', 235 parentId: 'non-existent-parent', 236 isTranscendent: false, 237 tabs: [], 238 metadata: createMetadata(), 239 }; 240 241 const result = validateHierarchy([child]); 242 expect(result.valid).toBe(false); 243 expect(result.errors.some((e) => e.message.includes('non-existent parent'))).toBe(true); 244 }); 245 246 it('detects missing children in parent array', () => { 247 const parent: ParentWorkspace = { 248 id: 'parent-1', 249 name: 'Parent', 250 type: 'parent', 251 parentId: null, 252 isTranscendent: false, 253 children: ['non-existent-child'], 254 metadata: createMetadata(), 255 }; 256 257 const result = validateHierarchy([parent]); 258 expect(result.valid).toBe(false); 259 expect(result.errors.some((e) => e.message.includes('non-existent child'))).toBe(true); 260 }); 261 }); 262 263 describe('canAddChild', () => { 264 it('allows adding standalone to parent', () => { 265 const parent: ParentWorkspace = { 266 id: 'parent-1', 267 name: 'Parent', 268 type: 'parent', 269 parentId: null, 270 isTranscendent: false, 271 children: [], 272 metadata: createMetadata(), 273 }; 274 275 const standalone: StandaloneWorkspace = { 276 id: 'standalone-1', 277 name: 'Standalone', 278 type: 'standalone', 279 parentId: null, 280 isTranscendent: false, 281 tabs: [], 282 metadata: createMetadata(), 283 }; 284 285 const result = canAddChild([parent, standalone], 'standalone-1', 'parent-1'); 286 expect(result.valid).toBe(true); 287 }); 288 289 it('rejects adding parent as child', () => { 290 const parent1: ParentWorkspace = { 291 id: 'parent-1', 292 name: 'Parent 1', 293 type: 'parent', 294 parentId: null, 295 isTranscendent: false, 296 children: [], 297 metadata: createMetadata(), 298 }; 299 300 const parent2: ParentWorkspace = { 301 id: 'parent-2', 302 name: 'Parent 2', 303 type: 'parent', 304 parentId: null, 305 isTranscendent: false, 306 children: [], 307 metadata: createMetadata(), 308 }; 309 310 const result = canAddChild([parent1, parent2], 'parent-2', 'parent-1'); 311 expect(result.valid).toBe(false); 312 expect(result.errors.some((e) => e.message.includes('single-level'))).toBe(true); 313 }); 314 });