|
1 | | -import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 1 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
2 | 2 |
|
3 | 3 | vi.mock('node:fs', () => ({ |
4 | 4 | readFileSync: vi.fn(), |
5 | 5 | writeFileSync: vi.fn(), |
6 | 6 | mkdirSync: vi.fn(), |
7 | 7 | existsSync: vi.fn(), |
| 8 | + unlinkSync: vi.fn(), |
| 9 | + rmdirSync: vi.fn(), |
8 | 10 | })); |
9 | 11 |
|
10 | 12 | vi.mock('node:os', () => ({ |
11 | 13 | homedir: vi.fn(() => '/home/test'), |
12 | 14 | })); |
13 | 15 |
|
14 | | -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; |
| 16 | +import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, rmdirSync } from 'node:fs'; |
15 | 17 |
|
16 | 18 | import { ConfigStore } from './config-store.js'; |
17 | 19 |
|
| 20 | +let origPlatform: PropertyDescriptor | undefined; |
| 21 | +let origXdgConfigHome: string | undefined; |
| 22 | + |
18 | 23 | beforeEach(() => { |
19 | 24 | vi.clearAllMocks(); |
| 25 | + origPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); |
| 26 | + origXdgConfigHome = process.env.XDG_CONFIG_HOME; |
| 27 | + delete process.env.XDG_CONFIG_HOME; |
| 28 | + // Default to linux so macOS migration doesn't trigger in most tests |
| 29 | + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); |
| 30 | +}); |
| 31 | + |
| 32 | +afterEach(() => { |
| 33 | + if (origPlatform) { |
| 34 | + Object.defineProperty(process, 'platform', origPlatform); |
| 35 | + } |
| 36 | + if (origXdgConfigHome !== undefined) { |
| 37 | + process.env.XDG_CONFIG_HOME = origXdgConfigHome; |
| 38 | + } else { |
| 39 | + delete process.env.XDG_CONFIG_HOME; |
| 40 | + } |
20 | 41 | }); |
21 | 42 |
|
22 | 43 | describe('ConfigStore', () => { |
@@ -110,13 +131,187 @@ describe('ConfigStore', () => { |
110 | 131 |
|
111 | 132 | describe('platform-specific config dir', () => { |
112 | 133 | it('uses XDG_CONFIG_HOME when set', () => { |
113 | | - const orig = process.env.XDG_CONFIG_HOME; |
114 | 134 | process.env.XDG_CONFIG_HOME = '/custom/config'; |
115 | 135 | vi.mocked(readFileSync).mockReturnValue('{}'); |
116 | 136 | const store = new ConfigStore('my-app'); |
117 | 137 | store.get('key'); |
118 | 138 | expect(readFileSync).toHaveBeenCalledWith(expect.stringContaining('/custom/config'), 'utf-8'); |
119 | | - process.env.XDG_CONFIG_HOME = orig; |
| 139 | + }); |
| 140 | + }); |
| 141 | + |
| 142 | + describe('legacy config migration', () => { |
| 143 | + const legacyPath = '/home/test/.config/my-app/config.json'; |
| 144 | + const nativePath = '/home/test/Library/Application Support/my-app/config.json'; |
| 145 | + |
| 146 | + beforeEach(() => { |
| 147 | + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); |
| 148 | + }); |
| 149 | + |
| 150 | + it('migrates legacy config to macOS-native path', () => { |
| 151 | + const legacyData = { organizationId: '30059', userId: '500521' }; |
| 152 | + |
| 153 | + vi.mocked(existsSync).mockImplementation((path) => { |
| 154 | + if (path === legacyPath) return true; |
| 155 | + return true; // ensureConfigDir |
| 156 | + }); |
| 157 | + |
| 158 | + vi.mocked(readFileSync).mockImplementation((path) => { |
| 159 | + if (path === legacyPath) return JSON.stringify(legacyData); |
| 160 | + // Current config is empty (file not found or empty) |
| 161 | + throw new Error('ENOENT'); |
| 162 | + }); |
| 163 | + |
| 164 | + const store = new ConfigStore('my-app'); |
| 165 | + |
| 166 | + // Should have written merged config to native path |
| 167 | + expect(writeFileSync).toHaveBeenCalledWith( |
| 168 | + nativePath, |
| 169 | + JSON.stringify(legacyData, null, 2), |
| 170 | + 'utf-8', |
| 171 | + ); |
| 172 | + |
| 173 | + // Should have cleaned up legacy file |
| 174 | + expect(unlinkSync).toHaveBeenCalledWith(legacyPath); |
| 175 | + expect(rmdirSync).toHaveBeenCalledWith('/home/test/.config/my-app'); |
| 176 | + |
| 177 | + // Should report migration |
| 178 | + expect(store.didMigrate).toBe(true); |
| 179 | + |
| 180 | + // Should return migrated values |
| 181 | + expect(store.get('organizationId')).toBe('30059'); |
| 182 | + expect(store.get('userId')).toBe('500521'); |
| 183 | + }); |
| 184 | + |
| 185 | + it('merges legacy config with existing native config (native takes precedence)', () => { |
| 186 | + const legacyData = { organizationId: '30059', userId: '500521', baseUrl: 'https://old.api' }; |
| 187 | + const nativeData = { organizationId: '99999' }; |
| 188 | + |
| 189 | + vi.mocked(existsSync).mockReturnValue(true); |
| 190 | + |
| 191 | + vi.mocked(readFileSync).mockImplementation((path) => { |
| 192 | + if (path === legacyPath) return JSON.stringify(legacyData); |
| 193 | + return JSON.stringify(nativeData); |
| 194 | + }); |
| 195 | + |
| 196 | + const store = new ConfigStore('my-app'); |
| 197 | + |
| 198 | + // Native value should win for organizationId |
| 199 | + expect(store.get('organizationId')).toBe('99999'); |
| 200 | + // Legacy values should fill in missing keys |
| 201 | + expect(store.get('userId')).toBe('500521'); |
| 202 | + expect(store.get('baseUrl')).toBe('https://old.api'); |
| 203 | + expect(store.didMigrate).toBe(true); |
| 204 | + }); |
| 205 | + |
| 206 | + it('does not migrate when legacy config is empty', () => { |
| 207 | + vi.mocked(existsSync).mockImplementation((path) => { |
| 208 | + if (path === legacyPath) return true; |
| 209 | + return false; |
| 210 | + }); |
| 211 | + |
| 212 | + vi.mocked(readFileSync).mockImplementation((path) => { |
| 213 | + if (path === legacyPath) return JSON.stringify({}); |
| 214 | + throw new Error('ENOENT'); |
| 215 | + }); |
| 216 | + |
| 217 | + const store = new ConfigStore('my-app'); |
| 218 | + |
| 219 | + // Should not have written anything (no save calls from migration) |
| 220 | + expect(writeFileSync).not.toHaveBeenCalled(); |
| 221 | + expect(store.didMigrate).toBe(false); |
| 222 | + }); |
| 223 | + |
| 224 | + it('does not migrate when legacy file does not exist', () => { |
| 225 | + vi.mocked(existsSync).mockReturnValue(false); |
| 226 | + vi.mocked(readFileSync).mockImplementation(() => { |
| 227 | + throw new Error('ENOENT'); |
| 228 | + }); |
| 229 | + |
| 230 | + const store = new ConfigStore('my-app'); |
| 231 | + |
| 232 | + expect(writeFileSync).not.toHaveBeenCalled(); |
| 233 | + expect(unlinkSync).not.toHaveBeenCalled(); |
| 234 | + expect(store.didMigrate).toBe(false); |
| 235 | + }); |
| 236 | + |
| 237 | + it('does not migrate when XDG_CONFIG_HOME is set', () => { |
| 238 | + process.env.XDG_CONFIG_HOME = '/custom/config'; |
| 239 | + |
| 240 | + vi.mocked(readFileSync).mockReturnValue('{}'); |
| 241 | + vi.mocked(existsSync).mockReturnValue(false); |
| 242 | + |
| 243 | + const store = new ConfigStore('my-app'); |
| 244 | + |
| 245 | + expect(unlinkSync).not.toHaveBeenCalled(); |
| 246 | + expect(store.didMigrate).toBe(false); |
| 247 | + }); |
| 248 | + |
| 249 | + it('does not migrate on non-macOS platforms', () => { |
| 250 | + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); |
| 251 | + |
| 252 | + vi.mocked(readFileSync).mockReturnValue('{}'); |
| 253 | + vi.mocked(existsSync).mockReturnValue(false); |
| 254 | + |
| 255 | + const store = new ConfigStore('my-app'); |
| 256 | + |
| 257 | + expect(unlinkSync).not.toHaveBeenCalled(); |
| 258 | + expect(store.didMigrate).toBe(false); |
| 259 | + }); |
| 260 | + |
| 261 | + it('does not migrate when native config already has all keys', () => { |
| 262 | + const legacyData = { organizationId: '30059' }; |
| 263 | + const nativeData = { organizationId: '99999' }; |
| 264 | + |
| 265 | + vi.mocked(existsSync).mockReturnValue(true); |
| 266 | + |
| 267 | + vi.mocked(readFileSync).mockImplementation((path) => { |
| 268 | + if (path === legacyPath) return JSON.stringify(legacyData); |
| 269 | + return JSON.stringify(nativeData); |
| 270 | + }); |
| 271 | + |
| 272 | + const store = new ConfigStore('my-app'); |
| 273 | + |
| 274 | + // No new keys to add, so no write from migration |
| 275 | + expect(writeFileSync).not.toHaveBeenCalled(); |
| 276 | + expect(store.didMigrate).toBe(false); |
| 277 | + |
| 278 | + // Legacy file should still be cleaned up |
| 279 | + expect(unlinkSync).toHaveBeenCalledWith(legacyPath); |
| 280 | + }); |
| 281 | + |
| 282 | + it('handles unreadable legacy file gracefully', () => { |
| 283 | + vi.mocked(existsSync).mockImplementation((path) => { |
| 284 | + if (path === legacyPath) return true; |
| 285 | + return false; |
| 286 | + }); |
| 287 | + |
| 288 | + vi.mocked(readFileSync).mockImplementation(() => { |
| 289 | + throw new Error('EACCES'); |
| 290 | + }); |
| 291 | + |
| 292 | + // Should not throw |
| 293 | + const store = new ConfigStore('my-app'); |
| 294 | + expect(store.didMigrate).toBe(false); |
| 295 | + }); |
| 296 | + |
| 297 | + it('handles legacy cleanup errors gracefully', () => { |
| 298 | + const legacyData = { userId: '500521' }; |
| 299 | + |
| 300 | + vi.mocked(existsSync).mockReturnValue(true); |
| 301 | + |
| 302 | + vi.mocked(readFileSync).mockImplementation((path) => { |
| 303 | + if (path === legacyPath) return JSON.stringify(legacyData); |
| 304 | + throw new Error('ENOENT'); |
| 305 | + }); |
| 306 | + |
| 307 | + vi.mocked(unlinkSync).mockImplementation(() => { |
| 308 | + throw new Error('EPERM'); |
| 309 | + }); |
| 310 | + |
| 311 | + // Should not throw even if cleanup fails |
| 312 | + const store = new ConfigStore('my-app'); |
| 313 | + expect(store.didMigrate).toBe(true); |
| 314 | + expect(store.get('userId')).toBe('500521'); |
120 | 315 | }); |
121 | 316 | }); |
122 | 317 | }); |
0 commit comments