< Summary

Information
Class: ShopGuard.Core.Api.BookingApiClient
Assembly: ShopGuard.Core
File(s): /home/runner/work/ShopGuard/ShopGuard/ShopGuard/ShopGuard.Core/Api/BookingApiClient.cs
Line coverage
98%
Covered lines: 65
Uncovered lines: 1
Coverable lines: 66
Total lines: 153
Line coverage: 98.4%
Branch coverage
83%
Covered branches: 15
Total branches: 18
Branch coverage: 83.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%22100%
get_Token()100%11100%
AuthenticateAsync()100%11100%
PingAsync()100%11100%
GetBookingIdsAsync()100%11100%
GetBookingAsync()100%22100%
CreateBookingAsync()100%11100%
UpdateBookingAsync()100%11100%
DeleteBookingAsync()100%44100%
SendRawAsync(...)100%210%
AddAuthCookie(...)100%22100%
EnsureSuccessAsync()100%22100%
ToApiExceptionAsync()50%44100%
ReadAsAsync()50%22100%

File(s)

/home/runner/work/ShopGuard/ShopGuard/ShopGuard/ShopGuard.Core/Api/BookingApiClient.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http.Headers;
 3using System.Net.Http.Json;
 4using System.Text.Json;
 5using ShopGuard.Core.Models;
 6
 7namespace ShopGuard.Core.Api;
 8
 9/// <summary>
 10/// Typed client for the restful-booker API (https://restful-booker.herokuapp.com).
 11/// The HttpClient is injected so tests can mock the message handler;
 12/// its BaseAddress must point to the API root.
 13/// </summary>
 14public sealed class BookingApiClient
 15{
 116    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
 17
 18    private readonly HttpClient httpClient;
 19
 1520    public BookingApiClient(HttpClient httpClient)
 21    {
 1522        this.httpClient = httpClient;
 23        // restful-booker answers 418 "I'm a Teapot" when no Accept header is sent.
 1524        if (!httpClient.DefaultRequestHeaders.Accept.Any())
 25        {
 1526            httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
 27        }
 1528    }
 29
 30    /// <summary>Auth token used as cookie for write operations (PUT/PATCH/DELETE).</summary>
 1531    public string? Token { get; private set; }
 32
 33    /// <summary>
 34    /// Requests a token via POST /auth. The API answers HTTP 200 even for bad
 35    /// credentials and signals failure through a "reason" field instead.
 36    /// </summary>
 37    /// <returns>The token, or null when the credentials were rejected.</returns>
 38    public async Task<string?> AuthenticateAsync(string username, string password, CancellationToken ct = default)
 39    {
 640        var response = await httpClient.PostAsJsonAsync(
 641            "auth", new AuthRequest { Username = username, Password = password }, JsonOptions, ct);
 642        await EnsureSuccessAsync(response, ct);
 43
 644        var auth = await ReadAsAsync<AuthResponse>(response, ct);
 645        Token = auth.Token;
 646        return auth.Token;
 647    }
 48
 49    /// <summary>Checks API availability via GET /ping (returns 201 by contract).</summary>
 50    public async Task<bool> PingAsync(CancellationToken ct = default)
 51    {
 252        var response = await httpClient.GetAsync("ping", ct);
 253        return response.StatusCode == HttpStatusCode.Created;
 254    }
 55
 56    /// <summary>Returns all booking ids via GET /booking.</summary>
 57    public async Task<IReadOnlyList<int>> GetBookingIdsAsync(CancellationToken ct = default)
 58    {
 259        var response = await httpClient.GetAsync("booking", ct);
 260        await EnsureSuccessAsync(response, ct);
 61
 262        var ids = await ReadAsAsync<List<BookingId>>(response, ct);
 563        return ids.Select(b => b.Id).ToList();
 264    }
 65
 66    /// <summary>Returns a single booking via GET /booking/{id}, or null when it does not exist.</summary>
 67    public async Task<Booking?> GetBookingAsync(int bookingId, CancellationToken ct = default)
 68    {
 269        var response = await httpClient.GetAsync($"booking/{bookingId}", ct);
 270        if (response.StatusCode == HttpStatusCode.NotFound)
 71        {
 172            return null;
 73        }
 74
 175        await EnsureSuccessAsync(response, ct);
 176        return await ReadAsAsync<Booking>(response, ct);
 277    }
 78
 79    /// <summary>Creates a booking via POST /booking.</summary>
 80    public async Task<BookingCreatedResponse> CreateBookingAsync(Booking booking, CancellationToken ct = default)
 81    {
 282        var response = await httpClient.PostAsJsonAsync("booking", booking, JsonOptions, ct);
 283        await EnsureSuccessAsync(response, ct);
 184        return await ReadAsAsync<BookingCreatedResponse>(response, ct);
 185    }
 86
 87    /// <summary>Replaces a booking via PUT /booking/{id}. Requires a prior <see cref="AuthenticateAsync"/>.</summary>
 88    public async Task<Booking> UpdateBookingAsync(int bookingId, Booking booking, CancellationToken ct = default)
 89    {
 290        using var request = new HttpRequestMessage(HttpMethod.Put, $"booking/{bookingId}")
 291        {
 292            Content = JsonContent.Create(booking, options: JsonOptions),
 293        };
 294        AddAuthCookie(request);
 95
 196        var response = await httpClient.SendAsync(request, ct);
 197        await EnsureSuccessAsync(response, ct);
 198        return await ReadAsAsync<Booking>(response, ct);
 199    }
 100
 101    /// <summary>Deletes a booking via DELETE /booking/{id}. Requires a prior <see cref="AuthenticateAsync"/>.</summary>
 102    public async Task DeleteBookingAsync(int bookingId, CancellationToken ct = default)
 103    {
 2104        using var request = new HttpRequestMessage(HttpMethod.Delete, $"booking/{bookingId}");
 2105        AddAuthCookie(request);
 106
 2107        var response = await httpClient.SendAsync(request, ct);
 108        // The API answers 201 Created on successful delete — another documented quirk.
 2109        if (response.StatusCode is not (HttpStatusCode.Created or HttpStatusCode.OK))
 110        {
 1111            throw await ToApiExceptionAsync(response, ct);
 112        }
 1113    }
 114
 115    /// <summary>Sends a raw status-code probe; used by negative tests that bypass the typed methods.</summary>
 116    public Task<HttpResponseMessage> SendRawAsync(HttpRequestMessage request, CancellationToken ct = default)
 0117        => httpClient.SendAsync(request, ct);
 118
 119    private void AddAuthCookie(HttpRequestMessage request)
 120    {
 4121        if (Token is null)
 122        {
 1123            throw new InvalidOperationException(
 1124                "No auth token available. Call AuthenticateAsync before write operations.");
 125        }
 126
 3127        request.Headers.Add("Cookie", $"token={Token}");
 3128    }
 129
 130    private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct)
 131    {
 12132        if (!response.IsSuccessStatusCode)
 133        {
 1134            throw await ToApiExceptionAsync(response, ct);
 135        }
 11136    }
 137
 138    private static async Task<ApiException> ToApiExceptionAsync(HttpResponseMessage response, CancellationToken ct)
 139    {
 2140        var body = await response.Content.ReadAsStringAsync(ct);
 2141        return new ApiException(
 2142            response.StatusCode,
 2143            body,
 2144            $"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri} failed with {(int)response.StatusC
 2145    }
 146
 147    private static async Task<T> ReadAsAsync<T>(HttpResponseMessage response, CancellationToken ct)
 148    {
 11149        var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, ct);
 11150        return result ?? throw new ApiException(
 11151            response.StatusCode, null, "Response body was empty or could not be deserialized.");
 11152    }
 153}