refactor: update dependencies and remove unused code
- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
178
neode-ui/src/api/__tests__/container-client.test.ts
Normal file
178
neode-ui/src/api/__tests__/container-client.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { containerClient } from '../container-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('containerClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('installApp calls container-install with manifest path', async () => {
|
||||
mockedRpc.call.mockResolvedValue('container-abc123')
|
||||
|
||||
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
|
||||
})
|
||||
expect(result).toBe('container-abc123')
|
||||
})
|
||||
|
||||
it('startContainer calls container-start with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-start',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
})
|
||||
|
||||
it('stopContainer calls container-stop with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopContainer('lnd')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-stop',
|
||||
params: { app_id: 'lnd' },
|
||||
})
|
||||
})
|
||||
|
||||
it('removeContainer calls container-remove with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.removeContainer('mempool')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-remove',
|
||||
params: { app_id: 'mempool' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status for a container', async () => {
|
||||
const mockStatus = {
|
||||
id: '1',
|
||||
name: 'bitcoin-knots',
|
||||
state: 'running' as const,
|
||||
image: 'bitcoinknots:29',
|
||||
created: '2026-01-01',
|
||||
ports: ['8332'],
|
||||
lan_address: 'http://localhost:8332',
|
||||
}
|
||||
mockedRpc.call.mockResolvedValue(mockStatus)
|
||||
|
||||
const result = await containerClient.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-status',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
expect(result).toEqual(mockStatus)
|
||||
})
|
||||
|
||||
it('getContainerLogs returns log lines with default line count', async () => {
|
||||
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
|
||||
mockedRpc.call.mockResolvedValue(mockLogs)
|
||||
|
||||
const result = await containerClient.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'bitcoin-knots', lines: 100 },
|
||||
})
|
||||
expect(result).toEqual(mockLogs)
|
||||
})
|
||||
|
||||
it('getContainerLogs respects custom line count', async () => {
|
||||
mockedRpc.call.mockResolvedValue([])
|
||||
|
||||
await containerClient.getContainerLogs('lnd', 50)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'lnd', lines: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listContainers returns all containers', async () => {
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
|
||||
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
|
||||
]
|
||||
mockedRpc.call.mockResolvedValue(mockContainers)
|
||||
|
||||
const result = await containerClient.listContainers()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('getHealthStatus returns health map', async () => {
|
||||
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
|
||||
mockedRpc.call.mockResolvedValue(mockHealth)
|
||||
|
||||
const result = await containerClient.getHealthStatus()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toEqual(mockHealth)
|
||||
})
|
||||
|
||||
it('startBundledApp sends full app config', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
const app = {
|
||||
id: 'filebrowser',
|
||||
name: 'FileBrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
}
|
||||
|
||||
await containerClient.startBundledApp(app)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-start',
|
||||
params: {
|
||||
app_id: 'filebrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('stopBundledApp calls bundled-app-stop', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopBundledApp('filebrowser')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-stop',
|
||||
params: { app_id: 'filebrowser' },
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates RPC errors from the client', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
|
||||
|
||||
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
|
||||
})
|
||||
})
|
||||
330
neode-ui/src/api/__tests__/filebrowser-client.test.ts
Normal file
330
neode-ui/src/api/__tests__/filebrowser-client.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { sanitizePath } from '../filebrowser-client'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// FileBrowserClient reads window.location.origin in constructor, so stub it
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Import after stubs
|
||||
const { fileBrowserClient } = await import('../filebrowser-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileBrowserClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('authenticates and stores token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
|
||||
|
||||
// We need a fresh instance to test login — use the exported singleton
|
||||
const result = await fileBrowserClient.login('admin', 'admin')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/app/filebrowser/api/login'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false on failed login', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
const result = await fileBrowserClient.login('admin', 'wrong')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fileBrowserClient.login()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists items in a directory', async () => {
|
||||
// Ensure authenticated first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockItems = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 1,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
|
||||
|
||||
const items = await fileBrowserClient.listDirectory('/')
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.name).toBe('photos')
|
||||
expect(items[1]!.extension).toBe('txt')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
|
||||
|
||||
await fileBrowserClient.listDirectory('photos')
|
||||
|
||||
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos')
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadUrl', () => {
|
||||
it('constructs download URL for file path', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
|
||||
|
||||
expect(url).toContain('/api/raw/photos/sunset.jpg')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('file.txt')
|
||||
|
||||
expect(url).toContain('/api/raw/file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload', () => {
|
||||
it('uploads a file to the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
await fileBrowserClient.upload('/documents', file)
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/test.txt')
|
||||
expect(url).toContain('override=true')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(file)
|
||||
})
|
||||
|
||||
it('throws on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
||||
const file = new File(['data'], 'big.bin')
|
||||
|
||||
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('creates a folder at the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.createFolder('/documents', 'photos')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/photos/')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('sends DELETE request for the item', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.deleteItem('/photos/old.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsage', () => {
|
||||
it('returns usage summary for root directory', async () => {
|
||||
// Login first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockData = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 2,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage.totalSize).toBe(2000)
|
||||
expect(usage.folderCount).toBe(1)
|
||||
expect(usage.fileCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros on failed request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTextFile', () => {
|
||||
it('identifies text file extensions', () => {
|
||||
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for binary files', () => {
|
||||
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('sends PATCH request with new destination', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
|
||||
})
|
||||
|
||||
it('throws on rename failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
||||
|
||||
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizePath', () => {
|
||||
it('returns / for empty path', () => {
|
||||
expect(sanitizePath('')).toBe('/')
|
||||
})
|
||||
|
||||
it('preserves simple paths', () => {
|
||||
expect(sanitizePath('/photos')).toBe('/photos')
|
||||
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
|
||||
})
|
||||
|
||||
it('adds leading slash', () => {
|
||||
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves . segments', () => {
|
||||
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves .. segments', () => {
|
||||
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
|
||||
})
|
||||
|
||||
it('prevents traversal past root', () => {
|
||||
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
|
||||
expect(sanitizePath('/../../..')).toBe('/')
|
||||
})
|
||||
|
||||
it('handles multiple consecutive .. at root', () => {
|
||||
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
|
||||
})
|
||||
|
||||
it('handles mixed . and .. segments', () => {
|
||||
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
|
||||
})
|
||||
|
||||
it('removes trailing slashes in segments', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
211
neode-ui/src/api/__tests__/rpc-marketplace.test.ts
Normal file
211
neode-ui/src/api/__tests__/rpc-marketplace.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// Import after stubbing fetch
|
||||
const { rpcClient } = await import('../rpc-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve(body),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status, statusText),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('marketplaceDiscover', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns apps array and relay_count on success', async () => {
|
||||
const payload = {
|
||||
apps: [
|
||||
{
|
||||
manifest: {
|
||||
app_id: 'bitcoin',
|
||||
name: 'Bitcoin Core',
|
||||
version: '27.0',
|
||||
description: { short: 'Full node', long: 'Bitcoin Core full node' },
|
||||
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
|
||||
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
|
||||
category: 'bitcoin',
|
||||
icon_url: '/icons/bitcoin.png',
|
||||
repo_url: 'https://github.com/bitcoin/bitcoin',
|
||||
license: 'MIT',
|
||||
},
|
||||
trust_score: 95,
|
||||
trust_tier: 'verified',
|
||||
relay_count: 8,
|
||||
first_seen: '2025-01-15T00:00:00Z',
|
||||
nostr_pubkey: 'npub1abc',
|
||||
},
|
||||
],
|
||||
relay_count: 12,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toHaveLength(1)
|
||||
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
|
||||
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
|
||||
expect(result.apps[0]!.trust_score).toBe(95)
|
||||
expect(result.relay_count).toBe(12)
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('marketplace.discover')
|
||||
expect(body.params).toEqual({})
|
||||
})
|
||||
|
||||
it('handles empty results', async () => {
|
||||
const payload = {
|
||||
apps: [],
|
||||
relay_count: 0,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toEqual([])
|
||||
expect(result.apps).toHaveLength(0)
|
||||
expect(result.relay_count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskStatus', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns expected fields', async () => {
|
||||
const payload = {
|
||||
used_bytes: 500_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 500_000_000_000,
|
||||
used_percent: 50,
|
||||
level: 'ok' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.used_bytes).toBe(500_000_000_000)
|
||||
expect(result.total_bytes).toBe(1_000_000_000_000)
|
||||
expect(result.free_bytes).toBe(500_000_000_000)
|
||||
expect(result.used_percent).toBe(50)
|
||||
expect(result.level).toBe('ok')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-status')
|
||||
})
|
||||
|
||||
it('level is warning when percent >= 85', async () => {
|
||||
const payload = {
|
||||
used_bytes: 850_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 150_000_000_000,
|
||||
used_percent: 85,
|
||||
level: 'warning' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('warning')
|
||||
expect(result.used_percent).toBe(85)
|
||||
})
|
||||
|
||||
it('level is critical when percent >= 90', async () => {
|
||||
const payload = {
|
||||
used_bytes: 950_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 50_000_000_000,
|
||||
used_percent: 95,
|
||||
level: 'critical' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('critical')
|
||||
expect(result.used_percent).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskCleanup', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns freed_bytes and actions array', async () => {
|
||||
const payload = {
|
||||
freed_bytes: 2_000_000_000,
|
||||
freed_human: '2 GB',
|
||||
actions: ['Removed 5 dangling images', 'Cleared build cache'],
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskCleanup()
|
||||
|
||||
expect(result.freed_bytes).toBe(2_000_000_000)
|
||||
expect(result.freed_human).toBe('2 GB')
|
||||
expect(result.actions).toHaveLength(2)
|
||||
expect(result.actions[0]).toBe('Removed 5 dangling images')
|
||||
expect(result.actions[1]).toBe('Cleared build cache')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-cleanup')
|
||||
})
|
||||
|
||||
it('uses 60s timeout', async () => {
|
||||
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
mockFetch.mockRejectedValue(abortError)
|
||||
|
||||
const promise = rpcClient.diskCleanup()
|
||||
|
||||
// The call should eventually reject with timeout after retries
|
||||
await expect(promise).rejects.toThrow('Request timeout')
|
||||
|
||||
// Verify all 3 attempts used the signal (timeout is set via AbortController)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
expect(call[1].signal).toBeInstanceOf(AbortSignal)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user