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_client.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/platform/token_storage.dart';
|
import '../../../core/platform/token_storage.dart';
|
||||||
|
|
||||||
class AuthRepository {
|
class AuthRepository {
|
||||||
@@ -11,7 +12,7 @@ class AuthRepository {
|
|||||||
Future<String> login(String username, String password) async {
|
Future<String> login(String username, String password) async {
|
||||||
try {
|
try {
|
||||||
final data = await _api.postJson(
|
final data = await _api.postJson(
|
||||||
'/auth/login',
|
AuthApiPaths.login,
|
||||||
body: {'username': username, 'password': password},
|
body: {'username': username, 'password': password},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
import '../domain/inventory_consumption.dart';
|
import '../domain/inventory_consumption.dart';
|
||||||
|
|
||||||
@@ -20,21 +21,21 @@ class InventoryRepository {
|
|||||||
? ''
|
? ''
|
||||||
: '?${params.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}';
|
: '?${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>;
|
final list = data as List<dynamic>;
|
||||||
return list.map((e) => InventoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
return list.map((e) => InventoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<InventoryItem> fetchInventoryItem(int id, {String? token}) async {
|
Future<InventoryItem> fetchInventoryItem(int id, {String? token}) async {
|
||||||
final data = await _api.getJson('/inventory/$id', token: token);
|
final items = await fetchInventory(token: token);
|
||||||
return InventoryItem.fromJson(data as Map<String, dynamic>);
|
return items.firstWhere((item) => item.id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<InventoryItem> createInventoryItem(
|
Future<InventoryItem> createInventoryItem(
|
||||||
Map<String, dynamic> body, {
|
Map<String, dynamic> body, {
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) 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>);
|
return InventoryItem.fromJson(data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +44,12 @@ class InventoryRepository {
|
|||||||
Map<String, dynamic> body, {
|
Map<String, dynamic> body, {
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) 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>);
|
return InventoryItem.fromJson(data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteInventoryItem(int id, {String? token}) async {
|
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(
|
Future<InventoryItem> consumeInventoryItem(
|
||||||
@@ -59,7 +60,7 @@ class InventoryRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
final body = <String, dynamic>{'amountUsed': amountUsed};
|
final body = <String, dynamic>{'amountUsed': amountUsed};
|
||||||
if (comment != null && comment.isNotEmpty) body['comment'] = comment;
|
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>);
|
return InventoryItem.fromJson(data as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ class InventoryRepository {
|
|||||||
int id, {
|
int id, {
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) 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>;
|
final list = data as List<dynamic>;
|
||||||
return list
|
return list
|
||||||
.map((e) => InventoryConsumption.fromJson(e as Map<String, dynamic>))
|
.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 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_providers.dart';
|
import '../../../core/api/api_providers.dart';
|
||||||
import '../../../core/forms/form_options.dart';
|
import '../../../core/forms/form_options.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
@@ -54,7 +55,7 @@ class _CreateInventoryScreenState
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final data = await api.getJson('/products', token: token);
|
final data = await api.getJson(ProductApiPaths.list, token: token);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_products = (data as List<dynamic>)
|
_products = (data as List<dynamic>)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../domain/parsed_recipe.dart';
|
import '../domain/parsed_recipe.dart';
|
||||||
import '../domain/recipe.dart';
|
import '../domain/recipe.dart';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ class RecipeRepository {
|
|||||||
|
|
||||||
Future<List<Recipe>> fetchRecipes({String? token}) async {
|
Future<List<Recipe>> fetchRecipes({String? token}) async {
|
||||||
try {
|
try {
|
||||||
final data = await _api.getJson('/recipes', token: token);
|
final data = await _api.getJson(RecipeApiPaths.list, token: token);
|
||||||
if (data is! List) {
|
if (data is! List) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
||||||
@@ -28,7 +29,7 @@ class RecipeRepository {
|
|||||||
|
|
||||||
Future<Recipe> fetchRecipeDetail(int id, {String? token}) async {
|
Future<Recipe> fetchRecipeDetail(int id, {String? token}) async {
|
||||||
try {
|
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>) {
|
if (data is! Map<String, dynamic>) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
||||||
@@ -46,7 +47,7 @@ class RecipeRepository {
|
|||||||
{String? token}) async {
|
{String? token}) async {
|
||||||
try {
|
try {
|
||||||
final data =
|
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>) {
|
if (data is! Map<String, dynamic>) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
||||||
@@ -63,8 +64,11 @@ class RecipeRepository {
|
|||||||
Future<Recipe> updateRecipe(int id, Map<String, dynamic> body,
|
Future<Recipe> updateRecipe(int id, Map<String, dynamic> body,
|
||||||
{String? token}) async {
|
{String? token}) async {
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = await _api.patchJson(
|
||||||
await _api.patchJson('/recipes/$id', body: body, token: token);
|
RecipeApiPaths.update(id),
|
||||||
|
body: body,
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
if (data is! Map<String, dynamic>) {
|
if (data is! Map<String, dynamic>) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
type: ApiErrorType.unknown, message: 'Ogiltigt svar fran servern.');
|
||||||
@@ -80,7 +84,7 @@ class RecipeRepository {
|
|||||||
|
|
||||||
Future<void> deleteRecipe(int id, {String? token}) async {
|
Future<void> deleteRecipe(int id, {String? token}) async {
|
||||||
try {
|
try {
|
||||||
await _api.deleteJson('/recipes/$id', token: token);
|
await _api.deleteJson(RecipeApiPaths.remove(id), token: token);
|
||||||
} on ApiException {
|
} on ApiException {
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -93,7 +97,7 @@ class RecipeRepository {
|
|||||||
{String? token}) async {
|
{String? token}) async {
|
||||||
try {
|
try {
|
||||||
final data = await _api.postJson(
|
final data = await _api.postJson(
|
||||||
'/recipes/parse-markdown',
|
RecipeApiPaths.parseMarkdown,
|
||||||
body: {'markdown': markdown},
|
body: {'markdown': markdown},
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_providers.dart';
|
import '../../../core/api/api_providers.dart';
|
||||||
import '../../../core/forms/form_options.dart';
|
import '../../../core/forms/form_options.dart';
|
||||||
import '../../../core/ui/async_state_views.dart';
|
import '../../../core/ui/async_state_views.dart';
|
||||||
@@ -109,7 +110,7 @@ class _RecipeEditScreenState extends ConsumerState<RecipeEditScreen> {
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
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;
|
if (!mounted) return;
|
||||||
final products = (data as List<dynamic>)
|
final products = (data as List<dynamic>)
|
||||||
.map((e) => e as Map<String, 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