Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions compass_app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,85 @@ cd app
$ flutter test integration_test/app_server_data_test.dart
```

**Integration test with auth-gated local flow**

```bash
cd app
$ flutter test integration_test/app_booking_flow_test.dart
```

Running the tests together with `flutter test integration_test` will fail.
See: https://github.com/flutter/flutter/issues/101031

## Part 2 Answers

### 1) State -> UI trace (user taps one booking item on Home)

- Tap on a booking row triggers `_Booking.onTap` and navigates via `Routes.bookingWithId`.
- Inside the booking `GoRoute` builder in `router.dart`, the booking ID is parsed, the `BookingViewModel` is created, and `viewModel.loadBooking.execute(id)` is called before rendering `BookingScreen`.
- In `booking_viewmodel.dart`, the load command fetches data through the repository abstraction (`BookingRepository`).
- In the remote implementation (`BookingRepositoryRemote.getBooking`), the repository composes booking details using API calls (destinations and destination activities).
- Back in the view model, `Result` (`Ok`/`Error`) is handled and UI state is updated.
- In `booking_screen.dart`, `ListenableBuilder` reacts to command state, showing loading while running and body content when completed.
- In `booking_body.dart`, once booking data is available, header and activities are rendered.

### 2) Routing and auth gate

- In `router.dart`, `GoRouter` is configured with `redirect` and `refreshListenable`.
- `AuthRepository` extends `ChangeNotifier`, so auth changes are observable by the router.
- On `login()` and `logout()`, the auth repo calls `notifyListeners()`.
- That refresh triggers `_redirect()` immediately, which enforces route guards:
- unauthenticated user -> `/login`
- authenticated user on `/login` -> `/home`
- otherwise no redirect

### 3) Data boundary

- UI/domain layers depend on repository interfaces (for example `BookingRepository`) rather than direct HTTP/local services.
- Remote path: repositories call `ApiClient` for network requests.
- Local/dummy path: repositories read from `LocalDataService`/memory-backed sources.
- Provider selection happens in `dependencies.dart` (`providersRemote` vs `providersLocal`), and each flavor main entrypoint chooses the provider set.
- This design is testable because view models/use cases receive injected interfaces, so tests can supply fakes/mocks.

### 4) Possible enhancement

- Add API-boundary caching for read-heavy GET endpoints.
- Strategy: return cached data first, refresh in background, and invalidate with TTL/auth changes.
- Add metrics/logging to validate hit rate, latency reduction, and stale-data behavior.

### app_booking_flow_test.dart

`integration_test/app_booking_flow_test.dart` is an end-to-end integration test that validates:

- auth-gated entry (starts logged out and lands on Login)
- login action
- full booking creation flow (search -> destination -> activity confirm)
- booking visibility after returning Home

Approach used:

- It runs on `providersLocal` for deterministic local data.
- It overrides only `AuthRepository` with a test implementation (`TestAuthRepository`) to simulate logged-out -> logged-in transitions while still exercising the real navigation, view models, and local repositories.

Run command:

```bash
cd app
flutter test integration_test/app_booking_flow_test.dart
```

## Part 4 Answers

### 1) What was the hardest part of this codebase to understand, and how did you figure it out?

- It was the chosen state management. While it wasn't hard to decipher, all of the Flutter projects that I handled were using BLoC/Cubit pattern and so I'm more used to it. I still have yet to figure it out completely but I have a good overview. Two things. First I started with the API calls then tracing them up to the repos then VMs then to widgets and screens. Second, I checked/mapped out the builders in the widgets which is similar to the BlocBuilders in Cubit.

### 2) If you owned this app for the next year, what's the first thing you'd change?

- Since this app already has a solid foundation code-wise, I'll prepare for better scaling. It would be a set of changes though and not a single one. Changes would be like implementing CI/CD, more helpful logging (ideally utiilizing Firebase Crashlytics), and API resilience (timeouts, retries, etc.).

### 3) What three questions would you ask the team before starting?

- Do we have some technical or feature scope overview documentation?
- How's the current feedback of our app in production (from end-users and key stakeholders)?
- What are the high priority goals for the next couple of months?
145 changes: 145 additions & 0 deletions compass_app/app/integration_test/app_booking_flow_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import 'package:compass_app/config/dependencies.dart';
import 'package:compass_app/data/repositories/auth/auth_repository.dart';
import 'package:compass_app/main.dart';
import 'package:compass_app/ui/activities/widgets/activities_screen.dart';
import 'package:compass_app/ui/auth/login/widgets/login_screen.dart';
import 'package:compass_app/ui/booking/widgets/booking_screen.dart';
import 'package:compass_app/ui/core/ui/custom_checkbox.dart';
import 'package:compass_app/ui/core/ui/home_button.dart';
import 'package:compass_app/ui/home/widgets/home_screen.dart';
import 'package:compass_app/ui/results/widgets/result_card.dart';
import 'package:compass_app/ui/results/widgets/results_screen.dart';
import 'package:compass_app/ui/search_form/widgets/search_form_guests.dart';
import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart';
import 'package:compass_app/ui/search_form/widgets/search_form_submit.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

// mocked AuthRepository to control the authentication state in the test and trigger UI changes.
class TestAuthRepository extends AuthRepository {
bool _authenticated = false;

@override
Future<bool> get isAuthenticated => Future.value(_authenticated);

@override
Future<Result<void>> login({
required String email,
required String password,
}) async {
_authenticated = true;
notifyListeners();
return const Result.ok(null);
}

@override
Future<Result<void>> logout() async {
_authenticated = false;
notifyListeners();
return const Result.ok(null);
}
}

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

group('end-to-end booking flow', () {
testWidgets(
'starts logged out, creates itinerary, and go back to home screen',
(tester) async {
// clears data to ensure the app starts logged out, then logs in with a test auth repository.
final sharedPreferences = await SharedPreferences.getInstance();
await sharedPreferences.clear();
final testAuthRepository = TestAuthRepository();

await tester.pumpWidget(
MultiProvider(
providers: [
...providersLocal,
ChangeNotifierProvider<AuthRepository>.value(
value:
testAuthRepository, // override the AuthRepository with our test implementation
),
],
child: const MainApp(),
),
);
await tester.pumpAndSettle();

// Auth-gated entry with local data.
expect(find.byType(LoginScreen), findsOneWidget);
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();

expect(find.byType(HomeScreen), findsOneWidget);

// Select create new booking
await tester.tap(find.byKey(const ValueKey(bookingButtonKey)));
await tester.pumpAndSettle();
expect(find.byType(SearchFormScreen), findsOneWidget);

// Search destinations screen
await tester.tap(find.text('Europe'), warnIfMissed: false);

await tester.tap(find.text('Add Dates'));
await tester.pumpAndSettle();
final tomorrow = DateTime.now().add(const Duration(days: 1)).day;
final nextDay = DateTime.now().add(const Duration(days: 2)).day;
await tester.tap(find.text(tomorrow.toString()).first);
await tester.pumpAndSettle();
await tester.tap(find.text(nextDay.toString()).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();

// Select guests
await tester.tap(find.byKey(const ValueKey(addGuestsKey)));
await tester.pumpAndSettle();

// Perform search and navigate to next screen
await tester.tap(find.byKey(const ValueKey(searchFormSubmitButtonKey)));
await tester.pumpAndSettle();

// Results Screen
expect(find.byType(ResultsScreen), findsOneWidget);

// Select a destination
final firstResultCardFinder = find.byType(ResultCard).first;
expect(firstResultCardFinder, findsOneWidget);
final selectedDestination = tester
.widget<ResultCard>(firstResultCardFinder)
.destination;
await tester.tap(firstResultCardFinder);
await tester.pumpAndSettle();

// Activities Screen
expect(find.byType(ActivitiesScreen), findsOneWidget);

// Select one activity
await tester.tap(find.byType(CustomCheckbox).first);
await tester.pumpAndSettle();
expect(find.text('1 selected'), findsOneWidget);

await tester.tap(find.byKey(const ValueKey(confirmButtonKey)));
await tester.pumpAndSettle();
expect(find.byType(BookingScreen), findsOneWidget);
expect(find.text(selectedDestination.name), findsOneWidget);

// Verify booking is visible on Home.
await tester.tap(find.byType(HomeButton).first);
await tester.pumpAndSettle();
expect(find.byType(HomeScreen), findsOneWidget);
expect(
find.text(
'${selectedDestination.name}, ${selectedDestination.continent}',
),
findsOneWidget,
);
},
);
});
}