|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect } from 'vitest'; |
| 4 | +import { |
| 5 | + normalizeMetadataCollection, |
| 6 | + normalizeStackInput, |
| 7 | + MAP_SUPPORTED_FIELDS, |
| 8 | +} from './metadata-collection.zod'; |
| 9 | + |
| 10 | +describe('normalizeMetadataCollection', () => { |
| 11 | + describe('pass-through (no-op) cases', () => { |
| 12 | + it('should return null as-is', () => { |
| 13 | + expect(normalizeMetadataCollection(null)).toBeNull(); |
| 14 | + }); |
| 15 | + |
| 16 | + it('should return undefined as-is', () => { |
| 17 | + expect(normalizeMetadataCollection(undefined)).toBeUndefined(); |
| 18 | + }); |
| 19 | + |
| 20 | + it('should return an array as-is', () => { |
| 21 | + const arr = [{ name: 'task', fields: {} }]; |
| 22 | + expect(normalizeMetadataCollection(arr)).toBe(arr); // same reference |
| 23 | + }); |
| 24 | + |
| 25 | + it('should return an empty array as-is', () => { |
| 26 | + const arr: unknown[] = []; |
| 27 | + expect(normalizeMetadataCollection(arr)).toBe(arr); |
| 28 | + }); |
| 29 | + }); |
| 30 | + |
| 31 | + describe('map → array conversion', () => { |
| 32 | + it('should convert a map to an array with key as name', () => { |
| 33 | + const input = { |
| 34 | + account: { label: 'Account' }, |
| 35 | + contact: { label: 'Contact' }, |
| 36 | + }; |
| 37 | + const result = normalizeMetadataCollection(input); |
| 38 | + expect(result).toEqual([ |
| 39 | + { name: 'account', label: 'Account' }, |
| 40 | + { name: 'contact', label: 'Contact' }, |
| 41 | + ]); |
| 42 | + }); |
| 43 | + |
| 44 | + it('should convert an empty map to an empty array', () => { |
| 45 | + expect(normalizeMetadataCollection({})).toEqual([]); |
| 46 | + }); |
| 47 | + |
| 48 | + it('should convert a single-entry map', () => { |
| 49 | + const result = normalizeMetadataCollection({ |
| 50 | + task: { fields: { title: { type: 'text' } } }, |
| 51 | + }); |
| 52 | + expect(result).toEqual([ |
| 53 | + { name: 'task', fields: { title: { type: 'text' } } }, |
| 54 | + ]); |
| 55 | + }); |
| 56 | + }); |
| 57 | + |
| 58 | + describe('name field precedence', () => { |
| 59 | + it('should use existing name from value (value takes precedence over key)', () => { |
| 60 | + const result = normalizeMetadataCollection({ |
| 61 | + my_key: { name: 'actual_name', label: 'Test' }, |
| 62 | + }); |
| 63 | + expect(result).toEqual([ |
| 64 | + { name: 'actual_name', label: 'Test' }, |
| 65 | + ]); |
| 66 | + }); |
| 67 | + |
| 68 | + it('should inject key only when name is missing', () => { |
| 69 | + const result = normalizeMetadataCollection({ |
| 70 | + task: { label: 'Task' }, |
| 71 | + }); |
| 72 | + expect(result).toEqual([ |
| 73 | + { name: 'task', label: 'Task' }, |
| 74 | + ]); |
| 75 | + }); |
| 76 | + |
| 77 | + it('should inject key when name is undefined', () => { |
| 78 | + const result = normalizeMetadataCollection({ |
| 79 | + task: { name: undefined, label: 'Task' }, |
| 80 | + }); |
| 81 | + expect(result).toEqual([ |
| 82 | + { name: 'task', label: 'Task' }, |
| 83 | + ]); |
| 84 | + }); |
| 85 | + |
| 86 | + it('should not overwrite an empty string name', () => { |
| 87 | + // Empty string is a "present" value — it's the user's choice (Zod will validate it) |
| 88 | + const result = normalizeMetadataCollection({ |
| 89 | + task: { name: '', label: 'Task' }, |
| 90 | + }); |
| 91 | + expect(result).toEqual([ |
| 92 | + { name: '', label: 'Task' }, |
| 93 | + ]); |
| 94 | + }); |
| 95 | + }); |
| 96 | + |
| 97 | + describe('custom keyField', () => { |
| 98 | + it('should inject key into a custom field', () => { |
| 99 | + const result = normalizeMetadataCollection( |
| 100 | + { contact: { fields: {} } }, |
| 101 | + 'extend', |
| 102 | + ); |
| 103 | + expect(result).toEqual([ |
| 104 | + { extend: 'contact', fields: {} }, |
| 105 | + ]); |
| 106 | + }); |
| 107 | + |
| 108 | + it('should not overwrite existing custom field', () => { |
| 109 | + const result = normalizeMetadataCollection( |
| 110 | + { my_key: { extend: 'existing', fields: {} } }, |
| 111 | + 'extend', |
| 112 | + ); |
| 113 | + expect(result).toEqual([ |
| 114 | + { extend: 'existing', fields: {} }, |
| 115 | + ]); |
| 116 | + }); |
| 117 | + }); |
| 118 | + |
| 119 | + describe('edge cases', () => { |
| 120 | + it('should handle non-object map values gracefully', () => { |
| 121 | + // Unusual case — let Zod handle the error downstream |
| 122 | + const result = normalizeMetadataCollection({ |
| 123 | + bad: 'not an object', |
| 124 | + }); |
| 125 | + expect(result).toEqual(['not an object']); |
| 126 | + }); |
| 127 | + |
| 128 | + it('should preserve nested objects without modification', () => { |
| 129 | + const result = normalizeMetadataCollection({ |
| 130 | + task: { |
| 131 | + label: 'Task', |
| 132 | + fields: { |
| 133 | + title: { type: 'text', label: 'Title' }, |
| 134 | + status: { type: 'select', label: 'Status' }, |
| 135 | + }, |
| 136 | + }, |
| 137 | + }); |
| 138 | + expect(result).toEqual([ |
| 139 | + { |
| 140 | + name: 'task', |
| 141 | + label: 'Task', |
| 142 | + fields: { |
| 143 | + title: { type: 'text', label: 'Title' }, |
| 144 | + status: { type: 'select', label: 'Status' }, |
| 145 | + }, |
| 146 | + }, |
| 147 | + ]); |
| 148 | + }); |
| 149 | + |
| 150 | + it('should preserve order of map entries', () => { |
| 151 | + const result = normalizeMetadataCollection({ |
| 152 | + alpha: { label: 'Alpha' }, |
| 153 | + beta: { label: 'Beta' }, |
| 154 | + gamma: { label: 'Gamma' }, |
| 155 | + }) as Array<{ name: string }>; |
| 156 | + expect(result.map((r) => r.name)).toEqual(['alpha', 'beta', 'gamma']); |
| 157 | + }); |
| 158 | + }); |
| 159 | +}); |
| 160 | + |
| 161 | +describe('normalizeStackInput', () => { |
| 162 | + it('should normalize map-formatted metadata fields to arrays', () => { |
| 163 | + const input = { |
| 164 | + manifest: { id: 'test', name: 'test', version: '1.0.0', type: 'app' }, |
| 165 | + objects: { |
| 166 | + task: { fields: { title: { type: 'text' } } }, |
| 167 | + }, |
| 168 | + apps: { |
| 169 | + project_manager: { label: 'PM' }, |
| 170 | + }, |
| 171 | + }; |
| 172 | + |
| 173 | + const result = normalizeStackInput(input); |
| 174 | + |
| 175 | + expect(result.manifest).toBe(input.manifest); // non-metadata fields unchanged |
| 176 | + expect(result.objects).toEqual([ |
| 177 | + { name: 'task', fields: { title: { type: 'text' } } }, |
| 178 | + ]); |
| 179 | + expect(result.apps).toEqual([ |
| 180 | + { name: 'project_manager', label: 'PM' }, |
| 181 | + ]); |
| 182 | + }); |
| 183 | + |
| 184 | + it('should leave array-formatted fields unchanged', () => { |
| 185 | + const input = { |
| 186 | + objects: [{ name: 'task', fields: {} }], |
| 187 | + apps: [{ name: 'sales', label: 'Sales' }], |
| 188 | + }; |
| 189 | + |
| 190 | + const result = normalizeStackInput(input); |
| 191 | + expect(result.objects).toBe(input.objects); // same reference |
| 192 | + expect(result.apps).toBe(input.apps); |
| 193 | + }); |
| 194 | + |
| 195 | + it('should handle mixed array and map formats', () => { |
| 196 | + const input = { |
| 197 | + objects: { task: { fields: { title: { type: 'text' } } } }, // map |
| 198 | + apps: [{ name: 'sales', label: 'Sales' }], // array |
| 199 | + views: [{ list: { type: 'grid' } }], // not in MAP_SUPPORTED_FIELDS if ViewSchema doesn't have name |
| 200 | + }; |
| 201 | + |
| 202 | + const result = normalizeStackInput(input); |
| 203 | + expect(Array.isArray(result.objects)).toBe(true); |
| 204 | + expect(result.apps).toBe(input.apps); |
| 205 | + expect(result.views).toBe(input.views); |
| 206 | + }); |
| 207 | + |
| 208 | + it('should not modify fields not in MAP_SUPPORTED_FIELDS', () => { |
| 209 | + const input = { |
| 210 | + manifest: { id: 'test' }, |
| 211 | + i18n: { defaultLocale: 'en' }, |
| 212 | + plugins: ['@objectstack/plugin-dev'], |
| 213 | + views: [{ list: { type: 'grid' } }], |
| 214 | + }; |
| 215 | + |
| 216 | + const result = normalizeStackInput(input); |
| 217 | + expect(result.manifest).toBe(input.manifest); |
| 218 | + expect(result.i18n).toBe(input.i18n); |
| 219 | + expect(result.plugins).toBe(input.plugins); |
| 220 | + expect(result.views).toBe(input.views); |
| 221 | + }); |
| 222 | + |
| 223 | + it('should handle an empty input', () => { |
| 224 | + expect(normalizeStackInput({})).toEqual({}); |
| 225 | + }); |
| 226 | + |
| 227 | + it('should handle undefined metadata fields', () => { |
| 228 | + const input = { objects: undefined, apps: undefined }; |
| 229 | + const result = normalizeStackInput(input); |
| 230 | + expect(result.objects).toBeUndefined(); |
| 231 | + expect(result.apps).toBeUndefined(); |
| 232 | + }); |
| 233 | +}); |
| 234 | + |
| 235 | +describe('MAP_SUPPORTED_FIELDS', () => { |
| 236 | + it('should contain only fields that exist on ObjectStackDefinitionSchema', () => { |
| 237 | + // Sanity check — these are the fields we expect to support map format |
| 238 | + expect(MAP_SUPPORTED_FIELDS).toContain('objects'); |
| 239 | + expect(MAP_SUPPORTED_FIELDS).toContain('apps'); |
| 240 | + expect(MAP_SUPPORTED_FIELDS).toContain('pages'); |
| 241 | + expect(MAP_SUPPORTED_FIELDS).toContain('dashboards'); |
| 242 | + expect(MAP_SUPPORTED_FIELDS).toContain('reports'); |
| 243 | + expect(MAP_SUPPORTED_FIELDS).toContain('actions'); |
| 244 | + expect(MAP_SUPPORTED_FIELDS).toContain('themes'); |
| 245 | + expect(MAP_SUPPORTED_FIELDS).toContain('workflows'); |
| 246 | + expect(MAP_SUPPORTED_FIELDS).toContain('approvals'); |
| 247 | + expect(MAP_SUPPORTED_FIELDS).toContain('flows'); |
| 248 | + expect(MAP_SUPPORTED_FIELDS).toContain('roles'); |
| 249 | + expect(MAP_SUPPORTED_FIELDS).toContain('permissions'); |
| 250 | + expect(MAP_SUPPORTED_FIELDS).toContain('datasources'); |
| 251 | + expect(MAP_SUPPORTED_FIELDS).toContain('connectors'); |
| 252 | + }); |
| 253 | + |
| 254 | + it('should NOT contain fields without a name identifier', () => { |
| 255 | + expect(MAP_SUPPORTED_FIELDS).not.toContain('views'); |
| 256 | + expect(MAP_SUPPORTED_FIELDS).not.toContain('objectExtensions'); |
| 257 | + expect(MAP_SUPPORTED_FIELDS).not.toContain('data'); |
| 258 | + expect(MAP_SUPPORTED_FIELDS).not.toContain('translations'); |
| 259 | + expect(MAP_SUPPORTED_FIELDS).not.toContain('plugins'); |
| 260 | + expect(MAP_SUPPORTED_FIELDS).not.toContain('devPlugins'); |
| 261 | + }); |
| 262 | +}); |
0 commit comments