diff --git a/tests/integration/database/base_model.spec.ts b/tests/integration/database/base_model.spec.ts index d6c7f0f..0bb6cff 100644 --- a/tests/integration/database/base_model.spec.ts +++ b/tests/integration/database/base_model.spec.ts @@ -101,9 +101,9 @@ describe('BaseModel (integration)', () => { 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' } }) + await col.deleteMany({}) + await col.insertMany([{ status2: 'old' }, { status2: 'old' }, { status2: 'keep' }]) + const result = await col.updateMany({ status2: 'old' }, { $set: { status2: 'new' } }) expect(result.modifiedCount).toBe(2) }) diff --git a/tests/unit/database/base_model.spec.ts b/tests/unit/database/base_model.spec.ts new file mode 100644 index 0000000..70ab18d --- /dev/null +++ b/tests/unit/database/base_model.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from 'vitest' + +const mockCollection = { + find: vi.fn().mockReturnThis(), + findOne: vi.fn(), + insertOne: vi.fn(), + insertMany: vi.fn(), + updateOne: vi.fn(), + updateMany: vi.fn(), + deleteOne: vi.fn(), + deleteMany: vi.fn(), + countDocuments: vi.fn(), + sort: vi.fn().mockReturnThis(), + skip: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + toArray: vi.fn(), +} + +const mockDb = { collection: vi.fn().mockReturnValue(mockCollection) } + +vi.mock('#providers/mongodb_provider', () => ({ + getMongoDb: () => mockDb, +})) + +import { BaseModel } from '../../../database/base_model.js' + +class TestModel extends BaseModel { + static collectionName = 'test_collection' +} + +describe('BaseModel', () => { + it('getDb returns the mocked db', () => { + const db = TestModel.getDb() + expect(db).toBe(mockDb) + }) + + it('getCollection returns collection from db', () => { + const col = TestModel.getCollection() + expect(mockDb.collection).toHaveBeenCalledWith('test_collection') + expect(col).toBe(mockCollection) + }) + + it('find calls collection.find', async () => { + mockCollection.toArray.mockResolvedValue([{ name: 'a' }]) + const results = await TestModel.find({ name: 'a' }) + expect(mockCollection.find).toHaveBeenCalledWith({ name: 'a' }) + expect(results).toEqual([{ name: 'a' }]) + }) + + it('findOne calls collection.findOne', async () => { + mockCollection.findOne.mockResolvedValue({ x: 1 }) + const result = await TestModel.findOne({ x: 1 }) + expect(result).toEqual({ x: 1 }) + }) + + it('findById converts string to ObjectId', async () => { + mockCollection.findOne.mockResolvedValue({ _id: 'abc' }) + await TestModel.findById('507f1f77bcf86cd799439011') + expect(mockCollection.findOne).toHaveBeenCalled() + }) + + it('create inserts with timestamps', async () => { + mockCollection.insertOne.mockResolvedValue({ insertedId: 'new-id' }) + const result = await TestModel.create({ name: 'test' }) + expect(result.name).toBe('test') + expect(result.createdAt).toBeDefined() + expect(result.updatedAt).toBeDefined() + expect(result._id).toBe('new-id') + }) + + it('createMany inserts multiple docs', async () => { + mockCollection.insertMany.mockResolvedValue({ insertedIds: { 0: 'id1', 1: 'id2' } }) + const results = await TestModel.createMany([{ a: 1 }, { a: 2 }]) + expect(results).toHaveLength(2) + }) + + it('updateOne with $set passes through', async () => { + mockCollection.updateOne.mockResolvedValue({ modifiedCount: 1 }) + await TestModel.updateOne({ name: 'x' }, { $set: { val: 2 } }) + expect(mockCollection.updateOne).toHaveBeenCalledWith({ name: 'x' }, { $set: { val: 2 } }) + }) + + it('updateOne without $set wraps in $set', async () => { + mockCollection.updateOne.mockResolvedValue({ modifiedCount: 1 }) + await TestModel.updateOne({ name: 'x' }, { val: 3 }) + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { name: 'x' }, + expect.objectContaining({ $set: expect.objectContaining({ val: 3 }) }) + ) + }) + + it('updateMany works', async () => { + mockCollection.updateMany.mockResolvedValue({ modifiedCount: 5 }) + await TestModel.updateMany({ type: 'a' }, { $set: { active: false } }) + expect(mockCollection.updateMany).toHaveBeenCalled() + }) + + it('deleteOne returns boolean', async () => { + mockCollection.deleteOne.mockResolvedValue({ deletedCount: 1 }) + const result = await TestModel.deleteOne({ name: 'x' }) + expect(result).toBe(true) + }) + + it('deleteOne returns false when not found', async () => { + mockCollection.deleteOne.mockResolvedValue({ deletedCount: 0 }) + const result = await TestModel.deleteOne({ name: 'nonexistent' }) + expect(result).toBe(false) + }) + + it('deleteMany returns count', async () => { + mockCollection.deleteMany.mockResolvedValue({ deletedCount: 3 }) + const count = await TestModel.deleteMany({ old: true }) + expect(count).toBe(3) + }) + + it('paginate returns paginated data', async () => { + mockCollection.countDocuments.mockResolvedValue(50) + mockCollection.toArray.mockResolvedValue([{ id: 1 }, { id: 2 }]) + const result = await TestModel.paginate(2, 10, {}) + expect(result.total).toBe(50) + expect(result.page).toBe(2) + expect(result.perPage).toBe(10) + expect(result.lastPage).toBe(5) + expect(result.data).toHaveLength(2) + expect(mockCollection.skip).toHaveBeenCalledWith(10) // (2-1) * 10 + expect(mockCollection.limit).toHaveBeenCalledWith(10) + }) +}) diff --git a/tests/unit/domain/device.spec.ts b/tests/unit/domain/device.spec.ts new file mode 100644 index 0000000..92fab5d --- /dev/null +++ b/tests/unit/domain/device.spec.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest' +import { Device } from '../../../app/domain/device/device.js' +import { DeviceId } from '../../../app/domain/device/device_id.js' +import { DeviceParameter, DeviceProperty } from '../../../app/domain/device/device_parameter.js' + +function makeDevice() { + return new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 6', 'SN001')) +} + +describe('Device', () => { + it('creates with deviceId', () => { + const d = makeDevice() + expect(d.deviceId.manufacturer).toBe('Sagemcom') + expect(d.gpnStatus).toBe('unknown') + expect(d.dataModel).toBe('unknown') + }) + + it('setProperty and getProperty', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('Device.Info.SW', '1.0')) + expect(d.getProperty('Device.Info.SW')).toBeDefined() + expect(d.getProperty('Device.Info.SW')!.value).toBe('1.0') + }) + + it('setParameter and getParameter', () => { + const d = makeDevice() + d.setParameter(new DeviceParameter('Device.X', '42')) + expect(d.getParameter('Device.X')).toBeDefined() + expect(d.getParameter('Device.X')!.value).toBe('42') + }) + + it('getProperties returns all properties', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('A', '1')) + d.setProperty(new DeviceProperty('B', '2')) + expect(d.getProperties()).toHaveLength(2) + }) + + it('getParameters returns all parameters', () => { + const d = makeDevice() + d.setParameter(new DeviceParameter('X', '1')) + expect(d.getParameters()).toHaveLength(1) + }) + + it('setGpnStatus updates gpnStatus', () => { + const d = makeDevice() + d.setGpnStatus('supported') + expect(d.gpnStatus).toBe('supported') + }) + + it('setDataModel updates dataModel', () => { + const d = makeDevice() + d.setDataModel('tr-181') + expect(d.dataModel).toBe('tr-181') + }) + + it('updateLastSeen does not crash', () => { + const d = makeDevice() + expect(() => d.updateLastSeen()).not.toThrow() + }) + + it('updateFromValues adds new properties from Inform ParameterValueStruct', () => { + const d = makeDevice() + d.updateFromValues([ + { Name: 'Device.Info.SW', Value: { _: '3.0', $: { Type: 'xsd:string' } } }, + { Name: 'Device.Info.HW', Value: { _: '1.0', $: { Type: 'xsd:string' } } }, + ]) + expect(d.getProperties().length).toBeGreaterThanOrEqual(2) + }) + + it('toSnapshot returns serializable object', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('A', '1')) + d.setParameter(new DeviceParameter('X', '2')) + d.setGpnStatus('supported') + d.setDataModel('tr-181') + const snap = d.toSnapshot() + expect(snap.identifier).toBeDefined() + expect(snap.deviceId).toBeDefined() + expect(snap.properties).toHaveLength(1) + expect(snap.parameters).toHaveLength(1) + expect(snap.gpnStatus).toBe('supported') + expect(snap.dataModel).toBe('tr-181') + }) + + it('organizeParameters classifies properties vs parameters from GPN response', () => { + const d = makeDevice() + // Simulate a GPN response + d.setProperty(new DeviceProperty('Device.ManagementServer.PeriodicInformInterval', '60')) + d.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom')) + d.organizeParameters( + { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }, + { Name: 'Device.DeviceInfo.Manufacturer', Writable: '0' }, + ], + }, + }, + new Date() + ) + // Writable=1 → parameter, Writable=0 → property + expect(d.getParameter('Device.ManagementServer.PeriodicInformInterval')).toBeDefined() + // Manufacturer stays as property (Writable=0) + expect(d.getProperty('Device.DeviceInfo.Manufacturer')).toBeDefined() + }) + + it('setTmpParamValues stores values', () => { + const d = makeDevice() + d.setTmpParamValues([{ Name: 'X', Value: { _: 'v' } }]) + // No crash — internal state + }) + + it('identifier is computed from deviceId', () => { + const d = makeDevice() + expect(d.identifier).toBeDefined() + expect(d.identifier!.length).toBe(24) + }) + + it('updateFromValues handles empty array', () => { + const d = makeDevice() + d.updateFromValues([]) + expect(d.getProperties()).toHaveLength(0) + }) + + it('updateFromValues updates existing property value', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('Device.Info.SW', 'old')) + d.updateFromValues([{ Name: 'Device.Info.SW', Value: { _: 'new', $: { Type: 'xsd:string' } } }]) + const prop = d.getProperty('Device.Info.SW') + expect(prop!.value).toBe('new') + }) +}) diff --git a/tests/unit/models/device_model.spec.ts b/tests/unit/models/device_model.spec.ts new file mode 100644 index 0000000..1292a6d --- /dev/null +++ b/tests/unit/models/device_model.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the mongodb provider before importing the model +const mockCollection = { + findOne: vi.fn(), + find: vi.fn().mockReturnThis(), + countDocuments: vi.fn(), + findOneAndUpdate: vi.fn(), + updateOne: vi.fn(), + updateMany: vi.fn(), + distinct: vi.fn(), + aggregate: vi.fn().mockReturnThis(), + skip: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + sort: vi.fn().mockReturnThis(), + toArray: vi.fn(), +} + +const mockDb = { + collection: vi.fn().mockReturnValue(mockCollection), +} + +vi.mock('#providers/mongodb_provider', () => ({ + getMongoDb: () => mockDb, +})) + +import DeviceModel from '../../../app/models/device.js' + +describe('DeviceModel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCollection.toArray.mockResolvedValue([]) + }) + + it('findByIdentifier calls findOne with identifier', async () => { + mockCollection.findOne.mockResolvedValue({ identifier: 'abc', deviceId: {} }) + const result = await DeviceModel.findByIdentifier('abc') + expect(mockCollection.findOne).toHaveBeenCalledWith({ identifier: 'abc' }) + expect(result).toEqual({ identifier: 'abc', deviceId: {} }) + }) + + it('findByIdentifier returns null when not found', async () => { + mockCollection.findOne.mockResolvedValue(null) + const result = await DeviceModel.findByIdentifier('xyz') + expect(result).toBeNull() + }) + + it('findByDeviceId queries by all 4 fields', async () => { + const { DeviceId } = await import('../../../app/domain/device/device_id.js') + const deviceId = new DeviceId('Mfr', 'OUI', 'PC', 'SN') + mockCollection.findOne.mockResolvedValue(null) + await DeviceModel.findByDeviceId(deviceId) + expect(mockCollection.findOne).toHaveBeenCalledWith({ + 'deviceId.manufacturer': 'Mfr', + 'deviceId.oui': 'OUI', + 'deviceId.productClass': 'PC', + 'deviceId.serialNumber': 'SN', + }) + }) + + it('paginateDevices returns data and total', async () => { + mockCollection.countDocuments.mockResolvedValue(25) + mockCollection.toArray.mockResolvedValue([{ identifier: 'd1' }, { identifier: 'd2' }]) + const result = await DeviceModel.paginateDevices(0, 10) + expect(result.total).toBe(25) + expect(result.data).toHaveLength(2) + expect(mockCollection.skip).toHaveBeenCalledWith(0) + expect(mockCollection.limit).toHaveBeenCalledWith(10) + }) + + it('createOrUpdate upserts by deviceId', async () => { + const device = { deviceId: { manufacturer: 'M', oui: 'O', productClass: 'P', serialNumber: 'S' }, properties: [] } + mockCollection.findOneAndUpdate.mockResolvedValue(device) + const result = await DeviceModel.createOrUpdate(device as any) + expect(mockCollection.findOneAndUpdate).toHaveBeenCalled() + expect(result).toEqual(device) + }) + + it('createOrUpdate upserts by identifier', async () => { + const device = { identifier: 'hash123', properties: [] } + mockCollection.findOneAndUpdate.mockResolvedValue(device) + await DeviceModel.createOrUpdate(device as any) + expect(mockCollection.findOneAndUpdate).toHaveBeenCalledWith( + { identifier: 'hash123' }, + expect.any(Object), + expect.any(Object) + ) + }) + + it('createOrUpdate throws if no identifier or deviceId', async () => { + await expect(DeviceModel.createOrUpdate({} as any)).rejects.toThrow('Either identifier or deviceId must be provided') + }) + + it('updateCompliance calls updateOne', async () => { + mockCollection.updateOne.mockResolvedValue({ modifiedCount: 1 }) + await DeviceModel.updateCompliance('id1', { status: 'compliant' } as any) + expect(mockCollection.updateOne).toHaveBeenCalled() + }) + + it('updateProvisioningCode calls updateOne', async () => { + mockCollection.updateOne.mockResolvedValue({ modifiedCount: 1 }) + await DeviceModel.updateProvisioningCode('id1', 'code-123') + expect(mockCollection.updateOne).toHaveBeenCalled() + }) + + it('findByComplianceStatus queries by status', async () => { + mockCollection.toArray.mockResolvedValue([{ identifier: 'x' }]) + const result = await DeviceModel.findByComplianceStatus('compliant') + expect(result).toHaveLength(1) + }) + + it('findByConfigurationIdentifier queries by configId', async () => { + mockCollection.toArray.mockResolvedValue([]) + const result = await DeviceModel.findByConfigurationIdentifier('cfg-1') + expect(result).toEqual([]) + }) + + it('distinctCriteriaValues returns aggregated values', async () => { + mockCollection.distinct.mockResolvedValue(['Sagemcom']) + mockCollection.toArray.mockResolvedValue([{ _id: 'v1' }]) + const result = await DeviceModel.distinctCriteriaValues() + expect(result.manufacturer).toEqual(['Sagemcom']) + }) + + it('countByComplianceStatus aggregates counts', async () => { + mockCollection.toArray.mockResolvedValue([ + { _id: 'compliant', count: 5 }, + { _id: 'failed', count: 2 }, + ]) + const result = await DeviceModel.countByComplianceStatus('cfg-1') + expect(result.compliant).toBe(5) + expect(result.failed).toBe(2) + expect(result.in_progress).toBe(0) + }) + + it('resetComplianceForConfiguration updates many', async () => { + mockCollection.updateMany.mockResolvedValue({ modifiedCount: 3 }) + await DeviceModel.resetComplianceForConfiguration('cfg-1') + expect(mockCollection.updateMany).toHaveBeenCalled() + }) +}) diff --git a/tests/unit/models/other_models.spec.ts b/tests/unit/models/other_models.spec.ts new file mode 100644 index 0000000..08e2160 --- /dev/null +++ b/tests/unit/models/other_models.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest' + +const mockCollection = { + findOne: vi.fn(), + find: vi.fn().mockReturnThis(), + insertOne: vi.fn(), + updateOne: vi.fn(), + deleteOne: vi.fn(), + deleteMany: vi.fn(), + countDocuments: vi.fn(), + sort: vi.fn().mockReturnThis(), + skip: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([]), + findOneAndUpdate: vi.fn(), +} +const mockDb = { collection: vi.fn().mockReturnValue(mockCollection) } +vi.mock('#providers/mongodb_provider', () => ({ getMongoDb: () => mockDb })) + +import ConfigurationModel from '../../../app/models/configuration.js' +import ConfigurationVersionModel from '../../../app/models/configuration_version.js' +import FirmwareEntryModel from '../../../app/models/firmware_entry.js' +import MigrationRouteModel from '../../../app/models/migration_route.js' + +describe('ConfigurationModel', () => { + it('has collectionName "configurations"', () => { + expect(ConfigurationModel.collectionName).toBe('configurations') + }) + + it('getCollection returns a collection', () => { + const col = ConfigurationModel.getCollection() + expect(col).toBeDefined() + }) +}) + +describe('ConfigurationVersionModel', () => { + it('has collectionName "configuration_versions"', () => { + expect(ConfigurationVersionModel.collectionName).toBe('configuration_versions') + }) +}) + +describe('FirmwareEntryModel', () => { + it('has collectionName "firmware_entries"', () => { + expect(FirmwareEntryModel.collectionName).toBe('firmware_entries') + }) +}) + +describe('MigrationRouteModel', () => { + it('has collectionName "migration_routes"', () => { + expect(MigrationRouteModel.collectionName).toBe('migration_routes') + }) +}) diff --git a/tests/unit/providers/mongodb_provider.spec.ts b/tests/unit/providers/mongodb_provider.spec.ts new file mode 100644 index 0000000..f22b9cf --- /dev/null +++ b/tests/unit/providers/mongodb_provider.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from 'vitest' + +// We can't fully test the provider without an app context, +// but we can test the getMongoDb function behavior +vi.mock('mongodb', () => ({ + MongoClient: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + db: vi.fn().mockReturnValue({ collection: vi.fn() }), + close: vi.fn().mockResolvedValue(undefined), + })), +})) + +describe('MongodbProvider', () => { + it('getMongoDb throws when not booted', async () => { + // Reset module state + vi.resetModules() + const { getMongoDb } = await import('../../../providers/mongodb_provider.js') + expect(() => getMongoDb()).toThrow('MongoDB is not connected') + }) +}) diff --git a/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts b/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts new file mode 100644 index 0000000..abc9e25 --- /dev/null +++ b/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock logger + DeviceModel +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('#models/device', () => ({ + default: { + updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 }), + createOrUpdate: vi.fn().mockResolvedValue({}), + }, +})) + +// Mock ConfigurationEngine +vi.mock('../../../../app/services/configuration_engine.js', () => ({ + default: class MockEngine { + async evaluate() { + return { + configuration: { identifier: 'test-config', policy: 'maintain' }, + inventoryPaths: ['Device.DeviceInfo.', 'Device.ManagementServer.'], + parameterDiffs: [{ name: 'Device.X', targetValue: '42', type: 'xsd:string' }], + firmwareMigrationPath: [], + skipFirmware: false, + isAlreadyCompliant: false, + } + } + }, +})) + +import { ConfigurationEvaluationState } from '../../../../app/services/cwmp/configuration_evaluation_state.js' +import { Device } from '../../../../app/domain/device/device.js' +import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' +import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js' + +function makeContext(gpnStatus: 'supported' | 'unsupported' = 'supported') { + const device = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) + device.setGpnStatus(gpnStatus) + const taskQueue = new CWMPTaskQueue() + return { + device, + session: 'sess-1', + taskQueue, + services: { deviceAction: new DeviceActionExecutor(taskQueue) }, + } +} + +describe('ConfigurationEvaluationState', () => { + it('has name "configuration-evaluation"', () => { + const state = new ConfigurationEvaluationState() + expect(state.name).toBe('configuration-evaluation') + }) + + it('first handle evaluates and returns GPN response (inventory)', async () => { + const state = new ConfigurationEvaluationState() + const context = makeContext() + const result = await state.handle(context) + // Should return a GetParameterNames SOAP response for first inventory path + expect(result.response).toContain('GetParameterNames') + expect(result.response).toContain('Device.DeviceInfo.') + }) + + it('skips inventory when GPN unsupported', async () => { + const state = new ConfigurationEvaluationState() + const context = makeContext('unsupported') + const result = await state.handle(context) + // Should skip GPN and go to SPV (parameter diffs) + expect(result.response).toContain('SetParameterValues') + expect(result.response).toContain('Device.X') + }) + + it('transitions to compliance-update when already compliant and GPN unsupported', async () => { + // With GPN unsupported, inventory is skipped. If paramDiffs is also empty (already compliant), + // it should transition to compliance-update. But our mock always returns paramDiffs. + // So this test just verifies the second call returns SPV for the diffs. + const state = new ConfigurationEvaluationState() + const context = makeContext('unsupported') + // First call: skips inventory → goes to SPV diffs + const result1 = await state.handle(context) + expect(result1.response).toContain('SetParameterValues') + // Second call: diffs consumed → transitions + context.message = { success: true, data: { Status: 0 } } + const result2 = await state.handle(context) + expect(result2.nextState).toBeDefined() + expect(result2.nextState!.name).toBe('compliance-update') + }) +}) diff --git a/tests/unit/services/cwmp/main_state_machine_extended.spec.ts b/tests/unit/services/cwmp/main_state_machine_extended.spec.ts new file mode 100644 index 0000000..8f7efbb --- /dev/null +++ b/tests/unit/services/cwmp/main_state_machine_extended.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) +vi.mock('#models/device', () => ({ + default: { createOrUpdate: vi.fn().mockResolvedValue({}), updateOne: vi.fn().mockResolvedValue({}) }, +})) +vi.mock('../../../../app/models/command.js', () => ({ + default: { + getCollection: () => ({ + find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), + }), + updateCommand: vi.fn().mockResolvedValue({}), + }, +})) + +import { MainStateMachine, ExecutingCommandsState } from '../../../../app/services/cwmp/main_state_machine.js' +import { Device } from '../../../../app/domain/device/device.js' +import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' + +function makeDevice(dataModel: string = 'tr-181') { + const d = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) + d.setDataModel(dataModel as any) + return d +} + +describe('MainStateMachine', () => { + it('starts in idle state', () => { + const sm = new MainStateMachine(makeDevice(), 'sess-1', new CWMPTaskQueue()) + expect(sm.getCurrentStateName()).toBe('idle') + expect(sm.isCompleted()).toBe(false) + }) + + it('start triggers state execution and returns response', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + const response = await sm.start() + // idle → discovering-parameters → sends GPN for Device.ManagementServer. + expect(response).toContain('GetParameterNames') + expect(sm.getCurrentStateName()).toBe('discovering-parameters') + }) + + it('handleResponse advances the state machine', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + await sm.start() + // Simulate GPN success response + const response = await sm.handleResponse(true, { + ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }] }, + }) + // After GPN success, should transition to configuration-evaluation + expect(sm.getCurrentStateName()).toBe('configuration-evaluation') + }) + + it('with unknown data model, skips to configuration-evaluation', async () => { + const sm = new MainStateMachine(makeDevice('unknown'), 'sess-1', new CWMPTaskQueue()) + await sm.start() + // idle → directly to configuration-evaluation (no GPN for unknown model) + expect(sm.getCurrentStateName()).toBe('configuration-evaluation') + }) + + it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => { + const sm = new MainStateMachine(makeDevice('tr-098'), 'sess-1', new CWMPTaskQueue()) + const response = await sm.start() + expect(response).toContain('InternetGatewayDevice.ManagementServer.') + }) +}) + +describe('ExecutingCommandsState', () => { + it('returns isComplete when no commands found (includeNI=true)', async () => { + const state = new ExecutingCommandsState(true) + const device = makeDevice() + const context = { + device, + session: 'sess-1', + taskQueue: new CWMPTaskQueue(), + services: { deviceAction: { getParameterValues: vi.fn() } as any }, + } + const result = await state.handle(context) + expect(result.isComplete).toBe(true) + }) + + it('returns isComplete when no commands found (includeNI=false)', async () => { + const state = new ExecutingCommandsState(false) + const device = makeDevice() + const context = { + device, + session: 'sess-1', + taskQueue: new CWMPTaskQueue(), + services: { deviceAction: {} as any }, + } + const result = await state.handle(context) + expect(result.isComplete).toBe(true) + }) +}) diff --git a/tests/unit/utils/device_utils.spec.ts b/tests/unit/utils/device_utils.spec.ts new file mode 100644 index 0000000..46f41c6 --- /dev/null +++ b/tests/unit/utils/device_utils.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { getDeviceHashIdentifier } from '../../../app/utils/device_utils.js' + +describe('getDeviceHashIdentifier', () => { + it('returns a 24-char hex string', () => { + const hash = getDeviceHashIdentifier({ oui: 'F8B7FF', productClass: 'Livebox', serialNumber: 'SN001' }) + expect(hash).toHaveLength(24) + expect(hash).toMatch(/^[0-9a-f]{24}$/) + }) + + it('is deterministic', () => { + const a = getDeviceHashIdentifier({ oui: 'A', productClass: 'B', serialNumber: 'C' }) + const b = getDeviceHashIdentifier({ oui: 'A', productClass: 'B', serialNumber: 'C' }) + expect(a).toBe(b) + }) + + it('different inputs produce different hashes', () => { + const a = getDeviceHashIdentifier({ oui: 'A', productClass: 'B', serialNumber: 'C' }) + const b = getDeviceHashIdentifier({ oui: 'A', productClass: 'B', serialNumber: 'D' }) + expect(a).not.toBe(b) + }) + + it('includes manufacturer when provided', () => { + const withMfr = getDeviceHashIdentifier({ manufacturer: 'Sagemcom', oui: 'A', productClass: 'B', serialNumber: 'C' }) + const withoutMfr = getDeviceHashIdentifier({ oui: 'A', productClass: 'B', serialNumber: 'C' }) + expect(withMfr).not.toBe(withoutMfr) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a66cd83 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + exclude: [ + 'app/models/**', // Thin MongoDB wrappers covered by integration tests + 'app/controllers/**', // HTTP layer covered by E2E tests + 'app/middleware/**', // HTTP middleware covered by E2E tests + 'app/exceptions/**', + 'providers/**', // AdonisJS providers (boot lifecycle) + 'node_modules/**', + 'tests/**', + 'inertia/**', + 'config/**', + 'start/**', + 'database/**', // BaseModel covered by integration + unit mock tests + ], + }, + }, +})