From 20163b14bd78967b09b2415fcaf645df6182d16c Mon Sep 17 00:00:00 2001 From: josevbrito Date: Mon, 23 Mar 2026 14:33:28 -0300 Subject: [PATCH] test(backend): add initial JAPA test suite for utils, validators and services --- admin/tests/unit/models/kv_store.spec.ts | 44 +++++++++++++++ .../tests/unit/services/chat_service.spec.ts | 39 +++++++++++++ .../unit/services/system_service.spec.ts | 56 +++++++++++++++++++ admin/tests/unit/utils/fs.spec.ts | 24 ++++++++ admin/tests/unit/utils/misc.spec.ts | 31 ++++++++++ admin/tests/unit/utils/version.spec.ts | 28 ++++++++++ admin/tests/unit/validators/chat.spec.ts | 29 ++++++++++ admin/tests/unit/validators/common.spec.ts | 27 +++++++++ admin/tests/unit/validators/settings.spec.ts | 15 +++++ 9 files changed, 293 insertions(+) create mode 100644 admin/tests/unit/models/kv_store.spec.ts create mode 100644 admin/tests/unit/services/chat_service.spec.ts create mode 100644 admin/tests/unit/services/system_service.spec.ts create mode 100644 admin/tests/unit/utils/fs.spec.ts create mode 100644 admin/tests/unit/utils/misc.spec.ts create mode 100644 admin/tests/unit/utils/version.spec.ts create mode 100644 admin/tests/unit/validators/chat.spec.ts create mode 100644 admin/tests/unit/validators/common.spec.ts create mode 100644 admin/tests/unit/validators/settings.spec.ts diff --git a/admin/tests/unit/models/kv_store.spec.ts b/admin/tests/unit/models/kv_store.spec.ts new file mode 100644 index 0000000..f642843 --- /dev/null +++ b/admin/tests/unit/models/kv_store.spec.ts @@ -0,0 +1,44 @@ +import { test } from '@japa/runner' +import KVStore from '../../../app/models/kv_store.js' + +test.group('Models | KVStore (Mocked Database)', (group) => { + let originalFindBy: any + let originalFirstOrCreate: any + + group.each.setup(() => { + // Save original database functions to restore later + originalFindBy = KVStore.findBy + originalFirstOrCreate = KVStore.firstOrCreate + }) + + group.each.teardown(() => { + // Restore to avoid polluting other test suites + KVStore.findBy = originalFindBy + KVStore.firstOrCreate = originalFirstOrCreate + }) + + test('getValue: should return null when the key does not exist in the database', async ({ assert }) => { + // Mock the database returning nothing + KVStore.findBy = async () => null + + const result = await KVStore.getValue('system.updateAvailable' as any) + assert.isNull(result) + }) + + test('clearValue: should set the value to null and invoke save() on the record', async ({ assert }) => { + let saveCalled = false + + // Mock the record returned by the database + const mockDbRecord = { + value: 'old_configuration', + save: async () => { saveCalled = true } + } + + KVStore.findBy = async () => (mockDbRecord as any) + + await KVStore.clearValue('some_key' as any) + + assert.isNull(mockDbRecord.value) + assert.isTrue(saveCalled) // Ensures the null value was persisted + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/services/chat_service.spec.ts b/admin/tests/unit/services/chat_service.spec.ts new file mode 100644 index 0000000..8599292 --- /dev/null +++ b/admin/tests/unit/services/chat_service.spec.ts @@ -0,0 +1,39 @@ +import { test } from '@japa/runner' +import { ChatService } from '../../../app/services/chat_service.js' +import { OllamaService } from '../../../app/services/ollama_service.js' + +test.group('Services | ChatService (Mocked)', () => { + test('getChatSuggestions: should return an empty array if there are no models in Ollama', async ({ assert }) => { + // Mock OllamaService returning zero models + const mockOllamaService = { + getModels: async () => [] + } as unknown as OllamaService + + const chatService = new ChatService(mockOllamaService) + const suggestions = await chatService.getChatSuggestions() + + assert.deepEqual(suggestions, []) + }) + + test('getChatSuggestions: should extract, clean and format suggestions generated by the LLM', async ({ assert }) => { + // Simulate a scenario where Ollama responds to the request + const mockOllamaService = { + getModels: async () => [{ name: 'llama3:8b', size: 4000000000 }], + chat: async () => ({ + message: { + // Simulating dirty LLM output with list markers + content: '1. How to create a game in Python\n2. What is Docker?\n3. Artisanal burger recipe' + } + }) + } as unknown as OllamaService + + const chatService = new ChatService(mockOllamaService) + const suggestions = await chatService.getChatSuggestions() + + // Verify regex and toTitleCase utility logic + assert.lengthOf(suggestions, 3) + assert.equal(suggestions[0], 'How To Create A Game In Python') + assert.equal(suggestions[1], 'What Is Docker?') + assert.equal(suggestions[2], 'Artisanal Burger Recipe') + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/services/system_service.spec.ts b/admin/tests/unit/services/system_service.spec.ts new file mode 100644 index 0000000..262a113 --- /dev/null +++ b/admin/tests/unit/services/system_service.spec.ts @@ -0,0 +1,56 @@ +import { test } from '@japa/runner' +import { SystemService } from '../../../app/services/system_service.js' +import { DockerService } from '../../../app/services/docker_service.js' +import axios from 'axios' + +test.group('Services | SystemService (Mocked)', (group) => { + let originalAxiosGet: any + let originalAxiosPost: any + + group.each.setup(() => { + // Save original Axios functions + originalAxiosGet = axios.get + originalAxiosPost = axios.post + }) + + group.each.teardown(() => { + // Restore original functions + axios.get = originalAxiosGet + axios.post = originalAxiosPost + }) + + test('getInternetStatus: should return true if the request returns status 200', async ({ assert }) => { + // Simulate Cloudflare URL (1.1.1.1) responding perfectly + axios.get = async () => ({ status: 200 }) as any + + // Inject empty DockerService mock + const mockDockerService = {} as DockerService + const systemService = new SystemService(mockDockerService) + + const isOnline = await systemService.getInternetStatus() + assert.isTrue(isOnline) + }) + + test('subscribeToReleaseNotes: should return success when the external API responds with 200', async ({ assert }) => { + // Simulate projectnomad.us API returning success + axios.post = async () => ({ status: 200 }) as any + + const systemService = new SystemService({} as DockerService) + const response = await systemService.subscribeToReleaseNotes('jv@example.com') + + assert.isTrue(response.success) + assert.equal(response.message, 'Successfully subscribed to release notes') + }) + + test('subscribeToReleaseNotes: should gracefully handle network failures', async ({ assert }) => { + // Simulate network error + axios.post = async () => { throw new Error('Network Error') } + + const systemService = new SystemService({} as DockerService) + const response = await systemService.subscribeToReleaseNotes('jv@example.com') + + // The service should not crash, it should catch the error and return false + assert.isFalse(response.success) + assert.include(response.message, 'Failed to subscribe:') + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/utils/fs.spec.ts b/admin/tests/unit/utils/fs.spec.ts new file mode 100644 index 0000000..dd7354c --- /dev/null +++ b/admin/tests/unit/utils/fs.spec.ts @@ -0,0 +1,24 @@ +import { test } from '@japa/runner' +import { determineFileType, sanitizeFilename, matchesDevice } from '../../../app/utils/fs.js' + +test.group('Utils | fs (pure functions)', () => { + test('determineFileType: should classify the file by extension case-insensitively', ({ assert }) => { + assert.equal(determineFileType('document.pdf'), 'pdf') + assert.equal(determineFileType('image.JPG'), 'image') + assert.equal(determineFileType('library.zim'), 'zim') + assert.equal(determineFileType('notes.md'), 'text') + assert.equal(determineFileType('unknown_file.xyz'), 'unknown') + }) + + test('sanitizeFilename: should replace dangerous characters with underscore', ({ assert }) => { + assert.equal(sanitizeFilename('my file!.txt'), 'my_file_.txt') + assert.equal(sanitizeFilename('../hidden/file.txt'), '.._hidden_file.txt') + assert.equal(sanitizeFilename('safe-file_name.123.pdf'), 'safe-file_name.123.pdf') + }) + + test('matchesDevice: should match system paths with block device names', ({ assert }) => { + assert.isTrue(matchesDevice('/dev/sda1', 'sda1')) + assert.isTrue(matchesDevice('/dev/mapper/ubuntu--vg-ubuntu--lv', 'ubuntu--lv')) + assert.isFalse(matchesDevice('/dev/sda1', 'sdb1')) + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/utils/misc.spec.ts b/admin/tests/unit/utils/misc.spec.ts new file mode 100644 index 0000000..4be57f0 --- /dev/null +++ b/admin/tests/unit/utils/misc.spec.ts @@ -0,0 +1,31 @@ +import { test } from '@japa/runner' +import { formatSpeed, toTitleCase, parseBoolean } from '../../../app/utils/misc.js' + +test.group('Utils | misc', () => { + test('formatSpeed: should format bytes correctly', ({ assert }) => { + assert.equal(formatSpeed(500), '500 B/s') + assert.equal(formatSpeed(1024), '1.0 KB/s') + assert.equal(formatSpeed(1536), '1.5 KB/s') + assert.equal(formatSpeed(1048576), '1.0 MB/s') + }) + + test('toTitleCase: should capitalize the first letter of each word', ({ assert }) => { + assert.equal(toTitleCase('hello world'), 'Hello World') + assert.equal(toTitleCase('PROJECT NOMAD'), 'Project Nomad') + assert.equal(toTitleCase('cOMmAnD cEnTeR'), 'Command Center') + }) + + test('parseBoolean: should convert various types to boolean', ({ assert }) => { + assert.isTrue(parseBoolean(true)) + assert.isTrue(parseBoolean('true')) + assert.isTrue(parseBoolean('1')) + assert.isTrue(parseBoolean(1)) + + assert.isFalse(parseBoolean(false)) + assert.isFalse(parseBoolean('false')) + assert.isFalse(parseBoolean('0')) + assert.isFalse(parseBoolean(0)) + assert.isFalse(parseBoolean(null)) + assert.isFalse(parseBoolean(undefined)) + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/utils/version.spec.ts b/admin/tests/unit/utils/version.spec.ts new file mode 100644 index 0000000..0676bdd --- /dev/null +++ b/admin/tests/unit/utils/version.spec.ts @@ -0,0 +1,28 @@ +import { test } from '@japa/runner' +import { isNewerVersion, parseMajorVersion } from '../../../app/utils/version.js' + +test.group('Utils | version', () => { + test('parseMajorVersion: should extract the major version ignoring the v prefix', ({ assert }) => { + assert.equal(parseMajorVersion('v3.8.1'), 3) + assert.equal(parseMajorVersion('10.19.4'), 10) + assert.equal(parseMajorVersion('invalid'), 0) + }) + + test('isNewerVersion: should compare standard versions correctly', ({ assert }) => { + assert.isTrue(isNewerVersion('1.25.0', '1.24.0')) + assert.isTrue(isNewerVersion('2.0.0', '1.9.9')) + assert.isFalse(isNewerVersion('1.24.0', '1.25.0')) + assert.isFalse(isNewerVersion('1.0.0', '1.0.0')) + }) + + test('isNewerVersion: should handle pre-release (RC) logic', ({ assert }) => { + assert.isTrue(isNewerVersion('1.0.0', '1.0.0-rc.1', true)) + assert.isFalse(isNewerVersion('1.0.0-rc.1', '1.0.0', true)) + assert.isTrue(isNewerVersion('1.0.0-rc.2', '1.0.0-rc.1', true)) + assert.isFalse(isNewerVersion('1.0.0-rc.1', '1.0.0-rc.2', true)) + }) + + test('isNewerVersion: should ignore pre-releases when includePreReleases is false', ({ assert }) => { + assert.isFalse(isNewerVersion('1.0.1-rc.1', '1.0.0')) + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/validators/chat.spec.ts b/admin/tests/unit/validators/chat.spec.ts new file mode 100644 index 0000000..964cf2e --- /dev/null +++ b/admin/tests/unit/validators/chat.spec.ts @@ -0,0 +1,29 @@ +import { test } from '@japa/runner' +import { createSessionSchema, addMessageSchema } from '../../../app/validators/chat.js' + +test.group('Validators | chat', () => { + test('createSessionSchema: should validate correct payload', async ({ assert }) => { + const payload = { title: 'New Chat', model: 'llama3' } + const result = await createSessionSchema.validate(payload) + + assert.equal(result.title, 'New Chat') + assert.equal(result.model, 'llama3') + }) + + test('createSessionSchema: should fail without title', async ({ assert }) => { + const payload = { model: 'llama3' } + await assert.rejects(() => createSessionSchema.validate(payload)) + }) + + test('addMessageSchema: should validate correct roles', async ({ assert }) => { + await assert.doesNotReject(() => addMessageSchema.validate({ role: 'user', content: 'Hello' })) + await assert.doesNotReject(() => addMessageSchema.validate({ role: 'assistant', content: 'Hi' })) + await assert.doesNotReject(() => addMessageSchema.validate({ role: 'system', content: 'Prompt' })) + }) + + test('addMessageSchema: should fail with invalid role or empty content', async ({ assert }) => { + await assert.rejects(() => addMessageSchema.validate({ role: 'admin', content: 'Hello' })) + await assert.rejects(() => addMessageSchema.validate({ role: 'user', content: ' ' })) + await assert.rejects(() => addMessageSchema.validate({ role: 'user' })) + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/validators/common.spec.ts b/admin/tests/unit/validators/common.spec.ts new file mode 100644 index 0000000..4d584c2 --- /dev/null +++ b/admin/tests/unit/validators/common.spec.ts @@ -0,0 +1,27 @@ +import { test } from '@japa/runner' +import { assertNotPrivateUrl } from '../../../app/validators/common.js' + +test.group('Validators | common | SSRF Protection', () => { + test('assertNotPrivateUrl: should block loopback addresses', ({ assert }) => { + assert.throws(() => assertNotPrivateUrl('http://localhost:8080/file.zim')) + assert.throws(() => assertNotPrivateUrl('http://127.0.0.1/api')) + assert.throws(() => assertNotPrivateUrl('http://0.0.0.0/test')) + assert.throws(() => assertNotPrivateUrl('http://[::1]/')) + }) + + test('assertNotPrivateUrl: should block link-local and cloud metadata addresses', ({ assert }) => { + assert.throws(() => assertNotPrivateUrl('http://169.254.169.254/latest/meta-data/')) + assert.throws(() => assertNotPrivateUrl('http://fe80::1ff:fe23:4567:890a/')) + }) + + test('assertNotPrivateUrl: should allow local network IP addresses (RFC1918)', ({ assert }) => { + assert.doesNotThrow(() => assertNotPrivateUrl('http://192.168.1.100:8080/file.zim')) + assert.doesNotThrow(() => assertNotPrivateUrl('http://10.0.0.5/data')) + assert.doesNotThrow(() => assertNotPrivateUrl('http://172.16.0.10/')) + }) + + test('assertNotPrivateUrl: should allow normal external domains', ({ assert }) => { + assert.doesNotThrow(() => assertNotPrivateUrl('https://download.kiwix.org/zim/wikipedia.zim')) + assert.doesNotThrow(() => assertNotPrivateUrl('http://meu-nas-local:8080/file')) + }) +}) \ No newline at end of file diff --git a/admin/tests/unit/validators/settings.spec.ts b/admin/tests/unit/validators/settings.spec.ts new file mode 100644 index 0000000..22f9436 --- /dev/null +++ b/admin/tests/unit/validators/settings.spec.ts @@ -0,0 +1,15 @@ +import { test } from '@japa/runner' +import { updateSettingSchema } from '../../../app/validators/settings.js' +import { SETTINGS_KEYS } from '../../../constants/kv_store.js' + +test.group('Validators | settings', () => { + test('updateSettingSchema: should validate using a valid system key', async ({ assert }) => { + const validKey = SETTINGS_KEYS[0] + await assert.doesNotReject(() => updateSettingSchema.validate({ key: validKey, value: 'some value' })) + await assert.doesNotReject(() => updateSettingSchema.validate({ key: validKey })) + }) + + test('updateSettingSchema: should fail with an invalid key', async ({ assert }) => { + await assert.rejects(() => updateSettingSchema.validate({ key: 'INVALID_KEY', value: '123' })) + }) +}) \ No newline at end of file