From c163821bad7b76dcb296adabfed355b245c7a023 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Wed, 22 Apr 2026 10:21:07 +0200 Subject: [PATCH] feat: refactor API paths for authentication, inventory, and recipes; add contract tests for repositories --- flutter/lib/core/api/api_paths.dart | 23 ++++ .../features/auth/data/auth_repository.dart | 3 +- .../inventory/data/inventory_repository.dart | 17 +-- .../presentation/create_inventory_screen.dart | 3 +- .../recipes/data/recipe_repository.dart | 20 ++-- .../presentation/recipe_edit_screen.dart | 3 +- .../inventory_repository_contract_test.dart | 102 ++++++++++++++++++ .../data/recipe_repository_contract_test.dart | 94 ++++++++++++++++ 8 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 flutter/lib/core/api/api_paths.dart create mode 100644 flutter/test/features/inventory/data/inventory_repository_contract_test.dart create mode 100644 flutter/test/features/recipes/data/recipe_repository_contract_test.dart diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart new file mode 100644 index 00000000..18e07ab5 --- /dev/null +++ b/flutter/lib/core/api/api_paths.dart @@ -0,0 +1,23 @@ +class AuthApiPaths { + static const login = '/auth/login'; +} + +class ProductApiPaths { + static const list = '/products'; +} + +class RecipeApiPaths { + static const list = '/recipes'; + static String detail(int id) => '/recipes/$id'; + static String update(int id) => '/recipes/$id'; + static String remove(int id) => '/recipes/$id'; + static const parseMarkdown = '/recipes/parse-markdown'; +} + +class InventoryApiPaths { + static const list = '/inventory'; + static String update(int id) => '/inventory/$id'; + static String remove(int id) => '/inventory/$id'; + static String consume(int id) => '/inventory/$id/consume'; + static String consumptionHistory(int id) => '/inventory/$id/consumption-history'; +} \ No newline at end of file diff --git a/flutter/lib/features/auth/data/auth_repository.dart b/flutter/lib/features/auth/data/auth_repository.dart index b970b4ac..d07feb42 100644 --- a/flutter/lib/features/auth/data/auth_repository.dart +++ b/flutter/lib/features/auth/data/auth_repository.dart @@ -1,5 +1,6 @@ import '../../../core/api/api_client.dart'; import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_paths.dart'; import '../../../core/platform/token_storage.dart'; class AuthRepository { @@ -11,7 +12,7 @@ class AuthRepository { Future login(String username, String password) async { try { final data = await _api.postJson( - '/auth/login', + AuthApiPaths.login, body: {'username': username, 'password': password}, ); diff --git a/flutter/lib/features/inventory/data/inventory_repository.dart b/flutter/lib/features/inventory/data/inventory_repository.dart index c6003acf..2b6b2a58 100644 --- a/flutter/lib/features/inventory/data/inventory_repository.dart +++ b/flutter/lib/features/inventory/data/inventory_repository.dart @@ -1,4 +1,5 @@ import '../../../core/api/api_client.dart'; +import '../../../core/api/api_paths.dart'; import '../domain/inventory_item.dart'; import '../domain/inventory_consumption.dart'; @@ -20,21 +21,21 @@ class InventoryRepository { ? '' : '?${params.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}'; - final data = await _api.getJson('/inventory$query', token: token); + final data = await _api.getJson('${InventoryApiPaths.list}$query', token: token); final list = data as List; return list.map((e) => InventoryItem.fromJson(e as Map)).toList(); } Future fetchInventoryItem(int id, {String? token}) async { - final data = await _api.getJson('/inventory/$id', token: token); - return InventoryItem.fromJson(data as Map); + final items = await fetchInventory(token: token); + return items.firstWhere((item) => item.id == id); } Future createInventoryItem( Map body, { String? token, }) async { - final data = await _api.postJson('/inventory', body: body, token: token); + final data = await _api.postJson(InventoryApiPaths.list, body: body, token: token); return InventoryItem.fromJson(data as Map); } @@ -43,12 +44,12 @@ class InventoryRepository { Map body, { String? token, }) async { - final data = await _api.patchJson('/inventory/$id', body: body, token: token); + final data = await _api.patchJson(InventoryApiPaths.update(id), body: body, token: token); return InventoryItem.fromJson(data as Map); } Future deleteInventoryItem(int id, {String? token}) async { - await _api.deleteJson('/inventory/$id', token: token); + await _api.deleteJson(InventoryApiPaths.remove(id), token: token); } Future consumeInventoryItem( @@ -59,7 +60,7 @@ class InventoryRepository { }) async { final body = {'amountUsed': amountUsed}; if (comment != null && comment.isNotEmpty) body['comment'] = comment; - final data = await _api.postJson('/inventory/$id/consume', body: body, token: token); + final data = await _api.postJson(InventoryApiPaths.consume(id), body: body, token: token); return InventoryItem.fromJson(data as Map); } @@ -67,7 +68,7 @@ class InventoryRepository { int id, { String? token, }) async { - final data = await _api.getJson('/inventory/$id/consumption-history', token: token); + final data = await _api.getJson(InventoryApiPaths.consumptionHistory(id), token: token); final list = data as List; return list .map((e) => InventoryConsumption.fromJson(e as Map)) diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index 6856efdc..b2a4c217 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_paths.dart'; import '../../../core/api/api_providers.dart'; import '../../../core/forms/form_options.dart'; import '../../auth/data/auth_providers.dart'; @@ -54,7 +55,7 @@ class _CreateInventoryScreenState try { final token = await ref.read(authStateProvider.future); final api = ref.read(apiClientProvider); - final data = await api.getJson('/products', token: token); + final data = await api.getJson(ProductApiPaths.list, token: token); if (mounted) { setState(() { _products = (data as List) diff --git a/flutter/lib/features/recipes/data/recipe_repository.dart b/flutter/lib/features/recipes/data/recipe_repository.dart index 8d4b81fe..02555202 100644 --- a/flutter/lib/features/recipes/data/recipe_repository.dart +++ b/flutter/lib/features/recipes/data/recipe_repository.dart @@ -1,5 +1,6 @@ import '../../../core/api/api_client.dart'; import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_paths.dart'; import '../domain/parsed_recipe.dart'; import '../domain/recipe.dart'; @@ -10,7 +11,7 @@ class RecipeRepository { Future> fetchRecipes({String? token}) async { try { - final data = await _api.getJson('/recipes', token: token); + final data = await _api.getJson(RecipeApiPaths.list, token: token); if (data is! List) { throw const ApiException( type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); @@ -28,7 +29,7 @@ class RecipeRepository { Future fetchRecipeDetail(int id, {String? token}) async { try { - final data = await _api.getJson('/recipes/$id', token: token); + final data = await _api.getJson(RecipeApiPaths.detail(id), token: token); if (data is! Map) { throw const ApiException( type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); @@ -45,8 +46,8 @@ class RecipeRepository { Future createRecipe(Map body, {String? token}) async { try { - final data = - await _api.postJson('/recipes', body: body, token: token); + final data = + await _api.postJson(RecipeApiPaths.list, body: body, token: token); if (data is! Map) { throw const ApiException( type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); @@ -63,8 +64,11 @@ class RecipeRepository { Future updateRecipe(int id, Map body, {String? token}) async { try { - final data = - await _api.patchJson('/recipes/$id', body: body, token: token); + final data = await _api.patchJson( + RecipeApiPaths.update(id), + body: body, + token: token, + ); if (data is! Map) { throw const ApiException( type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.'); @@ -80,7 +84,7 @@ class RecipeRepository { Future deleteRecipe(int id, {String? token}) async { try { - await _api.deleteJson('/recipes/$id', token: token); + await _api.deleteJson(RecipeApiPaths.remove(id), token: token); } on ApiException { rethrow; } catch (_) { @@ -93,7 +97,7 @@ class RecipeRepository { {String? token}) async { try { final data = await _api.postJson( - '/recipes/parse-markdown', + RecipeApiPaths.parseMarkdown, body: {'markdown': markdown}, token: token, ); diff --git a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart index 1424e8ae..5ccc822a 100644 --- a/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart +++ b/flutter/lib/features/recipes/presentation/recipe_edit_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_exception.dart'; +import '../../../core/api/api_paths.dart'; import '../../../core/api/api_providers.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/ui/async_state_views.dart'; @@ -109,7 +110,7 @@ class _RecipeEditScreenState extends ConsumerState { try { final token = await ref.read(authStateProvider.future); final api = ref.read(apiClientProvider); - final data = await api.getJson('/products', token: token); + final data = await api.getJson(ProductApiPaths.list, token: token); if (!mounted) return; final products = (data as List) .map((e) => e as Map) diff --git a/flutter/test/features/inventory/data/inventory_repository_contract_test.dart b/flutter/test/features/inventory/data/inventory_repository_contract_test.dart new file mode 100644 index 00000000..18cec828 --- /dev/null +++ b/flutter/test/features/inventory/data/inventory_repository_contract_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/core/api/api_client.dart'; +import 'package:recipe_flutter/features/inventory/data/inventory_repository.dart'; + +class FakeInventoryApiClient extends ApiClient { + FakeInventoryApiClient(); + + String? lastMethod; + String? lastPath; + Object? lastBody; + + @override + Future getJson(String path, {String? token}) async { + lastMethod = 'GET'; + lastPath = path; + if (path.startsWith('/inventory')) { + return [ + { + 'id': 43, + 'productId': 1, + 'quantity': 2, + 'unit': 'st', + 'opened': false, + 'product': {'name': 'Agg'} + } + ]; + } + return []; + } + + @override + Future postJson(String path, {Object? body, String? token}) async { + lastMethod = 'POST'; + lastPath = path; + lastBody = body; + return { + 'id': 43, + 'productId': 1, + 'quantity': 1, + 'unit': 'st', + 'opened': false, + 'product': {'name': 'Agg'} + }; + } + + @override + Future patchJson(String path, {Object? body, String? token}) async { + lastMethod = 'PATCH'; + lastPath = path; + lastBody = body; + return { + 'id': 43, + 'productId': 1, + 'quantity': 1, + 'unit': 'st', + 'opened': false, + 'product': {'name': 'Agg'} + }; + } + + @override + Future deleteJson(String path, {String? token}) async { + lastMethod = 'DELETE'; + lastPath = path; + return null; + } +} + +void main() { + group('InventoryRepository API contract', () { + test('fetchInventoryItem uses list endpoint, not missing detail endpoint', () async { + final api = FakeInventoryApiClient(); + final repository = InventoryRepository(api); + + final item = await repository.fetchInventoryItem(43); + + expect(item.id, 43); + expect(api.lastMethod, 'GET'); + expect(api.lastPath, '/inventory'); + }); + + test('updateInventoryItem uses PATCH /inventory/:id', () async { + final api = FakeInventoryApiClient(); + final repository = InventoryRepository(api); + + await repository.updateInventoryItem(43, {'unit': 'st'}); + + expect(api.lastMethod, 'PATCH'); + expect(api.lastPath, '/inventory/43'); + }); + + test('consumeInventoryItem uses POST /inventory/:id/consume', () async { + final api = FakeInventoryApiClient(); + final repository = InventoryRepository(api); + + await repository.consumeInventoryItem(43, amountUsed: 0.5); + + expect(api.lastMethod, 'POST'); + expect(api.lastPath, '/inventory/43/consume'); + }); + }); +} \ No newline at end of file diff --git a/flutter/test/features/recipes/data/recipe_repository_contract_test.dart b/flutter/test/features/recipes/data/recipe_repository_contract_test.dart new file mode 100644 index 00000000..00c5d887 --- /dev/null +++ b/flutter/test/features/recipes/data/recipe_repository_contract_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/core/api/api_client.dart'; +import 'package:recipe_flutter/features/recipes/data/recipe_repository.dart'; + +class FakeRecipeApiClient extends ApiClient { + FakeRecipeApiClient(); + + String? lastMethod; + String? lastPath; + Object? lastBody; + + @override + Future getJson(String path, {String? token}) async { + lastMethod = 'GET'; + lastPath = path; + return []; + } + + @override + Future postJson(String path, {Object? body, String? token}) async { + lastMethod = 'POST'; + lastPath = path; + lastBody = body; + if (path == '/recipes/parse-markdown') { + return { + 'name': 'Recept', + 'ingredients': [], + }; + } + return { + 'id': 1, + 'name': 'Recept', + 'ingredients': [], + }; + } + + @override + Future patchJson(String path, {Object? body, String? token}) async { + lastMethod = 'PATCH'; + lastPath = path; + lastBody = body; + return { + 'id': 1, + 'name': 'Recept', + 'ingredients': [], + }; + } + + @override + Future deleteJson(String path, {String? token}) async { + lastMethod = 'DELETE'; + lastPath = path; + return null; + } +} + +void main() { + group('RecipeRepository API contract', () { + test('updateRecipe uses PATCH /recipes/:id', () async { + final api = FakeRecipeApiClient(); + final repository = RecipeRepository(api); + + await repository.updateRecipe(2, { + 'name': 'Nytt namn', + 'ingredients': [ + {'productId': 1, 'quantity': 1, 'unit': 'st'} + ], + }); + + expect(api.lastMethod, 'PATCH'); + expect(api.lastPath, '/recipes/2'); + }); + + test('deleteRecipe uses DELETE /recipes/:id', () async { + final api = FakeRecipeApiClient(); + final repository = RecipeRepository(api); + + await repository.deleteRecipe(2); + + expect(api.lastMethod, 'DELETE'); + expect(api.lastPath, '/recipes/2'); + }); + + test('parseMarkdown uses POST /recipes/parse-markdown', () async { + final api = FakeRecipeApiClient(); + final repository = RecipeRepository(api); + + await repository.parseMarkdown('# Test'); + + expect(api.lastMethod, 'POST'); + expect(api.lastPath, '/recipes/parse-markdown'); + }); + }); +} \ No newline at end of file