main
dart 191 lines 5.56 KB
Raw
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2 import 'package:flutter_test/flutter_test.dart';
3 import 'package:localmind/core/models/enums.dart';
4 import 'package:localmind/core/providers/storage_providers.dart';
5 import 'package:localmind/core/storage/entities.dart';
6 import 'package:localmind/core/storage/objectbox_store.dart';
7 import 'package:localmind/features/servers/data/models/server.dart';
8 import 'package:localmind/features/servers/providers/server_providers.dart';
9 import 'package:localmind/objectbox.g.dart';
10
11 void main() {
12 late _FakeServerBox fakeServerBox;
13 late _FakeObjectBoxStore fakeStore;
14
15 setUp(() {
16 fakeServerBox = _FakeServerBox();
17 fakeStore = _FakeObjectBoxStore(fakeServerBox);
18 });
19
20 test(
21 'ServersNotifier._loadAll deduplicates servers with duplicate id and heals typeIndex in ObjectBox',
22 () async {
23 final now = DateTime(2026, 1, 1);
24
25 // Seed duplicate on-device entities:
26 // Entity 1: corrupted typeIndex 4 (shifted from onDevice to openRouter)
27 final corruptedEntity = ServerEntity(
28 internalId: 1,
29 id: 'on-device',
30 name: 'On-Device',
31 typeIndex: 4,
32 host: '',
33 port: 0,
34 createdAt: now,
35 lastConnectedAt: now,
36 statusIndex: 1,
37 );
38 // Entity 2: duplicate on-device inserted by bootstrap with typeIndex 5
39 final duplicateEntity = ServerEntity(
40 internalId: 2,
41 id: 'on-device',
42 name: 'On-Device',
43 typeIndex: 5,
44 host: '',
45 port: 0,
46 createdAt: now,
47 lastConnectedAt: now,
48 statusIndex: 1,
49 );
50
51 fakeServerBox.put(corruptedEntity);
52 fakeServerBox.put(duplicateEntity);
53 expect(fakeServerBox.count(), 2);
54
55 final container = ProviderContainer(
56 overrides: [databaseProvider.overrideWithValue(fakeStore)],
57 );
58 addTearDown(container.dispose);
59
60 final servers = await container.read(serversProvider.future);
61
62 // Exactly one on-device server should be returned
63 expect(servers.length, 1);
64 final onDevice = servers.first;
65 expect(onDevice.id, 'on-device');
66 expect(onDevice.type, ServerType.onDevice);
67 expect(onDevice.isOnDevice, isTrue);
68
69 // The database itself should now only have 1 entity, with healed typeIndex 5
70 expect(fakeServerBox.count(), 1);
71 final persisted = fakeServerBox.getAll().first;
72 expect(persisted.id, 'on-device');
73 expect(persisted.typeIndex, ServerType.onDevice.index);
74 },
75 );
76
77 test(
78 'ServersNotifier._loadAll heals shifted typeIndex 4 when single on-device record exists',
79 () async {
80 final now = DateTime(2026, 1, 1);
81 final corruptedEntity = ServerEntity(
82 internalId: 1,
83 id: 'on-device',
84 name: 'On-Device',
85 typeIndex: 4, // Was onDevice, shifted to openRouter
86 host: '',
87 port: 0,
88 createdAt: now,
89 lastConnectedAt: now,
90 statusIndex: 1,
91 );
92
93 fakeServerBox.put(corruptedEntity);
94
95 final container = ProviderContainer(
96 overrides: [databaseProvider.overrideWithValue(fakeStore)],
97 );
98 addTearDown(container.dispose);
99
100 final servers = await container.read(serversProvider.future);
101
102 expect(servers.length, 1);
103 expect(servers.first.type, ServerType.onDevice);
104 expect(fakeServerBox.getAll().first.typeIndex, ServerType.onDevice.index);
105 },
106 );
107
108 test(
109 'ServersNotifier.addServer is idempotent and does not create duplicates for existing id',
110 () async {
111 final now = DateTime(2026, 1, 1);
112 final server = Server(
113 id: 'on-device',
114 name: 'On-Device',
115 type: ServerType.onDevice,
116 host: '',
117 port: 0,
118 createdAt: now,
119 lastConnectedAt: now,
120 status: ConnectionStatus.connected,
121 );
122
123 final container = ProviderContainer(
124 overrides: [databaseProvider.overrideWithValue(fakeStore)],
125 );
126 addTearDown(container.dispose);
127
128 // Call addServer twice
129 await container.read(serversProvider.notifier).addServer(server);
130 await container.read(serversProvider.notifier).addServer(server);
131
132 final servers = await container.read(serversProvider.future);
133 expect(servers.length, 1);
134 expect(fakeServerBox.count(), 1);
135 },
136 );
137 }
138
139 class _FakeServerBox extends Fake implements Box<ServerEntity> {
140 final List<ServerEntity> _entities;
141 int _nextId = 1;
142
143 _FakeServerBox([List<ServerEntity>? initial])
144 : _entities = List<ServerEntity>.from(initial ?? []) {
145 for (final e in _entities) {
146 if (e.internalId == 0) {
147 e.internalId = _nextId++;
148 } else if (e.internalId >= _nextId) {
149 _nextId = e.internalId + 1;
150 }
151 }
152 }
153
154 @override
155 List<ServerEntity> getAll() => List<ServerEntity>.from(_entities);
156
157 @override
158 int put(ServerEntity entity, {PutMode mode = PutMode.put}) {
159 if (entity.internalId == 0) {
160 entity.internalId = _nextId++;
161 _entities.add(entity);
162 } else {
163 final idx = _entities.indexWhere(
164 (e) => e.internalId == entity.internalId,
165 );
166 if (idx >= 0) {
167 _entities[idx] = entity;
168 } else {
169 _entities.add(entity);
170 }
171 }
172 return entity.internalId;
173 }
174
175 @override
176 bool remove(int id) {
177 final before = _entities.length;
178 _entities.removeWhere((e) => e.internalId == id);
179 return _entities.length < before;
180 }
181
182 @override
183 int count({int limit = 0}) => _entities.length;
184 }
185
186 class _FakeObjectBoxStore extends Fake implements ObjectBoxStore {
187 _FakeObjectBoxStore(this.serverBox);
188
189 @override
190 final Box<ServerEntity> serverBox;
191 }