test: add test harness and widget tests for DataGrid pages
Add comprehensive testing infrastructure: - Test harness with mock auth, API client, and fixtures - Mock Dio interceptor for canned API responses - Fixtures for containers, domains, users, groups Add widget tests for all DataGrid pages: - ContainersListPage (21 tests) - ProxyHostsPage (16 tests) - UsersListPage (21 tests) - GroupsListPage (18 tests) Add unit tests: - RoomRegistry (28 tests) - DataGrid components (35 tests) Test count: 282 -> 357 (+75 tests) Coverage: 5.7% -> 19.1% (+504 lines) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
09e120221d
commit
d6fd9aea60
+306
@@ -0,0 +1,306 @@
|
||||
import 'package:flutter/material.dart' hide Container;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
|
||||
|
||||
import '../../../../../harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
group('ContainersListPage', () {
|
||||
testWidgets('displays loading state initially', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers(); // Need response to avoid timeout
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
// Just pump once without settling to catch loading state
|
||||
await tester.pump();
|
||||
|
||||
// Should show loading indicator (before data loads)
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Now settle to complete the test cleanly
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('displays containers from API', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display container names from fixtures
|
||||
expect(find.text('nginx-proxy'), findsOneWidget);
|
||||
expect(find.text('postgres-db'), findsOneWidget);
|
||||
expect(find.text('redis-cache'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays container images', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display container images from fixtures
|
||||
expect(find.text('nginx:latest'), findsOneWidget);
|
||||
expect(find.text('postgres:15'), findsOneWidget);
|
||||
expect(find.text('redis:7-alpine'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays search input', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have search field
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search containers...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays refresh button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have refresh button
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays port information', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display port mappings from fixtures
|
||||
expect(find.textContaining('80:80'), findsOneWidget);
|
||||
expect(find.textContaining('443:443'), findsOneWidget);
|
||||
expect(find.textContaining('5432:5432'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays status information', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display status from fixtures (health indicator stripped)
|
||||
expect(find.textContaining('Up 3 days'), findsOneWidget);
|
||||
expect(find.textContaining('Up 5 days'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('search filters containers', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Find search field and enter text
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'nginx');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show nginx container
|
||||
expect(find.text('nginx-proxy'), findsOneWidget);
|
||||
// Other containers should be filtered out
|
||||
expect(find.text('postgres-db'), findsNothing);
|
||||
expect(find.text('redis-cache'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays empty state when no containers match search',
|
||||
(tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Search for non-existent container
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'nonexistent');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No containers should be visible
|
||||
expect(find.text('nginx-proxy'), findsNothing);
|
||||
expect(find.text('postgres-db'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays error state on API failure', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenApiError(
|
||||
method: 'GET',
|
||||
path: '/infrastructure/containers',
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
// Pump a few frames to allow error to propagate
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
// Should show error state (check for error icon or text)
|
||||
expect(
|
||||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
|
||||
find.textContaining('error').evaluate().isNotEmpty ||
|
||||
find.textContaining('Error').evaluate().isNotEmpty,
|
||||
isTrue,
|
||||
reason: 'Should display an error indicator',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('calls API on mount', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenContainers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ContainersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify API was called
|
||||
harness.verifyApiCalled('GET', '/infrastructure/containers');
|
||||
});
|
||||
});
|
||||
|
||||
group('ContainerData', () {
|
||||
test('parses JSON correctly', () {
|
||||
final json = Fixtures.containers[0];
|
||||
final container = ContainerData.fromJson(json);
|
||||
|
||||
expect(container.name, 'nginx-proxy');
|
||||
expect(container.image, 'nginx:latest');
|
||||
expect(container.state, 'running');
|
||||
expect(container.ports.length, 2);
|
||||
expect(container.health, ContainerHealth.healthy);
|
||||
});
|
||||
|
||||
test('strips leading slash from name', () {
|
||||
final json = {
|
||||
'Id': 'abc123',
|
||||
'Names': ['/my-container'],
|
||||
'Image': 'test:latest',
|
||||
'State': 'running',
|
||||
'Status': 'Up 1 day',
|
||||
'Ports': [],
|
||||
};
|
||||
|
||||
final container = ContainerData.fromJson(json);
|
||||
expect(container.name, 'my-container');
|
||||
});
|
||||
|
||||
test('truncates ID to 12 characters', () {
|
||||
final json = {
|
||||
'Id': 'abcdef123456789',
|
||||
'Names': ['/test'],
|
||||
'Image': 'test:latest',
|
||||
'State': 'running',
|
||||
'Status': 'Up 1 day',
|
||||
'Ports': [],
|
||||
};
|
||||
|
||||
final container = ContainerData.fromJson(json);
|
||||
expect(container.id, 'abcdef123456');
|
||||
expect(container.fullId, 'abcdef123456789');
|
||||
});
|
||||
|
||||
test('parses health status correctly', () {
|
||||
expect(
|
||||
ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'Status': 'Up 1 day (healthy)',
|
||||
}).health,
|
||||
ContainerHealth.healthy,
|
||||
);
|
||||
|
||||
expect(
|
||||
ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'Status': 'Up 1 day (unhealthy)',
|
||||
}).health,
|
||||
ContainerHealth.unhealthy,
|
||||
);
|
||||
|
||||
expect(
|
||||
ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'Status': 'Up 1 day (health: starting)',
|
||||
}).health,
|
||||
ContainerHealth.starting,
|
||||
);
|
||||
|
||||
expect(
|
||||
ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'Status': 'Up 1 day',
|
||||
}).health,
|
||||
ContainerHealth.none,
|
||||
);
|
||||
});
|
||||
|
||||
test('displayStatus strips health indicator', () {
|
||||
final container = ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'Status': 'Up 3 days (healthy)',
|
||||
});
|
||||
|
||||
expect(container.displayStatus, 'Up 3 days');
|
||||
});
|
||||
|
||||
test('canStart is true for exited containers', () {
|
||||
final container = ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'State': 'exited',
|
||||
});
|
||||
|
||||
expect(container.canStart, isTrue);
|
||||
expect(container.canStop, isFalse);
|
||||
expect(container.canRestart, isFalse);
|
||||
});
|
||||
|
||||
test('canStop and canRestart are true for running containers', () {
|
||||
final container = ContainerData.fromJson({
|
||||
...Fixtures.containers[0],
|
||||
'State': 'running',
|
||||
});
|
||||
|
||||
expect(container.canStart, isFalse);
|
||||
expect(container.canStop, isTrue);
|
||||
expect(container.canRestart, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('ContainerPort', () {
|
||||
test('parses JSON correctly', () {
|
||||
final port = ContainerPort.fromJson({
|
||||
'PrivatePort': 80,
|
||||
'PublicPort': 8080,
|
||||
'Type': 'tcp',
|
||||
});
|
||||
|
||||
expect(port.privatePort, 80);
|
||||
expect(port.publicPort, 8080);
|
||||
expect(port.type, 'tcp');
|
||||
});
|
||||
|
||||
test('formatted shows port mapping', () {
|
||||
final port = ContainerPort(privatePort: 80, publicPort: 8080);
|
||||
expect(port.formatted, '8080:80');
|
||||
});
|
||||
|
||||
test('formatted shows only private port when no public port', () {
|
||||
final port = ContainerPort(privatePort: 6379);
|
||||
expect(port.formatted, '6379');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_hosts_page.dart';
|
||||
|
||||
import '../../../../../harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
group('ProxyHostsPage', () {
|
||||
testWidgets('displays loading state initially', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('displays domains from API', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('home.schweitz.net'), findsOneWidget);
|
||||
expect(find.text('api.schweitz.net'), findsOneWidget);
|
||||
expect(find.text('git.schweitz.net'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays service information', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('192.168.86.149:8092'), findsOneWidget);
|
||||
expect(find.text('192.168.86.149:8083'), findsOneWidget);
|
||||
expect(find.text('192.168.86.149:3000'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays SSL badges', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should show SSL badges for enabled domains
|
||||
expect(find.text('SSL'), findsWidgets);
|
||||
// Should show HTTP badge for non-SSL domain
|
||||
expect(find.text('HTTP'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays lock icons for SSL status', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should have lock icons for SSL-enabled domains
|
||||
expect(find.byIcon(Icons.lock), findsWidgets);
|
||||
// Should have open lock for non-SSL domain
|
||||
expect(find.byIcon(Icons.lock_open), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays search input', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search domains...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays refresh button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays New button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('New'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('search filters domains', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'api');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('api.schweitz.net'), findsOneWidget);
|
||||
expect(find.text('home.schweitz.net'), findsNothing);
|
||||
expect(find.text('git.schweitz.net'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays empty state when no domains match search',
|
||||
(tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'nonexistent');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('home.schweitz.net'), findsNothing);
|
||||
expect(find.text('api.schweitz.net'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays error state on API failure', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenApiError(
|
||||
method: 'GET',
|
||||
path: '/infrastructure/domains',
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
|
||||
find.textContaining('error').evaluate().isNotEmpty ||
|
||||
find.textContaining('Error').evaluate().isNotEmpty,
|
||||
isTrue,
|
||||
reason: 'Should display an error indicator',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('calls API on mount', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenDomains();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const ProxyHostsPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
harness.verifyApiCalled('GET', '/infrastructure/domains');
|
||||
});
|
||||
});
|
||||
|
||||
group('DomainData', () {
|
||||
test('parses JSON correctly', () {
|
||||
final json = Fixtures.domains[0];
|
||||
final domain = DomainData.fromJson(json);
|
||||
|
||||
expect(domain.domain, 'home.schweitz.net');
|
||||
expect(domain.service, '192.168.86.149:8092');
|
||||
expect(domain.proxyHostId, 1);
|
||||
expect(domain.sslEnabled, isTrue);
|
||||
expect(domain.certificateId, 1);
|
||||
});
|
||||
|
||||
test('handles missing certificate_id', () {
|
||||
final json = {
|
||||
'domain': 'test.example.com',
|
||||
'service': '192.168.1.1:80',
|
||||
'proxy_host_id': 99,
|
||||
'ssl_enabled': false,
|
||||
};
|
||||
|
||||
final domain = DomainData.fromJson(json);
|
||||
expect(domain.certificateId, isNull);
|
||||
});
|
||||
|
||||
test('defaults ssl_enabled to false when missing', () {
|
||||
final json = {
|
||||
'domain': 'test.example.com',
|
||||
'service': '192.168.1.1:80',
|
||||
'proxy_host_id': 99,
|
||||
};
|
||||
|
||||
final domain = DomainData.fromJson(json);
|
||||
expect(domain.sslEnabled, isFalse);
|
||||
});
|
||||
|
||||
test('parses all fixture domains correctly', () {
|
||||
for (final json in Fixtures.domains) {
|
||||
final domain = DomainData.fromJson(json);
|
||||
expect(domain.domain, isNotEmpty);
|
||||
expect(domain.service, isNotEmpty);
|
||||
expect(domain.proxyHostId, isPositive);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/features/security/groups/groups_list_page.dart';
|
||||
|
||||
import '../../../harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
group('GroupsListPage', () {
|
||||
testWidgets('displays loading state initially', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('displays groups from API', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('admins'), findsOneWidget);
|
||||
// 'users' appears twice: as group name and as parent of developers
|
||||
expect(find.text('users'), findsNWidgets(2));
|
||||
expect(find.text('developers'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays member counts', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Member counts from fixtures: admins=1, users=3, developers=1
|
||||
expect(find.text('1'), findsNWidgets(2)); // admins and developers
|
||||
expect(find.text('3'), findsOneWidget); // users
|
||||
});
|
||||
|
||||
testWidgets('displays Superuser badge for superuser groups', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// admins group is superuser
|
||||
expect(find.text('Superuser'), findsOneWidget);
|
||||
// users and developers are standard
|
||||
expect(find.text('Standard'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('displays parent group name', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// developers has parent_name = 'users'
|
||||
// Find 'users' appearing twice: once as group name, once as parent
|
||||
expect(find.text('users'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('displays dash for groups without parent', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// admins and users have null parent_name, shown as '-'
|
||||
expect(find.text('-'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('displays search input', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search groups...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays refresh button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays Sync from Authentik button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sync from Authentik'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.sync), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('search filters groups', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'admin');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('admins'), findsOneWidget);
|
||||
expect(find.text('users'), findsNothing);
|
||||
expect(find.text('developers'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays empty state when no groups match search',
|
||||
(tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'nonexistent');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('admins'), findsNothing);
|
||||
expect(find.text('users'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays error state on API failure', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenApiError(
|
||||
method: 'GET',
|
||||
path: '/auth/groups',
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
|
||||
find.textContaining('error').evaluate().isNotEmpty ||
|
||||
find.textContaining('Error').evaluate().isNotEmpty,
|
||||
isTrue,
|
||||
reason: 'Should display an error indicator',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('calls API on mount', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenGroups();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const GroupsListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
harness.verifyApiCalled('GET', '/auth/groups');
|
||||
});
|
||||
});
|
||||
|
||||
group('GroupData', () {
|
||||
test('parses JSON correctly', () {
|
||||
final json = Fixtures.groups[0];
|
||||
final group = GroupData.fromJson(json);
|
||||
|
||||
expect(group.id, 'group-001');
|
||||
expect(group.name, 'admins');
|
||||
expect(group.isSuperuser, isTrue);
|
||||
expect(group.memberCount, 1);
|
||||
expect(group.parentName, isNull);
|
||||
});
|
||||
|
||||
test('parses group with parent correctly', () {
|
||||
final json = Fixtures.groups[2]; // developers with parent
|
||||
final group = GroupData.fromJson(json);
|
||||
|
||||
expect(group.name, 'developers');
|
||||
expect(group.parentName, 'users');
|
||||
});
|
||||
|
||||
test('defaults isSuperuser to false when missing', () {
|
||||
final json = {
|
||||
'id': 'test',
|
||||
'name': 'test-group',
|
||||
'member_count': 5,
|
||||
};
|
||||
final group = GroupData.fromJson(json);
|
||||
expect(group.isSuperuser, isFalse);
|
||||
});
|
||||
|
||||
test('defaults memberCount to 0 when missing', () {
|
||||
final json = {
|
||||
'id': 'test',
|
||||
'name': 'test-group',
|
||||
'is_superuser': false,
|
||||
};
|
||||
final group = GroupData.fromJson(json);
|
||||
expect(group.memberCount, 0);
|
||||
});
|
||||
|
||||
test('parses all fixture groups correctly', () {
|
||||
for (final json in Fixtures.groups) {
|
||||
final group = GroupData.fromJson(json);
|
||||
expect(group.id, isNotEmpty);
|
||||
expect(group.name, isNotEmpty);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/features/security/users/users_list_page.dart';
|
||||
|
||||
import '../../../harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
group('UsersListPage', () {
|
||||
testWidgets('displays loading state initially', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('displays users from API', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Admin User'), findsOneWidget);
|
||||
expect(find.text('John Doe'), findsOneWidget);
|
||||
expect(find.text('Jane Smith'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays user emails', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('admin@example.com'), findsOneWidget);
|
||||
expect(find.text('john@example.com'), findsOneWidget);
|
||||
expect(find.text('jane@example.com'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays Admin badge for admin users', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Admin User should have Admin badge
|
||||
expect(find.text('Admin'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays user initials in avatar', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display initials for users without avatar
|
||||
expect(find.text('AU'), findsOneWidget); // Admin User
|
||||
expect(find.text('JD'), findsOneWidget); // John Doe
|
||||
expect(find.text('JS'), findsOneWidget); // Jane Smith
|
||||
});
|
||||
|
||||
testWidgets('displays search input', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search users...'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays refresh button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays Sync from Authentik button', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sync from Authentik'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.sync), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('search filters users', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'Admin');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Admin User'), findsOneWidget);
|
||||
expect(find.text('John Doe'), findsNothing);
|
||||
expect(find.text('Jane Smith'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays empty state when no users match search',
|
||||
(tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byType(TextField);
|
||||
await tester.enterText(searchField, 'nonexistent');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Admin User'), findsNothing);
|
||||
expect(find.text('John Doe'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('displays "No roles" for users without roles', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Jane Smith has no roles
|
||||
expect(find.text('No roles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays role badges', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should display role names (last part after colon)
|
||||
expect(find.text('admin'), findsOneWidget);
|
||||
expect(find.text('viewer'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays error state on API failure', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenApiError(
|
||||
method: 'GET',
|
||||
path: '/auth/users',
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.error_outline).evaluate().isNotEmpty ||
|
||||
find.textContaining('error').evaluate().isNotEmpty ||
|
||||
find.textContaining('Error').evaluate().isNotEmpty,
|
||||
isTrue,
|
||||
reason: 'Should display an error indicator',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('calls API on mount', (tester) async {
|
||||
harness.givenAuthenticatedUser();
|
||||
harness.givenUsers();
|
||||
|
||||
await tester.pumpWidget(harness.wrap(const UsersListPage()));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
harness.verifyApiCalled('GET', '/auth/users');
|
||||
});
|
||||
});
|
||||
|
||||
group('UserData', () {
|
||||
test('parses JSON correctly', () {
|
||||
final json = Fixtures.users[0];
|
||||
final user = UserData.fromJson(json);
|
||||
|
||||
expect(user.id, 'user-001');
|
||||
expect(user.email, 'admin@example.com');
|
||||
expect(user.name, 'Admin User');
|
||||
expect(user.roles, contains('admin.general:admin'));
|
||||
expect(user.isAdmin, isTrue);
|
||||
});
|
||||
|
||||
test('isAdmin returns false for non-admin users', () {
|
||||
final json = Fixtures.users[1];
|
||||
final user = UserData.fromJson(json);
|
||||
|
||||
expect(user.isAdmin, isFalse);
|
||||
});
|
||||
|
||||
test('initials returns correct value for two-word names', () {
|
||||
final user = UserData.fromJson(Fixtures.users[0]);
|
||||
expect(user.initials, 'AU'); // Admin User
|
||||
});
|
||||
|
||||
test('initials returns correct value for single-word names', () {
|
||||
final json = {
|
||||
'id': 'test',
|
||||
'email': 'test@test.com',
|
||||
'name': 'Madonna',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'roles': [],
|
||||
};
|
||||
final user = UserData.fromJson(json);
|
||||
expect(user.initials, 'MA');
|
||||
});
|
||||
|
||||
test('lastLoginFormatted returns "Never" for null lastLogin', () {
|
||||
final json = Fixtures.users[2]; // Jane Smith has null last_login
|
||||
final user = UserData.fromJson(json);
|
||||
expect(user.lastLoginFormatted, 'Never');
|
||||
});
|
||||
|
||||
test('handles missing roles gracefully', () {
|
||||
final json = {
|
||||
'id': 'test',
|
||||
'email': 'test@test.com',
|
||||
'name': 'Test User',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
};
|
||||
final user = UserData.fromJson(json);
|
||||
expect(user.roles, isEmpty);
|
||||
});
|
||||
|
||||
test('parses all fixture users correctly', () {
|
||||
for (final json in Fixtures.users) {
|
||||
final user = UserData.fromJson(json);
|
||||
expect(user.id, isNotEmpty);
|
||||
expect(user.email, isNotEmpty);
|
||||
expect(user.name, isNotEmpty);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/// Test fixtures for mock API responses.
|
||||
///
|
||||
/// Provides realistic test data for various API endpoints.
|
||||
class Fixtures {
|
||||
Fixtures._();
|
||||
|
||||
// ============================================================
|
||||
// Containers
|
||||
// ============================================================
|
||||
|
||||
static const containers = [
|
||||
{
|
||||
'Id': 'abc123def456',
|
||||
'Names': ['/nginx-proxy'],
|
||||
'Image': 'nginx:latest',
|
||||
'State': 'running',
|
||||
'Status': 'Up 3 days (healthy)',
|
||||
'Created': 1704067200,
|
||||
'Ports': [
|
||||
{'PrivatePort': 80, 'PublicPort': 80, 'Type': 'tcp'},
|
||||
{'PrivatePort': 443, 'PublicPort': 443, 'Type': 'tcp'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'Id': 'def456ghi789',
|
||||
'Names': ['/postgres-db'],
|
||||
'Image': 'postgres:15',
|
||||
'State': 'running',
|
||||
'Status': 'Up 5 days',
|
||||
'Created': 1703894400,
|
||||
'Ports': [
|
||||
{'PrivatePort': 5432, 'PublicPort': 5432, 'Type': 'tcp'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'Id': 'ghi789jkl012',
|
||||
'Names': ['/redis-cache'],
|
||||
'Image': 'redis:7-alpine',
|
||||
'State': 'running',
|
||||
'Status': 'Up 2 days',
|
||||
'Created': 1704153600,
|
||||
'Ports': [
|
||||
{'PrivatePort': 6379, 'Type': 'tcp'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'Id': 'jkl012mno345',
|
||||
'Names': ['/stopped-service'],
|
||||
'Image': 'myapp:latest',
|
||||
'State': 'exited',
|
||||
'Status': 'Exited (0) 12 hours ago',
|
||||
'Created': 1704067200,
|
||||
'Ports': [],
|
||||
},
|
||||
];
|
||||
|
||||
static Map<String, dynamic> container(int index) => containers[index];
|
||||
|
||||
// ============================================================
|
||||
// Domains (for ProxyHostsPage DataGrid)
|
||||
// ============================================================
|
||||
|
||||
static const domains = [
|
||||
{
|
||||
'domain': 'home.schweitz.net',
|
||||
'service': '192.168.86.149:8092',
|
||||
'proxy_host_id': 1,
|
||||
'ssl_enabled': true,
|
||||
'certificate_id': 1,
|
||||
},
|
||||
{
|
||||
'domain': 'api.schweitz.net',
|
||||
'service': '192.168.86.149:8083',
|
||||
'proxy_host_id': 2,
|
||||
'ssl_enabled': true,
|
||||
'certificate_id': 2,
|
||||
},
|
||||
{
|
||||
'domain': 'git.schweitz.net',
|
||||
'service': '192.168.86.149:3000',
|
||||
'proxy_host_id': 3,
|
||||
'ssl_enabled': true,
|
||||
'certificate_id': 3,
|
||||
},
|
||||
{
|
||||
'domain': 'internal.schweitz.net',
|
||||
'service': '192.168.86.100:80',
|
||||
'proxy_host_id': 4,
|
||||
'ssl_enabled': false,
|
||||
'certificate_id': null,
|
||||
},
|
||||
];
|
||||
|
||||
static Map<String, dynamic> domain(int index) =>
|
||||
Map<String, dynamic>.from(domains[index]);
|
||||
|
||||
// ============================================================
|
||||
// Proxy Hosts (raw NPM format)
|
||||
// ============================================================
|
||||
|
||||
static const proxyHosts = [
|
||||
{
|
||||
'id': 1,
|
||||
'domain_names': ['home.schweitz.net'],
|
||||
'forward_host': '192.168.86.149',
|
||||
'forward_port': 8092,
|
||||
'forward_scheme': 'http',
|
||||
'ssl_forced': true,
|
||||
'enabled': 1,
|
||||
'meta': {'nginx_online': true},
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'domain_names': ['api.schweitz.net'],
|
||||
'forward_host': '192.168.86.149',
|
||||
'forward_port': 8083,
|
||||
'forward_scheme': 'http',
|
||||
'ssl_forced': true,
|
||||
'enabled': 1,
|
||||
'meta': {'nginx_online': true},
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'domain_names': ['git.schweitz.net'],
|
||||
'forward_host': '192.168.86.149',
|
||||
'forward_port': 3000,
|
||||
'forward_scheme': 'http',
|
||||
'ssl_forced': true,
|
||||
'enabled': 1,
|
||||
'meta': {'nginx_online': true},
|
||||
},
|
||||
];
|
||||
|
||||
static Map<String, dynamic> proxyHost(int index) =>
|
||||
Map<String, dynamic>.from(proxyHosts[index]);
|
||||
|
||||
// ============================================================
|
||||
// Users (for UsersListPage DataGrid)
|
||||
// ============================================================
|
||||
|
||||
static const users = [
|
||||
{
|
||||
'id': 'user-001',
|
||||
'email': 'admin@example.com',
|
||||
'name': 'Admin User',
|
||||
'avatar_url': null,
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'last_login': '2024-01-15T10:30:00Z',
|
||||
'roles': ['admin.general:admin'],
|
||||
},
|
||||
{
|
||||
'id': 'user-002',
|
||||
'email': 'john@example.com',
|
||||
'name': 'John Doe',
|
||||
'avatar_url': null,
|
||||
'created_at': '2024-01-05T00:00:00Z',
|
||||
'last_login': '2024-01-14T08:00:00Z',
|
||||
'roles': ['security.users:viewer'],
|
||||
},
|
||||
{
|
||||
'id': 'user-003',
|
||||
'email': 'jane@example.com',
|
||||
'name': 'Jane Smith',
|
||||
'avatar_url': null,
|
||||
'created_at': '2024-01-10T00:00:00Z',
|
||||
'last_login': null,
|
||||
'roles': [],
|
||||
},
|
||||
];
|
||||
|
||||
static Map<String, dynamic> user(int index) =>
|
||||
Map<String, dynamic>.from(users[index]);
|
||||
|
||||
// ============================================================
|
||||
// Groups
|
||||
// ============================================================
|
||||
|
||||
static const groups = [
|
||||
{
|
||||
'id': 'group-001',
|
||||
'name': 'admins',
|
||||
'is_superuser': true,
|
||||
'member_count': 1,
|
||||
'parent_name': null,
|
||||
},
|
||||
{
|
||||
'id': 'group-002',
|
||||
'name': 'users',
|
||||
'is_superuser': false,
|
||||
'member_count': 3,
|
||||
'parent_name': null,
|
||||
},
|
||||
{
|
||||
'id': 'group-003',
|
||||
'name': 'developers',
|
||||
'is_superuser': false,
|
||||
'member_count': 1,
|
||||
'parent_name': 'users',
|
||||
},
|
||||
];
|
||||
|
||||
static Map<String, dynamic> group(int index) =>
|
||||
Map<String, dynamic>.from(groups[index]);
|
||||
|
||||
// ============================================================
|
||||
// Quick Links
|
||||
// ============================================================
|
||||
|
||||
static const quickLinks = [
|
||||
{
|
||||
'id': 1,
|
||||
'title': 'Portainer',
|
||||
'url': 'https://portainer.schweitz.net',
|
||||
'icon': 'dns',
|
||||
'category': 'Infrastructure',
|
||||
'position': 0,
|
||||
'is_visible': true,
|
||||
'link_type': 'iframe',
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'title': 'Gitea',
|
||||
'url': 'https://git.schweitz.net',
|
||||
'icon': 'code',
|
||||
'category': 'Development',
|
||||
'position': 1,
|
||||
'is_visible': true,
|
||||
'link_type': 'new_tab',
|
||||
},
|
||||
];
|
||||
|
||||
static Map<String, dynamic> quickLink(int index) =>
|
||||
Map<String, dynamic>.from(quickLinks[index]);
|
||||
|
||||
// ============================================================
|
||||
// Auth Sync Response
|
||||
// ============================================================
|
||||
|
||||
static Map<String, dynamic> authSyncResponse({
|
||||
String userId = 'user-001',
|
||||
String authentikId = 'ak-001',
|
||||
String name = 'Test User',
|
||||
String email = 'test@example.com',
|
||||
bool isAdmin = false,
|
||||
}) {
|
||||
return {
|
||||
'user_id': userId,
|
||||
'authentik_id': authentikId,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'avatar_url': null,
|
||||
'roles': isAdmin
|
||||
? [
|
||||
{
|
||||
'id': 'admin-role',
|
||||
'name': 'admin.general:admin',
|
||||
'domain': 'admin',
|
||||
'category': 'general',
|
||||
'action': 'admin',
|
||||
}
|
||||
]
|
||||
: [],
|
||||
'preferences': {
|
||||
'theme': 'system',
|
||||
'default_room': 'front-hall',
|
||||
'preferences_json': {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// System Stats
|
||||
// ============================================================
|
||||
|
||||
static const systemStats = {
|
||||
'cpu_percent': 23.5,
|
||||
'memory_percent': 45.2,
|
||||
'disk_percent': 67.8,
|
||||
'uptime_seconds': 864000,
|
||||
'container_count': 12,
|
||||
'container_running': 10,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
/// Mock Dio client for testing API interactions.
|
||||
///
|
||||
/// Allows registering canned responses for specific endpoints.
|
||||
class MockApiClient {
|
||||
MockApiClient() {
|
||||
_dio = Dio(BaseOptions(baseUrl: 'https://test.api'))
|
||||
..interceptors.add(_MockInterceptor(this));
|
||||
}
|
||||
|
||||
late final Dio _dio;
|
||||
final Map<String, _MockResponse> _responses = {};
|
||||
final List<RequestOptions> _requests = [];
|
||||
|
||||
/// Get the mock Dio instance.
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// Get all recorded requests.
|
||||
List<RequestOptions> get requests => List.unmodifiable(_requests);
|
||||
|
||||
/// Get the last request made.
|
||||
RequestOptions? get lastRequest => _requests.isEmpty ? null : _requests.last;
|
||||
|
||||
/// Clear all recorded requests.
|
||||
void clearRequests() => _requests.clear();
|
||||
|
||||
/// Register a mock response for an endpoint.
|
||||
///
|
||||
/// [method] is the HTTP method (GET, POST, PUT, DELETE).
|
||||
/// [path] is the endpoint path (can include wildcards like /users/*).
|
||||
/// [response] is the response data.
|
||||
/// [statusCode] is the HTTP status code (default 200).
|
||||
void when({
|
||||
required String method,
|
||||
required String path,
|
||||
required dynamic response,
|
||||
int statusCode = 200,
|
||||
Map<String, dynamic>? headers,
|
||||
}) {
|
||||
final key = '${method.toUpperCase()}:$path';
|
||||
_responses[key] = _MockResponse(
|
||||
data: response,
|
||||
statusCode: statusCode,
|
||||
headers: headers ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
/// Register a GET response.
|
||||
void whenGet(String path, dynamic response, {int statusCode = 200}) {
|
||||
when(method: 'GET', path: path, response: response, statusCode: statusCode);
|
||||
}
|
||||
|
||||
/// Register a POST response.
|
||||
void whenPost(String path, dynamic response, {int statusCode = 200}) {
|
||||
when(method: 'POST', path: path, response: response, statusCode: statusCode);
|
||||
}
|
||||
|
||||
/// Register a PUT response.
|
||||
void whenPut(String path, dynamic response, {int statusCode = 200}) {
|
||||
when(method: 'PUT', path: path, response: response, statusCode: statusCode);
|
||||
}
|
||||
|
||||
/// Register a DELETE response.
|
||||
void whenDelete(String path, dynamic response, {int statusCode = 200}) {
|
||||
when(method: 'DELETE', path: path, response: response, statusCode: statusCode);
|
||||
}
|
||||
|
||||
/// Register an error response.
|
||||
void whenError({
|
||||
required String method,
|
||||
required String path,
|
||||
required int statusCode,
|
||||
String? message,
|
||||
}) {
|
||||
when(
|
||||
method: method,
|
||||
path: path,
|
||||
response: {'error': message ?? 'Mock error'},
|
||||
statusCode: statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// Find a mock response for the given request.
|
||||
_MockResponse? _findResponse(String method, String path) {
|
||||
// Try exact match first
|
||||
final exactKey = '${method.toUpperCase()}:$path';
|
||||
if (_responses.containsKey(exactKey)) {
|
||||
return _responses[exactKey];
|
||||
}
|
||||
|
||||
// Try wildcard matches
|
||||
for (final entry in _responses.entries) {
|
||||
final pattern = entry.key;
|
||||
if (_matchesPattern(pattern, exactKey)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _matchesPattern(String pattern, String key) {
|
||||
// Simple wildcard matching: /users/* matches /users/123
|
||||
if (!pattern.contains('*')) return false;
|
||||
|
||||
final patternParts = pattern.split('/');
|
||||
final keyParts = key.split('/');
|
||||
|
||||
if (patternParts.length != keyParts.length) return false;
|
||||
|
||||
for (var i = 0; i < patternParts.length; i++) {
|
||||
if (patternParts[i] == '*') continue;
|
||||
if (patternParts[i] != keyParts[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Reset all mock responses and requests.
|
||||
void reset() {
|
||||
_responses.clear();
|
||||
_requests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _MockResponse {
|
||||
_MockResponse({
|
||||
required this.data,
|
||||
required this.statusCode,
|
||||
required this.headers,
|
||||
});
|
||||
|
||||
final dynamic data;
|
||||
final int statusCode;
|
||||
final Map<String, dynamic> headers;
|
||||
}
|
||||
|
||||
class _MockInterceptor extends Interceptor {
|
||||
_MockInterceptor(this._client);
|
||||
|
||||
final MockApiClient _client;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
_client._requests.add(options);
|
||||
|
||||
final response = _client._findResponse(options.method, options.path);
|
||||
|
||||
if (response != null) {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
handler.resolve(Response(
|
||||
requestOptions: options,
|
||||
data: response.data,
|
||||
statusCode: response.statusCode,
|
||||
headers: Headers.fromMap(
|
||||
response.headers.map((k, v) => MapEntry(k, [v.toString()])),
|
||||
),
|
||||
));
|
||||
} else {
|
||||
handler.reject(DioException(
|
||||
requestOptions: options,
|
||||
response: Response(
|
||||
requestOptions: options,
|
||||
data: response.data,
|
||||
statusCode: response.statusCode,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// No mock registered - return 404
|
||||
handler.reject(DioException(
|
||||
requestOptions: options,
|
||||
response: Response(
|
||||
requestOptions: options,
|
||||
data: {'error': 'No mock registered for ${options.method} ${options.path}'},
|
||||
statusCode: 404,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
message: 'No mock registered for ${options.method} ${options.path}',
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:tatlock_ui/core/auth/auth_state.dart';
|
||||
import 'package:tatlock_ui/core/auth/permissions.dart';
|
||||
import 'package:tatlock_ui/core/auth/user_preferences.dart';
|
||||
|
||||
/// Mock authentication states for testing.
|
||||
class MockAuth {
|
||||
MockAuth._();
|
||||
|
||||
/// Unauthenticated state.
|
||||
static const guest = AuthState();
|
||||
|
||||
/// Basic authenticated user with no special permissions.
|
||||
static AuthState user({
|
||||
String id = 'test-user-id',
|
||||
String name = 'Test User',
|
||||
String email = 'test@example.com',
|
||||
String defaultRoom = 'front-hall',
|
||||
String theme = 'system',
|
||||
}) {
|
||||
return AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: 'mock-access-token-${DateTime.now().millisecondsSinceEpoch}',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
userId: id,
|
||||
authentikId: 'authentik-$id',
|
||||
userName: name,
|
||||
userEmail: email,
|
||||
roles: [],
|
||||
preferences: UserPreferences(
|
||||
defaultRoom: defaultRoom,
|
||||
theme: theme,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Admin user with full permissions.
|
||||
static AuthState admin({
|
||||
String id = 'admin-user-id',
|
||||
String name = 'Admin User',
|
||||
String email = 'admin@example.com',
|
||||
}) {
|
||||
return AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: 'mock-admin-token-${DateTime.now().millisecondsSinceEpoch}',
|
||||
refreshToken: 'mock-admin-refresh-token',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
userId: id,
|
||||
authentikId: 'authentik-$id',
|
||||
userName: name,
|
||||
userEmail: email,
|
||||
roles: [
|
||||
Role(
|
||||
id: 'admin-role',
|
||||
name: 'admin.general:admin',
|
||||
domain: Domain.admin,
|
||||
category: 'general',
|
||||
action: Action.admin,
|
||||
),
|
||||
],
|
||||
preferences: const UserPreferences(),
|
||||
);
|
||||
}
|
||||
|
||||
/// User with specific permissions.
|
||||
static AuthState withPermissions({
|
||||
String id = 'perm-user-id',
|
||||
String name = 'Permission User',
|
||||
String email = 'perm@example.com',
|
||||
required List<Role> roles,
|
||||
}) {
|
||||
return AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: 'mock-perm-token-${DateTime.now().millisecondsSinceEpoch}',
|
||||
refreshToken: 'mock-perm-refresh-token',
|
||||
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
userId: id,
|
||||
authentikId: 'authentik-$id',
|
||||
userName: name,
|
||||
userEmail: email,
|
||||
roles: roles,
|
||||
preferences: const UserPreferences(),
|
||||
);
|
||||
}
|
||||
|
||||
/// User with expired token (for refresh testing).
|
||||
static AuthState expired({
|
||||
String id = 'expired-user-id',
|
||||
String name = 'Expired User',
|
||||
}) {
|
||||
return AuthState(
|
||||
isAuthenticated: true,
|
||||
accessToken: 'mock-expired-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresAt: DateTime.now().subtract(const Duration(hours: 1)),
|
||||
userId: id,
|
||||
userName: name,
|
||||
roles: [],
|
||||
preferences: const UserPreferences(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a role for testing.
|
||||
static Role role({
|
||||
String id = 'test-role',
|
||||
required Domain domain,
|
||||
String category = 'general',
|
||||
required Action action,
|
||||
}) {
|
||||
return Role(
|
||||
id: id,
|
||||
name: '${domain.value}.$category:${action.name}',
|
||||
domain: domain,
|
||||
category: category,
|
||||
action: action,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tatlock_ui/core/api/api_client.dart';
|
||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||
import 'package:tatlock_ui/core/auth/auth_state.dart';
|
||||
import 'package:tatlock_ui/core/theme/theme_provider.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
|
||||
// Import room routers for registry initialization
|
||||
import 'package:tatlock_ui/features/front_hall/router.dart';
|
||||
import 'package:tatlock_ui/features/control_room/router.dart';
|
||||
import 'package:tatlock_ui/features/security/router.dart';
|
||||
import 'package:tatlock_ui/features/parlor/router.dart';
|
||||
import 'package:tatlock_ui/features/media_room/router.dart';
|
||||
|
||||
import 'fixtures.dart';
|
||||
import 'mock_api_client.dart';
|
||||
import 'mock_auth.dart';
|
||||
|
||||
export 'fixtures.dart';
|
||||
export 'mock_api_client.dart';
|
||||
export 'mock_auth.dart';
|
||||
|
||||
/// Test harness for widget and integration tests.
|
||||
///
|
||||
/// Provides a consistent testing environment with:
|
||||
/// - Mock authentication state
|
||||
/// - Mock API client with fixtures
|
||||
/// - Room registry initialization
|
||||
/// - Provider overrides
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// void main() {
|
||||
/// final harness = TestHarness();
|
||||
///
|
||||
/// setUp(() => harness.setUp());
|
||||
/// tearDown(() => harness.tearDown());
|
||||
///
|
||||
/// testWidgets('example test', (tester) async {
|
||||
/// harness.givenAuthenticatedUser();
|
||||
/// harness.givenContainers(Fixtures.containers);
|
||||
///
|
||||
/// await tester.pumpWidget(harness.wrap(MyWidget()));
|
||||
/// // assertions...
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
class TestHarness {
|
||||
TestHarness();
|
||||
|
||||
/// Mock API client for registering responses.
|
||||
final api = MockApiClient();
|
||||
|
||||
/// Current auth state for the test.
|
||||
AuthState _authState = MockAuth.guest;
|
||||
|
||||
/// Current theme mode for the test.
|
||||
ThemeMode _themeMode = ThemeMode.light;
|
||||
|
||||
/// Set up the test environment.
|
||||
///
|
||||
/// Call this in setUp() for each test.
|
||||
Future<void> setUp() async {
|
||||
// Initialize SharedPreferences with empty values
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
// Initialize room registry if needed
|
||||
_initializeRoomRegistry();
|
||||
|
||||
// Reset state
|
||||
_authState = MockAuth.guest;
|
||||
_themeMode = ThemeMode.light;
|
||||
api.reset();
|
||||
}
|
||||
|
||||
/// Tear down the test environment.
|
||||
///
|
||||
/// Call this in tearDown() for each test.
|
||||
Future<void> tearDown() async {
|
||||
api.reset();
|
||||
}
|
||||
|
||||
void _initializeRoomRegistry() {
|
||||
if (roomRegistry.all.isEmpty) {
|
||||
registerFrontHall();
|
||||
registerControlRoom();
|
||||
registerSecurity();
|
||||
registerParlor();
|
||||
registerMediaRoom();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Given - Set up preconditions
|
||||
// ============================================================
|
||||
|
||||
/// Set up an unauthenticated (guest) user.
|
||||
void givenGuestUser() {
|
||||
_authState = MockAuth.guest;
|
||||
}
|
||||
|
||||
/// Set up an authenticated user.
|
||||
void givenAuthenticatedUser({
|
||||
String name = 'Test User',
|
||||
String email = 'test@example.com',
|
||||
String defaultRoom = 'front-hall',
|
||||
}) {
|
||||
_authState = MockAuth.user(
|
||||
name: name,
|
||||
email: email,
|
||||
defaultRoom: defaultRoom,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up an admin user.
|
||||
void givenAdminUser({
|
||||
String name = 'Admin User',
|
||||
String email = 'admin@example.com',
|
||||
}) {
|
||||
_authState = MockAuth.admin(name: name, email: email);
|
||||
}
|
||||
|
||||
/// Set up a custom auth state.
|
||||
void givenAuthState(AuthState state) {
|
||||
_authState = state;
|
||||
}
|
||||
|
||||
/// Set up mock containers response.
|
||||
void givenContainers([List<Map<String, dynamic>>? containers]) {
|
||||
api.whenGet(
|
||||
'/infrastructure/containers',
|
||||
containers ?? Fixtures.containers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up mock domains response (for ProxyHostsPage).
|
||||
void givenDomains([List<Map<String, dynamic>>? domains]) {
|
||||
api.whenGet(
|
||||
'/infrastructure/domains',
|
||||
domains ?? Fixtures.domains,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up mock proxy hosts response (raw NPM format).
|
||||
void givenProxyHosts([List<Map<String, dynamic>>? hosts]) {
|
||||
api.whenGet(
|
||||
'/infrastructure/npm/proxy-hosts',
|
||||
hosts ?? Fixtures.proxyHosts,
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up mock users response.
|
||||
void givenUsers([List<Map<String, dynamic>>? users]) {
|
||||
api.whenGet('/auth/users', users ?? Fixtures.users);
|
||||
}
|
||||
|
||||
/// Set up mock groups response.
|
||||
void givenGroups([List<Map<String, dynamic>>? groups]) {
|
||||
api.whenGet('/auth/groups', groups ?? Fixtures.groups);
|
||||
}
|
||||
|
||||
/// Set up mock quick links response.
|
||||
void givenQuickLinks([List<Map<String, dynamic>>? links]) {
|
||||
api.whenGet('/quick-links', links ?? Fixtures.quickLinks);
|
||||
}
|
||||
|
||||
/// Set up mock system stats response.
|
||||
void givenSystemStats([Map<String, dynamic>? stats]) {
|
||||
api.whenGet('/tools/system/stats', stats ?? Fixtures.systemStats);
|
||||
}
|
||||
|
||||
/// Set up theme mode.
|
||||
void givenThemeMode(ThemeMode mode) {
|
||||
_themeMode = mode;
|
||||
}
|
||||
|
||||
/// Set up an API error response.
|
||||
void givenApiError({
|
||||
required String method,
|
||||
required String path,
|
||||
int statusCode = 500,
|
||||
String? message,
|
||||
}) {
|
||||
api.whenError(
|
||||
method: method,
|
||||
path: path,
|
||||
statusCode: statusCode,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Wrap - Create test widget with providers
|
||||
// ============================================================
|
||||
|
||||
/// Wrap a widget with the test harness providers.
|
||||
///
|
||||
/// This sets up:
|
||||
/// - ProviderScope with auth, theme, and API overrides
|
||||
/// - MaterialApp with theme
|
||||
/// - Scaffold for proper widget context
|
||||
Widget wrap(Widget child, {bool useScaffold = true}) {
|
||||
final content = useScaffold ? Scaffold(body: child) : child;
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
// Override auth provider to return our mock state
|
||||
authProvider.overrideWith(() => _MockAuthNotifier(_authState)),
|
||||
// Override theme provider
|
||||
themeProvider.overrideWith(() => _MockThemeNotifier(_themeMode)),
|
||||
// Override API client to use mock
|
||||
coreApiClientProvider.overrideWithValue(api.dio),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.light(useMaterial3: true),
|
||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||
themeMode: _themeMode,
|
||||
home: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrap with a custom route for navigation testing.
|
||||
Widget wrapWithRouter(Widget child) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
authProvider.overrideWith(() => _MockAuthNotifier(_authState)),
|
||||
themeProvider.overrideWith(() => _MockThemeNotifier(_themeMode)),
|
||||
coreApiClientProvider.overrideWithValue(api.dio),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: ThemeData.light(useMaterial3: true),
|
||||
darkTheme: ThemeData.dark(useMaterial3: true),
|
||||
themeMode: _themeMode,
|
||||
home: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Verify - Check assertions
|
||||
// ============================================================
|
||||
|
||||
/// Verify that a specific API endpoint was called.
|
||||
void verifyApiCalled(String method, String path) {
|
||||
final called = api.requests.any(
|
||||
(r) => r.method.toUpperCase() == method.toUpperCase() && r.path == path,
|
||||
);
|
||||
expect(called, isTrue, reason: 'Expected $method $path to be called');
|
||||
}
|
||||
|
||||
/// Verify that no API calls were made.
|
||||
void verifyNoApiCalls() {
|
||||
expect(api.requests, isEmpty, reason: 'Expected no API calls');
|
||||
}
|
||||
|
||||
/// Get the request body of the last API call.
|
||||
dynamic getLastRequestBody() {
|
||||
return api.lastRequest?.data;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock auth notifier that returns a fixed state.
|
||||
class _MockAuthNotifier extends AuthNotifier {
|
||||
_MockAuthNotifier(this._state);
|
||||
final AuthState _state;
|
||||
|
||||
@override
|
||||
Future<AuthState> build() async => _state;
|
||||
}
|
||||
|
||||
/// Mock theme notifier that returns a fixed theme.
|
||||
class _MockThemeNotifier extends ThemeNotifier {
|
||||
_MockThemeNotifier(this._mode);
|
||||
final ThemeMode _mode;
|
||||
|
||||
@override
|
||||
ThemeSetting build() {
|
||||
return switch (_mode) {
|
||||
ThemeMode.light => ThemeSetting.light,
|
||||
ThemeMode.dark => ThemeSetting.dark,
|
||||
ThemeMode.system => ThemeSetting.system,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
|
||||
import '../harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
group('RoomDefinition', () {
|
||||
test('creates room with all required fields', () {
|
||||
final room = RoomDefinition(
|
||||
id: 'test-room',
|
||||
label: 'Test Room',
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home,
|
||||
defaultRoute: '/test-room',
|
||||
routes: () => [],
|
||||
);
|
||||
|
||||
expect(room.id, 'test-room');
|
||||
expect(room.label, 'Test Room');
|
||||
expect(room.icon, Icons.home_outlined);
|
||||
expect(room.selectedIcon, Icons.home);
|
||||
expect(room.defaultRoute, '/test-room');
|
||||
expect(room.requiredPermissions, isEmpty);
|
||||
});
|
||||
|
||||
test('creates room with permissions', () {
|
||||
final room = RoomDefinition(
|
||||
id: 'admin-room',
|
||||
label: 'Admin Room',
|
||||
icon: Icons.admin_panel_settings_outlined,
|
||||
selectedIcon: Icons.admin_panel_settings,
|
||||
defaultRoute: '/admin',
|
||||
routes: () => [],
|
||||
requiredPermissions: ['admin:access', 'admin:read'],
|
||||
);
|
||||
|
||||
expect(room.requiredPermissions.length, 2);
|
||||
expect(room.requiredPermissions, contains('admin:access'));
|
||||
});
|
||||
|
||||
test('routes function returns RouteBase list', () {
|
||||
final room = RoomDefinition(
|
||||
id: 'test-room',
|
||||
label: 'Test Room',
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home,
|
||||
defaultRoute: '/test-room',
|
||||
routes: () => [
|
||||
GoRoute(
|
||||
path: '/test-room',
|
||||
builder: (context, state) => const SizedBox(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final routes = room.routes();
|
||||
expect(routes.length, 1);
|
||||
expect(routes.first, isA<GoRoute>());
|
||||
});
|
||||
});
|
||||
|
||||
group('RoomRegistry', () {
|
||||
test('all returns registered rooms', () {
|
||||
// Rooms are registered by harness.setUp()
|
||||
final rooms = roomRegistry.all;
|
||||
expect(rooms, isNotEmpty);
|
||||
});
|
||||
|
||||
test('byId returns room for valid ID', () {
|
||||
final room = roomRegistry.byId('front-hall');
|
||||
expect(room, isNotNull);
|
||||
expect(room!.label, 'Front Hall');
|
||||
});
|
||||
|
||||
test('byId returns null for invalid ID', () {
|
||||
final room = roomRegistry.byId('non-existent-room');
|
||||
expect(room, isNull);
|
||||
});
|
||||
|
||||
test('indexOfId returns correct index', () {
|
||||
final index = roomRegistry.indexOfId('front-hall');
|
||||
expect(index, 0);
|
||||
|
||||
final controlRoomIndex = roomRegistry.indexOfId('control-room');
|
||||
expect(controlRoomIndex, 1);
|
||||
});
|
||||
|
||||
test('indexOfId returns -1 for invalid ID', () {
|
||||
final index = roomRegistry.indexOfId('non-existent');
|
||||
expect(index, -1);
|
||||
});
|
||||
|
||||
test('indexOfRoute matches exact route', () {
|
||||
final index = roomRegistry.indexOfRoute('/front-hall');
|
||||
expect(index, 0);
|
||||
});
|
||||
|
||||
test('indexOfRoute matches sub-routes', () {
|
||||
final index = roomRegistry.indexOfRoute('/control-room/containers');
|
||||
expect(index, 1);
|
||||
|
||||
final securityIndex = roomRegistry.indexOfRoute('/security/users');
|
||||
expect(securityIndex, 2);
|
||||
});
|
||||
|
||||
test('indexOfRoute returns 0 for unknown route', () {
|
||||
final index = roomRegistry.indexOfRoute('/unknown-route');
|
||||
expect(index, 0); // Defaults to first room
|
||||
});
|
||||
|
||||
test('allRoutes returns routes from all rooms', () {
|
||||
final routes = roomRegistry.allRoutes();
|
||||
expect(routes, isNotEmpty);
|
||||
// Should have routes from all registered rooms
|
||||
});
|
||||
|
||||
test('accessibleTo filters by permissions', () {
|
||||
// All current rooms have no required permissions
|
||||
final accessible = roomRegistry.accessibleTo({});
|
||||
expect(accessible.length, roomRegistry.all.length);
|
||||
});
|
||||
|
||||
test('accessibleTo returns all rooms for empty permissions when no rooms require permissions', () {
|
||||
final accessible = roomRegistry.accessibleTo({'some:permission'});
|
||||
// All rooms should be accessible since none require permissions
|
||||
expect(accessible.length, roomRegistry.all.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('Room Registration Order', () {
|
||||
test('front-hall is first', () {
|
||||
expect(roomRegistry.all[0].id, 'front-hall');
|
||||
});
|
||||
|
||||
test('control-room is second', () {
|
||||
expect(roomRegistry.all[1].id, 'control-room');
|
||||
});
|
||||
|
||||
test('security is third', () {
|
||||
expect(roomRegistry.all[2].id, 'security');
|
||||
});
|
||||
|
||||
test('parlor is fourth', () {
|
||||
expect(roomRegistry.all[3].id, 'parlor');
|
||||
});
|
||||
|
||||
test('media-room is fifth', () {
|
||||
expect(roomRegistry.all[4].id, 'media-room');
|
||||
});
|
||||
});
|
||||
|
||||
group('Room Default Routes', () {
|
||||
test('front-hall default route is /front-hall', () {
|
||||
final room = roomRegistry.byId('front-hall');
|
||||
expect(room?.defaultRoute, '/front-hall');
|
||||
});
|
||||
|
||||
test('control-room default route is /control-room/containers', () {
|
||||
final room = roomRegistry.byId('control-room');
|
||||
expect(room?.defaultRoute, '/control-room/containers');
|
||||
});
|
||||
|
||||
test('security default route is /security/users', () {
|
||||
final room = roomRegistry.byId('security');
|
||||
expect(room?.defaultRoute, '/security/users');
|
||||
});
|
||||
|
||||
test('parlor default route is /parlor', () {
|
||||
final room = roomRegistry.byId('parlor');
|
||||
expect(room?.defaultRoute, '/parlor');
|
||||
});
|
||||
|
||||
test('media-room default route is /media-room', () {
|
||||
final room = roomRegistry.byId('media-room');
|
||||
expect(room?.defaultRoute, '/media-room');
|
||||
});
|
||||
});
|
||||
|
||||
group('Room Icons', () {
|
||||
test('each room has distinct icons', () {
|
||||
final rooms = roomRegistry.all;
|
||||
final icons = rooms.map((r) => r.icon).toSet();
|
||||
final selectedIcons = rooms.map((r) => r.selectedIcon).toSet();
|
||||
|
||||
// All icons should be unique
|
||||
expect(icons.length, rooms.length);
|
||||
expect(selectedIcons.length, rooms.length);
|
||||
});
|
||||
|
||||
test('icon and selectedIcon are different for each room', () {
|
||||
for (final room in roomRegistry.all) {
|
||||
expect(room.icon, isNot(equals(room.selectedIcon)),
|
||||
reason: '${room.label} should have different icon and selectedIcon');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/data_grid_column.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/data_grid_config.dart';
|
||||
import 'package:tatlock_ui/shared/components/data_grid/data_grid_state.dart';
|
||||
|
||||
import '../../../harness/test_harness.dart';
|
||||
|
||||
/// Test data class for DataGrid tests.
|
||||
class TestItem {
|
||||
const TestItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.value,
|
||||
this.isActive = true,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final int value;
|
||||
final bool isActive;
|
||||
}
|
||||
|
||||
void main() {
|
||||
final harness = TestHarness();
|
||||
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
group('DataGridColumn', () {
|
||||
test('creates column with required fields', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
);
|
||||
|
||||
expect(column.id, 'name');
|
||||
expect(column.header, 'Name');
|
||||
expect(column.sortable, isFalse);
|
||||
expect(column.searchable, isFalse);
|
||||
});
|
||||
|
||||
test('creates sortable column', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
sortable: true,
|
||||
);
|
||||
|
||||
expect(column.sortable, isTrue);
|
||||
});
|
||||
|
||||
test('creates searchable column', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
searchable: true,
|
||||
);
|
||||
|
||||
expect(column.searchable, isTrue);
|
||||
});
|
||||
|
||||
test('valueBuilder extracts correct value', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
);
|
||||
|
||||
final item = TestItem(id: '1', name: 'Test Item', value: 42);
|
||||
expect(column.valueBuilder(item), 'Test Item');
|
||||
});
|
||||
|
||||
test('supports fixed width', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
valueBuilder: (item) => item.id,
|
||||
width: const DataGridColumnWidth.fixed(100),
|
||||
);
|
||||
|
||||
expect(column.width, isA<GridFixedWidth>());
|
||||
expect((column.width as GridFixedWidth).width, 100);
|
||||
});
|
||||
|
||||
test('supports flex width', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
width: const DataGridColumnWidth.flex(2),
|
||||
);
|
||||
|
||||
expect(column.width, isA<GridFlexWidth>());
|
||||
expect((column.width as GridFlexWidth).flex, 2);
|
||||
});
|
||||
|
||||
test('default width is flex(1)', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
);
|
||||
|
||||
expect(column.width, isA<GridFlexWidth>());
|
||||
expect((column.width as GridFlexWidth).flex, 1);
|
||||
});
|
||||
|
||||
test('supports custom alignment', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'value',
|
||||
header: 'Value',
|
||||
valueBuilder: (item) => item.value.toString(),
|
||||
alignment: DataGridColumnAlignment.end,
|
||||
);
|
||||
|
||||
expect(column.alignment, DataGridColumnAlignment.end);
|
||||
expect(column.textAlign, TextAlign.end);
|
||||
expect(column.crossAxisAlignment, CrossAxisAlignment.end);
|
||||
});
|
||||
|
||||
test('effectiveSortField defaults to lowercase header', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Full Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
);
|
||||
|
||||
expect(column.effectiveSortField, 'full name');
|
||||
});
|
||||
|
||||
test('effectiveSortField uses sortField when provided', () {
|
||||
final column = DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Full Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
sortField: 'name',
|
||||
);
|
||||
|
||||
expect(column.effectiveSortField, 'name');
|
||||
});
|
||||
});
|
||||
|
||||
group('DataGridConfig', () {
|
||||
test('creates config with columns', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
valueBuilder: (item) => item.id,
|
||||
),
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(config.columns.length, 2);
|
||||
});
|
||||
|
||||
test('enableSearch defaults to false', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [],
|
||||
);
|
||||
|
||||
expect(config.enableSearch, isFalse);
|
||||
});
|
||||
|
||||
test('enables search when specified', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
searchable: true,
|
||||
),
|
||||
],
|
||||
enableSearch: true,
|
||||
);
|
||||
|
||||
expect(config.enableSearch, isTrue);
|
||||
});
|
||||
|
||||
test('supports custom search hint', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [],
|
||||
enableSearch: true,
|
||||
searchHint: 'Search items...',
|
||||
);
|
||||
|
||||
expect(config.searchHint, 'Search items...');
|
||||
});
|
||||
|
||||
test('supports row selection mode', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [],
|
||||
rowsSelectable: true,
|
||||
);
|
||||
|
||||
expect(config.rowsSelectable, isTrue);
|
||||
});
|
||||
|
||||
test('visibleColumns filters hidden columns', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
valueBuilder: (item) => item.id,
|
||||
visible: false,
|
||||
),
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(config.visibleColumns.length, 1);
|
||||
expect(config.visibleColumns.first.id, 'name');
|
||||
});
|
||||
|
||||
test('searchableColumnIndices returns correct indices', () {
|
||||
final config = DataGridConfig<TestItem>(
|
||||
columns: [
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'id',
|
||||
header: 'ID',
|
||||
valueBuilder: (item) => item.id,
|
||||
),
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
valueBuilder: (item) => item.name,
|
||||
searchable: true,
|
||||
),
|
||||
DataGridColumn<TestItem>(
|
||||
id: 'value',
|
||||
header: 'Value',
|
||||
valueBuilder: (item) => item.value.toString(),
|
||||
searchable: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(config.searchableColumnIndices, [1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
group('DataGridState', () {
|
||||
test('creates initial state with defaults', () {
|
||||
const state = DataGridState<TestItem>();
|
||||
|
||||
expect(state.items, isEmpty);
|
||||
expect(state.isLoading, isFalse);
|
||||
expect(state.selectedIds, isEmpty);
|
||||
expect(state.searchQuery, isEmpty);
|
||||
expect(state.isInitialLoad, isTrue);
|
||||
});
|
||||
|
||||
test('creates loading state', () {
|
||||
const state = DataGridState<TestItem>(
|
||||
isLoading: true,
|
||||
);
|
||||
|
||||
expect(state.isLoading, isTrue);
|
||||
});
|
||||
|
||||
test('stores items', () {
|
||||
final items = [
|
||||
const TestItem(id: '1', name: 'Item 1', value: 10),
|
||||
const TestItem(id: '2', name: 'Item 2', value: 20),
|
||||
];
|
||||
|
||||
final state = DataGridState<TestItem>(
|
||||
items: items,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
expect(state.items.length, 2);
|
||||
expect(state.items[0].name, 'Item 1');
|
||||
});
|
||||
|
||||
test('stores selected IDs', () {
|
||||
const state = DataGridState<TestItem>(
|
||||
selectedIds: {'1', '2', '3'},
|
||||
);
|
||||
|
||||
expect(state.selectedIds.length, 3);
|
||||
expect(state.selectedIds.contains('1'), isTrue);
|
||||
});
|
||||
|
||||
test('stores search query', () {
|
||||
const state = DataGridState<TestItem>(
|
||||
searchQuery: 'test query',
|
||||
);
|
||||
|
||||
expect(state.searchQuery, 'test query');
|
||||
});
|
||||
|
||||
test('stores sort column index', () {
|
||||
const state = DataGridState<TestItem>(
|
||||
sortColumnIndex: 1,
|
||||
sortDescending: true,
|
||||
);
|
||||
|
||||
expect(state.sortColumnIndex, 1);
|
||||
expect(state.sortDescending, isTrue);
|
||||
});
|
||||
|
||||
test('stores error', () {
|
||||
const state = DataGridState<TestItem>(
|
||||
error: 'Something went wrong',
|
||||
);
|
||||
|
||||
expect(state.error, 'Something went wrong');
|
||||
});
|
||||
|
||||
test('stores pagination info', () {
|
||||
const state = DataGridState<TestItem>(
|
||||
currentPage: 2,
|
||||
totalCount: 100,
|
||||
);
|
||||
|
||||
expect(state.currentPage, 2);
|
||||
expect(state.totalCount, 100);
|
||||
});
|
||||
});
|
||||
|
||||
group('DataGridColumnWidth', () {
|
||||
test('fixed width creates GridFixedWidth', () {
|
||||
const width = DataGridColumnWidth.fixed(150);
|
||||
expect(width, isA<GridFixedWidth>());
|
||||
expect((width as GridFixedWidth).width, 150);
|
||||
});
|
||||
|
||||
test('flex width creates GridFlexWidth', () {
|
||||
const width = DataGridColumnWidth.flex(2);
|
||||
expect(width, isA<GridFlexWidth>());
|
||||
expect((width as GridFlexWidth).flex, 2);
|
||||
});
|
||||
|
||||
test('default flex is 1', () {
|
||||
const width = DataGridColumnWidth.flex();
|
||||
expect((width as GridFlexWidth).flex, 1);
|
||||
});
|
||||
|
||||
test('fraction width creates GridFractionWidth', () {
|
||||
const width = DataGridColumnWidth.fraction(0.5);
|
||||
expect(width, isA<GridFractionWidth>());
|
||||
expect((width as GridFractionWidth).fraction, 0.5);
|
||||
});
|
||||
});
|
||||
|
||||
group('DataGridColumnAlignment', () {
|
||||
test('has start alignment', () {
|
||||
expect(DataGridColumnAlignment.start, isNotNull);
|
||||
});
|
||||
|
||||
test('has center alignment', () {
|
||||
expect(DataGridColumnAlignment.center, isNotNull);
|
||||
});
|
||||
|
||||
test('has end alignment', () {
|
||||
expect(DataGridColumnAlignment.end, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('DataGridDataMode', () {
|
||||
test('all mode', () {
|
||||
const mode = DataGridDataMode.all();
|
||||
expect(mode, isA<AllDataMode>());
|
||||
});
|
||||
|
||||
test('paginated mode with default page size', () {
|
||||
const mode = DataGridDataMode.paginated();
|
||||
expect(mode, isA<PaginatedDataMode>());
|
||||
expect((mode as PaginatedDataMode).pageSize, 25);
|
||||
});
|
||||
|
||||
test('paginated mode with custom page size', () {
|
||||
const mode = DataGridDataMode.paginated(pageSize: 50);
|
||||
expect((mode as PaginatedDataMode).pageSize, 50);
|
||||
});
|
||||
|
||||
test('infinite mode with defaults', () {
|
||||
const mode = DataGridDataMode.infinite();
|
||||
expect(mode, isA<InfiniteDataMode>());
|
||||
expect((mode as InfiniteDataMode).initialLoad, 50);
|
||||
expect((mode as InfiniteDataMode).loadMoreThreshold, 10);
|
||||
});
|
||||
|
||||
test('infinite mode with custom values', () {
|
||||
const mode = DataGridDataMode.infinite(
|
||||
initialLoad: 100,
|
||||
loadMoreThreshold: 20,
|
||||
);
|
||||
expect((mode as InfiniteDataMode).initialLoad, 100);
|
||||
expect((mode as InfiniteDataMode).loadMoreThreshold, 20);
|
||||
});
|
||||
});
|
||||
}
|
||||
+172
-11
@@ -1,19 +1,180 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
|
||||
import 'package:tatlock_ui/app.dart';
|
||||
import 'harness/test_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App renders dashboard', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(
|
||||
child: TatlockApp(),
|
||||
),
|
||||
);
|
||||
final harness = TestHarness();
|
||||
|
||||
// Wait for async operations
|
||||
await tester.pumpAndSettle();
|
||||
setUp(() => harness.setUp());
|
||||
tearDown(() => harness.tearDown());
|
||||
|
||||
expect(find.text('Welcome to Tatlock'), findsOneWidget);
|
||||
group('Room Registry', () {
|
||||
test('is initialized with all rooms', () {
|
||||
expect(roomRegistry.all.isNotEmpty, isTrue);
|
||||
expect(roomRegistry.all.length, greaterThanOrEqualTo(5));
|
||||
});
|
||||
|
||||
test('contains expected rooms', () {
|
||||
final roomIds = roomRegistry.all.map((r) => r.id).toList();
|
||||
|
||||
expect(roomIds, contains('front-hall'));
|
||||
expect(roomIds, contains('control-room'));
|
||||
expect(roomIds, contains('security'));
|
||||
expect(roomIds, contains('parlor'));
|
||||
expect(roomIds, contains('media-room'));
|
||||
});
|
||||
|
||||
test('returns correct index for route', () {
|
||||
expect(roomRegistry.indexOfRoute('/front-hall'), 0);
|
||||
expect(roomRegistry.indexOfRoute('/control-room/containers'), 1);
|
||||
expect(roomRegistry.indexOfRoute('/security/users'), 2);
|
||||
expect(roomRegistry.indexOfRoute('/parlor'), 3);
|
||||
expect(roomRegistry.indexOfRoute('/media-room'), 4);
|
||||
});
|
||||
|
||||
test('finds room by ID', () {
|
||||
final frontHall = roomRegistry.byId('front-hall');
|
||||
expect(frontHall, isNotNull);
|
||||
expect(frontHall!.label, 'Front Hall');
|
||||
expect(frontHall.defaultRoute, '/front-hall');
|
||||
|
||||
final controlRoom = roomRegistry.byId('control-room');
|
||||
expect(controlRoom, isNotNull);
|
||||
expect(controlRoom!.label, 'Control Room');
|
||||
|
||||
final nonExistent = roomRegistry.byId('non-existent');
|
||||
expect(nonExistent, isNull);
|
||||
});
|
||||
|
||||
test('returns default route for room', () {
|
||||
final frontHall = roomRegistry.byId('front-hall');
|
||||
expect(frontHall?.defaultRoute, '/front-hall');
|
||||
|
||||
final controlRoom = roomRegistry.byId('control-room');
|
||||
expect(controlRoom?.defaultRoute, '/control-room/containers');
|
||||
|
||||
final security = roomRegistry.byId('security');
|
||||
expect(security?.defaultRoute, '/security/users');
|
||||
});
|
||||
});
|
||||
|
||||
group('Test Harness', () {
|
||||
test('provides guest auth state by default', () async {
|
||||
harness.givenGuestUser();
|
||||
expect(MockAuth.guest.isAuthenticated, isFalse);
|
||||
});
|
||||
|
||||
test('provides authenticated user state', () {
|
||||
harness.givenAuthenticatedUser(name: 'John Doe');
|
||||
// The auth state is set internally, verified via wrap()
|
||||
});
|
||||
|
||||
test('provides admin user state', () {
|
||||
harness.givenAdminUser();
|
||||
final adminState = MockAuth.admin();
|
||||
expect(adminState.isGlobalAdmin, isTrue);
|
||||
});
|
||||
|
||||
test('mock API client registers responses', () {
|
||||
harness.givenContainers();
|
||||
// Verify the mock is registered (would be called during widget tests)
|
||||
});
|
||||
});
|
||||
|
||||
group('Mock Auth', () {
|
||||
test('guest state is not authenticated', () {
|
||||
expect(MockAuth.guest.isAuthenticated, isFalse);
|
||||
expect(MockAuth.guest.accessToken, isNull);
|
||||
});
|
||||
|
||||
test('user state is authenticated', () {
|
||||
final user = MockAuth.user();
|
||||
expect(user.isAuthenticated, isTrue);
|
||||
expect(user.accessToken, isNotNull);
|
||||
expect(user.userName, 'Test User');
|
||||
});
|
||||
|
||||
test('admin state has admin role', () {
|
||||
final admin = MockAuth.admin();
|
||||
expect(admin.isAuthenticated, isTrue);
|
||||
expect(admin.isGlobalAdmin, isTrue);
|
||||
expect(admin.roles.length, 1);
|
||||
});
|
||||
|
||||
test('expired state has past expiry', () {
|
||||
final expired = MockAuth.expired();
|
||||
expect(expired.isTokenExpired, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Mock API Client', () {
|
||||
test('returns registered response', () async {
|
||||
final client = MockApiClient();
|
||||
client.whenGet('/test', {'message': 'hello'});
|
||||
|
||||
final response = await client.dio.get<Map<String, dynamic>>('/test');
|
||||
expect(response.data?['message'], 'hello');
|
||||
});
|
||||
|
||||
test('returns 404 for unregistered endpoint', () async {
|
||||
final client = MockApiClient();
|
||||
|
||||
expect(
|
||||
() => client.dio.get('/unknown'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('records requests', () async {
|
||||
final client = MockApiClient();
|
||||
client.whenGet('/test1', {});
|
||||
client.whenGet('/test2', {});
|
||||
|
||||
await client.dio.get('/test1');
|
||||
await client.dio.get('/test2');
|
||||
|
||||
expect(client.requests.length, 2);
|
||||
expect(client.requests[0].path, '/test1');
|
||||
expect(client.requests[1].path, '/test2');
|
||||
});
|
||||
|
||||
test('supports error responses', () async {
|
||||
final client = MockApiClient();
|
||||
client.whenError(method: 'GET', path: '/error', statusCode: 500);
|
||||
|
||||
expect(
|
||||
() => client.dio.get('/error'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Fixtures', () {
|
||||
test('provides container data', () {
|
||||
expect(Fixtures.containers.length, 4);
|
||||
expect(Fixtures.containers[0]['Names'], contains('/nginx-proxy'));
|
||||
});
|
||||
|
||||
test('provides proxy host data', () {
|
||||
expect(Fixtures.proxyHosts.length, 3);
|
||||
expect(Fixtures.proxyHosts[0]['domain_names'], contains('home.schweitz.net'));
|
||||
});
|
||||
|
||||
test('provides user data', () {
|
||||
expect(Fixtures.users.length, 3);
|
||||
expect(Fixtures.users[0]['name'], 'Admin User');
|
||||
});
|
||||
|
||||
test('provides group data', () {
|
||||
expect(Fixtures.groups.length, 3);
|
||||
expect(Fixtures.groups[0]['name'], 'admins');
|
||||
});
|
||||
|
||||
test('provides auth sync response', () {
|
||||
final response = Fixtures.authSyncResponse(name: 'Test');
|
||||
expect(response['name'], 'Test');
|
||||
expect(response['preferences'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user