diff --git a/.gitignore b/.gitignore
index 3ec2506..aa0a24a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ yarn-error.log
# Platform specific
.DS_Store
+coverage/
diff --git a/inertia/pages/Home.vue b/inertia/pages/Home.vue
index 12df4ec..ea78033 100644
--- a/inertia/pages/Home.vue
+++ b/inertia/pages/Home.vue
@@ -140,7 +140,7 @@ async function deleteSelected() {
{{ device.deviceId?.manufacturer }} |
{{ device.deviceId?.productClass }} |
{{ device.deviceId?.serialNumber }} |
- {{ 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') || '—' }} |
+ {{ 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') || '—' }} |
{{ getPropertyValue(device.properties, 'InternetGatewayDevice.DeviceInfo.SoftwareVersion') || getPropertyValue(device.properties, 'Device.DeviceInfo.SoftwareVersion') || '—' }} |
diff --git a/package-lock.json b/package-lock.json
index 913f29c..9dafba8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index d20635f..9ddd311 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/tests/integration/database/base_model.spec.ts b/tests/integration/database/base_model.spec.ts
new file mode 100644
index 0000000..d6c7f0f
--- /dev/null
+++ b/tests/integration/database/base_model.spec.ts
@@ -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')
+ })
+})
diff --git a/tests/integration/models/device_model.spec.ts b/tests/integration/models/device_model.spec.ts
new file mode 100644
index 0000000..a116701
--- /dev/null
+++ b/tests/integration/models/device_model.spec.ts
@@ -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')
+ })
+})
diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts
new file mode 100644
index 0000000..1ea1b8f
--- /dev/null
+++ b/tests/integration/setup.ts
@@ -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 {
+ 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 {
+ if (db) {
+ await db.dropDatabase()
+ }
+ if (client) {
+ await client.close()
+ }
+}
+
+export async function cleanCollections(): Promise {
+ 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
+}
diff --git a/tests/unit/domain/command.spec.ts b/tests/unit/domain/command.spec.ts
new file mode 100644
index 0000000..44e768b
--- /dev/null
+++ b/tests/unit/domain/command.spec.ts
@@ -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)
+ })
+})
diff --git a/tests/unit/domain/device_id.spec.ts b/tests/unit/domain/device_id.spec.ts
new file mode 100644
index 0000000..067e23d
--- /dev/null
+++ b/tests/unit/domain/device_id.spec.ts
@@ -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')
+ })
+})
diff --git a/tests/unit/domain/device_parameter.spec.ts b/tests/unit/domain/device_parameter.spec.ts
new file mode 100644
index 0000000..f50684d
--- /dev/null
+++ b/tests/unit/domain/device_parameter.spec.ts
@@ -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()
+ })
+})
diff --git a/tests/unit/domain/mode.spec.ts b/tests/unit/domain/mode.spec.ts
new file mode 100644
index 0000000..eda77df
--- /dev/null
+++ b/tests/unit/domain/mode.spec.ts
@@ -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()
+ })
+})
diff --git a/tests/unit/protocol/cwmp_message_builder.spec.ts b/tests/unit/protocol/cwmp_message_builder.spec.ts
new file mode 100644
index 0000000..3d5d761
--- /dev/null
+++ b/tests/unit/protocol/cwmp_message_builder.spec.ts
@@ -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('1')
+ })
+
+ it('getParameterNames with nextLevel=false', () => {
+ const xml = CWMPMessageBuilder.getParameterNames('id-3', 'Device.', false)
+ expect(xml).toContain('0')
+ })
+
+ 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('1')
+ expect(xml).toContain('2')
+ })
+
+ it('emptyResponse returns empty string', () => {
+ expect(CWMPMessageBuilder.emptyResponse()).toBe('')
+ })
+})
diff --git a/tests/unit/protocol/soap.spec.ts b/tests/unit/protocol/soap.spec.ts
new file mode 100644
index 0000000..5598edd
--- /dev/null
+++ b/tests/unit/protocol/soap.spec.ts
@@ -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 = `
+
+
+ 0001
+
+
+
+ Test
+
+
+`
+ 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 = `
+ 42
+ 1
+`
+ const result = parseSoapXml(xml)
+ expect(String(result.header.id)).toBe('42')
+ })
+
+ it('handles missing header gracefully', () => {
+ const xml = `
+
+`
+ 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', '')
+ expect(xml).toContain('soap-env:Envelope')
+ expect(xml).toContain('id-123')
+ expect(xml).toContain('')
+ })
+
+ it('builds envelope without id', () => {
+ const xml = buildSoapEnvelope(undefined, '')
+ expect(xml).toContain('')
+ })
+})
diff --git a/tests/unit/services/references/config_loader.spec.ts b/tests/unit/services/references/config_loader.spec.ts
new file mode 100644
index 0000000..a0f0347
--- /dev/null
+++ b/tests/unit/services/references/config_loader.spec.ts
@@ -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)
+ })
+})