Skip to content

Commit 50b1acf

Browse files
committed
test(api): add unit tests for API v1 endpoints and error handling middleware
- Add unit tests for GET, PUT, and DELETE methods on /api/v1/countries/{isoCode} - Add unit tests for GET and POST methods on /api/v1/countries - Add unit test for /api/v1 index endpoint - Add tests for error handling middleware, including handling of custom exceptions
1 parent c8489a6 commit 50b1acf

File tree

4 files changed

+714
-0
lines changed

4 files changed

+714
-0
lines changed
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:dart_frog/dart_frog.dart';
5+
import 'package:ht_countries_client/ht_countries_client.dart';
6+
import 'package:mocktail/mocktail.dart';
7+
import 'package:test/test.dart';
8+
9+
// Import the route handler function directly.
10+
import '../../../../../routes/api/v1/countries/[isoCode].dart' as route;
11+
12+
// --- Mocks ---
13+
class _MockRequestContext extends Mock implements RequestContext {}
14+
15+
class _MockHttpRequest extends Mock
16+
implements Request {} // Use dart_frog Request
17+
18+
class _MockHtCountriesClient extends Mock implements HtCountriesClient {}
19+
// --- End Mocks ---
20+
21+
void main() {
22+
late RequestContext context;
23+
late Request request;
24+
late HtCountriesClient mockClient;
25+
const isoCode = 'US'; // Example ISO code for tests
26+
final country = Country(
27+
isoCode: isoCode,
28+
name: 'United States',
29+
flagUrl: 'http://example.com/us.png',
30+
id: 'us_id_123',
31+
);
32+
33+
setUp(() {
34+
context = _MockRequestContext();
35+
request = _MockHttpRequest();
36+
mockClient = _MockHtCountriesClient();
37+
38+
// Provide the mock client instance
39+
when(() => context.read<HtCountriesClient>()).thenReturn(mockClient);
40+
// Link request mock to context mock
41+
when(() => context.request).thenReturn(request);
42+
43+
// Register fallback values
44+
registerFallbackValue(HttpMethod.get);
45+
registerFallbackValue(Country(isoCode: 'XX', name: 'X', flagUrl: 'x'));
46+
});
47+
48+
group('GET /api/v1/countries/{isoCode}', () {
49+
setUp(() {
50+
when(() => request.method).thenReturn(HttpMethod.get);
51+
});
52+
53+
test('returns 200 OK with country data on success', () async {
54+
// Stub the client call
55+
when(() => mockClient.fetchCountry(isoCode))
56+
.thenAnswer((_) async => country);
57+
58+
// Execute route handler
59+
final response = await route.onRequest(context, isoCode);
60+
61+
// Assertions
62+
expect(response.statusCode, equals(HttpStatus.ok));
63+
expect(await response.json(), equals(country.toJson()));
64+
verify(() => mockClient.fetchCountry(isoCode)).called(1);
65+
});
66+
67+
test('lets CountryNotFound bubble up (handled globally)', () async {
68+
final exception = CountryNotFound('Country $isoCode not found');
69+
when(() => mockClient.fetchCountry(isoCode)).thenThrow(exception);
70+
71+
expect(
72+
() => route.onRequest(context, isoCode),
73+
throwsA(isA<CountryNotFound>()),
74+
);
75+
verify(() => mockClient.fetchCountry(isoCode)).called(1);
76+
});
77+
78+
test('lets CountryFetchFailure bubble up (handled globally)', () async {
79+
final exception = CountryFetchFailure('Network error');
80+
when(() => mockClient.fetchCountry(isoCode)).thenThrow(exception);
81+
82+
expect(
83+
() => route.onRequest(context, isoCode),
84+
throwsA(isA<CountryFetchFailure>()),
85+
);
86+
verify(() => mockClient.fetchCountry(isoCode)).called(1);
87+
});
88+
});
89+
90+
group('PUT /api/v1/countries/{isoCode}', () {
91+
final updatedCountry = Country(
92+
isoCode: isoCode, // Must match path isoCode
93+
name: 'United States of America',
94+
flagUrl: 'http://example.com/us_new.png',
95+
id: country.id, // Usually keep the same ID or let backend handle
96+
);
97+
final requestBodyJson = updatedCountry.toJson();
98+
99+
setUp(() {
100+
when(() => request.method).thenReturn(HttpMethod.put);
101+
// Stub request body parsing
102+
when(() => request.json()).thenAnswer((_) async => requestBodyJson);
103+
// Stub successful update call by default - overridden in failure tests
104+
when(() => mockClient.updateCountry(any())).thenAnswer((_) async {});
105+
});
106+
107+
test('returns 200 OK on successful update', () async {
108+
final response = await route.onRequest(context, isoCode);
109+
110+
expect(response.statusCode, equals(HttpStatus.ok));
111+
// Verify client was called with the correct updated country object
112+
verify(
113+
() => mockClient.updateCountry(
114+
any(
115+
that: isA<Country>()
116+
.having((c) => c.isoCode, 'isoCode', updatedCountry.isoCode)
117+
.having((c) => c.name, 'name', updatedCountry.name)
118+
.having((c) => c.flagUrl, 'flagUrl', updatedCountry.flagUrl)
119+
.having((c) => c.id, 'id', updatedCountry.id),
120+
),
121+
),
122+
).called(1);
123+
});
124+
125+
test('returns 400 Bad Request for invalid JSON body', () async {
126+
when(() => request.json()).thenThrow(FormatException('Bad JSON'));
127+
128+
final response = await route.onRequest(context, isoCode);
129+
130+
expect(response.statusCode, equals(HttpStatus.badRequest));
131+
expect(await response.body(),
132+
equals('Invalid JSON format in request body.'));
133+
verifyNever(() => mockClient.updateCountry(any()));
134+
});
135+
136+
test('returns 400 Bad Request for invalid country data structure',
137+
() async {
138+
final invalidJson = {'name': 'Missing Iso Code'}; // Invalid structure
139+
when(() => request.json()).thenAnswer((_) async => invalidJson);
140+
141+
final response = await route.onRequest(context, isoCode);
142+
143+
expect(response.statusCode, equals(HttpStatus.badRequest));
144+
expect(
145+
await response.json(),
146+
containsPair('error', startsWith('Invalid country data:')),
147+
);
148+
verifyNever(() => mockClient.updateCountry(any()));
149+
});
150+
151+
test('returns 400 Bad Request when path isoCode mismatches body isoCode',
152+
() async {
153+
final mismatchedBody = Map<String, dynamic>.from(requestBodyJson)
154+
..['iso_code'] = 'XX'; // Different ISO code in body
155+
when(() => request.json()).thenAnswer((_) async => mismatchedBody);
156+
157+
final response = await route.onRequest(context, isoCode); // Path is 'US'
158+
159+
expect(response.statusCode, equals(HttpStatus.badRequest));
160+
expect(
161+
await response.json(),
162+
equals({
163+
'error': 'ISO code in request body ("XX") does not match ISO code '
164+
'in URL path ("US").'
165+
}),
166+
);
167+
verifyNever(() => mockClient.updateCountry(any()));
168+
});
169+
170+
test('lets CountryNotFound bubble up (handled globally)', () async {
171+
final exception = CountryNotFound('Cannot update non-existent country');
172+
// Ensure the specific exception is thrown by the mock
173+
when(() => mockClient.updateCountry(any())).thenThrow(exception);
174+
175+
// Expect the call to onRequest to throw the exception
176+
expect(
177+
() => route.onRequest(context, isoCode),
178+
throwsA(isA<CountryNotFound>()),
179+
);
180+
});
181+
182+
test('lets CountryUpdateFailure bubble up (handled globally)', () async {
183+
final exception = CountryUpdateFailure('DB conflict during update');
184+
// Ensure the specific exception is thrown by the mock
185+
when(() => mockClient.updateCountry(any())).thenThrow(exception);
186+
187+
// Expect the call to onRequest to throw the exception
188+
expect(
189+
() => route.onRequest(context, isoCode),
190+
throwsA(isA<CountryUpdateFailure>()),
191+
);
192+
});
193+
});
194+
195+
group('DELETE /api/v1/countries/{isoCode}', () {
196+
setUp(() {
197+
when(() => request.method).thenReturn(HttpMethod.delete);
198+
// Stub successful delete call by default
199+
when(() => mockClient.deleteCountry(any())).thenAnswer((_) async {});
200+
});
201+
202+
test('returns 204 No Content on successful deletion', () async {
203+
final response = await route.onRequest(context, isoCode);
204+
205+
expect(response.statusCode, equals(HttpStatus.noContent));
206+
verify(() => mockClient.deleteCountry(isoCode)).called(1);
207+
});
208+
209+
test('lets CountryNotFound bubble up (handled globally)', () async {
210+
final exception = CountryNotFound('Cannot delete non-existent country');
211+
when(() => mockClient.deleteCountry(isoCode)).thenThrow(exception);
212+
213+
expect(
214+
() => route.onRequest(context, isoCode),
215+
throwsA(isA<CountryNotFound>()),
216+
);
217+
verify(() => mockClient.deleteCountry(isoCode)).called(1);
218+
});
219+
220+
test('lets CountryDeleteFailure bubble up (handled globally)', () async {
221+
final exception = CountryDeleteFailure('Permission error');
222+
when(() => mockClient.deleteCountry(isoCode)).thenThrow(exception);
223+
224+
expect(
225+
() => route.onRequest(context, isoCode),
226+
throwsA(isA<CountryDeleteFailure>()),
227+
);
228+
verify(() => mockClient.deleteCountry(isoCode)).called(1);
229+
});
230+
});
231+
232+
test('returns 405 Method Not Allowed for unsupported methods', () async {
233+
// Test with POST, etc.
234+
when(() => request.method).thenReturn(HttpMethod.post);
235+
final responsePost = await route.onRequest(context, isoCode);
236+
expect(responsePost.statusCode, equals(HttpStatus.methodNotAllowed));
237+
238+
verifyNever(() => mockClient.fetchCountry(any()));
239+
verifyNever(() => mockClient.updateCountry(any()));
240+
verifyNever(() => mockClient.deleteCountry(any()));
241+
});
242+
}

0 commit comments

Comments
 (0)