diff --git a/src/FlightRecorder.BusinessLogic/Api/AeroDataBox/AeroDataBoxFlightsApi.cs b/src/FlightRecorder.BusinessLogic/Api/AeroDataBox/AeroDataBoxFlightsApi.cs
new file mode 100644
index 0000000..6ab39ad
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Api/AeroDataBox/AeroDataBoxFlightsApi.cs
@@ -0,0 +1,111 @@
+using FlightRecorder.Entities.Api;
+using FlightRecorder.Entities.Interfaces;
+using FlightRecorder.Entities.Logging;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.BusinessLogic.Api.AeroDataBox
+{
+ public class AeroDataBoxFlightsApi : ExternalApiBase, IFlightsApi
+ {
+ private const string DateFormat = "yyyy-MM-dd";
+
+ private readonly string _baseAddress;
+ private readonly string _host;
+ private readonly string _key;
+
+ public AeroDataBoxFlightsApi(
+ IFlightRecorderLogger logger,
+ IFlightRecorderHttpClient client,
+ string url,
+ string key)
+ : base(logger, client)
+ {
+ _baseAddress = url;
+ _key = key;
+
+ // The URL contains the protocol, host and base route (if any), but we need to extract the host name only
+ // to pass in the headers as the RapidAPI host, so capture the host and the full URL
+ Uri uri = new Uri(url);
+ _host = uri.Host;
+ }
+
+ ///
+ /// Look up flight details given a flight number
+ ///
+ ///
+ ///
+ ///
+ public async Task> LookupFlightByNumber(string number)
+ {
+ Logger.LogMessage(Severity.Info, $"Looking up flight {number}");
+ var properties = await MakeApiRequest(number);
+ return properties;
+ }
+
+ ///
+ /// Look up flight details given a flight number and date
+ ///
+ ///
+ ///
+ ///
+ public async Task> LookupFlightByNumberAndDate(string number, DateTime date)
+ {
+ Logger.LogMessage(Severity.Info, $"Looking up flight {number} on {date}");
+ var parameters = $"{number}/{date.ToString(DateFormat)}";
+ var properties = await MakeApiRequest(parameters);
+ return properties;
+ }
+
+ ///
+ /// Make a request for flight details using the specified parameters
+ ///
+ ///
+ ///
+ private async Task> MakeApiRequest(string parameters)
+ {
+ Dictionary properties = null;
+
+ // Definte the properties to be
+ List definitions = new List
+ {
+ new ApiPropertyDefinition{ PropertyType = ApiPropertyType.DepartureAirportIATA, JsonPath = "departure.airport.iata" },
+ new ApiPropertyDefinition{ PropertyType = ApiPropertyType.DestinationAirportIATA, JsonPath = "arrival.airport.iata" },
+ new ApiPropertyDefinition{ PropertyType = ApiPropertyType.AirlineName, JsonPath = "airline.name" }
+ };
+
+ // Set the headers
+ SetHeaders(new Dictionary
+ {
+ { "X-RapidAPI-Host", _host },
+ { "X-RapidAPI-Key", _key }
+ });
+
+ // Make a request for the data from the API
+ var url = $"{_baseAddress}{parameters}";
+ var node = await SendRequest(url);
+
+ if (node != null)
+ {
+ try
+ {
+ // Extract the required properties from the response
+ properties = GetPropertyValuesFromResponse(node![0], definitions);
+
+ // Log the properties dictionary
+ LogProperties(properties!);
+ }
+ catch (Exception ex)
+ {
+ var message = $"Error processing response: {ex.Message}";
+ Logger.LogMessage(Severity.Error, message);
+ Logger.LogException(ex);
+ properties = null;
+ }
+ }
+
+ return properties;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Api/ExternalApiBase.cs b/src/FlightRecorder.BusinessLogic/Api/ExternalApiBase.cs
new file mode 100644
index 0000000..171e7f3
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Api/ExternalApiBase.cs
@@ -0,0 +1,143 @@
+using FlightRecorder.Entities.Api;
+using FlightRecorder.Entities.Interfaces;
+using FlightRecorder.Entities.Logging;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.BusinessLogic.Api
+{
+ public abstract class ExternalApiBase
+ {
+ private readonly IFlightRecorderHttpClient _client;
+ protected IFlightRecorderLogger Logger { get; private set; }
+
+ protected ExternalApiBase(IFlightRecorderLogger logger, IFlightRecorderHttpClient client)
+ {
+ Logger = logger;
+ _client = client;
+ }
+
+ ///
+ /// Set the request headers
+ ///
+ ///
+ protected virtual void SetHeaders(Dictionary headers)
+ => _client.SetHeaders(headers);
+
+ ///
+ /// Make a request to the specified URL and return the response properties as a JSON DOM
+ ///
+ ///
+ ///
+ protected virtual async Task SendRequest(string endpoint)
+ {
+ JsonNode node = null;
+
+ try
+ {
+ // Make a request for the data from the API
+ using (var response = await _client.GetAsync(endpoint))
+ {
+ // Check the request was successful
+ if (response.IsSuccessStatusCode)
+ {
+ // Read the response, parse to a JSON DOM
+ var json = await response.Content.ReadAsStringAsync();
+ node = JsonNode.Parse(json);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ var message = $"Error calling {endpoint}: {ex.Message}";
+ Logger.LogMessage(Severity.Error, message);
+ Logger.LogException(ex);
+ node = null;
+ }
+
+ return node;
+ }
+
+ ///
+ /// Given a JSON node and the path to an element, return the value at that element
+ ///
+ ///
+ ///
+ ///
+ private static string GetPropertyValueByPath(JsonNode node, ApiPropertyDefinition definition)
+ {
+ string value = null;
+ var current = node;
+
+ // Walk the JSON document to the requested element
+ foreach (var element in definition.JsonPath.Split(".", StringSplitOptions.RemoveEmptyEntries))
+ {
+ current = current?[element];
+ }
+
+ // Check the element is a type that can yield a value
+ if (current is JsonValue)
+ {
+ // Extract the value as a string and if "cleanup" has been specified perform it
+ value = current?.GetValue();
+ }
+
+ return value;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected virtual Dictionary GetPropertyValuesFromResponse(JsonNode node, IEnumerable propertyDefinitions)
+ {
+ var properties = new Dictionary();
+
+ // Iterate over the property definitions
+ foreach (var definition in propertyDefinitions)
+ {
+ // Get the value from
+ var value = GetPropertyValueByPath(node, definition);
+ properties.Add(definition.PropertyType, value ?? "");
+ }
+
+ // Log the properties dictionary
+ LogProperties(properties!);
+
+ return properties;
+ }
+
+ ///
+ /// Log the content of a properties dictionary resulting from an external API call
+ ///
+ ///
+ [ExcludeFromCodeCoverage]
+ protected void LogProperties(Dictionary properties)
+ {
+ // Check the properties dictionary isn't NULL
+ if (properties != null)
+ {
+ // Not a NULL dictionary, so iterate over all the properties it contains
+ foreach (var property in properties)
+ {
+ // Construct a message containing the property name and the value, replacing
+ // null values with "NULL"
+ var value = property.Value != null ? property.Value.ToString() : "NULL";
+ var message = $"API property {property.Key.ToString()} = {value}";
+
+ // Log the message for this property
+ Logger.LogMessage(Severity.Info, message);
+ }
+ }
+ else
+ {
+ // Log the fact that the properties dictionary is NULL
+ Logger.LogMessage(Severity.Warning, "API lookup generated a NULL properties dictionary");
+ }
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Api/FlightRecorderHttpClient.cs b/src/FlightRecorder.BusinessLogic/Api/FlightRecorderHttpClient.cs
new file mode 100644
index 0000000..d5a14a3
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Api/FlightRecorderHttpClient.cs
@@ -0,0 +1,61 @@
+using FlightRecorder.Entities.Interfaces;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.BusinessLogic.Api
+{
+ [ExcludeFromCodeCoverage]
+ public class FlightRecorderHttpClient : IFlightRecorderHttpClient
+ {
+ private readonly static HttpClient _client = new();
+ private static FlightRecorderHttpClient? _instance = null;
+ private readonly static object _lock = new();
+
+ private FlightRecorderHttpClient() { }
+
+ ///
+ /// Return the singleton instance of the client
+ ///
+ public static FlightRecorderHttpClient Instance
+ {
+ get
+ {
+ if (_instance == null)
+ {
+ lock (_lock)
+ {
+ if (_instance == null)
+ {
+ _instance = new FlightRecorderHttpClient();
+ }
+ }
+ }
+
+ return _instance;
+ }
+ }
+
+ ///
+ /// Set the request headers
+ ///
+ ///
+ public void SetHeaders(Dictionary headers)
+ {
+ _client.DefaultRequestHeaders.Clear();
+ foreach (var header in headers)
+ {
+ _client.DefaultRequestHeaders.Add(header.Key, header.Value);
+ }
+ }
+
+ ///
+ /// Send a GET request to the specified URI and return the response
+ ///
+ ///
+ ///
+ public async Task GetAsync(string uri)
+ => await _client.GetAsync(uri);
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/AircraftManager.cs b/src/FlightRecorder.BusinessLogic/Database/AircraftManager.cs
new file mode 100644
index 0000000..7c24834
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/AircraftManager.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.BusinessLogic.Factory;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class AircraftManager : IAircraftManager
+ {
+ private const int AllModelsPageSize = 1000000;
+
+ private readonly FlightRecorderFactory _factory;
+
+ internal AircraftManager(FlightRecorderFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Get the first aircraft matching the specified criteria along with the associated model
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List aircraft = await ListAsync(predicate, 1, 1).ToListAsync();
+ return aircraft.FirstOrDefault();
+ }
+
+ ///
+ /// Get the aircraft matching the specified criteria along with the associated models
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable aircraft;
+
+ if (predicate == null)
+ {
+ aircraft = _factory.Context.Aircraft
+ .Include(a => a.Model)
+ .ThenInclude(m => m.Manufacturer)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ aircraft = _factory.Context.Aircraft
+ .Include(a => a.Model)
+ .ThenInclude(m => m.Manufacturer)
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return aircraft;
+ }
+
+ ///
+ /// Get the aircraft of a specified model
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByModelAsync(string modelName, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable matches = null;
+
+ modelName = modelName.CleanString();
+ Model model = await _factory.Models.GetAsync(m => m.Name == modelName);
+ if (model != null)
+ {
+ matches = ListAsync(m => m.ModelId == model.Id, pageNumber, pageSize);
+ }
+
+ return matches;
+ }
+
+ ///
+ /// Get the aircraft manufactured by a given manufacturer
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByManufacturerAsync(string manufacturerName, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable matches = null;
+
+ manufacturerName = manufacturerName.CleanString();
+ Manufacturer manufacturer = await _factory.Manufacturers
+ .GetAsync(m => m.Name == manufacturerName);
+ if (manufacturer != null)
+ {
+ // Model retrieval uses an arbitrarily large page size to retrieve all models
+ List modelIds = await _factory.Models
+ .ListAsync(m => m.ManufacturerId == manufacturer.Id, 1, AllModelsPageSize)
+ .Select(m => m.Id)
+ .ToListAsync();
+ if (modelIds.Any())
+ {
+ matches = ListAsync(a => modelIds.Contains(a.ModelId ?? -1), pageNumber, pageSize);
+ }
+ }
+
+ return matches;
+ }
+
+ ///
+ /// Add an aircraft
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task AddAsync(string registration, string serialNumber, long? yearOfManufacture, string modelName, string manufacturerName)
+ {
+ registration = registration.CleanString().ToUpper();
+ Aircraft aircraft = await GetAsync(a => a.Registration == registration);
+
+ if (aircraft == null)
+ {
+ // If partial aircraft details have been supplied, the manufacturer and/or model may not
+ // be specified
+ long? modelId = null;
+ if (!string.IsNullOrEmpty(manufacturerName) && !string.IsNullOrEmpty(modelName))
+ {
+ Model model = await _factory.Models.AddAsync(modelName, manufacturerName);
+ modelId = model.Id;
+ }
+
+ // Similarly, the serial number should be cleaned and then treated as null when assigning
+ // to the new aircraft, below, if the result is empty
+ var cleanSerialNumber = serialNumber.CleanString().ToUpper();
+ var haveSerialNumber = !string.IsNullOrEmpty(cleanSerialNumber);
+
+ // Finally, year of manufacture should only be stored if we have a model or serial number
+ long? manufactured = haveSerialNumber && (modelId != null) ? yearOfManufacture : null;
+
+ aircraft = new Aircraft
+ {
+ Registration = registration,
+ SerialNumber = haveSerialNumber ? cleanSerialNumber : null,
+ Manufactured = manufactured,
+ ModelId = modelId
+ };
+
+ await _factory.Context.AddAsync(aircraft);
+ await _factory.Context.SaveChangesAsync();
+ await _factory.Context.Entry(aircraft).Reference(m => m.Model).LoadAsync();
+ if (aircraft.Model != null)
+ {
+ await _factory.Context.Entry(aircraft.Model).Reference(m => m.Manufacturer).LoadAsync();
+ }
+ }
+
+ return aircraft;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/AirlineManager.cs b/src/FlightRecorder.BusinessLogic/Database/AirlineManager.cs
new file mode 100644
index 0000000..2d9c7a9
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/AirlineManager.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class AirlineManager : IAirlineManager
+ {
+ private readonly FlightRecorderDbContext _context;
+
+ internal AirlineManager(FlightRecorderDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Get the first airline matching the specified criteria
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List airlines = await ListAsync(predicate, 1, 1).ToListAsync();
+ return airlines.FirstOrDefault();
+ }
+
+ ///
+ /// Return all entities matching the specified criteria
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable results;
+ if (predicate == null)
+ {
+ results = _context.Airlines
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ results = _context.Airlines.Where(predicate).AsAsyncEnumerable();
+ }
+
+ return results;
+ }
+
+ ///
+ /// Add a named airline, if it doesn't already exist
+ ///
+ ///
+ ///
+ public async Task AddAsync(string name)
+ {
+ name = name.CleanString();
+ Airline airline = await GetAsync(a => a.Name == name);
+
+ if (airline == null)
+ {
+ airline = new Airline { Name = name };
+ await _context.Airlines.AddAsync(airline);
+ await _context.SaveChangesAsync();
+ }
+
+ return airline;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/AirportManager.cs b/src/FlightRecorder.BusinessLogic/Database/AirportManager.cs
new file mode 100644
index 0000000..f0d33c0
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/AirportManager.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.BusinessLogic.Factory;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class AirportManager : IAirportManager
+ {
+ private readonly FlightRecorderFactory _factory;
+
+ internal AirportManager(FlightRecorderFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Get the first airport matching the specified criteria along with the associated airline
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List airports = await ListAsync(predicate, 1, 1).ToListAsync();
+ return airports.FirstOrDefault();
+ }
+
+ ///
+ /// Get the airports matching the specified criteria along with the associated airlines
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable airports;
+
+ if (predicate == null)
+ {
+ airports = _factory.Context.Airports
+ .Include(m => m.Country)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ airports = _factory.Context.Airports
+ .Include(m => m.Country)
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return airports;
+ }
+
+ ///
+ /// Add a new airport
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task AddAsync(string code, string name, string countryName)
+ {
+ code = code.CleanString().ToUpper();
+ name = name.CleanString();
+ countryName = countryName.CleanString();
+ Airport airport = await GetAsync(a => (a.Code == code));
+
+ if (airport == null)
+ {
+ Country country = await _factory.Countries.AddAsync(countryName);
+
+ airport = new Airport
+ {
+ Code = code,
+ Name = name,
+ CountryId = country.Id
+ };
+
+ await _factory.Context.Airports.AddAsync(airport);
+ await _factory.Context.SaveChangesAsync();
+ await _factory.Context.Entry(airport).Reference(m => m.Country).LoadAsync();
+ }
+
+ return airport;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/CountryManager.cs b/src/FlightRecorder.BusinessLogic/Database/CountryManager.cs
new file mode 100644
index 0000000..f6c50d0
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/CountryManager.cs
@@ -0,0 +1,80 @@
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class CountryManager : ICountryManager
+ {
+ private readonly FlightRecorderDbContext _context;
+
+ internal CountryManager(FlightRecorderDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Get the first airline matching the specified criteria
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List countries = await ListAsync(predicate, 1, 1).ToListAsync();
+ return countries.FirstOrDefault();
+ }
+
+ ///
+ /// Return all entities matching the specified criteria
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable results;
+ if (predicate == null)
+ {
+ results = _context.Countries
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ results = _context.Countries.Where(predicate).AsAsyncEnumerable();
+ }
+
+ return results;
+ }
+
+ ///
+ /// Add a named airline, if it doesn't already exist
+ ///
+ ///
+ ///
+ public async Task AddAsync(string name)
+ {
+ name = name.CleanString();
+ Country country = await GetAsync(a => a.Name == name);
+
+ if (country == null)
+ {
+ country = new Country { Name = name };
+ await _context.Countries.AddAsync(country);
+ await _context.SaveChangesAsync();
+ }
+
+ return country;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/DateBasedReport.cs b/src/FlightRecorder.BusinessLogic/Database/DateBasedReport.cs
new file mode 100644
index 0000000..0d19e9d
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/DateBasedReport.cs
@@ -0,0 +1,67 @@
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Interfaces;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ [ExcludeFromCodeCoverage]
+ internal class DateBasedReport : ReportManagerBase, IDateBasedReport where T : class
+ {
+ private const string DateFormat = "yyyy-MM-dd";
+ private const string FromDatePlaceHolder = "$from";
+ private const string ToDatePlaceHolder = "$to";
+
+ internal DateBasedReport(FlightRecorderDbContext context) : base(context)
+ {
+ }
+
+ ///
+ /// Generate a datebased report for reporting entity type T
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> GenerateReportAsync(DateTime? from, DateTime? to, int pageNumber, int pageSize)
+ {
+ // SQL report files are named after the keyless entity type they map to with a .sql extension
+ var sqlFile = $"{typeof(T).Name}.sql";
+
+ // Load the SQL file and perform date range place-holder replacements
+ var query = ReadDateBasedSqlReportResource(sqlFile, from, to);
+
+ // Run the query and return the results
+ var results = await GenerateReportAsync(query, pageNumber, pageSize);
+ return results;
+ }
+
+ ///
+ /// Read the SQL report file for a sightings-based report with a date range in it
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static string ReadDateBasedSqlReportResource(string reportFile, DateTime? from, DateTime? to)
+ {
+ // Get non-NULL versions of the from and to dates
+ var nonNullFromDate = (from ?? DateTime.MinValue).ToString(DateFormat);
+ var nonNullToDate = (to ?? DateTime.MaxValue).ToString(DateFormat);
+
+ // Read and return the query, replacing the date range parameters
+ var query = ReadSqlResource(reportFile, new Dictionary
+ {
+ { FromDatePlaceHolder, nonNullFromDate },
+ { ToDatePlaceHolder, nonNullToDate }
+
+ });
+
+ return query;
+ }
+
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/FlightManager.cs b/src/FlightRecorder.BusinessLogic/Database/FlightManager.cs
new file mode 100644
index 0000000..16fd66a
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/FlightManager.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.BusinessLogic.Factory;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class FlightManager : IFlightManager
+ {
+ private readonly FlightRecorderFactory _factory;
+
+ internal FlightManager(FlightRecorderFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Get the first flight matching the specified criteria along with the associated airline
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List flights = await ListAsync(predicate, 1, 1).ToListAsync();
+ return flights.FirstOrDefault();
+ }
+
+ ///
+ /// Get the flights matching the specified criteria along with the associated airlines
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable flights;
+
+ if (predicate == null)
+ {
+ flights = _factory.Context.Flights
+ .Include(m => m.Airline)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ flights = _factory.Context.Flights
+ .Include(m => m.Airline)
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return flights;
+ }
+
+ ///
+ /// Get the flights for a named airline
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByAirlineAsync(string airlineName, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable matches = null;
+
+ airlineName = airlineName.CleanString();
+ Airline airline = await _factory.Airlines
+ .GetAsync(m => m.Name == airlineName);
+ if (airline != null)
+ {
+ matches = ListAsync(m => m.AirlineId == airline.Id, pageNumber, pageSize);
+ }
+
+ return matches;
+ }
+
+ ///
+ /// Add a new flight
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task AddAsync(string number, string embarkation, string destination, string airlineName)
+ {
+ number = number.CleanString().ToUpper();
+ embarkation = embarkation.CleanString().ToUpper();
+ destination = destination.CleanString().ToUpper();
+ Flight flight = await GetAsync(a => (a.Number == number) &&
+ (a.Embarkation == embarkation) &&
+ (a.Destination == destination));
+
+ if (flight == null)
+ {
+ Airline airline = await _factory.Airlines.AddAsync(airlineName);
+
+ flight = new Flight
+ {
+ Number = number,
+ Embarkation = embarkation,
+ Destination = destination,
+ AirlineId = airline.Id
+ };
+
+ await _factory.Context.Flights.AddAsync(flight);
+ await _factory.Context.SaveChangesAsync();
+ await _factory.Context.Entry(flight).Reference(m => m.Airline).LoadAsync();
+ }
+
+ return flight;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/JobStatusManager.cs b/src/FlightRecorder.BusinessLogic/Database/JobStatusManager.cs
new file mode 100644
index 0000000..fb09cbc
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/JobStatusManager.cs
@@ -0,0 +1,98 @@
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class JobStatusManager : IJobStatusManager
+ {
+ private readonly FlightRecorderDbContext _context;
+
+ internal JobStatusManager(FlightRecorderDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Get the first job status matching the specified criteria
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List statuses = await ListAsync(predicate, 1, 1).ToListAsync();
+ return statuses.FirstOrDefault();
+ }
+
+ ///
+ /// Return all entities matching the specified criteria
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable results;
+ if (predicate == null)
+ {
+ results = _context.JobStatuses
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ results = _context.JobStatuses.Where(predicate).AsAsyncEnumerable();
+ }
+
+ return results;
+ }
+
+ ///
+ /// Create a new job status
+ ///
+ ///
+ ///
+ public async Task AddAsync(string name, string parameters)
+ {
+ JobStatus status = new JobStatus
+ {
+ Name = name.CleanString(),
+ Parameters = parameters.CleanString(),
+ Start = DateTime.Now
+ };
+
+ await _context.JobStatuses.AddAsync(status);
+ await _context.SaveChangesAsync();
+
+ return status;
+ }
+
+ ///
+ /// Update a job status, setting the end timestamp and error result
+ ///
+ ///
+ ///
+ ///
+ public async Task UpdateAsync(long id, string error)
+ {
+ JobStatus status = await GetAsync(x => x.Id == id);
+ if (status != null)
+ {
+ status.End = DateTime.Now;
+ status.Error = error.CleanString();
+ await _context.SaveChangesAsync();
+ }
+
+ return status;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/LocationManager.cs b/src/FlightRecorder.BusinessLogic/Database/LocationManager.cs
new file mode 100644
index 0000000..22f5e62
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/LocationManager.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class LocationManager : ILocationManager
+ {
+ private readonly FlightRecorderDbContext _context;
+
+ internal LocationManager(FlightRecorderDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Return the first entity matching the specified criteria
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List locations = await ListAsync(predicate, 1, 1).ToListAsync();
+ return locations.FirstOrDefault();
+ }
+
+ ///
+ /// Return all entities matching the specified criteria
+ ///
+ ///
+ ///
+ ///
+ ///
+ public virtual IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable results;
+ if (predicate == null)
+ {
+ results = _context.Locations
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ results = _context.Locations
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return results;
+ }
+
+ ///
+ /// Add a named location, if it doesn't already exist
+ ///
+ ///
+ ///
+ public async Task AddAsync(string name)
+ {
+ name = name.CleanString();
+ Location location = await GetAsync(a => a.Name == name);
+
+ if (location == null)
+ {
+ location = new Location { Name = name };
+ await _context.Locations.AddAsync(location);
+ await _context.SaveChangesAsync();
+ }
+
+ return location;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/ManufacturerManager.cs b/src/FlightRecorder.BusinessLogic/Database/ManufacturerManager.cs
new file mode 100644
index 0000000..a7a1e58
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/ManufacturerManager.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class ManufacturerManager : IManufacturerManager
+ {
+ private readonly FlightRecorderDbContext _context;
+
+ internal ManufacturerManager(FlightRecorderDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Return the first entity matching the specified criteria
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List manufacturers = await ListAsync(predicate, 1, 1).ToListAsync();
+ return manufacturers.FirstOrDefault();
+ }
+
+ ///
+ /// Return all entities matching the specified criteria
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable results;
+ if (predicate == null)
+ {
+ results = _context.Manufacturers
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ results = _context.Manufacturers
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return results;
+ }
+
+ ///
+ /// Add a named manufacturer, if it doesn't already exist
+ ///
+ ///
+ ///
+ public async Task AddAsync(string name)
+ {
+ name = name.CleanString();
+ Manufacturer manufacturer = await GetAsync(a => a.Name == name);
+
+ if (manufacturer == null)
+ {
+ manufacturer = new Manufacturer { Name = name };
+ await _context.Manufacturers.AddAsync(manufacturer);
+ await _context.SaveChangesAsync();
+ }
+
+ return manufacturer;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/ModelManager.cs b/src/FlightRecorder.BusinessLogic/Database/ModelManager.cs
new file mode 100644
index 0000000..662cd84
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/ModelManager.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.BusinessLogic.Factory;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class ModelManager : IModelManager
+ {
+ private readonly FlightRecorderFactory _factory;
+
+ internal ModelManager(FlightRecorderFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Get the first model matching the specified criteria along with the associated manufacturer
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List models = await ListAsync(predicate, 1, 1).ToListAsync();
+ return models.FirstOrDefault();
+ }
+
+ ///
+ /// Get the models matching the specified criteria along with the associated manufacturers
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable models;
+
+ if (predicate == null)
+ {
+ models = _factory.Context.Models
+ .Include(m => m.Manufacturer)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ models = _factory.Context.Models
+ .Include(m => m.Manufacturer)
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return models;
+ }
+
+ ///
+ /// Get the models for a named manufacturer
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListByManufacturerAsync(string manufacturerName, int pageNumber, int pageSize)
+ {
+ manufacturerName = manufacturerName.CleanString();
+ IAsyncEnumerable models = _factory.Context.Models
+ .Include(m => m.Manufacturer)
+ .Where(m => m.Manufacturer.Name == manufacturerName)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ return models;
+ }
+
+ ///
+ /// Add a named model associated with the specified manufacturer, if it doesn't already exist
+ ///
+ ///
+ ///
+ ///
+ public async Task AddAsync(string name, string manufacturerName)
+ {
+ name = name.CleanString();
+ Model model = await GetAsync(a => a.Name == name);
+
+ if (model == null)
+ {
+ Manufacturer manufacturer = await _factory.Manufacturers.AddAsync(manufacturerName);
+ model = new Model { Name = name, ManufacturerId = manufacturer.Id };
+ await _factory.Context.Models.AddAsync(model);
+ await _factory.Context.SaveChangesAsync();
+ await _factory.Context.Entry(model).Reference(m => m.Manufacturer).LoadAsync();
+ }
+
+ return model;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/ReportManagerBase.cs b/src/FlightRecorder.BusinessLogic/Database/ReportManagerBase.cs
new file mode 100644
index 0000000..2b2a5b9
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/ReportManagerBase.cs
@@ -0,0 +1,120 @@
+using FlightRecorder.Data;
+using Microsoft.EntityFrameworkCore;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ [ExcludeFromCodeCoverage]
+ internal abstract class ReportManagerBase
+ {
+ private readonly FlightRecorderDbContext _context;
+
+ protected ReportManagerBase(FlightRecorderDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ /// Generate the report for entity type T given a query that returns rows mapping to that
+ /// type
+ ///
+ ///
+ ///
+ ///
+ protected async Task> GenerateReportAsync(string query, int pageNumber, int pageSize) where T : class
+ {
+ // Pagination using Skip and Take causes the database query to fail with FromSqlRaw, possible
+ // dependent on the DBS. To avoid this, the results are queried in two steps:
+ //
+ // 1) Query the database for all the report results and convert to a list
+ // 2) Extract the required page from the in-memory list
+ var all = await _context.Set().FromSqlRaw(query).ToListAsync();
+ var results = all.Skip((pageNumber - 1) * pageSize).Take(pageSize);
+ return results;
+ }
+
+ ///
+ /// Get the full path to a file held in the SQL sub-folder
+ ///
+ ///
+ ///
+ protected static string GetSqlFilePath(string fileName)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var assemblyFolder = Path.GetDirectoryName(assembly.Location);
+ var sqlFilePath = Path.Combine(assemblyFolder, "sql", fileName);
+ return sqlFilePath;
+ }
+
+ ///
+ /// Read a SQL file and perform placeholder value replacement
+ ///
+ ///
+ ///
+ protected static string ReadSqlFile(string file, Dictionary placeHolderValues)
+ {
+ string content = "";
+
+ // Open a stream reader to read the file content
+ using (var reader = new StreamReader(file))
+ {
+ // Read the file content
+ content = reader.ReadToEnd();
+
+ // Perform place holder replacement
+ content = ReplacePlaceHolders(content, placeHolderValues);
+ }
+
+ return content;
+ }
+
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ protected static string ReadSqlResource(string file, Dictionary placeHolderValues)
+ {
+ string content = "";
+
+ // Get the name of the resource and a resource stream for reading it
+ var assembly = Assembly.GetExecutingAssembly();
+ var sqlResourceName = $"FlightRecorder.BusinessLogic.Sql.{file}";
+ var resourceStream = assembly.GetManifestResourceStream(sqlResourceName);
+
+ // Open a stream reader to read the file content
+ using (var reader = new StreamReader(resourceStream))
+ {
+ // Read the file content
+ content = reader.ReadToEnd();
+
+ // Perform place holder replacement
+ content = ReplacePlaceHolders(content, placeHolderValues);
+ }
+
+ return content;
+ }
+
+ ///
+ /// Perform place holder replacement on content read from an embedded resource or SQL file
+ ///
+ ///
+ ///
+ ///
+ private static string ReplacePlaceHolders(string content, Dictionary placeHolderValues)
+ {
+ foreach (var placeHolder in placeHolderValues.Keys)
+ {
+ content = content.Replace(placeHolder, placeHolderValues[placeHolder]);
+ }
+
+ return content;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/SightingManager.cs b/src/FlightRecorder.BusinessLogic/Database/SightingManager.cs
new file mode 100644
index 0000000..56c64d5
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/SightingManager.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Threading.Tasks;
+using FlightRecorder.BusinessLogic.Extensions;
+using FlightRecorder.BusinessLogic.Factory;
+using FlightRecorder.Entities.DataExchange;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ internal class SightingManager : ISightingManager
+ {
+ private const int AllFlightsPageSize = 1000000;
+
+ private readonly FlightRecorderFactory _factory;
+
+ internal SightingManager(FlightRecorderFactory factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ /// Get the first sighting matching the specified criteria along with the associated entities
+ ///
+ ///
+ ///
+ public async Task GetAsync(Expression> predicate)
+ {
+ List sightings = await ListAsync(predicate, 1, 1).ToListAsync();
+ return sightings.FirstOrDefault();
+ }
+
+ ///
+ /// Get the sightings matching the specified criteria along with the associated entities
+ ///
+ ///
+ ///
+ ///
+ ///
+ public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable sightings;
+
+ if (predicate == null)
+ {
+ sightings = _factory.Context.Sightings
+ .Include(s => s.Location)
+ .Include(s => s.Flight)
+ .ThenInclude(f => f.Airline)
+ .Include(s => s.Aircraft)
+ .ThenInclude(a => a.Model)
+ .ThenInclude(m => m.Manufacturer)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+ else
+ {
+ sightings = _factory.Context.Sightings
+ .Include(s => s.Location)
+ .Include(s => s.Flight)
+ .ThenInclude(f => f.Airline)
+ .Include(s => s.Aircraft)
+ .ThenInclude(a => a.Model)
+ .ThenInclude(m => m.Manufacturer)
+ .Where(predicate)
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .AsAsyncEnumerable();
+ }
+
+ return sightings;
+ }
+
+ ///
+ /// Add a new sighting
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task AddAsync(long altitude, DateTime date, long locationId, long flightId, long aircraftId)
+ {
+ Sighting sighting = new Sighting
+ {
+ Altitude = altitude,
+ Date = date,
+ LocationId = locationId,
+ FlightId = flightId,
+ AircraftId = aircraftId
+ };
+
+ await _factory.Context.Sightings.AddAsync(sighting);
+ await _factory.Context.SaveChangesAsync();
+
+ // Loading the related entities using the following syntax is problematic if the model and/or
+ // manufacturer are NULL on the aircraft related to the sighting:
+ //
+ // await _factory.Context.Entry(sighting.Aircraft).Reference(a => a.Model).LoadAsync();
+ // await _factory.Context.Entry(sighting.Aircraft.Model).Reference(m => m.Manufacturer).LoadAsync();
+ //
+ // Instead, reload the sighting as this handles the above case
+ sighting = await GetAsync(x => x.Id == sighting.Id);
+ return sighting;
+ }
+
+ ///
+ /// Add a sighting to the database based on a flattened representation of a sighting
+ ///
+ ///
+ ///
+ public async Task AddAsync(FlattenedSighting flattened)
+ {
+ long? yearOfManufacture = !string.IsNullOrEmpty(flattened.Age) ? DateTime.Now.Year - long.Parse(flattened.Age) : null;
+ long aircraftId = (await _factory.Aircraft.AddAsync(flattened.Registration, flattened.SerialNumber, yearOfManufacture, flattened.Model, flattened.Manufacturer)).Id;
+ long flightId = (await _factory.Flights.AddAsync(flattened.FlightNumber, flattened.Embarkation, flattened.Destination, flattened.Airline)).Id;
+ long locationId = (await _factory.Locations.AddAsync(flattened.Location)).Id;
+ return await AddAsync(flattened.Altitude, flattened.Date, locationId, flightId, aircraftId);
+ }
+
+ ///
+ /// Return a list of sightings of a specified aircraft
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByAircraftAsync(string registration, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable sightings = null;
+
+ registration = registration.CleanString().ToUpper();
+ Aircraft aircraft = await _factory.Aircraft.GetAsync(a => a.Registration == registration);
+ if (aircraft != null)
+ {
+ sightings = ListAsync(s => s.AircraftId == aircraft.Id, pageNumber, pageSize);
+ }
+
+ return sightings;
+ }
+
+ ///
+ /// Return a list of sightings for a specified route
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByRouteAsync(string embarkation, string destination, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable sightings = null;
+
+ embarkation = embarkation.CleanString().ToUpper();
+ destination = destination.CleanString().ToUpper();
+ List flights = await _factory.Flights
+ .ListAsync(f => (f.Embarkation == embarkation) &&
+ (f.Destination == destination), 1, AllFlightsPageSize)
+ .ToListAsync();
+ if (flights.Any())
+ {
+ IEnumerable flightIds = flights.Select(f => f.Id);
+ sightings = ListAsync(s => flightIds.Contains(s.FlightId), pageNumber, pageSize);
+ }
+
+ return sightings;
+ }
+
+ ///
+ /// Return a list of sightings for a specified airline
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByAirlineAsync(string airlineName, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable sightings = null;
+
+ airlineName = airlineName.CleanString();
+ IAsyncEnumerable matches = await _factory.Flights.ListByAirlineAsync(airlineName, 1, AllFlightsPageSize);
+ if (matches != null)
+ {
+ List flights = await matches.ToListAsync();
+ if (flights.Any())
+ {
+ IEnumerable flightIds = flights.Select(f => f.Id);
+ sightings = ListAsync(s => flightIds.Contains(s.FlightId), pageNumber, pageSize);
+ }
+ }
+
+ return sightings;
+ }
+
+ ///
+ /// Return a list of sightings at a specified location
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> ListByLocationAsync(string locationName, int pageNumber, int pageSize)
+ {
+ IAsyncEnumerable sightings = null;
+
+ locationName = locationName.CleanString();
+ Location location = await _factory.Locations.GetAsync(a => a.Name == locationName);
+ if (location != null)
+ {
+ sightings = ListAsync(s => s.LocationId == location.Id, pageNumber, pageSize);
+ }
+
+ return sightings;
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Database/UserManager.cs b/src/FlightRecorder.BusinessLogic/Database/UserManager.cs
new file mode 100644
index 0000000..4485d53
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Database/UserManager.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading.Tasks;
+using FlightRecorder.Data;
+using FlightRecorder.Entities.Db;
+using FlightRecorder.Entities.Exceptions;
+using FlightRecorder.Entities.Interfaces;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace FlightRecorder.BusinessLogic.Database
+{
+ public class UserManager : IUserManager
+ {
+ private readonly Lazy> _hasher;
+ private readonly FlightRecorderDbContext _context;
+
+ public UserManager(FlightRecorderDbContext context)
+ {
+ _hasher = new Lazy>(() => new PasswordHasher());
+ _context = context;
+ }
+
+ ///
+ /// Return the user with the specified Id
+ ///
+ ///
+ ///
+ public async Task GetUserAsync(int userId)
+ {
+ User user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
+ ThrowIfUserNotFound(user, userId);
+ return user;
+ }
+
+ ///
+ /// Return the user with the specified Id
+ ///
+ ///
+ ///
+ public async Task GetUserAsync(string userName)
+ {
+ User user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == userName);
+ ThrowIfUserNotFound(user, userName);
+ return user;
+ }
+
+ ///
+ /// Get all the current user details
+ ///
+ public IAsyncEnumerable GetUsersAsync() =>
+ _context.Users.AsAsyncEnumerable();
+
+ ///
+ /// Add a new user, given their details
+ ///
+ ///
+ ///
+ ///
+ public async Task AddUserAsync(string userName, string password)
+ {
+ User user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == userName);
+ ThrowIfUserFound(user, userName);
+
+ user = new User
+ {
+ UserName = userName,
+ Password = _hasher.Value.HashPassword(userName, password)
+ };
+
+ await _context.Users.AddAsync(user);
+ await _context.SaveChangesAsync();
+ return user;
+ }
+
+ ///
+ /// Authenticate the specified user
+ ///
+ ///
+ ///
+ ///
+ public async Task AuthenticateAsync(string userName, string password)
+ {
+ User user = await GetUserAsync(userName);
+ PasswordVerificationResult result = _hasher.Value.VerifyHashedPassword(userName, user.Password, password);
+ if (result == PasswordVerificationResult.SuccessRehashNeeded)
+ {
+ user.Password = _hasher.Value.HashPassword(userName, password);
+ await _context.SaveChangesAsync();
+ }
+ return result != PasswordVerificationResult.Failed;
+ }
+
+ ///
+ /// Set the password for the specified user
+ ///
+ ///
+ ///
+ public async Task SetPasswordAsync(string userName, string password)
+ {
+ User user = await GetUserAsync(userName);
+ user.Password = _hasher.Value.HashPassword(userName, password);
+ await _context.SaveChangesAsync();
+ }
+
+ ///
+ /// Delete the specified user
+ ///
+ ///
+ public async Task DeleteUserAsync(string userName)
+ {
+ User user = await GetUserAsync(userName);
+ _context.Users.Remove(user);
+ await _context.SaveChangesAsync();
+ }
+
+ ///
+ /// Throw an exception if a user doesn't exist
+ ///
+ ///
+ ///
+ [ExcludeFromCodeCoverage]
+ private static void ThrowIfUserNotFound(User user, object userId)
+ {
+ if (user == null)
+ {
+ string message = $"User {userId} not found";
+ throw new UserNotFoundException(message);
+ }
+ }
+
+ ///
+ /// Throw an exception if a user already exists
+ ///
+ ///
+ ///
+ [ExcludeFromCodeCoverage]
+ private static void ThrowIfUserFound(User user, object userId)
+ {
+ if (user != null)
+ {
+ throw new UserExistsException($"User {userId} already exists");
+ }
+ }
+ }
+}
diff --git a/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs b/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs
index 502ae99..a71be3e 100644
--- a/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs
+++ b/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs
@@ -1,6 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
-using FlightRecorder.BusinessLogic.Logic;
+using FlightRecorder.BusinessLogic.Database;
using FlightRecorder.Data;
using FlightRecorder.Entities.Interfaces;
using FlightRecorder.Entities.Reporting;
diff --git a/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj b/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj
index cd6364e..97c9c8e 100644
--- a/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj
+++ b/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj
@@ -3,7 +3,7 @@
net7.0
FlightRecorder.BusinessLogic
- 1.4.0.0
+ 1.5.0.0
Dave Walker
Copyright (c) Dave Walker 2020, 2021, 2022, 2023
Dave Walker
@@ -16,7 +16,7 @@
https://github.com/davewalker5/FlightRecorderDb
MIT
false
- 1.4.0.0
+ 1.5.0.0
@@ -53,6 +53,8 @@
+
+
diff --git a/src/FlightRecorder.BusinessLogic/Logging/FileLogger.cs b/src/FlightRecorder.BusinessLogic/Logging/FileLogger.cs
new file mode 100644
index 0000000..56480ff
--- /dev/null
+++ b/src/FlightRecorder.BusinessLogic/Logging/FileLogger.cs
@@ -0,0 +1,112 @@
+using FlightRecorder.Entities.Interfaces;
+using FlightRecorder.Entities.Logging;
+using Serilog;
+using Serilog.Core;
+using Serilog.Events;
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace FlightRecorder.BusinessLogic.Logging
+{
+ [ExcludeFromCodeCoverage]
+ public class FileLogger : IFlightRecorderLogger
+ {
+ private bool _configured = false;
+
+ ///
+ /// Configure logging using Serilog
+ ///
+ ///
+ ///
+ public void Initialise(string logFile, Severity minimumSeverityToLog)
+ {
+ // If the log file's empty, return now without configuring a logger
+ if (string.IsNullOrEmpty(logFile))
+ {
+ return;
+ }
+
+ // Set the minimum log level
+ var levelSwitch = new LoggingLevelSwitch();
+ switch (minimumSeverityToLog)
+ {
+ case Severity.Debug:
+ levelSwitch.MinimumLevel = LogEventLevel.Debug;
+ break;
+ case Severity.Info:
+ levelSwitch.MinimumLevel = LogEventLevel.Information;
+ break;
+ case Severity.Warning:
+ levelSwitch.MinimumLevel = LogEventLevel.Warning;
+ break;
+ case Severity.Error:
+ levelSwitch.MinimumLevel = LogEventLevel.Error;
+ break;
+ default:
+ break;
+ }
+
+ // Configure the logger
+#pragma warning disable CS8602, S4792
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.ControlledBy(levelSwitch)
+ .WriteTo
+ .File(
+ logFile,
+ rollingInterval: RollingInterval.Day,
+ rollOnFileSizeLimit: true)
+ .CreateLogger();
+#pragma warning restore CS8602, S4792
+
+ // Set the "configured" flag
+ _configured = true;
+ }
+
+ ///
+ /// Log a message with the specified severity
+ ///
+ ///
+ ///
+ public void LogMessage(Severity severity, string message)
+ {
+ // Check the logger's been configured. If not, break out now
+ if (!_configured)
+ {
+ return;
+ }
+
+ // Log the message
+ switch (severity)
+ {
+ case Severity.Debug:
+ Log.Debug(message);
+ break;
+ case Severity.Info:
+ Log.Information(message);
+ break;
+ case Severity.Warning:
+ Log.Warning(message);
+ break;
+ case Severity.Error:
+ Log.Error(message);
+ break;
+ default:
+ break;
+ }
+ }
+
+ ///
+ /// Log exception details, including the stack trace
+ ///
+ ///
+ public void LogException(Exception ex)
+ {
+ // Check the logger's been configured and, if so, log the exception message and stack trace
+ if (!_configured)
+ {
+ Log.Error(ex.Message);
+ Log.Error(ex.ToString());
+ }
+ }
+ }
+}
diff --git a/src/FlightRecorder.Data/FlightRecorder.Data.csproj b/src/FlightRecorder.Data/FlightRecorder.Data.csproj
index 4cc1c1a..2899dad 100644
--- a/src/FlightRecorder.Data/FlightRecorder.Data.csproj
+++ b/src/FlightRecorder.Data/FlightRecorder.Data.csproj
@@ -3,7 +3,7 @@
net7.0
FlightRecorder.Data
- 1.4.0.0
+ 1.5.0.0
Dave Walker
Copyright (c) Dave Walker 2020, 2021, 2022, 2023
Dave Walker
@@ -16,7 +16,7 @@
https://github.com/davewalker5/FlightRecorderDb
MIT
false
- 1.4.0.0
+ 1.5.0.0
diff --git a/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj b/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj
index bc57d77..7ddbeca 100644
--- a/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj
+++ b/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj
@@ -3,7 +3,7 @@
net7.0
FlightRecorder.DataExchange
- 1.4.0.0
+ 1.5.0.0
Dave Walker
Copyright (c) Dave Walker 2020, 2021, 2022, 2023
Dave Walker
@@ -16,7 +16,7 @@
https://github.com/davewalker5/FlightRecorderDb
MIT
false
- 1.4.0.0
+ 1.5.0.0
diff --git a/src/FlightRecorder.Entities/Api/ApiPropertyDefinition.cs b/src/FlightRecorder.Entities/Api/ApiPropertyDefinition.cs
new file mode 100644
index 0000000..c011a83
--- /dev/null
+++ b/src/FlightRecorder.Entities/Api/ApiPropertyDefinition.cs
@@ -0,0 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace FlightRecorder.Entities.Api
+{
+ [ExcludeFromCodeCoverage]
+ public class ApiPropertyDefinition
+ {
+ public ApiPropertyType PropertyType { get; set; }
+ public string JsonPath { get; set; }
+ }
+}
diff --git a/src/FlightRecorder.Entities/Api/ApiPropertyType.cs b/src/FlightRecorder.Entities/Api/ApiPropertyType.cs
new file mode 100644
index 0000000..9a32d8a
--- /dev/null
+++ b/src/FlightRecorder.Entities/Api/ApiPropertyType.cs
@@ -0,0 +1,9 @@
+namespace FlightRecorder.Entities.Api
+{
+ public enum ApiPropertyType
+ {
+ DepartureAirportIATA,
+ DestinationAirportIATA,
+ AirlineName
+ }
+}
diff --git a/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj b/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj
index 372a7bd..ae9419d 100644
--- a/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj
+++ b/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj
@@ -3,7 +3,7 @@
net7.0
FlightRecorder.Entities
- 1.4.0.0
+ 1.5.0.0
Dave Walker
Copyright (c) Dave Walker 2020, 2021, 2022, 2023
Dave Walker
@@ -16,7 +16,7 @@
https://github.com/davewalker5/FlightRecorderDb
MIT
false
- 1.4.0.0
+ 1.5.0.0
diff --git a/src/FlightRecorder.Entities/Interfaces/IFlightRecorderHttpClient.cs b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderHttpClient.cs
new file mode 100644
index 0000000..db9a381
--- /dev/null
+++ b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderHttpClient.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.Entities.Interfaces
+{
+ public interface IFlightRecorderHttpClient
+ {
+ void SetHeaders(Dictionary headers);
+ Task GetAsync(string uri);
+ }
+}
\ No newline at end of file
diff --git a/src/FlightRecorder.Entities/Interfaces/IFlightRecorderLogger.cs b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderLogger.cs
new file mode 100644
index 0000000..6d80165
--- /dev/null
+++ b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderLogger.cs
@@ -0,0 +1,12 @@
+using FlightRecorder.Entities.Logging;
+using System;
+
+namespace FlightRecorder.Entities.Interfaces
+{
+ public interface IFlightRecorderLogger
+ {
+ void Initialise(string logFile, Severity minimumSeverityToLog);
+ void LogMessage(Severity severity, string message);
+ void LogException(Exception ex);
+ }
+}
diff --git a/src/FlightRecorder.Entities/Interfaces/IFlightsApi.cs b/src/FlightRecorder.Entities/Interfaces/IFlightsApi.cs
new file mode 100644
index 0000000..b3c84c4
--- /dev/null
+++ b/src/FlightRecorder.Entities/Interfaces/IFlightsApi.cs
@@ -0,0 +1,13 @@
+using FlightRecorder.Entities.Api;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.Entities.Interfaces
+{
+ public interface IFlightsApi
+ {
+ Task> LookupFlightByNumber(string number);
+ Task> LookupFlightByNumberAndDate(string number, DateTime date);
+ }
+}
\ No newline at end of file
diff --git a/src/FlightRecorder.Entities/Logging/Severity.cs b/src/FlightRecorder.Entities/Logging/Severity.cs
new file mode 100644
index 0000000..4740e0d
--- /dev/null
+++ b/src/FlightRecorder.Entities/Logging/Severity.cs
@@ -0,0 +1,10 @@
+namespace FlightRecorder.Entities.Logging
+{
+ public enum Severity
+ {
+ Debug,
+ Info,
+ Warning,
+ Error
+ }
+}
diff --git a/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj b/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj
index d0eb44b..3343b6b 100644
--- a/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj
+++ b/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj
@@ -3,9 +3,9 @@
Exe
net7.0
- 1.4.0.0
- 1.4.0.0
- 1.4.0.0
+ 1.5.0.0
+ 1.5.0.0
+ 1.5.0.0
Release;Debug
false
diff --git a/src/FlightRecorder.Tests/FlightLookupTest.cs b/src/FlightRecorder.Tests/FlightLookupTest.cs
new file mode 100644
index 0000000..62a13c7
--- /dev/null
+++ b/src/FlightRecorder.Tests/FlightLookupTest.cs
@@ -0,0 +1,100 @@
+using FlightRecorder.BusinessLogic.Api.AeroDataBox;
+using FlightRecorder.Entities.Api;
+using FlightRecorder.Entities.Interfaces;
+using FlightRecorder.Tests.Mocks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.Tests
+{
+ ///
+ /// These tests can't test authentication/authorisation at the service end, the lookup of data at the
+ /// service end or network transport. They're design to test the downstream logic once a response has
+ /// been received
+ ///
+ [TestClass]
+ public class FlightLookupTest
+ {
+ private const string EndpointBaseUrl = "https://aerodatabox.p.rapidapi.com/flights/number/";
+ private const string FlightNumber = "LS803";
+ private string MalformedResponse = "{\"departure\": {\"airport\": {\"iata\": \"MAN\"}}}";
+ private string Response = "[{\"departure\": {\"airport\": {\"iata\": \"MAN\"}},\"arrival\": {\"airport\": {\"iata\": \"BCN\"}},\"airline\": {\"name\": \"Jet2\"}}]";
+
+ private MockHttpClient _client;
+ private IFlightsApi _api;
+
+ [TestInitialize]
+ public void Initialise()
+ {
+ // Create the logger, mock client and API wrappers
+ var logger = new MockFileLogger();
+ _client = new MockHttpClient();
+ _api = new AeroDataBoxFlightsApi(logger, _client, EndpointBaseUrl, "");
+ }
+
+ [TestMethod]
+ public void FlightNotFoundByNumberTest()
+ {
+ _client!.AddResponse("");
+ var properties = Task.Run(() => _api.LookupFlightByNumber("Flight Doesn't Exist")).Result;
+ Assert.IsNull(properties);
+ }
+
+ [TestMethod]
+ public void FlightNotFoundByNumberAndDateTest()
+ {
+ _client!.AddResponse("");
+ var properties = Task.Run(() => _api.LookupFlightByNumberAndDate("Flight Doesn't Exist", DateTime.Now)).Result;
+ Assert.IsNull(properties);
+ }
+
+ [TestMethod]
+ public void NetworkErrorTest()
+ {
+ _client!.AddResponse(null);
+ var properties = Task.Run(() => _api.LookupFlightByNumber(FlightNumber)).Result;
+ Assert.IsNull(properties);
+ }
+
+ [TestMethod]
+ public void MalformedResponseTest()
+ {
+ _client!.AddResponse(MalformedResponse);
+ var properties = Task.Run(() => _api.LookupFlightByNumber(FlightNumber)).Result;
+ Assert.IsNull(properties);
+ }
+
+ [TestMethod]
+ public void LookupFlightByNumberTest()
+ {
+ _client!.AddResponse(Response);
+ var properties = Task.Run(() => _api.LookupFlightByNumber(FlightNumber)).Result;
+ Assert.IsNotNull(properties);
+
+ var embarkation = properties[ApiPropertyType.DepartureAirportIATA];
+ var destination = properties[ApiPropertyType.DestinationAirportIATA];
+ var airline = properties[ApiPropertyType.AirlineName];
+
+ Assert.AreEqual("MAN", embarkation);
+ Assert.AreEqual("BCN", destination);
+ Assert.AreEqual("Jet2", airline);
+ }
+
+ [TestMethod]
+ public void LookupFlightByNumberAndDateTest()
+ {
+ _client!.AddResponse(Response);
+ var properties = Task.Run(() => _api.LookupFlightByNumberAndDate(FlightNumber,DateTime.Now)).Result;
+ Assert.IsNotNull(properties);
+
+ var embarkation = properties[ApiPropertyType.DepartureAirportIATA];
+ var destination = properties[ApiPropertyType.DestinationAirportIATA];
+ var airline = properties[ApiPropertyType.AirlineName];
+
+ Assert.AreEqual("MAN", embarkation);
+ Assert.AreEqual("BCN", destination);
+ Assert.AreEqual("Jet2", airline);
+ }
+ }
+}
diff --git a/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj b/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj
index c6c0b46..7243975 100644
--- a/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj
+++ b/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj
@@ -4,7 +4,7 @@
net7.0
false
- 1.4.0.0
+ 1.5.0.0
@@ -26,4 +26,8 @@
+
+
+
+
diff --git a/src/FlightRecorder.Tests/Mocks/MockFileLogger.cs b/src/FlightRecorder.Tests/Mocks/MockFileLogger.cs
new file mode 100644
index 0000000..43f915b
--- /dev/null
+++ b/src/FlightRecorder.Tests/Mocks/MockFileLogger.cs
@@ -0,0 +1,27 @@
+using FlightRecorder.Entities.Interfaces;
+using FlightRecorder.Entities.Logging;
+using System;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace FlightRecorder.Tests.Mocks
+{
+ [ExcludeFromCodeCoverage]
+ public class MockFileLogger : IFlightRecorderLogger
+ {
+ public void Initialise(string logFile, Severity minimumSeverityToLog)
+ {
+ }
+
+ public void LogMessage(Severity severity, string message)
+ {
+ Debug.Print($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")} [{severity.ToString()}] {message}");
+ }
+
+ public void LogException(Exception ex)
+ {
+ LogMessage(Severity.Error, ex.Message);
+ LogMessage(Severity.Error, ex.ToString());
+ }
+ }
+}
diff --git a/src/FlightRecorder.Tests/Mocks/MockHttpClient.cs b/src/FlightRecorder.Tests/Mocks/MockHttpClient.cs
new file mode 100644
index 0000000..f4d126d
--- /dev/null
+++ b/src/FlightRecorder.Tests/Mocks/MockHttpClient.cs
@@ -0,0 +1,71 @@
+using FlightRecorder.Entities.Interfaces;
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+
+namespace FlightRecorder.Tests.Mocks
+{
+ internal class MockHttpClient : IFlightRecorderHttpClient
+ {
+ private readonly Queue _responses = new();
+
+ ///
+ /// Queue a response
+ ///
+ ///
+ public void AddResponse(string? response)
+ {
+ _responses.Enqueue(response);
+ }
+
+ ///
+ /// Queue a set of responses
+ ///
+ ///
+ public void AddResponses(IEnumerable responses)
+ {
+ foreach (string response in responses)
+ {
+ _responses.Enqueue(response);
+ }
+ }
+
+ ///
+ /// Set the HTTP request headers
+ ///
+ ///
+ public void SetHeaders(Dictionary headers)
+ {
+ }
+
+ ///
+ /// Construct and return the next response
+ ///
+ ///
+ ///
+#pragma warning disable CS1998
+ public async Task GetAsync(string uri)
+ {
+ // De-queue the next message
+ var content = _responses.Dequeue();
+
+ // If the content is null, raise an exception to test the exception handling
+ if (content == null)
+ {
+ throw new Exception();
+ }
+
+ // Construct an HTTP response
+ var response = new HttpResponseMessage
+ {
+ Content = new StringContent(content ?? ""),
+ StatusCode = HttpStatusCode.OK
+ };
+
+ return response;
+ }
+#pragma warning restore CS1998
+ }
+}