feat: refactor API paths for authentication, inventory, and recipes; add contract tests for repositories

This commit is contained in:
Nils-Johan Gynther
2026-04-22 10:21:07 +02:00
parent 655adf66ae
commit c163821bad
8 changed files with 246 additions and 19 deletions
+23
View File
@@ -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';
}
@@ -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<String> login(String username, String password) async {
try {
final data = await _api.postJson(
'/auth/login',
AuthApiPaths.login,
body: {'username': username, 'password': password},
);
@@ -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<dynamic>;
return list.map((e) => InventoryItem.fromJson(e as Map<String, dynamic>)).toList();
}
Future<InventoryItem> fetchInventoryItem(int id, {String? token}) async {
final data = await _api.getJson('/inventory/$id', token: token);
return InventoryItem.fromJson(data as Map<String, dynamic>);
final items = await fetchInventory(token: token);
return items.firstWhere((item) => item.id == id);
}
Future<InventoryItem> createInventoryItem(
Map<String, dynamic> 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<String, dynamic>);
}
@@ -43,12 +44,12 @@ class InventoryRepository {
Map<String, dynamic> 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<String, dynamic>);
}
Future<void> deleteInventoryItem(int id, {String? token}) async {
await _api.deleteJson('/inventory/$id', token: token);
await _api.deleteJson(InventoryApiPaths.remove(id), token: token);
}
Future<InventoryItem> consumeInventoryItem(
@@ -59,7 +60,7 @@ class InventoryRepository {
}) async {
final body = <String, dynamic>{'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<String, dynamic>);
}
@@ -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<dynamic>;
return list
.map((e) => InventoryConsumption.fromJson(e as Map<String, dynamic>))
@@ -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<dynamic>)
@@ -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<List<Recipe>> 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<Recipe> 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<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
@@ -45,8 +46,8 @@ class RecipeRepository {
Future<Recipe> createRecipe(Map<String, dynamic> 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<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
@@ -63,8 +64,11 @@ class RecipeRepository {
Future<Recipe> updateRecipe(int id, Map<String, dynamic> 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<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
@@ -80,7 +84,7 @@ class RecipeRepository {
Future<void> 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,
);
@@ -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<RecipeEditScreen> {
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<dynamic>)
.map((e) => e as Map<String, dynamic>)
@@ -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<dynamic> 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<dynamic> 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<dynamic> 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<dynamic> 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');
});
});
}
@@ -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<dynamic> getJson(String path, {String? token}) async {
lastMethod = 'GET';
lastPath = path;
return [];
}
@override
Future<dynamic> 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<dynamic> patchJson(String path, {Object? body, String? token}) async {
lastMethod = 'PATCH';
lastPath = path;
lastBody = body;
return {
'id': 1,
'name': 'Recept',
'ingredients': [],
};
}
@override
Future<dynamic> 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');
});
});
}