srs/test/http_api_test.dart
Aswin Biju 45fcdcebfb Initial commit
Add SRS app: results, shops, orders and users, wired to srs-api
Flutter web/PWA for the lottery agency, built on Riverpod and go_router,
talking to srs-api over HTTP (HttpApi, token kept in SharedPreferences).

- Results, Shops, Orders and Users tabs (Users for admins only), with
  phone, tablet and desktop layouts and shared widgets and theme tokens
- Users: list with search and Active/Inactive filters; user detail with
  shops, sign-in details, and an Access card
- WhatsApp results switch on each user (sendResults on
  PATCH /api/users/:id). Shows the WhatsApp logo (Simple Icons, CC0,
  drawn with flutter_svg), green only when results will really go out.
  Can't be turned on without a phone number ("Add a phone number first"
  opens the phone sheet), and is paused while the user is inactive. The
  users list marks people who get results with the logo.
- Each Access switch has a one-line description; the explanation
  paragraphs are gone
- Remove demo mode: the app always uses the real AP
  test/support and is used only by tests.
2026-11-04 23:21:50 +05:30

344 lines
10 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:srs_app/core/api.dart';
import 'package:srs_app/core/http_api.dart';
const _user = {
'id': 7,
'username': 'kzk.staff',
'phone': '919847012345',
'isAdmin': false,
'active': true,
'shopIds': [1, 2],
};
const _shop = {'id': 1, 'code': 'KZK', 'name': 'Kozhikode', 'active': true};
const _order = {
'id': 11,
'shop': _shop,
'drawDate': '2026-10-16',
'createdAt': '2026-10-16T05:30:00Z',
'edited': true,
'isPhoto': false,
'rawText': '1234-2',
'lines': [
{
'ending': '1234',
'qty': 2,
'hits': [
{'tier': 5, 'amount': 1000},
{'tier': 8, 'amount': 100},
],
},
{'ending': '5678', 'qty': 1, 'hits': <Object>[]},
],
};
http.Response _json(Object body, [int status = 200]) => http.Response(
jsonEncode(body),
status,
headers: {'content-type': 'application/json'},
);
Future<(HttpApi, List<http.Request>, SharedPreferences)> _api(
http.Response Function(http.Request) reply, {
Map<String, Object> prefs = const {},
}) async {
SharedPreferences.setMockInitialValues(prefs);
final store = await SharedPreferences.getInstance();
final seen = <http.Request>[];
final api = HttpApi(
baseUrl: 'https://srs.test/',
prefs: store,
client: MockClient((r) async {
seen.add(r);
return reply(r);
}),
);
return (api, seen, store);
}
void main() {
group('signing in', () {
test('keeps the token and sends it on later calls', () async {
final (api, seen, store) = await _api(
(r) => r.url.path == '/api/auth/login'
? _json({'token': 'abc', 'user': _user})
: _json({'date': '2026-10-16'}),
);
final u = await api.login(' kzk.staff ', 'secret-pass');
expect(u.username, 'kzk.staff');
expect(u.shopIds, [1, 2]);
expect(jsonDecode(seen.single.body), {
'username': 'kzk.staff',
'password': 'secret-pass',
});
expect(seen.single.headers['authorization'], isNull);
expect(store.getString('token'), 'abc');
expect(await api.latestDrawDate(), DateTime(2026, 10, 16));
expect(seen.last.url.toString(), 'https://srs.test/api/draws/latest');
expect(seen.last.headers['authorization'], 'Bearer abc');
});
test('shows the server message for a wrong password', () async {
final (api, _, store) = await _api(
(_) => _json({
'error': 'invalid_credentials',
'message': 'Incorrect username or password.',
}, 401),
);
await expectLater(
api.login('a', 'b'),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'Incorrect username or password.',
),
),
);
expect(store.getString('token'), isNull);
});
test('restores the user from a saved token, or none without one', () async {
final (none, calls, _) = await _api((_) => _json(_user));
expect(await none.restore(), isNull);
expect(calls, isEmpty);
final (api, _, _) = await _api(
(_) => _json(_user),
prefs: {'token': 'abc'},
);
expect((await api.restore())!.id, 7);
});
test('forgets an old token the server no longer accepts', () async {
final (api, _, store) = await _api(
(_) => _json({'error': 'unauthorized', 'message': 'Expired.'}, 401),
prefs: {'token': 'old'},
);
expect(await api.restore(), isNull);
expect(store.getString('token'), isNull);
});
test('logout drops the token', () async {
final (api, _, store) = await _api(
(_) => _json(_user),
prefs: {'token': 'abc'},
);
api.logout();
await Future<void>.delayed(Duration.zero);
expect(store.getString('token'), isNull);
});
});
group('a session that ends', () {
test('clears the token and tells the app', () async {
final (api, _, store) = await _api(
(_) => _json({'error': 'unauthorized', 'message': 'Ended.'}, 401),
prefs: {'token': 'abc'},
);
final ended = expectLater(api.sessionEnded, emits(anything));
await expectLater(
api.orders(DateTime(2026, 10, 16)),
throwsA(isA<ApiException>()),
);
await ended;
expect(store.getString('token'), isNull);
});
});
group('failures', () {
test('an unreachable server gives a plain message', () async {
SharedPreferences.setMockInitialValues({});
final api = HttpApi(
baseUrl: 'https://srs.test',
prefs: await SharedPreferences.getInstance(),
client: MockClient((_) async => throw http.ClientException('down')),
);
await expectLater(
api.activeShops(),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
startsWith('Unable to reach the server'),
),
),
);
});
test('a server error without a message is not shown raw', () async {
final (api, _, _) = await _api((_) => http.Response('<html>', 502));
await expectLater(
api.activeShops(),
throwsA(
isA<ApiException>().having(
(e) => e.message,
'message',
'Something went wrong on the server. Try again shortly.',
),
),
);
});
});
group('reading', () {
test(
'turns an order into models in Indian time with every prize',
() async {
final (api, seen, _) = await _api((_) => _json(_order));
final o = await api.order(11);
expect(seen.single.url.path, '/api/orders/11');
expect(o.time, DateTime(2026, 10, 16, 11, 0));
expect(o.drawDate, DateTime(2026, 10, 16));
expect(o.edited, isTrue);
expect(o.lines.first.tierText, '5th + 8th prize');
expect(o.lines.first.perTicket, 1100);
expect(o.prize, 2200);
expect(o.lines.last.wins, isFalse);
},
);
test('reads a day of results, an awaited draw included', () async {
final (api, seen, _) = await _api(
(_) => _json({
'draw': {
'date': '2026-10-17',
'scheme': null,
'code': null,
'published': false,
'hasWin': false,
},
'shops': [
{
'shop': _shop,
'orders': 2,
'tickets': 9,
'winTickets': 0,
'prize': 0,
},
],
}),
);
final r = await api.results(DateTime(2026, 10, 17));
expect(seen.single.url.path, '/api/draws/2026-10-17/results');
expect(r.draw.published, isFalse);
expect(r.draw.label, 'No result yet');
expect(r.shops.single.tickets, 9);
});
test('asks for a month and a shop day with the right addresses', () async {
final (api, seen, _) = await _api(
(r) => r.url.path == '/api/draws'
? _json({'days': <Object>[]})
: _json({
'shop': _shop,
'draw': {
'date': '2026-10-16',
'scheme': 'Suvarna Keralam',
'code': 'SK-74',
'published': true,
'hasWin': true,
},
'endings': <Object>[],
'orders': [_order],
}),
);
await api.month(2026, 3);
expect(
seen.last.url.toString(),
'https://srs.test/api/draws?month=2026-03',
);
final d = await api.shopDetail(1, DateTime(2026, 10, 16));
expect(seen.last.url.path, '/api/shops/1/draws/2026-10-16');
expect(d.draw.label, 'Suvarna Keralam · SK-74');
expect(d.orders.single.id, 11);
});
});
group('changing things', () {
test('sends each edit to the right place', () async {
final (api, seen, _) = await _api(
(r) => r.method == 'POST' ? _json(_shop, 201) : http.Response('', 204),
);
await api.setLineQty(11, '1234', 5);
await api.removeLine(11, '1234');
await api.cancelOrder(11);
await api.setShopActive(1, false);
await api.renameShop(1, 'New name');
await api.setUserShops(7, [1, 2]);
await api.deleteUser(7);
final shop = await api.createShop('Kozhikode', 'KZK');
final calls = [for (final r in seen) '${r.method} ${r.url.path}'];
expect(calls, [
'PUT /api/orders/11/lines/1234',
'DELETE /api/orders/11/lines/1234',
'DELETE /api/orders/11',
'PATCH /api/shops/1',
'PATCH /api/shops/1',
'PUT /api/users/7/shops',
'DELETE /api/users/7',
'POST /api/shops',
]);
expect(jsonDecode(seen[0].body), {'qty': 5});
expect(jsonDecode(seen[3].body), {'active': false});
expect(jsonDecode(seen[5].body), {
'shopIds': [1, 2],
});
expect(shop.code, 'KZK');
});
test('sends only the user fields that changed', () async {
final (api, seen, _) = await _api((_) => http.Response('', 204));
await api.updateUser(7, username: 'new.name');
await api.updateUser(7, clearPhone: true);
await api.updateUser(7, active: false, password: 'longenough');
await api.updateUser(7, sendResults: true);
expect(jsonDecode(seen[0].body), {'username': 'new.name'});
expect(jsonDecode(seen[1].body), {'phone': null});
expect(jsonDecode(seen[2].body), {
'password': 'longenough',
'active': false,
});
expect(jsonDecode(seen[3].body), {'sendResults': true});
});
test(
'reads the results setting, off when the server leaves it out',
() async {
final (api, _, _) = await _api(
(r) => r.url.path == '/api/users/7'
? _json({..._user, 'sendResults': true})
: _json(_user),
);
expect((await api.user(7)).sendResults, isTrue);
expect((await api.user(8)).sendResults, isFalse);
},
);
test('creates a user with a password and shops', () async {
final (api, seen, _) = await _api((_) => _json(_user, 201));
final u = await api.createUser(
username: 'kzk.staff',
password: 'longenough',
shopIds: [1, 2],
);
expect(jsonDecode(seen.single.body), {
'username': 'kzk.staff',
'phone': null,
'password': 'longenough',
'shopIds': [1, 2],
});
expect(u.id, 7);
});
});
}