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.
344 lines
10 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|