test(backend): add initial JAPA test suite for utils, validators and services

This commit is contained in:
josevbrito 2026-03-23 14:33:28 -03:00
parent f004c002a7
commit 20163b14bd
9 changed files with 293 additions and 0 deletions

View File

@ -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
})
})

View File

@ -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')
})
})

View File

@ -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:')
})
})

View File

@ -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'))
})
})

View File

@ -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))
})
})

View File

@ -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'))
})
})

View File

@ -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' }))
})
})

View File

@ -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'))
})
})

View File

@ -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' }))
})
})