feat: refactor API paths for authentication, inventory, and recipes; add contract tests for repositories
This commit is contained in:
@@ -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.');
|
||||
@@ -46,7 +47,7 @@ class RecipeRepository {
|
||||
{String? token}) async {
|
||||
try {
|
||||
final data =
|
||||
await _api.postJson('/recipes', body: body, token: token);
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user