Adds targeted unit tests to cover previously untested branches/functions in the business-critical (non-protocol) services: - device_service.ts: 35.8% -> 98.76% (CRUD, executeCommand, refreshDevice, calculatePagination, IPv4/IPv6 Connection Request fallback chain) - domain/device/device.ts: 76.7% -> 96.59% (GPN circuit breaker state machine, session lifecycle, canExecuteCommands, detectDataModelFromParams) - services/references/reference_engine.ts: 78.66% -> 100% (provider loading edge cases, rejection handling, singleton lifecycle) - services/configuration_engine.ts: 70.96% -> 98.7% (matchesCriteria branches, shouldApplyOnce policy variants, dryRun, complianceReport) - services/cwmp/state_machine_manager.ts: 70.9% -> 87.27% (getHashIdForRemoteIp, ensureSessionForRemote IP fallback, task callback error handling) - services/cwmp/main_state_machine.ts: 57.79% -> 94.29% (ExecutingCommandsState full command-type dispatch, MAX_STATE_TRANSITIONS guard, error-> PartialOperationalState transitions) Also fixes: - reference_engine_full.spec.ts previously depended on the real config/references.json (production config, wassup provider legitimately disabled there) instead of a test fixture — now mocks config_loader for deterministic, isolated assertions - main_state_machine.spec.ts CommandModel.getCollection mock returned a new object per call instead of a shared instance, breaking setupCommandMock() for the new command-dispatch tests Overall project coverage (excluding protocol/): 72.76% -> 82.34% statements. Full suite: 1005/1005 tests passing.
720 lines
26 KiB
TypeScript
720 lines
26 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
import DeviceService from '../../../app/services/device_service.js'
|
|
import type { DeviceRecord } from '../../../app/domain/device/device_record.js'
|
|
import { CommandStatus, CommandType, Priority } from '../../../app/domain/commands/command.js'
|
|
import { ModeType } from '../../../app/domain/shared/mode.js'
|
|
import DeviceModel from '../../../app/models/device.js'
|
|
import CommandModel from '../../../app/models/command.js'
|
|
|
|
// ── Mocks ──────────────────────────────────────────────────────────────────────
|
|
|
|
vi.mock('@adonisjs/core/services/logger', () => ({
|
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}))
|
|
|
|
vi.mock('../../../app/models/device.js', () => ({
|
|
default: {
|
|
findByIdentifier: vi.fn(),
|
|
paginateDevices: vi.fn(),
|
|
deleteOne: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('../../../app/models/command.js', () => ({
|
|
default: {
|
|
createCommand: vi.fn(),
|
|
getCollection: vi.fn(() => ({
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
})),
|
|
},
|
|
}))
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
function makeDevice(overrides: Partial<DeviceRecord> = {}): DeviceRecord {
|
|
return {
|
|
identifier: 'TestMfr-AABBCC-TestBox-SN001',
|
|
deviceId: {
|
|
manufacturer: 'TestMfr',
|
|
oui: 'AABBCC',
|
|
productClass: 'TestBox',
|
|
serialNumber: 'SN001',
|
|
},
|
|
properties: [],
|
|
parameters: [],
|
|
...overrides,
|
|
} as unknown as DeviceRecord
|
|
}
|
|
|
|
function makeProperty(name: string, value: string) {
|
|
return { name, value }
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
|
|
|
describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
|
|
let service: DeviceService
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
service = new DeviceService()
|
|
fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// ── IPv4 ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('IPv4 Connection Request', () => {
|
|
it('retourne true quand le CPE répond 200 sur une URL IPv4', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty(
|
|
'Device.ManagementServer.ConnectionRequestURL',
|
|
'http://192.168.1.100:7547/cr'
|
|
),
|
|
makeProperty('Device.ManagementServer.Username', 'admin'),
|
|
makeProperty('Device.ManagementServer.Password', 'secret'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
const result = await (service as any).sendWakeOnWlan(
|
|
new URL('http://192.168.1.100:7547/cr'),
|
|
device
|
|
)
|
|
|
|
expect(result).toBe(true)
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
const [calledUrl] = fetchMock.mock.calls[0]
|
|
expect(calledUrl).toBe('http://192.168.1.100:7547/cr')
|
|
})
|
|
|
|
it('retourne false et log error quand le CPE répond 401', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' })
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL('http://10.0.0.1:7547/'), device)
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('retourne false quand fetch lève une erreur réseau IPv4', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/'),
|
|
],
|
|
})
|
|
|
|
const networkError = Object.assign(new Error('fetch failed'), {
|
|
cause: { code: 'ECONNREFUSED' },
|
|
})
|
|
fetchMock.mockRejectedValue(networkError)
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL('http://10.0.0.1:7547/'), device)
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── IPv6 ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('IPv6 Connection Request', () => {
|
|
it('retourne true quand le CPE répond 200 sur une URL IPv6', async () => {
|
|
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/kOmisavuzLYxpksz'
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url),
|
|
makeProperty('Device.ManagementServer.Username', 'user'),
|
|
makeProperty('Device.ManagementServer.Password', 'pass'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
|
|
|
expect(result).toBe(true)
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
const [calledUrl, init] = fetchMock.mock.calls[0]
|
|
// L'URL doit conserver les crochets IPv6
|
|
expect(calledUrl).toBe(ipv6Url)
|
|
// Le header Authorization doit être présent
|
|
expect(init.headers?.Authorization).toMatch(/^Basic /)
|
|
})
|
|
|
|
it('envoie la bonne URL IPv6 complète avec path et port', async () => {
|
|
const ipv6Url = 'http://[::1]:8080/wake'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 204 })
|
|
|
|
await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
|
|
|
expect(fetchMock.mock.calls[0][0]).toBe('http://[::1]:8080/wake')
|
|
})
|
|
|
|
it("retourne false et ENETUNREACH est loggé quand l'IPv6 n'est pas joignable", async () => {
|
|
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/cr'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
|
})
|
|
|
|
const networkError = Object.assign(new Error('fetch failed'), {
|
|
cause: { code: 'ENETUNREACH' },
|
|
})
|
|
fetchMock.mockRejectedValue(networkError)
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('retourne false quand le CPE IPv6 répond 403', async () => {
|
|
const ipv6Url = 'http://[fe80::1]:7547/cr'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' })
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── Credentials fallback chain ─────────────────────────────────────────────
|
|
|
|
describe('Construction des credentials (Basic Auth)', () => {
|
|
it('utilise ManagementServer.Username/Password en priorité', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
|
makeProperty('Device.ManagementServer.Username', 'mgmtUser'),
|
|
makeProperty('Device.ManagementServer.Password', 'mgmtPass'),
|
|
makeProperty('Device.ManagementServer.ConnectionRequestUsername', 'crUser'),
|
|
makeProperty('Device.ManagementServer.ConnectionRequestPassword', 'crPass'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
|
|
|
|
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
|
|
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
|
|
expect(decoded).toBe('mgmtUser:mgmtPass')
|
|
})
|
|
|
|
it('utilise ConnectionRequestUsername/Password si ManagementServer absent', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
|
makeProperty('Device.ManagementServer.ConnectionRequestUsername', 'crUser'),
|
|
makeProperty('Device.ManagementServer.ConnectionRequestPassword', 'crPass'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
|
|
|
|
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
|
|
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
|
|
expect(decoded).toBe('crUser:crPass')
|
|
})
|
|
|
|
it('utilise OUI-ProductClass-SerialNumber comme username par défaut', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
|
|
|
|
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
|
|
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
|
|
// format: OUI-ProductClass-SerialNumber:
|
|
expect(decoded).toBe('AABBCC-TestBox-SN001:')
|
|
})
|
|
|
|
it('fonctionne avec un password vide', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
|
makeProperty('Device.ManagementServer.Username', 'admin'),
|
|
],
|
|
})
|
|
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
|
|
|
|
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
|
|
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
|
|
expect(decoded).toBe('admin:')
|
|
})
|
|
})
|
|
|
|
// ── IPv4 fallback (lastRemoteIp) ─────────────────────────────────────────────
|
|
|
|
describe('Fallback IPv4 via lastRemoteIp', () => {
|
|
it('essaie le fallback IPv4 quand la première URL (IPv6) échoue', async () => {
|
|
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:7547/cr'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
|
lastRemoteIp: '203.0.113.5',
|
|
} as any)
|
|
|
|
fetchMock
|
|
.mockRejectedValueOnce(
|
|
Object.assign(new Error('fetch failed'), { cause: { code: 'ENETUNREACH' } })
|
|
)
|
|
.mockResolvedValueOnce({ ok: true, status: 200 })
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
|
|
|
expect(result).toBe(true)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(fetchMock.mock.calls[1][0]).toBe('http://203.0.113.5:7547/cr')
|
|
})
|
|
|
|
it('formatte le fallback IPv6 entre crochets si lastRemoteIp est une IPv6', async () => {
|
|
const originalUrl = 'http://192.0.2.1:7547/cr'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', originalUrl)],
|
|
lastRemoteIp: '2001:db8::1',
|
|
} as any)
|
|
|
|
fetchMock
|
|
.mockRejectedValueOnce(new Error('fetch failed'))
|
|
.mockResolvedValueOnce({ ok: true, status: 200 })
|
|
|
|
await (service as any).sendWakeOnWlan(new URL(originalUrl), device)
|
|
|
|
expect(fetchMock.mock.calls[1][0]).toBe('http://[2001:db8::1]:7547/cr')
|
|
})
|
|
|
|
it('ne tente pas de fallback si lastRemoteIp est identique au hostname original', async () => {
|
|
const url = 'http://192.0.2.1:7547/cr'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', url)],
|
|
lastRemoteIp: '192.0.2.1',
|
|
} as any)
|
|
|
|
fetchMock.mockRejectedValue(new Error('fetch failed'))
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL(url), device)
|
|
|
|
expect(result).toBe(false)
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('retourne false si les deux URLs (originale + fallback) échouent', async () => {
|
|
const ipv6Url = 'http://[2a01:cb15::1]:7547/cr'
|
|
const device = makeDevice({
|
|
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
|
|
lastRemoteIp: '203.0.113.9',
|
|
} as any)
|
|
|
|
fetchMock.mockRejectedValue(new Error('fetch failed'))
|
|
|
|
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
|
|
|
|
expect(result).toBe(false)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
// ── applyImmediateCommandForDevice (via executeCommand mode I) ──────────────
|
|
|
|
describe('applyImmediateCommandForDevice', () => {
|
|
it('ne fait rien et log une erreur si ConnectionRequestURL est absent', async () => {
|
|
const device = makeDevice({ properties: [] })
|
|
|
|
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('log une erreur et ne fetch pas si ConnectionRequestURL est une URL invalide', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'not-a-valid-url'),
|
|
],
|
|
})
|
|
|
|
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('appelle sendWakeOnWlan puis met à jour le statut des commandes en attente', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
|
],
|
|
})
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
const collection = {
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn().mockResolvedValue([{ _id: 'cmd-1' }, { _id: 'cmd-2' }]),
|
|
})),
|
|
updateOne: vi.fn(),
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
|
|
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
|
|
|
expect(collection.updateOne).toHaveBeenCalledTimes(2)
|
|
expect(collection.updateOne).toHaveBeenCalledWith(
|
|
{ _id: 'cmd-1' },
|
|
expect.objectContaining({
|
|
$set: expect.objectContaining({ status: CommandStatus.WAITING }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('met les commandes en PENDING (pas WAITING) si la Connection Request échoue', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
|
|
],
|
|
})
|
|
fetchMock.mockRejectedValue(new Error('fetch failed'))
|
|
|
|
const collection = {
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([{ _id: 'cmd-1' }]) })),
|
|
updateOne: vi.fn(),
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
|
|
await (service as any).applyImmediateCommandForDevice(device, device.identifier)
|
|
|
|
expect(collection.updateOne).toHaveBeenCalledWith(
|
|
{ _id: 'cmd-1' },
|
|
expect.objectContaining({
|
|
$set: expect.objectContaining({ status: CommandStatus.PENDING }),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ── getDevices / getDeviceByIdentifier / deleteByIdentifier ───────────────────
|
|
|
|
describe('DeviceService — CRUD & pagination', () => {
|
|
let service: DeviceService
|
|
|
|
beforeEach(() => {
|
|
service = new DeviceService()
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('getDevices retourne data, nbDocuments et pager', async () => {
|
|
const devices = [makeDevice(), makeDevice({ identifier: 'device-2' })]
|
|
vi.mocked(DeviceModel.paginateDevices).mockResolvedValue({ data: devices, total: 50 })
|
|
|
|
const result = await service.getDevices(0, 25)
|
|
|
|
expect(DeviceModel.paginateDevices).toHaveBeenCalledWith(0, 25)
|
|
expect(result.data).toEqual(devices)
|
|
expect(result.nbDocuments).toBe(50)
|
|
expect(result.pager).toBeDefined()
|
|
})
|
|
|
|
it('getDevices utilise les valeurs par défaut offset=0, limit=100', async () => {
|
|
vi.mocked(DeviceModel.paginateDevices).mockResolvedValue({ data: [], total: 0 })
|
|
|
|
await service.getDevices()
|
|
|
|
expect(DeviceModel.paginateDevices).toHaveBeenCalledWith(0, 100)
|
|
})
|
|
|
|
it('getDeviceByIdentifier délègue à DeviceModel.findByIdentifier', async () => {
|
|
const device = makeDevice()
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
|
|
const result = await service.getDeviceByIdentifier('some-id')
|
|
|
|
expect(DeviceModel.findByIdentifier).toHaveBeenCalledWith('some-id')
|
|
expect(result).toEqual(device)
|
|
})
|
|
|
|
it('getDeviceByIdentifier retourne null si le device est introuvable', async () => {
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(null)
|
|
|
|
const result = await service.getDeviceByIdentifier('unknown-id')
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('deleteByIdentifier délègue à DeviceModel.deleteOne', async () => {
|
|
vi.mocked(DeviceModel.deleteOne).mockResolvedValue(true)
|
|
|
|
const result = await service.deleteByIdentifier('some-id')
|
|
|
|
expect(DeviceModel.deleteOne).toHaveBeenCalledWith({ identifier: 'some-id' })
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
it("deleteByIdentifier retourne false si rien n'a été supprimé", async () => {
|
|
vi.mocked(DeviceModel.deleteOne).mockResolvedValue(false)
|
|
|
|
const result = await service.deleteByIdentifier('unknown-id')
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('DeviceService.calculatePagination', () => {
|
|
let service: DeviceService
|
|
|
|
beforeEach(() => {
|
|
service = new DeviceService()
|
|
})
|
|
|
|
it('offset=0 avec total >= limit: pas de "previous", "last" et "next" présents', () => {
|
|
const pager = service.calculatePagination(0, 10, 50)
|
|
|
|
expect(pager.first).toEqual({ offset: 0, limit: 10 })
|
|
expect(pager.previous).toBeUndefined()
|
|
expect(pager.next).toEqual({ offset: 10, limit: 10 })
|
|
expect(pager.last).toEqual({ offset: 40, limit: 10 })
|
|
})
|
|
|
|
it('offset > 0: "previous" est calculé', () => {
|
|
const pager = service.calculatePagination(20, 10, 50)
|
|
|
|
expect(pager.previous).toEqual({ offset: 10, limit: 10 })
|
|
})
|
|
|
|
it('previous ne descend jamais sous 0 (Math.max)', () => {
|
|
const pager = service.calculatePagination(5, 10, 50)
|
|
|
|
expect(pager.previous).toEqual({ offset: 0, limit: 10 })
|
|
})
|
|
|
|
it('dernière page: pas de "next" quand offset + limit >= total', () => {
|
|
const pager = service.calculatePagination(40, 10, 50)
|
|
|
|
expect(pager.next).toBeUndefined()
|
|
})
|
|
|
|
it('total < limit: "last" pointe sur offset 0 (une seule page)', () => {
|
|
const pager = service.calculatePagination(0, 100, 5)
|
|
|
|
expect(pager.last).toEqual({ offset: 0, limit: 100 })
|
|
expect(pager.next).toBeUndefined()
|
|
})
|
|
|
|
it('total = 0: aucune page suivante, last à offset 0', () => {
|
|
const pager = service.calculatePagination(0, 10, 0)
|
|
|
|
expect(pager.last).toEqual({ offset: 0, limit: 10 })
|
|
expect(pager.previous).toBeUndefined()
|
|
expect(pager.next).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('DeviceService.executeCommand', () => {
|
|
let service: DeviceService
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
service = new DeviceService()
|
|
fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('lève une erreur si le device est introuvable', async () => {
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(null)
|
|
|
|
await expect(
|
|
service.executeCommand('unknown-id', { action: CommandType.REBOOT })
|
|
).rejects.toThrow('Device not found: unknown-id')
|
|
})
|
|
|
|
it('crée une commande en mode NI par défaut (pas de Connection Request)', async () => {
|
|
const device = makeDevice()
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
const collection = {
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
|
|
await service.executeCommand(device.identifier, { action: CommandType.REBOOT })
|
|
|
|
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
deviceIdentifier: device.identifier,
|
|
action: CommandType.REBOOT,
|
|
mode: ModeType.NI,
|
|
status: CommandStatus.PENDING,
|
|
priority: Priority.NORMAL,
|
|
})
|
|
)
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('mode "I" (insensible à la casse) déclenche une Connection Request', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
{ name: 'Device.ManagementServer.ConnectionRequestURL', value: 'http://1.2.3.4:7547/' },
|
|
],
|
|
})
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
const collection = {
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
await service.executeCommand(device.identifier, { action: CommandType.REBOOT, mode: 'i' })
|
|
|
|
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mode: ModeType.I })
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('lève une erreur si une commande identique est déjà PENDING (duplicate)', async () => {
|
|
const device = makeDevice()
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
const collection = {
|
|
findOne: vi.fn().mockResolvedValue({ _id: 'existing-cmd' }),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
|
|
await expect(
|
|
service.executeCommand(device.identifier, { action: CommandType.REBOOT })
|
|
).rejects.toThrow(/Duplicate pending command/)
|
|
|
|
expect(CommandModel.createCommand).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('utilise la priorité fournie plutôt que la valeur par défaut', async () => {
|
|
const device = makeDevice()
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
const collection = {
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
|
|
await service.executeCommand(device.identifier, {
|
|
action: CommandType.SET_PARAMETER_VALUES,
|
|
priority: Priority.HIGH,
|
|
})
|
|
|
|
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({ priority: Priority.HIGH })
|
|
)
|
|
})
|
|
|
|
it('transmet les parameters fournis, ou {} par défaut', async () => {
|
|
const device = makeDevice()
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
const collection = {
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
|
|
await service.executeCommand(device.identifier, {
|
|
action: CommandType.SET_PARAMETER_VALUES,
|
|
parameters: { 'Device.WiFi.SSID': 'MyWifi' },
|
|
})
|
|
|
|
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({ parameters: { 'Device.WiFi.SSID': 'MyWifi' } })
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('DeviceService.refreshDevice', () => {
|
|
let service: DeviceService
|
|
let fetchMock: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
service = new DeviceService()
|
|
fetchMock = vi.fn()
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('lève une erreur si le device est introuvable', async () => {
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(null)
|
|
|
|
await expect(service.refreshDevice('unknown-id')).rejects.toThrow(
|
|
'Device not found: unknown-id'
|
|
)
|
|
})
|
|
|
|
it('crée une commande REFRESH en mode I et déclenche la Connection Request', async () => {
|
|
const device = makeDevice({
|
|
properties: [
|
|
{ name: 'Device.ManagementServer.ConnectionRequestURL', value: 'http://1.2.3.4:7547/' },
|
|
],
|
|
})
|
|
vi.mocked(DeviceModel.findByIdentifier).mockResolvedValue(device)
|
|
const collection = {
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
|
|
updateOne: vi.fn(),
|
|
}
|
|
vi.mocked(CommandModel.getCollection).mockReturnValue(collection as any)
|
|
fetchMock.mockResolvedValue({ ok: true, status: 200 })
|
|
|
|
await service.refreshDevice(device.identifier)
|
|
|
|
expect(CommandModel.createCommand).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: CommandType.REFRESH,
|
|
mode: ModeType.I,
|
|
status: CommandStatus.PENDING,
|
|
priority: Priority.NORMAL,
|
|
})
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledOnce()
|
|
})
|
|
})
|