import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:nearle_pos/core/config/sync_config.dart'; import 'package:nearle_pos/data/remote/pos_auth_api.dart'; import 'package:nearle_pos/domain/entities/pos_session.dart'; /// Sign-in used to be two constants compiled into the app, compared after a /// fake 600ms delay. The store id came from a field in Settings, so a till /// named its own outlet and was believed — one number changed on one screen /// moved a terminal into another tenant's books. /// /// These cover the replacement: the outlet arrives *from* the back office, and /// everything the till does with that answer. void main() { group('a session read off the wire', () { test('takes its outlet from the back office, not from the till', () { final session = PosSession.fromJson({ 'token': 'abc.def', 'expires_at': '2026-09-05T10:00:00Z', 'user_id': 1229, 'full_name': 'Selvapuram', 'tenant_id': 1087, 'tenant_name': 'Ragul Stores', 'store_id': '1135', 'location_id': 1135, 'location_name': 'Ragul stores Selvapuram', }); expect(session.storeId, '1135'); expect(session.locationId, 1135); expect(session.tenantId, 1087); }); test('reads an id whether it arrives quoted or bare', () { // The backend sends `location_id` as a number and `store_id` as a string // for the same value. A till that accepted only one shape would read zero // for the other — which looks like "no outlet" rather than like a bug. final quoted = PosSession.fromJson({ 'location_id': '1135', 'token': 't', 'expires_at': '2026-09-05T10:00:00Z', }); final bare = PosSession.fromJson({ 'location_id': 1135, 'token': 't', 'expires_at': '2026-09-05T10:00:00Z', }); expect(quoted.locationId, 1135); expect(bare.locationId, 1135); }); test('falls back to the location id when no store id is sent', () { final session = PosSession.fromJson({ 'token': 't', 'expires_at': '2026-09-05T10:00:00Z', 'location_id': 1135, }); expect(session.storeId, '1135'); }); test('an unreadable expiry counts as already finished', () { // Guessing "valid" here would keep a till sending a token the server // stopped honouring hours ago, and reading the resulting refusals as a // server fault. final session = PosSession.fromJson({ 'token': 't', 'location_id': 1135, 'expires_at': 'not a date', }); expect(session.isValidAt(DateTime.now()), isFalse); }); test('survives a round trip through storage', () { final original = PosSession.fromJson({ 'token': 'abc.def', 'expires_at': '2026-09-05T10:00:00Z', 'user_id': 1305, 'full_name': 'Gokul R', 'email': 'raguladmin@example.test', 'tenant_id': 1087, 'tenant_name': 'Ragul Stores', 'store_id': '1097', 'location_id': 1097, 'location_name': 'Ragul stores', 'gstin': '33AABCU9603R1ZM', 'locations': [ {'location_id': 1097, 'location_name': 'Ragul stores'}, {'location_id': 1135, 'location_name': 'Ragul stores Selvapuram'}, ], }); final restored = PosSession.fromJson( jsonDecode(jsonEncode(original.toJson())) as Map, ); expect(restored.token, original.token); expect(restored.locationId, original.locationId); expect(restored.gstin, original.gstin); expect(restored.outlets.length, 2); expect(restored.expiresAt.toUtc(), original.expiresAt.toUtc()); }); test('offers a choice only when there is one', () { final single = PosSession.fromJson({ 'token': 't', 'expires_at': '2026-09-05T10:00:00Z', 'location_id': 1135, 'locations': [ {'location_id': 1135, 'location_name': 'Selvapuram'}, ], }); final several = PosSession.fromJson({ 'token': 't', 'expires_at': '2026-09-05T10:00:00Z', 'location_id': 1097, 'locations': [ {'location_id': 1097, 'location_name': 'Ragul stores'}, {'location_id': 1135, 'location_name': 'Selvapuram'}, ], }); expect(single.hasChoiceOfOutlet, isFalse); expect(several.hasChoiceOfOutlet, isTrue); }); }); group('the session is what authenticates a request', () { test('takes precedence over a static api key', () { // The key says "this came from our fleet". The session says which outlet // it came from — and only the second can stop a till reaching another // tenant's books. const config = SyncConfig( apiKey: 'fleet-wide-key', sessionToken: 'per-user-session', ); expect(config.bearerToken, 'per-user-session'); }); test('falls back to the api key before a terminal has signed in', () { const config = SyncConfig(apiKey: 'fleet-wide-key'); expect(config.bearerToken, 'fleet-wide-key'); }); test('an emptied session does not authenticate as itself', () { // Sign-out clears the token by writing an empty string rather than by // rebuilding the config. If that read as a credential, a signed-out till // would keep uploading as the shop that signed in this morning. const config = SyncConfig(sessionToken: '', apiKey: ''); expect(config.bearerToken, isNull); }); }); group('signing in against the back office', () { PosAuthApi apiReturning(int status, Object body) => PosAuthApi( baseUrl: 'https://example.invalid/pos', client: MockClient( (_) async => http.Response(jsonEncode(body), status, headers: {'content-type': 'application/json'},), ), ); test('returns the outlet the back office named', () async { final api = apiReturning(200, { 'code': 200, 'status': true, 'details': { 'token': 'abc.def', 'expires_at': '2026-09-05T10:00:00Z', 'location_id': 1135, 'store_id': '1135', 'location_name': 'Ragul stores Selvapuram', }, }); final session = await api.login(authname: 'a@b.test', password: 'pw'); expect(session.storeId, '1135'); expect(session.token, 'abc.def'); }); test('a wrong password is reported as one worth re-typing', () async { final api = apiReturning(401, { 'code': 401, 'status': false, 'message': 'those sign-in details were not recognised', }); await expectLater( api.login(authname: 'a@b.test', password: 'wrong'), throwsA( isA() .having((e) => e.isCredentialFailure, 'credential failure', true), ), ); }); test('a refused outlet is not reported as a wrong password', () async { // 403 is a real account that may not open this till. Telling someone to // re-type a password that was correct sends them round a loop. final api = apiReturning(403, { 'code': 403, 'status': false, 'message': 'this account cannot open a till at outlet 1185', }); await expectLater( api.login(authname: 'a@b.test', password: 'pw'), throwsA( isA() .having((e) => e.isCredentialFailure, 'credential failure', false), ), ); }); test('a session with no token is refused rather than saved', () async { // Saving it would fail against every later request instead of here, which // is much harder to diagnose from a shop floor. final api = apiReturning(200, { 'code': 200, 'status': true, 'details': {'location_id': 1135, 'expires_at': '2026-09-05T10:00:00Z'}, }); await expectLater( api.login(authname: 'a@b.test', password: 'pw'), throwsA(isA()), ); }); test('a session naming no outlet is refused', () async { final api = apiReturning(200, { 'code': 200, 'status': true, 'details': {'token': 'abc.def', 'expires_at': '2026-09-05T10:00:00Z'}, }); await expectLater( api.login(authname: 'a@b.test', password: 'pw'), throwsA(isA()), ); }); test('an unconfigured terminal says so instead of failing obscurely', () async { final api = PosAuthApi(baseUrl: ''); await expectLater( api.login(authname: 'a@b.test', password: 'pw'), throwsA( isA().having( (e) => e.message, 'message', contains('no back office configured'), ), ), ); }); }); } /// A client answering with one canned response. /// /// Hand-rolled rather than pulled from `http/testing.dart` so the test suite /// does not gain a dependency for four lines. class MockClient extends http.BaseClient { MockClient(this._handler); final Future Function(http.BaseRequest) _handler; @override Future send(http.BaseRequest request) async { final response = await _handler(request); return http.StreamedResponse( Stream.value(response.bodyBytes), response.statusCode, headers: response.headers, ); } }