import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http_mock_adapter/http_mock_adapter.dart'; import 'package:jiu_client/core/api/api_client.dart'; import 'package:jiu_client/core/exceptions.dart'; import 'package:jiu_client/repositories/product_repository.dart'; const _baseUrl = 'http://localhost:8080/api/v1'; ApiClient _buildClient(Dio dio) { // We inject the Dio directly by creating ApiClient and swapping it out // via a subclass-free approach: wrap with a test double. return _TestApiClient(dio); } /// A thin ApiClient subclass that delegates to an injected Dio. class _TestApiClient extends ApiClient { final Dio _testDio; _TestApiClient(this._testDio) : super(token: 'test-token'); @override Future get(String path, {Map? params}) => _testDio.get(path, queryParameters: params); @override Future post(String path, {dynamic data}) => _testDio.post(path, data: data); @override Future put(String path, {dynamic data}) => _testDio.put(path, data: data); @override Future delete(String path) => _testDio.delete(path); } void main() { late Dio dio; late DioAdapter adapter; late ProductRepository repo; setUp(() { dio = Dio(BaseOptions(baseUrl: _baseUrl)); adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher()); repo = ProductRepository(_buildClient(dio)); }); // --------------------------------------------------------------------------- // list() // --------------------------------------------------------------------------- group('ProductRepository.list()', () { test('returns PageResult with products on 200', () async { adapter.onGet( '/products', (server) => server.reply(200, { 'data': [ { 'id': 1, 'code': 'P001', 'name': '五粮液', 'unit': '瓶', 'barcode': null, 'series': null, 'spec': '500ml', 'category_id': null, 'brand': '五粮液集团', 'purchase_price': 500.0, 'sale_price': 680.0, 'min_stock': 10, 'remark': null, 'custom_fields': null, } ], 'total': 1, 'page': 1, 'page_size': 20, }), queryParameters: {'page': 1, 'page_size': 20}, ); final result = await repo.list(); expect(result.total, 1); expect(result.data.length, 1); expect(result.data.first.name, '五粮液'); expect(result.data.first.code, 'P001'); expect(result.data.first.purchasePrice, 500.0); }); test('returns empty list without error when data array is empty', () async { adapter.onGet( '/products', (server) => server.reply(200, { 'data': [], 'total': 0, 'page': 1, 'page_size': 20, }), queryParameters: {'page': 1, 'page_size': 20}, ); final result = await repo.list(); expect(result.data, isEmpty); expect(result.total, 0); }); test('passes keyword query param when provided', () async { adapter.onGet( '/products', (server) => server.reply(200, { 'data': [], 'total': 0, 'page': 1, 'page_size': 20, }), queryParameters: { 'page': 1, 'page_size': 20, 'keyword': '茅台', }, ); final result = await repo.list(keyword: '茅台'); expect(result.data, isEmpty); }); test('400 response throws AppException with server error message', () async { adapter.onGet( '/products', (server) => server.reply(400, {'error': 'invalid page parameter'}), queryParameters: {'page': 1, 'page_size': 20}, ); expect( () => repo.list(), throwsA( predicate( (e) => e is AppException && e.message == 'invalid page parameter' && e.statusCode == 400, ), ), ); }); test('401 response throws AppException with status 401', () async { adapter.onGet( '/products', (server) => server.reply(401, {'error': 'unauthorized'}), queryParameters: {'page': 1, 'page_size': 20}, ); expect( () => repo.list(), throwsA( predicate( (e) => e is AppException && e.statusCode == 401, ), ), ); }); test('network timeout throws AppException with fallback message', () async { final badDio = Dio(BaseOptions( baseUrl: 'http://localhost:19999', connectTimeout: const Duration(milliseconds: 100), )); final badRepo = ProductRepository(_buildClient(badDio)); try { await badRepo.list(); fail('Expected AppException'); } on AppException catch (e) { expect(e.message, isNotEmpty); } }); }); // --------------------------------------------------------------------------- // create() // --------------------------------------------------------------------------- group('ProductRepository.create()', () { test('returns created Product on 201', () async { final payload = {'name': '茅台', 'code': 'MT001', 'unit': '瓶'}; adapter.onPost( '/products', (server) => server.reply(201, { 'data': { 'id': 2, 'code': 'MT001', 'name': '茅台', 'unit': '瓶', 'barcode': null, 'series': null, 'spec': null, 'category_id': null, 'brand': null, 'purchase_price': null, 'sale_price': null, 'min_stock': null, 'remark': null, 'custom_fields': null, } }), data: payload, ); final product = await repo.create(payload); expect(product.id, 2); expect(product.name, '茅台'); expect(product.code, 'MT001'); }); test('400 response throws AppException', () async { adapter.onPost( '/products', (server) => server.reply(400, {'error': 'code already exists'}), data: {'name': '茅台', 'code': 'MT001', 'unit': '瓶'}, ); expect( () => repo.create({'name': '茅台', 'code': 'MT001', 'unit': '瓶'}), throwsA( predicate( (e) => e.message == 'code already exists' && e.statusCode == 400, ), ), ); }); }); // --------------------------------------------------------------------------- // update() // --------------------------------------------------------------------------- group('ProductRepository.update()', () { test('returns updated Product on 200', () async { final payload = {'name': '茅台(升级)', 'code': 'MT001', 'unit': '瓶'}; adapter.onPut( '/products/2', (server) => server.reply(200, { 'data': { 'id': 2, 'code': 'MT001', 'name': '茅台(升级)', 'unit': '瓶', 'barcode': null, 'series': null, 'spec': null, 'category_id': null, 'brand': null, 'purchase_price': null, 'sale_price': null, 'min_stock': null, 'remark': null, 'custom_fields': null, } }), data: payload, ); final product = await repo.update(2, payload); expect(product.id, 2); expect(product.name, '茅台(升级)'); }); test('404 response throws AppException with status 404', () async { adapter.onPut( '/products/999', (server) => server.reply(404, {'error': 'product not found'}), data: {'name': 'X', 'code': 'X', 'unit': '个'}, ); expect( () => repo.update(999, {'name': 'X', 'code': 'X', 'unit': '个'}), throwsA( predicate((e) => e.statusCode == 404), ), ); }); }); // --------------------------------------------------------------------------- // delete() // --------------------------------------------------------------------------- group('ProductRepository.delete()', () { test('completes without error on 200', () async { adapter.onDelete( '/products/1', (server) => server.reply(200, {'message': 'deleted'}), ); await expectLater(repo.delete(1), completes); }); test('404 response throws AppException', () async { adapter.onDelete( '/products/999', (server) => server.reply(404, {'error': 'product not found'}), ); expect( () => repo.delete(999), throwsA(isA()), ); }); }); }