diff --git a/test/features/control_room/containers/data/models/container_model_test.dart b/test/features/control_room/containers/data/models/container_model_test.dart new file mode 100644 index 0000000..d355200 --- /dev/null +++ b/test/features/control_room/containers/data/models/container_model_test.dart @@ -0,0 +1,458 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/features/control_room/containers/data/models/container_model.dart'; +import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart'; + +void main() { + group('ContainerModel', () { + group('fromJson', () { + test('deserializes minimal container', () { + final json = { + 'Id': 'abc123def456', + 'Names': ['/my-container'], + 'Image': 'nginx:latest', + 'State': 'running', + 'Status': 'Up 2 hours', + }; + + final model = ContainerModel.fromJson(json); + + expect(model.id, equals('abc123def456')); + expect(model.names, equals(['/my-container'])); + expect(model.image, equals('nginx:latest')); + expect(model.state, equals('running')); + expect(model.status, equals('Up 2 hours')); + }); + + test('deserializes full container with all fields', () { + final json = { + 'Id': 'abc123def456789xyz', + 'Names': ['/my-container', '/alias'], + 'Image': 'nginx:latest', + 'State': 'running', + 'Status': 'Up 2 hours', + 'Labels': { + 'com.docker.compose.project': 'mystack', + 'maintainer': 'test@example.com', + }, + 'Ports': [ + {'PrivatePort': 80, 'PublicPort': 8080, 'Type': 'tcp'}, + ], + 'Mounts': [ + { + 'Type': 'bind', + 'Source': '/host/path', + 'Destination': '/container/path', + 'Mode': 'rw', + 'RW': true, + }, + ], + 'NetworkSettings': { + 'Networks': {'bridge': {}, 'custom': {}}, + }, + 'Created': 1704067200, // 2024-01-01 00:00:00 UTC + 'SizeRw': 1024, + 'SizeRootFs': 2048, + }; + + final model = ContainerModel.fromJson(json); + + expect(model.labels['com.docker.compose.project'], equals('mystack')); + expect(model.ports.length, equals(1)); + expect(model.mounts.length, equals(1)); + expect(model.networkSettings?.networks.length, equals(2)); + expect(model.created, equals(1704067200)); + expect(model.sizeRw, equals(1024)); + expect(model.sizeRootFs, equals(2048)); + }); + + test('handles empty lists and maps', () { + final json = { + 'Id': 'abc123', + 'Names': ['/container'], + 'Image': 'alpine', + 'State': 'exited', + 'Status': 'Exited (0)', + 'Labels': {}, + 'Ports': >[], + 'Mounts': >[], + }; + + final model = ContainerModel.fromJson(json); + + expect(model.labels, isEmpty); + expect(model.ports, isEmpty); + expect(model.mounts, isEmpty); + }); + }); + + group('toEntity', () { + test('converts to entity with truncated ID', () { + final model = ContainerModel( + id: 'abc123def456789xyz', + names: ['/my-container'], + image: 'nginx:latest', + state: 'running', + status: 'Up 2 hours', + ); + + final entity = model.toEntity(); + + expect(entity.id, equals('abc123def456')); // Truncated to 12 chars + expect(entity.fullId, equals('abc123def456789xyz')); + }); + + test('strips leading slash from container name', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/my-container'], + image: 'nginx:latest', + state: 'running', + status: 'Up 2 hours', + ); + + final entity = model.toEntity(); + + expect(entity.name, equals('my-container')); + }); + + test('uses ID as name when names list is empty', () { + final model = ContainerModel( + id: 'abc123def456', + names: [], + image: 'nginx:latest', + state: 'running', + status: 'Up 2 hours', + ); + + final entity = model.toEntity(); + + expect(entity.name, equals('abc123def456')); + }); + + test('extracts stack name from compose labels', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: 'running', + status: 'Up', + labels: {'com.docker.compose.project': 'my-stack'}, + ); + + final entity = model.toEntity(); + + expect(entity.stackName, equals('my-stack')); + expect(entity.stackId, equals('my-stack')); + }); + + test('handles missing stack labels', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: 'running', + status: 'Up', + labels: {}, + ); + + final entity = model.toEntity(); + + expect(entity.stackName, isNull); + expect(entity.stackId, isNull); + }); + + test('converts created timestamp to DateTime', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: 'running', + status: 'Up', + created: 1704067200, // 2024-01-01 00:00:00 UTC + ); + + final entity = model.toEntity(); + + expect(entity.createdAt, isNotNull); + expect(entity.createdAt!.year, equals(2024)); + expect(entity.createdAt!.month, equals(1)); + expect(entity.createdAt!.day, equals(1)); + }); + + test('handles null created timestamp', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: 'running', + status: 'Up', + created: null, + ); + + final entity = model.toEntity(); + + expect(entity.createdAt, isNull); + }); + + test('extracts networks from network settings', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: 'running', + status: 'Up', + networkSettings: const NetworkSettingsModel( + networks: {'bridge': {}, 'custom-network': {}}, + ), + ); + + final entity = model.toEntity(); + + expect(entity.networks, containsAll(['bridge', 'custom-network'])); + }); + + test('handles null network settings', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: 'running', + status: 'Up', + networkSettings: null, + ); + + final entity = model.toEntity(); + + expect(entity.networks, isEmpty); + }); + }); + + group('_parseState', () { + final testCases = { + 'created': ContainerState.created, + 'Created': ContainerState.created, + 'CREATED': ContainerState.created, + 'running': ContainerState.running, + 'Running': ContainerState.running, + 'RUNNING': ContainerState.running, + 'paused': ContainerState.paused, + 'Paused': ContainerState.paused, + 'restarting': ContainerState.restarting, + 'Restarting': ContainerState.restarting, + 'removing': ContainerState.removing, + 'Removing': ContainerState.removing, + 'exited': ContainerState.exited, + 'Exited': ContainerState.exited, + 'dead': ContainerState.dead, + 'Dead': ContainerState.dead, + 'unknown': ContainerState.exited, // Unknown defaults to exited + '': ContainerState.exited, // Empty defaults to exited + 'invalid': ContainerState.exited, // Invalid defaults to exited + }; + + testCases.forEach((input, expected) { + test('parses "$input" to ${expected.name}', () { + final model = ContainerModel( + id: 'abc123def456', + names: ['/container'], + image: 'nginx', + state: input, + status: 'Status', + ); + + final entity = model.toEntity(); + + expect(entity.state, equals(expected)); + }); + }); + }); + }); + + group('PortModel', () { + group('fromJson', () { + test('deserializes port with all fields', () { + final json = { + 'IP': '0.0.0.0', + 'PrivatePort': 80, + 'PublicPort': 8080, + 'Type': 'tcp', + }; + + final model = PortModel.fromJson(json); + + expect(model.ip, equals('0.0.0.0')); + expect(model.privatePort, equals(80)); + expect(model.publicPort, equals(8080)); + expect(model.type, equals('tcp')); + }); + + test('deserializes port with minimal fields', () { + final json = { + 'PrivatePort': 443, + }; + + final model = PortModel.fromJson(json); + + expect(model.ip, isNull); + expect(model.privatePort, equals(443)); + expect(model.publicPort, isNull); + expect(model.type, equals('tcp')); // Default + }); + + test('handles UDP type', () { + final json = { + 'PrivatePort': 53, + 'Type': 'udp', + }; + + final model = PortModel.fromJson(json); + + expect(model.type, equals('udp')); + }); + }); + + group('toEntity', () { + test('converts to PortMapping entity', () { + const model = PortModel( + ip: '127.0.0.1', + privatePort: 80, + publicPort: 8080, + type: 'tcp', + ); + + final entity = model.toEntity(); + + expect(entity.hostIp, equals('127.0.0.1')); + expect(entity.hostPort, equals(8080)); + expect(entity.containerPort, equals(80)); + expect(entity.protocol, equals('tcp')); + }); + + test('handles null public port', () { + const model = PortModel( + privatePort: 80, + publicPort: null, + ); + + final entity = model.toEntity(); + + expect(entity.hostPort, isNull); + expect(entity.containerPort, equals(80)); + }); + }); + }); + + group('MountModel', () { + group('fromJson', () { + test('deserializes bind mount', () { + final json = { + 'Type': 'bind', + 'Source': '/host/path', + 'Destination': '/container/path', + 'Mode': 'rw', + 'RW': true, + }; + + final model = MountModel.fromJson(json); + + expect(model.type, equals('bind')); + expect(model.source, equals('/host/path')); + expect(model.destination, equals('/container/path')); + expect(model.mode, equals('rw')); + expect(model.rw, isTrue); + }); + + test('deserializes volume mount', () { + final json = { + 'Type': 'volume', + 'Source': 'my-volume', + 'Destination': '/data', + 'Mode': 'ro', + 'RW': false, + }; + + final model = MountModel.fromJson(json); + + expect(model.type, equals('volume')); + expect(model.rw, isFalse); + }); + + test('uses defaults for missing fields', () { + final json = { + 'Type': 'bind', + 'Source': '/src', + 'Destination': '/dst', + }; + + final model = MountModel.fromJson(json); + + expect(model.mode, equals('rw')); + expect(model.rw, isTrue); + }); + }); + + group('toEntity', () { + test('converts read-write mount to entity', () { + const model = MountModel( + type: 'bind', + source: '/host', + destination: '/container', + rw: true, + ); + + final entity = model.toEntity(); + + expect(entity.type, equals('bind')); + expect(entity.source, equals('/host')); + expect(entity.destination, equals('/container')); + expect(entity.mode, equals('rw')); + }); + + test('converts read-only mount to entity', () { + const model = MountModel( + type: 'volume', + source: 'vol', + destination: '/data', + rw: false, + ); + + final entity = model.toEntity(); + + expect(entity.mode, equals('ro')); + }); + }); + }); + + group('NetworkSettingsModel', () { + test('deserializes network settings', () { + final json = { + 'Networks': { + 'bridge': {'IPAddress': '172.17.0.2'}, + 'custom': {'IPAddress': '10.0.0.5'}, + }, + }; + + final model = NetworkSettingsModel.fromJson(json); + + expect(model.networks.keys, containsAll(['bridge', 'custom'])); + }); + + test('handles empty networks', () { + final json = { + 'Networks': {}, + }; + + final model = NetworkSettingsModel.fromJson(json); + + expect(model.networks, isEmpty); + }); + + test('uses empty map as default', () { + final json = {}; + + final model = NetworkSettingsModel.fromJson(json); + + expect(model.networks, isEmpty); + }); + }); +} diff --git a/test/features/control_room/npm/data/models/proxy_host_model_test.dart b/test/features/control_room/npm/data/models/proxy_host_model_test.dart new file mode 100644 index 0000000..0468dc4 --- /dev/null +++ b/test/features/control_room/npm/data/models/proxy_host_model_test.dart @@ -0,0 +1,335 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart'; + +void main() { + group('NullableIntOrBoolConverter', () { + const converter = NullableIntOrBoolConverter(); + + group('fromJson', () { + test('returns null for null input', () { + expect(converter.fromJson(null), isNull); + }); + + test('returns null for false input', () { + expect(converter.fromJson(false), isNull); + }); + + test('returns null for true input (treats as invalid)', () { + // true is not a valid ID, so we return null + expect(converter.fromJson(true), isNull); + }); + + test('returns int for int input', () { + expect(converter.fromJson(42), equals(42)); + }); + + test('returns int for positive int', () { + expect(converter.fromJson(1), equals(1)); + }); + + test('returns int for zero', () { + expect(converter.fromJson(0), equals(0)); + }); + + test('converts double to int', () { + expect(converter.fromJson(42.0), equals(42)); + }); + + test('converts double with decimals to int (truncates)', () { + expect(converter.fromJson(42.9), equals(42)); + }); + + test('returns null for string input', () { + expect(converter.fromJson('42'), isNull); + }); + + test('returns null for empty string', () { + expect(converter.fromJson(''), isNull); + }); + }); + + group('toJson', () { + test('returns null for null input', () { + expect(converter.toJson(null), isNull); + }); + + test('returns int for int input', () { + expect(converter.toJson(42), equals(42)); + }); + + test('returns zero for zero input', () { + expect(converter.toJson(0), equals(0)); + }); + }); + }); + + group('IntOrBoolConverter', () { + const converter = IntOrBoolConverter(); + + group('fromJson', () { + test('returns 0 for null input', () { + expect(converter.fromJson(null), equals(0)); + }); + + test('returns 0 for false input', () { + expect(converter.fromJson(false), equals(0)); + }); + + test('returns 1 for true input', () { + expect(converter.fromJson(true), equals(1)); + }); + + test('returns int for int input', () { + expect(converter.fromJson(42), equals(42)); + }); + + test('returns 0 for zero input', () { + expect(converter.fromJson(0), equals(0)); + }); + + test('returns 1 for one input', () { + expect(converter.fromJson(1), equals(1)); + }); + + test('converts double to int', () { + expect(converter.fromJson(42.0), equals(42)); + }); + + test('converts double with decimals to int (truncates)', () { + expect(converter.fromJson(42.9), equals(42)); + }); + + test('returns 0 for string input', () { + expect(converter.fromJson('42'), equals(0)); + }); + + test('returns 0 for empty string', () { + expect(converter.fromJson(''), equals(0)); + }); + + test('returns 0 for invalid types', () { + expect(converter.fromJson([1, 2, 3]), equals(0)); + expect(converter.fromJson({'key': 'value'}), equals(0)); + }); + }); + + group('toJson', () { + test('returns int for int input', () { + expect(converter.toJson(42), equals(42)); + }); + + test('returns 0 for zero input', () { + expect(converter.toJson(0), equals(0)); + }); + + test('returns 1 for one input', () { + expect(converter.toJson(1), equals(1)); + }); + }); + }); + + group('ProxyHostModel JSON serialization', () { + test('deserializes with boolean certificate_id (false)', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'http', + 'forward_host': 'localhost', + 'forward_port': 8080, + 'certificate_id': false, // NPM returns false for no certificate + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.certificateId, isNull); + }); + + test('deserializes with int certificate_id', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'http', + 'forward_host': 'localhost', + 'forward_port': 8080, + 'certificate_id': 42, + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.certificateId, equals(42)); + }); + + test('deserializes with null certificate_id', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'http', + 'forward_host': 'localhost', + 'forward_port': 8080, + 'certificate_id': null, + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.certificateId, isNull); + }); + + test('deserializes with boolean http2_support (true)', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'http', + 'forward_host': 'localhost', + 'forward_port': 8080, + 'http2_support': true, // NPM can return boolean + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.http2Support, equals(1)); + }); + + test('deserializes with boolean http2_support (false)', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'http', + 'forward_host': 'localhost', + 'forward_port': 8080, + 'http2_support': false, + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.http2Support, equals(0)); + }); + + test('deserializes with int http2_support', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'http', + 'forward_host': 'localhost', + 'forward_port': 8080, + 'http2_support': 1, + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.http2Support, equals(1)); + }); + + test('deserializes with all boolean flags', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'https', + 'forward_host': 'localhost', + 'forward_port': 443, + 'ssl_forced': true, + 'certificate_id': false, + 'enabled': true, + 'http2_support': true, + 'hsts_enabled': false, + 'access_list_id': false, + 'caching_enabled': true, + 'block_exploits': true, + 'allow_websocket_upgrade': false, + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.sslForced, isTrue); + expect(model.certificateId, isNull); + expect(model.enabled, equals(1)); + expect(model.http2Support, equals(1)); + expect(model.hstsEnabled, equals(0)); + expect(model.accessListId, isNull); + expect(model.cachingEnabled, equals(1)); + expect(model.blockExploits, equals(1)); + expect(model.allowWebsocketUpgrade, equals(0)); + }); + + test('deserializes with all int flags', () { + final json = { + 'id': 1, + 'domain_names': ['example.com'], + 'forward_scheme': 'https', + 'forward_host': 'localhost', + 'forward_port': 443, + 'ssl_forced': false, + 'certificate_id': 5, + 'enabled': 1, + 'http2_support': 1, + 'hsts_enabled': 0, + 'access_list_id': 3, + 'caching_enabled': 1, + 'block_exploits': 1, + 'allow_websocket_upgrade': 0, + }; + + final model = ProxyHostModel.fromJson(json); + + expect(model.sslForced, isFalse); + expect(model.certificateId, equals(5)); + expect(model.enabled, equals(1)); + expect(model.http2Support, equals(1)); + expect(model.hstsEnabled, equals(0)); + expect(model.accessListId, equals(3)); + expect(model.cachingEnabled, equals(1)); + expect(model.blockExploits, equals(1)); + expect(model.allowWebsocketUpgrade, equals(0)); + }); + + test('toEntity converts correctly', () { + final model = ProxyHostModel( + id: 1, + domainNames: ['example.com', 'www.example.com'], + forwardScheme: 'https', + forwardHost: 'backend', + forwardPort: 8080, + sslForced: true, + certificateId: 5, + enabled: 1, + http2Support: 1, + hstsEnabled: 1, + cachingEnabled: 0, + blockExploits: 1, + allowWebsocketUpgrade: 1, + ); + + final entity = model.toEntity(); + + expect(entity.id, equals(1)); + expect(entity.domainNames, equals(['example.com', 'www.example.com'])); + expect(entity.forwardScheme, equals('https')); + expect(entity.forwardHost, equals('backend')); + expect(entity.forwardPort, equals(8080)); + expect(entity.forceSSL, isTrue); + expect(entity.sslEnabled, isTrue); // certificateId > 0 + expect(entity.certificateId, equals(5)); + expect(entity.enabled, isTrue); + expect(entity.http2Support, isTrue); + expect(entity.hstsEnabled, isTrue); + expect(entity.cacheAssets, isFalse); + expect(entity.blockExploits, isTrue); + expect(entity.websocketSupport, isTrue); + }); + + test('toEntity handles null certificateId correctly', () { + final model = ProxyHostModel( + id: 1, + domainNames: ['example.com'], + forwardScheme: 'http', + forwardHost: 'backend', + forwardPort: 80, + certificateId: null, + ); + + final entity = model.toEntity(); + + expect(entity.sslEnabled, isFalse); + expect(entity.certificateId, isNull); + }); + }); +} diff --git a/test/features/control_room/stacks/data/models/stack_model_test.dart b/test/features/control_room/stacks/data/models/stack_model_test.dart new file mode 100644 index 0000000..f398b06 --- /dev/null +++ b/test/features/control_room/stacks/data/models/stack_model_test.dart @@ -0,0 +1,395 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/features/control_room/stacks/data/models/stack_model.dart'; +import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart'; + +void main() { + group('StackModel', () { + group('fromJson', () { + test('deserializes minimal stack', () { + final json = { + 'id': 'stack-123', + 'name': 'my-stack', + }; + + final model = StackModel.fromJson(json); + + expect(model.id, equals('stack-123')); + expect(model.name, equals('my-stack')); + expect(model.containerCount, equals(0)); // Default + expect(model.runningCount, equals(0)); // Default + }); + + test('deserializes full stack with all fields', () { + final json = { + 'id': 'stack-456', + 'name': 'production-stack', + 'type': 'compose', + 'status': 'active', + 'container_count': 5, + 'running_count': 5, + 'compose_file': '/path/to/docker-compose.yml', + 'environment': 'production', + 'created_at': '2024-01-15T10:30:00Z', + 'updated_at': '2024-01-20T15:45:00Z', + }; + + final model = StackModel.fromJson(json); + + expect(model.id, equals('stack-456')); + expect(model.name, equals('production-stack')); + expect(model.typeString, equals('compose')); + expect(model.statusString, equals('active')); + expect(model.containerCount, equals(5)); + expect(model.runningCount, equals(5)); + expect(model.composeFile, equals('/path/to/docker-compose.yml')); + expect(model.environment, equals('production')); + expect(model.createdAt, equals('2024-01-15T10:30:00Z')); + expect(model.updatedAt, equals('2024-01-20T15:45:00Z')); + }); + + test('handles null optional fields', () { + final json = { + 'id': 'stack-789', + 'name': 'minimal-stack', + 'type': null, + 'status': null, + 'compose_file': null, + 'environment': null, + 'created_at': null, + 'updated_at': null, + }; + + final model = StackModel.fromJson(json); + + expect(model.typeString, isNull); + expect(model.statusString, isNull); + expect(model.composeFile, isNull); + expect(model.environment, isNull); + expect(model.createdAt, isNull); + expect(model.updatedAt, isNull); + }); + + test('uses defaults for missing container counts', () { + final json = { + 'id': 'stack-abc', + 'name': 'test-stack', + }; + + final model = StackModel.fromJson(json); + + expect(model.containerCount, equals(0)); + expect(model.runningCount, equals(0)); + }); + }); + + group('toEntity', () { + test('converts to entity with all fields', () { + const model = StackModel( + id: 'stack-123', + name: 'my-stack', + typeString: 'compose', + statusString: 'active', + containerCount: 3, + runningCount: 2, + composeFile: '/compose.yml', + environment: 'staging', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T12:00:00Z', + ); + + final entity = model.toEntity(); + + expect(entity.id, equals('stack-123')); + expect(entity.name, equals('my-stack')); + expect(entity.type, equals(StackType.compose)); + expect(entity.status, equals(StackStatus.active)); + expect(entity.containerCount, equals(3)); + expect(entity.runningCount, equals(2)); + expect(entity.composeFile, equals('/compose.yml')); + expect(entity.environment, equals('staging')); + expect(entity.createdAt, isNotNull); + expect(entity.updatedAt, isNotNull); + }); + + test('handles null timestamps', () { + const model = StackModel( + id: 'stack-123', + name: 'my-stack', + createdAt: null, + updatedAt: null, + ); + + final entity = model.toEntity(); + + expect(entity.createdAt, isNull); + expect(entity.updatedAt, isNull); + }); + + test('handles invalid timestamp format', () { + const model = StackModel( + id: 'stack-123', + name: 'my-stack', + createdAt: 'invalid-date', + updatedAt: 'not-a-date', + ); + + final entity = model.toEntity(); + + expect(entity.createdAt, isNull); + expect(entity.updatedAt, isNull); + }); + + test('parses valid ISO 8601 timestamps', () { + const model = StackModel( + id: 'stack-123', + name: 'my-stack', + createdAt: '2024-06-15T14:30:00.000Z', + updatedAt: '2024-06-16T09:15:30.500Z', + ); + + final entity = model.toEntity(); + + expect(entity.createdAt?.year, equals(2024)); + expect(entity.createdAt?.month, equals(6)); + expect(entity.createdAt?.day, equals(15)); + expect(entity.updatedAt?.hour, equals(9)); + expect(entity.updatedAt?.minute, equals(15)); + }); + }); + + group('_parseStackType', () { + final testCases = { + 'compose': StackType.compose, + 'Compose': StackType.compose, + 'COMPOSE': StackType.compose, + 'swarm': StackType.swarm, + 'Swarm': StackType.swarm, + 'SWARM': StackType.swarm, + 'kubernetes': StackType.kubernetes, + 'Kubernetes': StackType.kubernetes, + 'KUBERNETES': StackType.kubernetes, + 'k8s': StackType.kubernetes, + 'K8S': StackType.kubernetes, + 'K8s': StackType.kubernetes, + 'unknown': StackType.compose, // Defaults to compose + '': StackType.compose, // Empty defaults to compose + 'invalid': StackType.compose, // Invalid defaults to compose + }; + + testCases.forEach((input, expected) { + test('parses "$input" to ${expected.name}', () { + final model = StackModel( + id: 'stack-123', + name: 'test', + typeString: input, + ); + + final entity = model.toEntity(); + + expect(entity.type, equals(expected)); + }); + }); + + test('handles null type', () { + const model = StackModel( + id: 'stack-123', + name: 'test', + typeString: null, + ); + + final entity = model.toEntity(); + + expect(entity.type, equals(StackType.compose)); + }); + }); + + group('_parseStackStatus', () { + final testCases = { + 'active': StackStatus.active, + 'Active': StackStatus.active, + 'ACTIVE': StackStatus.active, + 'running': StackStatus.active, + 'Running': StackStatus.active, + 'RUNNING': StackStatus.active, + 'inactive': StackStatus.inactive, + 'Inactive': StackStatus.inactive, + 'INACTIVE': StackStatus.inactive, + 'stopped': StackStatus.inactive, + 'Stopped': StackStatus.inactive, + 'STOPPED': StackStatus.inactive, + 'error': StackStatus.error, + 'Error': StackStatus.error, + 'ERROR': StackStatus.error, + 'unknown': StackStatus.unknown, // Defaults to unknown + '': StackStatus.unknown, // Empty defaults to unknown + 'invalid': StackStatus.unknown, // Invalid defaults to unknown + }; + + testCases.forEach((input, expected) { + test('parses "$input" to ${expected.name}', () { + final model = StackModel( + id: 'stack-123', + name: 'test', + statusString: input, + ); + + final entity = model.toEntity(); + + expect(entity.status, equals(expected)); + }); + }); + + test('handles null status', () { + const model = StackModel( + id: 'stack-123', + name: 'test', + statusString: null, + ); + + final entity = model.toEntity(); + + expect(entity.status, equals(StackStatus.unknown)); + }); + }); + }); + + group('Stack entity', () { + group('isHealthy', () { + test('returns true when all containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'healthy-stack', + containerCount: 5, + runningCount: 5, + ); + + expect(stack.isHealthy, isTrue); + }); + + test('returns false when some containers are not running', () { + const stack = Stack( + id: 'stack-1', + name: 'partial-stack', + containerCount: 5, + runningCount: 3, + ); + + expect(stack.isHealthy, isFalse); + }); + + test('returns false when no containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'stopped-stack', + containerCount: 5, + runningCount: 0, + ); + + expect(stack.isHealthy, isFalse); + }); + + test('returns false when containerCount is zero', () { + const stack = Stack( + id: 'stack-1', + name: 'empty-stack', + containerCount: 0, + runningCount: 0, + ); + + expect(stack.isHealthy, isFalse); + }); + }); + + group('hasRunningContainers', () { + test('returns true when some containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'running-stack', + containerCount: 5, + runningCount: 2, + ); + + expect(stack.hasRunningContainers, isTrue); + }); + + test('returns false when no containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'stopped-stack', + containerCount: 5, + runningCount: 0, + ); + + expect(stack.hasRunningContainers, isFalse); + }); + + test('returns true when all containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'healthy-stack', + containerCount: 3, + runningCount: 3, + ); + + expect(stack.hasRunningContainers, isTrue); + }); + }); + + group('isPartial', () { + test('returns true when some but not all containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'partial-stack', + containerCount: 5, + runningCount: 3, + ); + + expect(stack.isPartial, isTrue); + }); + + test('returns false when all containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'healthy-stack', + containerCount: 5, + runningCount: 5, + ); + + expect(stack.isPartial, isFalse); + }); + + test('returns false when no containers are running', () { + const stack = Stack( + id: 'stack-1', + name: 'stopped-stack', + containerCount: 5, + runningCount: 0, + ); + + expect(stack.isPartial, isFalse); + }); + + test('returns false when containerCount is zero', () { + const stack = Stack( + id: 'stack-1', + name: 'empty-stack', + containerCount: 0, + runningCount: 0, + ); + + expect(stack.isPartial, isFalse); + }); + + test('returns true when only one of many containers is running', () { + const stack = Stack( + id: 'stack-1', + name: 'degraded-stack', + containerCount: 10, + runningCount: 1, + ); + + expect(stack.isPartial, isTrue); + }); + }); + }); +}