import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { normalizeNavigation, readOrCreateNavigation, validateNavigation, writeJsonAtomic, } from '../server/navigation.mjs' const temporaryDirectories = [] afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) }) const catalog = { categories: [{ id: 'encoding' }, { id: 'crypto' }], tools: [ { id: 'hex', category: 'encoding' }, { id: 'base64', category: 'encoding' }, { id: 'cmac', category: 'crypto' }, ], } const defaults = { version: 1, categories: [ { id: 'encoding', tools: ['hex', 'base64'] }, { id: 'crypto', tools: ['cmac'] }, ], } describe('navigation config', () => { it('preserves custom order and appends tools added in a later release', () => { const current = { version: 1, categories: [{ id: 'encoding', tools: ['base64'] }] } expect(normalizeNavigation(current, catalog, defaults)).toEqual({ version: 1, categories: [ { id: 'encoding', tools: ['base64', 'hex'] }, { id: 'crypto', tools: ['cmac'] }, ], }) }) it('migrates the legacy ASCII/Hex ID without changing its saved position', async () => { const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-')) temporaryDirectories.push(directory) const filePath = path.join(directory, 'navigation.json') const utf8Catalog = { categories: [{ id: 'encoding' }], tools: [ { id: 'utf8-hex', category: 'encoding' }, { id: 'base64', category: 'encoding' }, ], } const utf8Defaults = { version: 1, categories: [{ id: 'encoding', tools: ['utf8-hex', 'base64'] }], } await writeFile(filePath, JSON.stringify({ version: 1, categories: [{ id: 'encoding', tools: ['base64', 'ascii-hex'] }], })) const expected = { version: 1, categories: [{ id: 'encoding', tools: ['base64', 'utf8-hex'] }], } await expect(readOrCreateNavigation(filePath, utf8Catalog, utf8Defaults)).resolves.toEqual(expected) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(expected) }) it('rejects duplicate or incomplete configurations', () => { expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex', 'hex'] }] }, catalog)).toThrow('重复') expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }, catalog)).toThrow('全部') }) it('writes valid JSON through an atomic replacement', async () => { const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-')) temporaryDirectories.push(directory) const filePath = path.join(directory, 'navigation.json') await writeJsonAtomic(filePath, defaults) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(defaults) }) it('closes the handle and removes the temporary file when writing fails', async () => { const calls = [] const failure = new Error('disk full') const operations = { mkdir: async () => undefined, open: async (temporaryPath) => ({ writeFile: async () => { throw failure }, sync: async () => undefined, close: async () => { calls.push('close') }, temporaryPath, }), rename: async () => { calls.push('rename') }, unlink: async (temporaryPath) => { calls.push(`unlink:${temporaryPath}`) }, } await expect(writeJsonAtomic('/virtual/navigation.json', defaults, operations)).rejects.toBe(failure) expect(calls[0]).toBe('close') expect(calls[1]).toMatch(/^unlink:\/virtual\/navigation\.json\..+\.tmp$/) expect(calls).not.toContain('rename') }) })