# HTTP Tests

`Http.fake()` lets you stub HTTP responses by URL or callback so controller and service tests run without a live server, with assertions to verify which requests were actually made.

- [Introduction](#introduction)
- [Faking Responses](#faking-responses)
    - [URL Pattern Stubs](#url-pattern-stubs)
    - [Callback Stubs](#callback-stubs)
- [Making Assertions](#making-assertions)
- [Preventing Stray Requests](#preventing-stray-requests)
- [Response Factory](#response-factory)
- [Unfaking](#unfaking)
- [Full Example](#full-example)
- [Testing fetchList and fetchOne](#testing-fetchlist-and-fetchone)
- [Related Documentation](#related-documentation)

## Introduction

Magic provides a first-class HTTP faking API that lets you swap the real network driver with a `FakeNetworkDriver` during tests. All requests are recorded and no real network traffic is made. This approach is inspired by Laravel's `Http::fake()` and requires no third-party mocking libraries.

```dart
setUp(() {
  MagicApp.reset();
  Magic.flush();

  Http.fake(); // Intercept all HTTP requests
});

tearDown(() {
  Http.unfake(); // Restore real driver
});
```

## Faking Responses

Call `Http.fake()` before the code under test runs. When called with no arguments, every request returns a 200 response with empty data.

```dart
final fake = Http.fake();

final response = await Http.get('/users');

expect(response.successful, isTrue);
expect(response.data, equals({}));
```

### URL Pattern Stubs

Pass a `Map<String, MagicResponse>` to stub specific URL patterns. Patterns support `*` as a wildcard.

```dart
final fake = Http.fake({
  'users/*': Http.response({'id': 1, 'name': 'Alice'}, 200),
  'posts': Http.response([], 200),
  'auth/login': Http.response({'token': 'test-token'}, 200),
});

final user = await Http.get('/users/42');
expect(user['name'], 'Alice');

final posts = await Http.get('/posts');
expect(posts.data, isEmpty);
```

Pattern matching is done after stripping the leading `/` so both `/users/42` and `users/42` match the pattern `users/*`.

### Callback Stubs

Pass a `FakeRequestHandler` (a function that receives a `MagicRequest` and returns a `MagicResponse`, or a `Future<MagicResponse>`) for dynamic stubbing logic.

```dart
final fake = Http.fake((request) {
  if (request.url.contains('admin')) {
    return Http.response({'error': 'Forbidden'}, 403);
  }

  return Http.response({'ok': true}, 200);
});

final adminResponse = await Http.get('/admin/users');
expect(adminResponse.forbidden, isTrue);

final publicResponse = await Http.get('/users');
expect(publicResponse.successful, isTrue);
```

#### Holding a request in flight

An `async` handler lets a test keep a request OUTSTANDING while it does
something else, which is the only way to script a concurrency case: what
happens when a sign-out, a navigation or a second call arrives before the
first response lands.

```dart
final gate = Completer<void>();

Http.fake((request) async {
  await gate.future;
  return Http.response({'ok': true}, 200);
});

final inFlight = controller.submit();   // outstanding, not finished

await controller.signOut();             // arrives mid-request
gate.complete();
await inFlight;
```

## Making Assertions

`Http.fake()` returns the `FakeNetworkDriver` instance. Use it to assert on recorded requests after the code under test runs.

### `assertSent`

Assert that at least one recorded request matches the predicate.

```dart
final fake = Http.fake();

await Http.post('/users', data: {'name': 'Bob'});

fake.assertSent((request) => request.url.contains('users'));
fake.assertSent(
  (request) => request.method == 'POST' && request.url == '/users',
);
```

### `assertNotSent`

Assert that no recorded request matches the predicate.

```dart
fake.assertNotSent((request) => request.url.contains('payments'));
```

### `assertNothingSent`

Assert that no requests were made at all.

```dart
final fake = Http.fake();

// Code that should not touch the network
doSomethingLocal();

fake.assertNothingSent();
```

### `assertSentCount`

Assert an exact number of requests were recorded.

```dart
final fake = Http.fake();

await Http.get('/users');
await Http.get('/users/1');

fake.assertSentCount(2);
```

### `recorded`

Access the full list of recorded `(MagicRequest, MagicResponse)` pairs for custom assertions.

```dart
final fake = Http.fake();

await Http.post('/orders', data: {'item': 'Widget'});

final pair = fake.recorded.first;
expect(pair.$1.method, 'POST');
expect(pair.$2.statusCode, 200);
```

## Preventing Stray Requests

Call `preventStrayRequests()` on the fake driver to throw a `StrayRequestException` whenever an unmatched request is made. This ensures every HTTP call in your test has an explicit stub.

```dart
final fake = Http.fake({
  'users': Http.response([], 200),
})..preventStrayRequests();

// This is fine: matched by stub
await Http.get('/users');

// This throws StrayRequestException: no matching stub
await Http.get('/notifications'); // throws!
```

> [!TIP]
> Enable `preventStrayRequests()` in tests that should be fully isolated from external services to catch accidental network calls early.

## Response Factory

`Http.response()` is a convenience factory for building `MagicResponse` objects to use as stub responses.

```dart
// 200 with empty data (default)
Http.response()

// 200 with a Map body
Http.response({'id': 1, 'name': 'Alice'})

// Custom status code
Http.response({'message': 'Not found'}, 404)

// List body
Http.response([{'id': 1}, {'id': 2}], 200)

// 422 validation errors
Http.response({
  'message': 'The given data was invalid.',
  'errors': {
    'email': ['The email field is required.'],
  },
}, 422)
```

## Unfaking

Call `Http.unfake()` in `tearDown` to remove the fake driver from the IoC container and restore the real network driver for subsequent tests.

```dart
tearDown(() {
  Http.unfake();
});
```

Alternatively, you can call `fake.reset()` on the driver instance to clear recorded requests and stubs without restoring the real driver. This is useful when reusing the same fake across multiple test cases.

```dart
setUp(() {
  MagicApp.reset();
  Magic.flush();
});

// In a group where all tests share one fake:
final fake = Http.fake();

test('first test', () async {
  await Http.get('/a');
  fake.assertSentCount(1);
  fake.reset(); // Clear for next test
});

test('second test', () async {
  await Http.get('/b');
  fake.assertSentCount(1);
});
```

## Full Example

A controller that fetches a user profile and the test verifying it:

```dart
// lib/app/controllers/profile_controller.dart
class ProfileController extends MagicController
    with MagicStateMixin<Map<String, dynamic>>, ValidatesRequests {
  Future<void> load(String userId) async {
    setLoading();

    final response = await Http.show('users', userId);

    if (response.successful) {
      setSuccess(response.data as Map<String, dynamic>);
    } else {
      setError(response.firstError ?? 'Failed to load profile');
    }
  }
}

// test/app/controllers/profile_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';

void main() {
  group('ProfileController', () {
    late ProfileController controller;
    late FakeNetworkDriver fake;

    setUp(() {
      MagicApp.reset();
      Magic.flush();

      controller = ProfileController();
      Magic.put<ProfileController>(controller);

      fake = Http.fake();
    });

    tearDown(() {
      Http.unfake();
    });

    test('sets success state on 200 response', () async {
      fake.stub(
        'users/*',
        Http.response({'id': '42', 'name': 'Alice'}, 200),
      );

      await controller.load('42');

      expect(controller.isSuccess, isTrue);
      expect(controller.rxState?['name'], 'Alice');
      fake.assertSent((r) => r.url.contains('users/42'));
    });

    test('sets error state on failure', () async {
      fake.stub(
        'users/*',
        Http.response({'message': 'User not found'}, 404),
      );

      await controller.load('999');

      expect(controller.isError, isTrue);
    });
  });
}
```

## Testing fetchList and fetchOne

Controllers that use `fetchList()` or `fetchOne()` from `MagicStateMixin` can be tested with `Http.fake()` the same way as any other HTTP call. Stub the URL the helper will request, then assert on the resulting controller state.

### fetchList

```dart
// lib/app/controllers/project_controller.dart
class ProjectController extends MagicController
    with MagicStateMixin<List<Project>> {
  Future<void> loadProjects(String teamId) =>
      fetchList('teams/$teamId/projects', Project.fromMap);
}

// test/app/controllers/project_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:my_app/app/controllers/project_controller.dart';

void main() {
  group('ProjectController.fetchList', () {
    late ProjectController controller;
    late FakeNetworkDriver fake;

    setUp(() {
      MagicApp.reset();
      Magic.flush();

      controller = ProjectController();
      Magic.put<ProjectController>(controller);

      fake = Http.fake({
        'teams/*/projects': Http.response({
          'data': [
            {'id': 1, 'name': 'Project A'},
            {'id': 2, 'name': 'Project B'},
          ],
        }, 200),
      });
    });

    tearDown(() => Http.unfake());

    test('sets success state with parsed list', () async {
      await controller.loadProjects('team-1');

      expect(controller.isSuccess, isTrue);
      expect(controller.rxState?.length, 2);
      expect(controller.rxState?.first.name, 'Project A');
      fake.assertSent((r) => r.url.contains('teams/team-1/projects'));
    });

    test('sets empty state when data list is empty', () async {
      fake.stub('teams/*/projects', Http.response({'data': []}, 200));

      await controller.loadProjects('team-1');

      expect(controller.isEmpty, isTrue);
    });

    test('sets error state on failed response', () async {
      fake.stub(
        'teams/*/projects',
        Http.response({'message': 'Unauthorized'}, 401),
      );

      await controller.loadProjects('team-1');

      expect(controller.isError, isTrue);
    });
  });
}
```

### fetchOne

```dart
// lib/app/controllers/project_detail_controller.dart
class ProjectDetailController extends MagicController
    with MagicStateMixin<Project> {
  Future<void> loadProject(String id) =>
      fetchOne('projects/$id', Project.fromMap);
}

// test/app/controllers/project_detail_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:my_app/app/controllers/project_detail_controller.dart';

void main() {
  group('ProjectDetailController.fetchOne', () {
    late ProjectDetailController controller;
    late FakeNetworkDriver fake;

    setUp(() {
      MagicApp.reset();
      Magic.flush();

      controller = ProjectDetailController();
      Magic.put<ProjectDetailController>(controller);

      fake = Http.fake({
        'projects/*': Http.response({
          'data': {'id': 1, 'name': 'Project A'},
        }, 200),
      });
    });

    tearDown(() => Http.unfake());

    test('sets success state with parsed model', () async {
      await controller.loadProject('1');

      expect(controller.isSuccess, isTrue);
      expect(controller.rxState?.name, 'Project A');
      fake.assertSent((r) => r.url.contains('projects/1'));
    });

    test('sets error state when data key is absent', () async {
      fake.stub('projects/*', Http.response({'meta': {}}, 200));

      await controller.loadProject('1');

      expect(controller.isError, isTrue);
    });
  });
}
```

> [!TIP]
> Use `fake.stub()` inside individual tests to override the default stub registered in `setUp`. This keeps the happy path in `setUp` and edge cases inline.

## Related Documentation

- [Getting Started](https://anilcancakir.com/magic/docs/testing/getting-started.md)
- [Facade Testing](https://anilcancakir.com/magic/docs/testing/facades.md): `Auth.fake()`, `Cache.fake()`, `Vault.fake()`, `Log.fake()`
- [HTTP Client](https://anilcancakir.com/magic/docs/basics/http-client.md)
