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