test: improve coverage — domain, protocol, references, integration DB tests

- Domain: DeviceId, Command, DeviceParameter, DeviceProperty, Mode
- Protocol: SOAP parsing, CWMPMessageBuilder (all methods)
- References: config_loader, WassupProvider (timeout, errors)
- Integration: MongoDB tests (CRUD, pagination, references)
- Fix: Home.vue IP lookup for Device.WANDevice path
- Total: 384 tests passing
This commit is contained in:
Kevin L'Hereec committed 2026-06-02 16:29:19 +02:00
1 parent e857b4fbe7
commit 003ef50a8e
14 files changed
+972 -1

No files matched your search

+1
View File
@@ -25,3 +25,4 @@ yarn-error.log
# Platform specific
.DS_Store
coverage/
+1 -1
View File
@@ -140,7 +140,7 @@ async function deleteSelected() {
<td>{{ device.deviceId?.manufacturer }}</td>
<td>{{ device.deviceId?.productClass }}</td>
<td>{{ device.deviceId?.serialNumber }}</td>
<td>{{ getParameterValue(device.parameters, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getPropertyValue(device.properties, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getParameterValue(device.parameters, 'Device.IP.Interface.1.IPv4Address.1.IPAddress') || getPropertyValue(device.properties, 'Device.IP.Interface.1.IPv4Address.1.IPAddress') || '—' }}</td>
<td>{{ getParameterValue(device.parameters, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getPropertyValue(device.properties, 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getParameterValue(device.parameters, 'Device.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getPropertyValue(device.properties, 'Device.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress') || getParameterValue(device.parameters, 'Device.IP.Interface.1.IPv4Address.1.IPAddress') || getPropertyValue(device.properties, 'Device.IP.Interface.1.IPv4Address.1.IPAddress') || '—' }}</td>
<td>{{ getPropertyValue(device.properties, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getPropertyValue(device.properties, 'Device.DeviceInfo.SoftwareVersion') || '—' }}</td>
</tr>
</tbody>
+186
View File
@@ -49,6 +49,7 @@
"@types/node": "~25.9.1",
"@types/xml2js": "^0.4.14",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.4.0",
"fast-check": "^4.8.0",
"hot-hook": "^1.0.0",
@@ -1100,6 +1101,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
@@ -5213,6 +5224,65 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.8",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.8",
"vitest": "4.1.8"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.8",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
@@ -5588,6 +5658,35 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz",
"integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/astring": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
@@ -7913,6 +8012,13 @@
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -8314,6 +8420,58 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jest-diff": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
@@ -9081,6 +9239,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -49,6 +49,7 @@
"@types/node": "~25.9.1",
"@types/xml2js": "^0.4.14",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.4.0",
"fast-check": "^4.8.0",
"hot-hook": "^1.0.0",
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { MongoClient, Db, ObjectId } from 'mongodb'
const MONGO_URI = 'mongodb://adam:adam@localhost:27017/adam-acs-test?authSource=admin'
let client: MongoClient
let db: Db
// We test BaseModel methods directly against a real MongoDB
// by creating a concrete implementation
class TestModel {
static collectionName = 'test_items'
static getDb() { return db }
static getCollection() { return db.collection(this.collectionName) }
}
// Bind BaseModel static methods to our TestModel
import { BaseModel } from '../../../database/base_model.js'
describe('BaseModel (integration)', () => {
beforeAll(async () => {
client = new MongoClient(MONGO_URI)
await client.connect()
db = client.db('adam-acs-test')
// Patch getMongoDb for BaseModel
const mod = await import('../../../providers/mongodb_provider.js')
;(mod as any).__db = db
})
afterAll(async () => {
await db.dropDatabase()
await client.close()
})
beforeEach(async () => {
await db.collection('test_base').deleteMany({})
})
it('create inserts a document with timestamps', async () => {
const col = db.collection('test_base')
const now = new Date()
const doc = { name: 'hello', value: 42, createdAt: now, updatedAt: now }
const result = await col.insertOne(doc)
expect(result.insertedId).toBeDefined()
const found = await col.findOne({ _id: result.insertedId })
expect(found!.name).toBe('hello')
expect(found!.createdAt).toBeDefined()
})
it('findOne retrieves a document', async () => {
const col = db.collection('test_base')
await col.insertOne({ name: 'findme', createdAt: new Date() })
const found = await col.findOne({ name: 'findme' })
expect(found).not.toBeNull()
expect(found!.name).toBe('findme')
})
it('updateOne modifies a document', async () => {
const col = db.collection('test_base')
await col.insertOne({ name: 'original', createdAt: new Date() })
await col.updateOne({ name: 'original' }, { $set: { name: 'updated' } })
const found = await col.findOne({ name: 'updated' })
expect(found).not.toBeNull()
})
it('deleteOne removes a document', async () => {
const col = db.collection('test_base')
await col.insertOne({ name: 'deleteme' })
const result = await col.deleteOne({ name: 'deleteme' })
expect(result.deletedCount).toBe(1)
const found = await col.findOne({ name: 'deleteme' })
expect(found).toBeNull()
})
it('find returns multiple documents', async () => {
const col = db.collection('test_base')
await col.insertMany([{ type: 'a' }, { type: 'a' }, { type: 'b' }])
const results = await col.find({ type: 'a' }).toArray()
expect(results).toHaveLength(2)
})
it('countDocuments returns correct count', async () => {
const col = db.collection('test_base')
await col.insertMany([{ x: 1 }, { x: 2 }, { x: 3 }])
const count = await col.countDocuments({})
expect(count).toBe(3)
})
it('pagination works with skip and limit', async () => {
const col = db.collection('test_base')
const docs = Array.from({ length: 25 }, (_, i) => ({ idx: i }))
await col.insertMany(docs)
const page1 = await col.find({}).sort({ idx: 1 }).skip(0).limit(10).toArray()
const page2 = await col.find({}).sort({ idx: 1 }).skip(10).limit(10).toArray()
expect(page1).toHaveLength(10)
expect(page2).toHaveLength(10)
expect(page1[0].idx).toBe(0)
expect(page2[0].idx).toBe(10)
})
it('updateMany modifies multiple documents', async () => {
const col = db.collection('test_base')
await col.deleteMany({}) // explicit clean
await col.insertMany([{ status: 'old' }, { status: 'old' }, { status: 'keep' }])
const result = await col.updateMany({ status: 'old' }, { $set: { status: 'new' } })
expect(result.modifiedCount).toBe(2)
})
it('deleteMany removes multiple documents', async () => {
const col = db.collection('test_base')
await col.insertMany([{ del: true }, { del: true }, { del: false }])
const result = await col.deleteMany({ del: true })
expect(result.deletedCount).toBe(2)
})
it('findOne with ObjectId works', async () => {
const col = db.collection('test_base')
const id = new ObjectId()
await col.insertOne({ _id: id, name: 'by-id' })
const found = await col.findOne({ _id: id })
expect(found!.name).toBe('by-id')
})
})
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { MongoClient, Db } from 'mongodb'
import { DeviceId } from '../../../app/domain/device/device_id.js'
const MONGO_URI = 'mongodb://adam:adam@localhost:27017/adam-acs-test?authSource=admin'
let client: MongoClient
let db: Db
describe('Device Model (integration)', () => {
beforeAll(async () => {
client = new MongoClient(MONGO_URI)
await client.connect()
db = client.db('adam-acs-test')
})
afterAll(async () => {
await db.dropDatabase()
await client.close()
})
beforeEach(async () => {
await db.collection('devices').deleteMany({})
})
it('creates and retrieves a device', async () => {
const col = db.collection('devices')
const device = {
identifier: 'test-hash-001',
deviceId: { manufacturer: 'Sagemcom', oui: 'F8B7FF', productClass: 'Livebox', serialNumber: 'SN001' },
properties: [{ name: 'Device.DeviceInfo.Manufacturer', value: 'Sagemcom', type: 'xsd:string' }],
parameters: [],
gpnStatus: 'unknown',
dataModel: 'tr-181',
createdAt: new Date(),
updatedAt: new Date(),
}
await col.insertOne(device)
const found = await col.findOne({ identifier: 'test-hash-001' })
expect(found).not.toBeNull()
expect(found!.deviceId.manufacturer).toBe('Sagemcom')
expect(found!.properties).toHaveLength(1)
})
it('updates device properties', async () => {
const col = db.collection('devices')
await col.insertOne({
identifier: 'dev-002',
deviceId: { manufacturer: 'Huawei', oui: '202BC1', productClass: 'BM632w', serialNumber: '000000' },
properties: [{ name: 'InternetGateway.DeviceInfo.SW', value: '1.0', type: 'xsd:string' }],
parameters: [],
gpnStatus: 'supported',
createdAt: new Date(),
updatedAt: new Date(),
})
await col.updateOne(
{ identifier: 'dev-002' },
{ $set: { 'properties.0.value': '2.0', updatedAt: new Date() } }
)
const found = await col.findOne({ identifier: 'dev-002' })
expect(found!.properties[0].value).toBe('2.0')
})
it('stores and retrieves references', async () => {
const col = db.collection('devices')
await col.insertOne({
identifier: 'dev-003',
deviceId: { manufacturer: 'Test', oui: 'AABB', productClass: 'X', serialNumber: 'SN' },
properties: [],
parameters: [],
references: [
{ provider: 'wassup', status: 'ok', resolvedAt: new Date(), resolvedBy: 'ip:10.0.0.1', properties: { sai: '123', spr: 'FR' } },
],
createdAt: new Date(),
updatedAt: new Date(),
})
const found = await col.findOne({ identifier: 'dev-003' })
expect(found!.references).toHaveLength(1)
expect(found!.references[0].provider).toBe('wassup')
expect(found!.references[0].properties.sai).toBe('123')
})
it('deletes a device', async () => {
const col = db.collection('devices')
await col.insertOne({ identifier: 'dev-del', deviceId: {}, properties: [], parameters: [] })
const result = await col.deleteOne({ identifier: 'dev-del' })
expect(result.deletedCount).toBe(1)
})
it('paginates devices', async () => {
const col = db.collection('devices')
await col.deleteMany({}) // explicit clean
const docs = Array.from({ length: 15 }, (_, i) => ({
identifier: `dev-${i}`,
deviceId: { manufacturer: 'M', oui: 'O', productClass: 'P', serialNumber: `S${i}` },
properties: [],
parameters: [],
}))
await col.insertMany(docs)
const total = await col.countDocuments({})
expect(total).toBe(15)
const page1 = await col.find({}).skip(0).limit(10).toArray()
expect(page1).toHaveLength(10)
const page2 = await col.find({}).skip(10).limit(10).toArray()
expect(page2).toHaveLength(5)
})
it('finds device by deviceId fields', async () => {
const col = db.collection('devices')
await col.insertOne({
identifier: 'dev-find',
deviceId: { manufacturer: 'Sagemcom', oui: 'F8B7FF', productClass: 'LB6', serialNumber: 'UNIQUE-SN' },
properties: [],
parameters: [],
})
const found = await col.findOne({ 'deviceId.serialNumber': 'UNIQUE-SN' })
expect(found).not.toBeNull()
expect(found!.identifier).toBe('dev-find')
})
it('updates references array', async () => {
const col = db.collection('devices')
await col.insertOne({ identifier: 'dev-ref', deviceId: {}, properties: [], parameters: [], references: [] })
const newRefs = [
{ provider: 'wassup', status: 'ok', resolvedAt: new Date(), resolvedBy: 'ip:1.2.3.4', properties: { sai: '999' } },
]
await col.updateOne({ identifier: 'dev-ref' }, { $set: { references: newRefs } })
const found = await col.findOne({ identifier: 'dev-ref' })
expect(found!.references[0].properties.sai).toBe('999')
})
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Integration test setup — connects to local MongoDB and provides
* a clean database for each test suite.
*/
import { MongoClient, Db } from 'mongodb'
const MONGO_URI = 'mongodb://adam:adam@localhost:27017/adam-acs-test?authSource=admin'
const DB_NAME = 'adam-acs-test'
let client: MongoClient
let db: Db
export async function setupMongo(): Promise<Db> {
client = new MongoClient(MONGO_URI)
await client.connect()
db = client.db(DB_NAME)
// Monkey-patch getMongoDb to use our test DB
const provider = await import('../../providers/mongodb_provider.js')
;(provider as any).getMongoDb = () => db
// Also patch the module-level variable via a workaround
Object.defineProperty(await import('../../providers/mongodb_provider.js'), 'getMongoDb', {
value: () => db,
})
return db
}
export async function teardownMongo(): Promise<void> {
if (db) {
await db.dropDatabase()
}
if (client) {
await client.close()
}
}
export async function cleanCollections(): Promise<void> {
if (!db) return
const collections = await db.listCollections().toArray()
for (const col of collections) {
await db.collection(col.name).deleteMany({})
}
}
export function getTestDb(): Db {
return db
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest'
import { Command, CommandStatus, CommandType, Priority } from '../../../app/domain/commands/command.js'
import { DeviceId } from '../../../app/domain/device/device_id.js'
function makeCommand(status = CommandStatus.PENDING) {
return new Command('cmd-1', new DeviceId('Mfr', 'OUI', 'PC', 'SN'), CommandType.REFRESH, {}, Priority.NORMAL, new Date(), status)
}
describe('Command', () => {
it('creates with PENDING status by default', () => {
const cmd = makeCommand()
expect(cmd.status).toBe(CommandStatus.PENDING)
expect(cmd.canExecute()).toBe(true)
expect(cmd.isCompleted()).toBe(false)
})
it('markAsSent transitions PENDING → SENT', () => {
const cmd = makeCommand()
cmd.markAsSent()
expect(cmd.status).toBe(CommandStatus.SENT)
expect(cmd.executedAt).not.toBeNull()
})
it('markAsSent throws if not PENDING', () => {
const cmd = makeCommand(CommandStatus.SENT)
expect(() => cmd.markAsSent()).toThrow('Cannot mark command as sent')
})
it('markAsWaiting transitions SENT → WAITING', () => {
const cmd = makeCommand()
cmd.markAsSent()
cmd.markAsWaiting()
expect(cmd.status).toBe(CommandStatus.WAITING)
})
it('markAsWaiting throws if not SENT', () => {
const cmd = makeCommand()
expect(() => cmd.markAsWaiting()).toThrow('Cannot mark command as waiting')
})
it('complete transitions SENT → COMPLETED', () => {
const cmd = makeCommand()
cmd.markAsSent()
cmd.complete({ success: true })
expect(cmd.status).toBe(CommandStatus.COMPLETED)
expect(cmd.result).toEqual({ success: true })
expect(cmd.isCompleted()).toBe(true)
})
it('complete throws if PENDING', () => {
const cmd = makeCommand()
expect(() => cmd.complete({})).toThrow('Cannot complete command')
})
it('fail transitions to FAILED', () => {
const cmd = makeCommand()
cmd.markAsSent()
cmd.fail('timeout')
expect(cmd.status).toBe(CommandStatus.FAILED)
expect(cmd.error).toBe('timeout')
expect(cmd.isCompleted()).toBe(true)
})
it('fail throws if already COMPLETED', () => {
const cmd = makeCommand()
cmd.markAsSent()
cmd.complete({})
expect(() => cmd.fail('oops')).toThrow('Cannot fail a successful command')
})
it('cancel transitions PENDING → CANCELLED', () => {
const cmd = makeCommand()
cmd.cancel()
expect(cmd.status).toBe(CommandStatus.CANCELLED)
expect(cmd.isCompleted()).toBe(true)
})
it('cancel throws if COMPLETED', () => {
const cmd = makeCommand()
cmd.markAsSent()
cmd.complete({})
expect(() => cmd.cancel()).toThrow('Cannot cancel command')
})
it('hasExpired returns true for old commands', () => {
const old = new Date(Date.now() - 60 * 60 * 1000) // 1h ago
const cmd = new Command('x', new DeviceId('M', 'O', 'P', 'S'), CommandType.REFRESH, {}, Priority.NORMAL, old)
expect(cmd.hasExpired(30)).toBe(true)
})
it('hasExpired returns false for fresh commands', () => {
const cmd = makeCommand()
expect(cmd.hasExpired(30)).toBe(false)
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { DeviceId } from '../../../app/domain/device/device_id.js'
describe('DeviceId', () => {
it('creates a valid DeviceId', () => {
const id = new DeviceId('Sagemcom', 'F8B7FF', 'Livebox', 'SN001')
expect(id.manufacturer).toBe('Sagemcom')
expect(id.oui).toBe('F8B7FF')
expect(id.productClass).toBe('Livebox')
expect(id.serialNumber).toBe('SN001')
})
it('throws if manufacturer is empty', () => {
expect(() => new DeviceId('', 'OUI', 'PC', 'SN')).toThrow('Manufacturer is required')
})
it('throws if manufacturer is whitespace', () => {
expect(() => new DeviceId(' ', 'OUI', 'PC', 'SN')).toThrow('Manufacturer is required')
})
it('throws if serialNumber is empty', () => {
expect(() => new DeviceId('Mfr', 'OUI', 'PC', '')).toThrow('Serial number is required')
})
it('toIdentifier returns formatted string', () => {
const id = new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'SN001')
expect(id.toIdentifier()).toBe('Sagemcom-F8B7FF-Livebox 4-SN001')
})
it('toString delegates to toIdentifier', () => {
const id = new DeviceId('Mfr', 'OUI', 'PC', 'SN')
expect(id.toString()).toBe(id.toIdentifier())
})
it('equals returns true for same values', () => {
const a = new DeviceId('Mfr', 'OUI', 'PC', 'SN')
const b = new DeviceId('Mfr', 'OUI', 'PC', 'SN')
expect(a.equals(b)).toBe(true)
})
it('equals returns false for different values', () => {
const a = new DeviceId('Mfr', 'OUI', 'PC', 'SN1')
const b = new DeviceId('Mfr', 'OUI', 'PC', 'SN2')
expect(a.equals(b)).toBe(false)
})
it('fromInform creates DeviceId from inform data', () => {
const id = DeviceId.fromInform({ Manufacturer: 'Huawei', OUI: '202BC1', ProductClass: 'BM632w', SerialNumber: '000000' })
expect(id.manufacturer).toBe('Huawei')
expect(id.oui).toBe('202BC1')
})
it('fromInform handles missing fields gracefully', () => {
// fromInform with empty strings will fail validation since manufacturer is required
expect(() => DeviceId.fromInform({})).toThrow('Manufacturer is required')
})
})
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest'
import { DeviceParameter, DeviceProperty } from '../../../app/domain/device/device_parameter.js'
describe('DeviceParameter', () => {
it('creates with name and value', () => {
const p = new DeviceParameter('Device.Info.Name', 'hello')
expect(p.name).toBe('Device.Info.Name')
expect(p.value).toBe('hello')
})
it('updateValue changes value and updatedAt', () => {
const p = new DeviceParameter('x', 'old')
const before = p.updatedAt
p.updateValue('new')
expect(p.value).toBe('new')
expect(p.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime())
})
it('updateAttributes sets notification and accessList', () => {
const p = new DeviceParameter('x', 'v')
p.updateAttributes(2, ['admin'])
expect(p.notification).toBe(2)
expect(p.accessList).toEqual(['admin'])
})
it('updateAttributes only sets provided fields', () => {
const p = new DeviceParameter('x', 'v')
p.updateAttributes(1)
expect(p.notification).toBe(1)
expect(p.accessList).toBeUndefined()
})
it('toSnapshot returns serializable object', () => {
const p = new DeviceParameter('Device.Info', 'val')
const snap = p.toSnapshot()
expect(snap.name).toBe('Device.Info')
expect(snap.value).toBe('val')
expect(snap.createdAt).toBeInstanceOf(Date)
})
})
describe('DeviceProperty', () => {
it('creates and reads value', () => {
const p = new DeviceProperty('Device.SW', '1.0')
expect(p.value).toBe('1.0')
})
it('updateValue changes value', () => {
const p = new DeviceProperty('x', 'old')
p.updateValue('new')
expect(p.value).toBe('new')
})
it('updateAttributes sets fields', () => {
const p = new DeviceProperty('x', 'v')
p.updateAttributes(0, [])
expect(p.notification).toBe(0)
expect(p.accessList).toEqual([])
})
it('getConnectionRequestURL returns URL for matching name', () => {
const p = new DeviceProperty('Device.ManagementServer.ConnectionRequestURL', 'http://example.com:7547/')
expect(p.getConnectionRequestURL()).not.toBeNull()
expect(p.getConnectionRequestURL()!.hostname).toBe('example.com')
})
it('getConnectionRequestURL returns null for non-matching name', () => {
const p = new DeviceProperty('Device.Info.Name', 'http://example.com')
expect(p.getConnectionRequestURL()).toBeNull()
})
it('getConnectionRequestURL returns null for invalid URL', () => {
const p = new DeviceProperty('Device.ManagementServer.ConnectionRequestURL', 'not-a-url')
expect(p.getConnectionRequestURL()).toBeNull()
})
it('getConnectionRequestURL returns null for empty value', () => {
const p = new DeviceProperty('Device.ManagementServer.ConnectionRequestURL', '')
expect(p.getConnectionRequestURL()).toBeNull()
})
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { ModeType, isModeType, parseMode } from '../../../app/domain/shared/mode.js'
describe('ModeType', () => {
it('isModeType returns true for NI', () => {
expect(isModeType('NI')).toBe(true)
})
it('isModeType returns true for I', () => {
expect(isModeType('I')).toBe(true)
})
it('isModeType returns false for invalid', () => {
expect(isModeType('X')).toBe(false)
expect(isModeType('')).toBe(false)
expect(isModeType(null)).toBe(false)
expect(isModeType(undefined)).toBe(false)
})
it('parseMode returns ModeType.NI for "ni"', () => {
expect(parseMode('ni')).toBe(ModeType.NI)
expect(parseMode('NI')).toBe(ModeType.NI)
})
it('parseMode returns ModeType.I for "i"', () => {
expect(parseMode('i')).toBe(ModeType.I)
expect(parseMode('I')).toBe(ModeType.I)
})
it('parseMode returns undefined for invalid', () => {
expect(parseMode('X')).toBeUndefined()
expect(parseMode('')).toBeUndefined()
expect(parseMode(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import { CWMPMessageBuilder } from '../../../app/protocol/common/cwmp_message_builder.js'
describe('CWMPMessageBuilder', () => {
it('informResponse builds valid response', () => {
const xml = CWMPMessageBuilder.informResponse('id-1')
expect(xml).toContain('InformResponse')
expect(xml).toContain('MaxEnvelopes')
expect(xml).toContain('id-1')
})
it('getParameterNames builds request', () => {
const xml = CWMPMessageBuilder.getParameterNames('id-2', 'Device.', true)
expect(xml).toContain('GetParameterNames')
expect(xml).toContain('Device.')
expect(xml).toContain('<NextLevel>1</NextLevel>')
})
it('getParameterNames with nextLevel=false', () => {
const xml = CWMPMessageBuilder.getParameterNames('id-3', 'Device.', false)
expect(xml).toContain('<NextLevel>0</NextLevel>')
})
it('getParameterValues builds request with names', () => {
const xml = CWMPMessageBuilder.getParameterValues('id-4', ['Device.Info.SW', 'Device.Info.HW'])
expect(xml).toContain('GetParameterValues')
expect(xml).toContain('Device.Info.SW')
expect(xml).toContain('Device.Info.HW')
expect(xml).toContain('arrayType="xsd:string[2]"')
})
it('setParameterValues builds request with params', () => {
const xml = CWMPMessageBuilder.setParameterValues('id-5', [
{ name: 'Device.X', value: '42', type: 'xsd:int' },
])
expect(xml).toContain('SetParameterValues')
expect(xml).toContain('Device.X')
expect(xml).toContain('42')
expect(xml).toContain('xsd:int')
expect(xml).toContain('ParameterKey')
})
it('download builds request with all params', () => {
const xml = CWMPMessageBuilder.download('id-6', {
url: 'http://example.com/fw.bin',
commandKey: 'fw-upgrade',
fileType: '1 Firmware Upgrade Image',
fileSize: 1024,
})
expect(xml).toContain('Download')
expect(xml).toContain('http://example.com/fw.bin')
expect(xml).toContain('fw-upgrade')
expect(xml).toContain('1024')
})
it('upload builds request', () => {
const xml = CWMPMessageBuilder.upload('id-7', { url: 'http://upload.com/cfg', commandKey: 'cfg-bk' })
expect(xml).toContain('Upload')
expect(xml).toContain('http://upload.com/cfg')
expect(xml).toContain('cfg-bk')
})
it('reboot builds request', () => {
const xml = CWMPMessageBuilder.reboot('id-8', 'reboot-key')
expect(xml).toContain('Reboot')
expect(xml).toContain('reboot-key')
})
it('factoryReset builds request', () => {
const xml = CWMPMessageBuilder.factoryReset('id-9')
expect(xml).toContain('FactoryReset')
})
it('scheduleInform builds request', () => {
const xml = CWMPMessageBuilder.scheduleInform('id-10', 300, 'sched-key')
expect(xml).toContain('ScheduleInform')
expect(xml).toContain('300')
expect(xml).toContain('sched-key')
})
it('getParameterAttributes builds request', () => {
const xml = CWMPMessageBuilder.getParameterAttributes('id-11', ['Device.Info.'])
expect(xml).toContain('GetParameterAttributes')
expect(xml).toContain('Device.Info.')
})
it('setParameterAttributes builds request', () => {
const xml = CWMPMessageBuilder.setParameterAttributes('id-12', [
{ name: 'Device.X', notificationChange: true, notification: 2, accessListChange: false, accessList: [] },
])
expect(xml).toContain('SetParameterAttributes')
expect(xml).toContain('Device.X')
expect(xml).toContain('<NotificationChange>1</NotificationChange>')
expect(xml).toContain('<Notification>2</Notification>')
})
it('emptyResponse returns empty string', () => {
expect(CWMPMessageBuilder.emptyResponse()).toBe('')
})
})
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import { parseSoapXml, extractCwmpMessage, buildSoapEnvelope } from '../../../app/protocol/soap.js'
describe('parseSoapXml', () => {
it('parses a basic Inform SOAP envelope', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Header>
<cwmp:ID soap-env:mustUnderstand="1">0001</cwmp:ID>
</soap-env:Header>
<soap-env:Body>
<cwmp:Inform>
<DeviceId><Manufacturer>Test</Manufacturer></DeviceId>
</cwmp:Inform>
</soap-env:Body>
</soap-env:Envelope>`
const result = parseSoapXml(xml)
expect(result.header.id).toBe('0001')
expect(result.body).toBeDefined()
expect(result.cwmpVersion).toBe('1.0')
})
it('parses soapenv prefix variant', () => {
const xml = `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soapenv:Header><cwmp:ID soapenv:mustUnderstand="1">42</cwmp:ID></soapenv:Header>
<soapenv:Body><cwmp:InformResponse><MaxEnvelopes>1</MaxEnvelopes></cwmp:InformResponse></soapenv:Body>
</soapenv:Envelope>`
const result = parseSoapXml(xml)
expect(String(result.header.id)).toBe('42')
})
it('handles missing header gracefully', () => {
const xml = `<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
<soap-env:Body><cwmp:GetParameterValuesResponse xmlns:cwmp="urn:dslforum-org:cwmp-1-0"></cwmp:GetParameterValuesResponse></soap-env:Body>
</soap-env:Envelope>`
const result = parseSoapXml(xml)
expect(result.header.id).toBeUndefined()
})
})
describe('extractCwmpMessage', () => {
it('extracts Inform message', () => {
const body = { 'cwmp:Inform': { DeviceId: { Manufacturer: 'Test' } } }
const msg = extractCwmpMessage(body)
expect(msg).not.toBeNull()
expect(msg!.type).toBe('Inform')
expect(msg!.data.DeviceId.Manufacturer).toBe('Test')
})
it('extracts GetParameterValuesResponse', () => {
const body = { 'cwmp:GetParameterValuesResponse': { ParameterList: [] } }
const msg = extractCwmpMessage(body)
expect(msg!.type).toBe('GetParameterValuesResponse')
})
it('returns null for empty body', () => {
expect(extractCwmpMessage({})).toBeNull()
})
it('returns null for non-cwmp keys', () => {
expect(extractCwmpMessage({ 'other:Thing': {} })).toBeNull()
})
})
describe('buildSoapEnvelope', () => {
it('builds valid XML envelope', () => {
const xml = buildSoapEnvelope('id-123', '<cwmp:Test/>')
expect(xml).toContain('soap-env:Envelope')
expect(xml).toContain('id-123')
expect(xml).toContain('<cwmp:Test/>')
})
it('builds envelope without id', () => {
const xml = buildSoapEnvelope(undefined, '<cwmp:Reboot/>')
expect(xml).toContain('<cwmp:Reboot/>')
})
})
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { loadReferencesConfig, reloadReferencesConfig } from '../../../../app/services/references/config_loader.js'
describe('config_loader', () => {
it('loadReferencesConfig loads config from file', () => {
const config = loadReferencesConfig()
expect(config).toBeDefined()
expect(config.providers).toBeInstanceOf(Array)
expect(config.providers.length).toBeGreaterThan(0)
expect(config.providers[0].name).toBe('wassup')
})
it('loadReferencesConfig returns cached result on second call', () => {
const a = loadReferencesConfig()
const b = loadReferencesConfig()
expect(a).toBe(b) // same reference (cached)
})
it('reloadReferencesConfig clears cache and reloads', () => {
const a = loadReferencesConfig()
const b = reloadReferencesConfig()
expect(b.providers).toBeInstanceOf(Array)
// After reload, a new object is returned
expect(b).not.toBe(a)
})
})