Skip to content

Commit 8bb747a

Browse files
Doug Borgclaude
authored andcommitted
Expand test coverage for auth and suspected location
AuthService: - restoreLoginLocal: simulate mode not-logged-in, cross-mode isolation - Round-trip: restoreLogin caches name, restoreLoginLocal reads it - restoreLogin: verify cache overwrite with fresh fetch - 401 logout: verify cached display name is also cleared AuthState: - refreshIfNeeded: token rejection clears username, name updates - onUploadModeChanged: not-logged-in clears state, error handling - getAccessToken: delegation to service SuspectedLocationState: - refreshIfNeeded: offline mode pass-through, progress cleanup - init (full): offlineMode parameter forwarding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3bdc02 commit 8bb747a

3 files changed

Lines changed: 190 additions & 2 deletions

File tree

test/services/auth_service_test.dart

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,15 @@ void main() {
268268
verifyNever(() => mockClient.get(any(), headers: any(named: 'headers')));
269269
});
270270

271+
test('returns null in simulate mode when not logged in', () async {
272+
SharedPreferences.setMockInitialValues({});
273+
service = createService(mode: UploadMode.simulate);
274+
275+
final result = await service.restoreLoginLocal();
276+
277+
expect(result, isNull);
278+
});
279+
271280
test('uses correct key per mode (sandbox)', () async {
272281
SharedPreferences.setMockInitialValues({
273282
'osm_token_sandbox': jsonEncode({'accessToken': 'sandbox-token'}),
@@ -279,6 +288,89 @@ void main() {
279288

280289
expect(result, equals('SandboxLocal'));
281290
});
291+
292+
test('does not cross-read production cache when in sandbox mode', () async {
293+
SharedPreferences.setMockInitialValues({
294+
'osm_token_sandbox': jsonEncode({'accessToken': 'sandbox-token'}),
295+
'cached_display_name_production': 'ProdUser',
296+
// No sandbox cache key
297+
});
298+
service = createService(mode: UploadMode.sandbox);
299+
300+
final result = await service.restoreLoginLocal();
301+
302+
// Should return empty string (no sandbox cache), not ProdUser
303+
expect(result, equals(''));
304+
});
305+
});
306+
307+
group('restoreLogin then restoreLoginLocal round-trip', () {
308+
test('restoreLogin caches name, then restoreLoginLocal reads it', () async {
309+
SharedPreferences.setMockInitialValues({
310+
'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}),
311+
});
312+
service = createService();
313+
314+
// First call: network fetch caches the name
315+
when(() => mockClient.get(any(), headers: any(named: 'headers')))
316+
.thenAnswer((_) async => http.Response(
317+
jsonEncode({
318+
'user': {'display_name': 'NetworkUser'}
319+
}),
320+
200,
321+
));
322+
await service.restoreLogin();
323+
324+
// Create a new service instance to simulate app restart
325+
service = createService();
326+
final result = await service.restoreLoginLocal();
327+
328+
expect(result, equals('NetworkUser'));
329+
// Only one HTTP call (from the first restoreLogin)
330+
verify(() => mockClient.get(any(), headers: any(named: 'headers'))).called(1);
331+
});
332+
});
333+
334+
group('restoreLogin updates stale cache', () {
335+
test('overwrites old cached name with fresh fetch', () async {
336+
SharedPreferences.setMockInitialValues({
337+
'osm_token_prod': jsonEncode({'accessToken': 'valid-token'}),
338+
'cached_display_name_production': 'OldName',
339+
});
340+
service = createService();
341+
342+
when(() => mockClient.get(any(), headers: any(named: 'headers')))
343+
.thenAnswer((_) async => http.Response(
344+
jsonEncode({
345+
'user': {'display_name': 'NewName'}
346+
}),
347+
200,
348+
));
349+
350+
await service.restoreLogin();
351+
352+
final prefs = await SharedPreferences.getInstance();
353+
expect(prefs.getString('cached_display_name_production'), equals('NewName'));
354+
});
355+
});
356+
357+
group('401/403 clears cached display name', () {
358+
test('logout on 401 also clears cached display name', () async {
359+
SharedPreferences.setMockInitialValues({
360+
'osm_token_prod': jsonEncode({'accessToken': 'expired-token'}),
361+
'cached_display_name_production': 'CachedUser',
362+
});
363+
service = createService();
364+
365+
when(() => mockClient.get(any(), headers: any(named: 'headers')))
366+
.thenAnswer((_) async => http.Response('Unauthorized', 401));
367+
368+
await service.restoreLogin();
369+
370+
final prefs = await SharedPreferences.getInstance();
371+
expect(prefs.getString('osm_token_prod'), isNull);
372+
expect(prefs.getString('cached_display_name_production'), isNull);
373+
});
282374
});
283375

284376
group('isLoggedIn', () {

test/state/auth_state_test.dart

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,42 @@ void main() {
118118

119119
expect(state.isLoggedIn, isFalse);
120120
});
121+
122+
test('clears username when restoreLogin returns null (token rejected)', () async {
123+
// First set up a logged-in state via init
124+
when(() => mockAuth.setUploadMode(any())).thenReturn(null);
125+
when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true);
126+
when(() => mockAuth.restoreLoginLocal())
127+
.thenAnswer((_) async => 'InitialUser');
128+
await state.init(UploadMode.production);
129+
expect(state.isLoggedIn, isTrue);
130+
131+
// Now refreshIfNeeded returns null (token was rejected, user logged out)
132+
when(() => mockAuth.restoreLogin()).thenAnswer((_) async => null);
133+
134+
await state.refreshIfNeeded();
135+
136+
expect(state.isLoggedIn, isFalse);
137+
expect(state.username, equals(''));
138+
});
139+
140+
test('updates username when restoreLogin returns new name', () async {
141+
// Set up initial state
142+
when(() => mockAuth.setUploadMode(any())).thenReturn(null);
143+
when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => true);
144+
when(() => mockAuth.restoreLoginLocal())
145+
.thenAnswer((_) async => 'OldUser');
146+
await state.init(UploadMode.production);
147+
expect(state.username, equals('OldUser'));
148+
149+
// refreshIfNeeded fetches updated name
150+
when(() => mockAuth.restoreLogin())
151+
.thenAnswer((_) async => 'UpdatedUser');
152+
153+
await state.refreshIfNeeded();
154+
155+
expect(state.username, equals('UpdatedUser'));
156+
});
121157
});
122158

123159
group('login', () {
@@ -187,18 +223,47 @@ void main() {
187223
});
188224

189225
test('clears username when not logged in for new mode', () async {
190-
// First set up a logged-in state
226+
// Start logged in
191227
when(() => mockAuth.login()).thenAnswer((_) async => 'User');
192228
await state.login();
193229
expect(state.isLoggedIn, isTrue);
194230

195-
// Switch mode where user is not logged in
231+
// Switch to sandbox where user is not logged in
196232
when(() => mockAuth.setUploadMode(any())).thenReturn(null);
197233
when(() => mockAuth.isLoggedIn()).thenAnswer((_) async => false);
198234

199235
await state.onUploadModeChanged(UploadMode.sandbox);
200236

201237
expect(state.isLoggedIn, isFalse);
202238
});
239+
240+
test('handles error during mode change gracefully', () async {
241+
when(() => mockAuth.setUploadMode(any())).thenReturn(null);
242+
when(() => mockAuth.isLoggedIn()).thenThrow(Exception('storage error'));
243+
244+
await state.onUploadModeChanged(UploadMode.sandbox);
245+
246+
expect(state.isLoggedIn, isFalse);
247+
});
248+
});
249+
250+
group('getAccessToken', () {
251+
test('delegates to auth service', () async {
252+
when(() => mockAuth.getAccessToken())
253+
.thenAnswer((_) async => 'my-token');
254+
255+
final token = await state.getAccessToken();
256+
257+
expect(token, equals('my-token'));
258+
});
259+
260+
test('returns null when no token', () async {
261+
when(() => mockAuth.getAccessToken())
262+
.thenAnswer((_) async => null);
263+
264+
final token = await state.getAccessToken();
265+
266+
expect(token, isNull);
267+
});
203268
});
204269
}

test/state/suspected_location_state_test.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,37 @@ void main() {
7878

7979
expect(state.isLoading, isFalse);
8080
});
81+
82+
test('passes offlineMode through to service', () async {
83+
when(() => mockService.refreshIfNeeded(offlineMode: true))
84+
.thenAnswer((_) async => false);
85+
86+
await state.refreshIfNeeded(offlineMode: true);
87+
88+
verify(() => mockService.refreshIfNeeded(offlineMode: true)).called(1);
89+
expect(state.isLoading, isFalse);
90+
});
91+
92+
test('clears download progress after refresh', () async {
93+
when(() => mockService.refreshIfNeeded(offlineMode: false))
94+
.thenAnswer((_) async => true);
95+
96+
await state.refreshIfNeeded();
97+
98+
expect(state.downloadProgress, isNull);
99+
});
100+
});
101+
102+
group('init (full — with network)', () {
103+
test('calls service.init with offlineMode', () async {
104+
when(() => mockService.init(offlineMode: true))
105+
.thenAnswer((_) async {});
106+
when(() => mockService.isEnabled).thenReturn(false);
107+
108+
await state.init(offlineMode: true);
109+
110+
verify(() => mockService.init(offlineMode: true)).called(1);
111+
});
81112
});
82113

83114
group('selection', () {

0 commit comments

Comments
 (0)