diff --git a/sql/cleardown-voyages.sql b/sql/cleardown-voyages.sql new file mode 100644 index 0000000..58880ef --- /dev/null +++ b/sql/cleardown-voyages.sql @@ -0,0 +1,7 @@ +DELETE FROM SIGHTING; +DELETE FROM VOYAGE_EVENT; +DELETE FROM VOYAGE; + +DELETE FROM SQLITE_SEQUENCE WHERE name='SIGHTING'; +DELETE FROM SQLITE_SEQUENCE WHERE name='VOYAGE_EVENT'; +DELETE FROM SQLITE_SEQUENCE WHERE name='VOYAGE'; diff --git a/src/ShippingRecorder.Api/Controllers/ExportController.cs b/src/ShippingRecorder.Api/Controllers/ExportController.cs index fbb1954..a251593 100644 --- a/src/ShippingRecorder.Api/Controllers/ExportController.cs +++ b/src/ShippingRecorder.Api/Controllers/ExportController.cs @@ -16,6 +16,7 @@ public class ExportController : Controller private readonly IBackgroundQueue _operatorQueue; private readonly IBackgroundQueue _vesselQueue; private readonly IBackgroundQueue _vesselTypeQueue; + private readonly IBackgroundQueue _voyageQueue; private readonly IBackgroundQueue _sightingQueue; public ExportController( @@ -24,6 +25,7 @@ public ExportController( IBackgroundQueue operatorQueue, IBackgroundQueue vesselQueue, IBackgroundQueue vesselTypeQueue, + IBackgroundQueue voyageQueue, IBackgroundQueue sightingQueue) { _countryQueue = countryQueue; @@ -31,6 +33,7 @@ public ExportController( _operatorQueue = operatorQueue; _vesselQueue = vesselQueue; _vesselTypeQueue = vesselTypeQueue; + _voyageQueue = voyageQueue; _sightingQueue = sightingQueue; } @@ -87,5 +90,14 @@ public IActionResult ExportSightings([FromBody] SightingExportWorkItem item) _sightingQueue.Enqueue(item); return Accepted(); } + + [HttpPost] + [Route("voyages")] + public IActionResult ExportVoyages([FromBody] VoyageExportWorkItem item) + { + item.JobName = "Voyage Export"; + _voyageQueue.Enqueue(item); + return Accepted(); + } } } diff --git a/src/ShippingRecorder.Api/Controllers/ImportController.cs b/src/ShippingRecorder.Api/Controllers/ImportController.cs index c3a7b85..3a6911d 100644 --- a/src/ShippingRecorder.Api/Controllers/ImportController.cs +++ b/src/ShippingRecorder.Api/Controllers/ImportController.cs @@ -17,6 +17,7 @@ public class ImportController : Controller private readonly IBackgroundQueue _portQueue; private readonly IBackgroundQueue _vesselQueue; private readonly IBackgroundQueue _vesselTypeQueue; + private readonly IBackgroundQueue _voyageQueue; private readonly IBackgroundQueue _sightingQueue; public ImportController( @@ -26,6 +27,7 @@ public ImportController( IBackgroundQueue portQueue, IBackgroundQueue vesselQueue, IBackgroundQueue vesselTypeQueue, + IBackgroundQueue voyageQueue, IBackgroundQueue sightingQueue) { _countryQueue = countryQueue; @@ -34,6 +36,7 @@ public ImportController( _portQueue = portQueue; _vesselQueue = vesselQueue; _vesselTypeQueue = vesselTypeQueue; + _voyageQueue = voyageQueue; _sightingQueue = sightingQueue; } @@ -99,5 +102,14 @@ public IActionResult ImportSightings([FromBody] SightingImportWorkItem item) _sightingQueue.Enqueue(item); return Accepted(); } + + [HttpPost] + [Route("voyages")] + public IActionResult ImportVoyages([FromBody] VoyageImportWorkItem item) + { + item.JobName = "Voyage Import"; + _voyageQueue.Enqueue(item); + return Accepted(); + } } } diff --git a/src/ShippingRecorder.Api/Entities/VoyageExportWorkItem.cs b/src/ShippingRecorder.Api/Entities/VoyageExportWorkItem.cs new file mode 100644 index 0000000..2ce3433 --- /dev/null +++ b/src/ShippingRecorder.Api/Entities/VoyageExportWorkItem.cs @@ -0,0 +1,8 @@ +using ShippingRecorder.Entities.Jobs; + +namespace ShippingRecorder.Api.Entities +{ + public class VoyageExportWorkItem : ExportWorkItem + { + } +} diff --git a/src/ShippingRecorder.Api/Entities/VoyageImportWorkItem.cs b/src/ShippingRecorder.Api/Entities/VoyageImportWorkItem.cs new file mode 100644 index 0000000..2e4ef95 --- /dev/null +++ b/src/ShippingRecorder.Api/Entities/VoyageImportWorkItem.cs @@ -0,0 +1,8 @@ +using ShippingRecorder.Entities.Jobs; + +namespace ShippingRecorder.Api.Entities +{ + public class VoyageImportWorkItem : ImportWorkItem + { + } +} diff --git a/src/ShippingRecorder.Api/Program.cs b/src/ShippingRecorder.Api/Program.cs index 166483b..0cbf2e5 100644 --- a/src/ShippingRecorder.Api/Program.cs +++ b/src/ShippingRecorder.Api/Program.cs @@ -122,6 +122,14 @@ public static void Main(string[] args) builder.Services.AddSingleton, BackgroundQueue>(); builder.Services.AddHostedService(); + // Add the voyage importer hosted service + builder.Services.AddSingleton, BackgroundQueue>(); + builder.Services.AddHostedService(); + + // Add the voyage exporter hosted service + builder.Services.AddSingleton, BackgroundQueue>(); + builder.Services.AddHostedService(); + // Configure JWT byte[] key = Encoding.ASCII.GetBytes(settings!.Secret); builder.Services.AddAuthentication(x => diff --git a/src/ShippingRecorder.Api/Services/VoyageExportService.cs b/src/ShippingRecorder.Api/Services/VoyageExportService.cs new file mode 100644 index 0000000..c2c9092 --- /dev/null +++ b/src/ShippingRecorder.Api/Services/VoyageExportService.cs @@ -0,0 +1,46 @@ +using ShippingRecorder.Api.Entities; +using ShippingRecorder.Api.Interfaces; +using ShippingRecorder.DataExchange.Export; +using Microsoft.Extensions.Options; +using ShippingRecorder.Entities.Interfaces; +using ShippingRecorder.Entities.Config; + +namespace ShippingRecorder.Api.Services +{ + public class VoyageExportService : BackgroundQueueProcessor + { + private readonly ShippingRecorderApplicationSettings _settings; + + public VoyageExportService( + ILogger> logger, + IBackgroundQueue queue, + IServiceScopeFactory serviceScopeFactory, + IOptions settings) + : base(logger, queue, serviceScopeFactory) + { + _settings = settings.Value; + } + + /// + /// Export the data + /// + /// + /// + /// + protected override async Task ProcessWorkItemAsync(VoyageExportWorkItem item, IShippingRecorderFactory factory) + { + // Get the list of items to export + MessageLogger.LogInformation("Retrieving voyages for export"); + var voyages = await factory.Voyages.ListAsync(x => true, 1, int.MaxValue).ToListAsync(); + + // Get the full path to the export file + var filePath = Path.Combine(_settings.ExportPath, item.FileName); + + // Export the items + MessageLogger.LogInformation($"Exporting {voyages.Count} voyages to {filePath}"); + var exporter = new VoyageExporter(factory); + await exporter.ExportAsync(voyages, filePath); + MessageLogger.LogInformation("Voyage export completed"); + } + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Api/Services/VoyageImportService.cs b/src/ShippingRecorder.Api/Services/VoyageImportService.cs new file mode 100644 index 0000000..d8b602c --- /dev/null +++ b/src/ShippingRecorder.Api/Services/VoyageImportService.cs @@ -0,0 +1,46 @@ +using ShippingRecorder.Api.Entities; +using ShippingRecorder.Api.Interfaces; +using ShippingRecorder.DataExchange.Import; +using ShippingRecorder.Entities.Interfaces; +using ShippingRecorder.DataExchange.Entities; + +namespace ShippingRecorder.Api.Services +{ + public class VoyageImportService : BackgroundQueueProcessor + { + public VoyageImportService( + ILogger> logger, + IBackgroundQueue queue, + IServiceScopeFactory serviceScopeFactory) + : base(logger, queue, serviceScopeFactory) + { + } + + /// + /// Import from the data specified in the work item + /// + /// + /// + /// + protected override async Task ProcessWorkItemAsync(VoyageImportWorkItem item, IShippingRecorderFactory factory) + { + MessageLogger.LogInformation("Voyage import started"); + var records = item.Content.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + var count = records.Length - 1; + if (count > 0) + { + var messageEnding = (count > 1) ? "s" : ""; + MessageLogger.LogInformation($"Importing {records.Count() - 1} voyage{messageEnding}"); + var importer = new VoyageImporter(factory, ExportableVoyage.CsvRecordPattern); + await importer.ImportAsync(records); + } + else + { + MessageLogger.LogWarning("No records found to import"); + } + + MessageLogger.LogInformation("Voyage import completed"); + } + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.BusinessLogic/Config/ManagerCommandLineParser.cs b/src/ShippingRecorder.BusinessLogic/Config/ManagerCommandLineParser.cs index 34722a1..5d59fd2 100644 --- a/src/ShippingRecorder.BusinessLogic/Config/ManagerCommandLineParser.cs +++ b/src/ShippingRecorder.BusinessLogic/Config/ManagerCommandLineParser.cs @@ -14,12 +14,14 @@ public ManagerCommandLineParser(IHelpGenerator generator) : base(generator) Add(CommandLineOptionType.ImportOperators, false, "--import-operators", "-io", "Import operators from a CSV file", 1, 1); Add(CommandLineOptionType.ImportVesselTypes, false, "--import-vessel-types", "-ivt", "Import vessel types from a CSV file", 1, 1); Add(CommandLineOptionType.ImportVessels, false, "--import-vessels", "-iv", "Import vessels from a CSV file", 1, 1); + Add(CommandLineOptionType.ImportVoyages, false, "--import-voyages", "-ivo", "Import voyages from a CSV file", 1, 1); Add(CommandLineOptionType.ImportSightings, false, "--import-sightings", "-is", "Import sightings from a CSV file", 1, 1); Add(CommandLineOptionType.ExportCountries, false, "--export-countries", "-ec", "Export countries to a CSV file", 1, 1); Add(CommandLineOptionType.ExportLocations, false, "--export-locations", "-el", "Export locations to a CSV file", 1, 1); Add(CommandLineOptionType.ExportOperators, false, "--export-operators", "-eo", "Export operators to a CSV file", 1, 1); Add(CommandLineOptionType.ExportVesselTypes, false, "--export-vessel-typess", "-evt", "Export vessel types to a CSV file", 1, 1); Add(CommandLineOptionType.ExportVessels, false, "--export-vessels", "-ev", "Export vessels to a CSV file", 1, 1); + Add(CommandLineOptionType.ExportVoyages, false, "--export-voyages", "-evo", "Export voyages to a CSV file", 1, 1); Add(CommandLineOptionType.ExportSightings, false, "--export-sightings", "-es", "Export sightings to a CSV file", 1, 1); Add(CommandLineOptionType.AddUser, false, "--add-user", "-au", "Add a user to the database", 2, 2); Add(CommandLineOptionType.SetPassword, false, "--set-password", "-sp", "Set the password for an existing user", 2, 2); diff --git a/src/ShippingRecorder.Client/ApiClient/VoyageClient.cs b/src/ShippingRecorder.Client/ApiClient/VoyageClient.cs index 2caaf16..375e4e3 100644 --- a/src/ShippingRecorder.Client/ApiClient/VoyageClient.cs +++ b/src/ShippingRecorder.Client/ApiClient/VoyageClient.cs @@ -6,12 +6,15 @@ using System.Net.Http; using System.Linq; using System.Collections.Generic; +using System.IO; namespace ShippingRecorder.Client.ApiClient { public class VoyageClient : ShippingRecorderClientBase, IVoyageClient { private const string RouteKey = "Voyage"; + private const string ImportRouteKey = "ImportVoyage"; + private const string ExportRouteKey = "ExportVoyage"; public VoyageClient( IShippingRecorderHttpClient client, @@ -116,5 +119,38 @@ public async Task> ListAsync(long operatorId, int pageNumber, int p List countries = Deserialize>(json); return countries; } + + /// + /// Request an import of voyages from the content of a file + /// + /// + /// + public async Task ImportFromFileContentAsync(string content) + { + dynamic data = new{ Content = content }; + var json = Serialize(data); + await SendIndirectAsync(ImportRouteKey, json, HttpMethod.Post); + } + + /// + /// Request an import of voyages given the path to a file + /// + /// + /// + public async Task ImportFromFileAsync(string filePath) + => await ImportFromFileContentAsync(File.ReadAllText(filePath)); + + /// + /// Request an export of voyages to a named file in the export voyage + /// + /// + /// + /// + public async Task ExportAsync(string fileName) + { + dynamic data = new{ FileName = fileName }; + var json = Serialize(data); + await SendIndirectAsync(ExportRouteKey, json, HttpMethod.Post); + } } } diff --git a/src/ShippingRecorder.Client/Interfaces/IVoyageClient.cs b/src/ShippingRecorder.Client/Interfaces/IVoyageClient.cs index a4a14bf..b6be2f1 100644 --- a/src/ShippingRecorder.Client/Interfaces/IVoyageClient.cs +++ b/src/ShippingRecorder.Client/Interfaces/IVoyageClient.cs @@ -4,7 +4,7 @@ namespace ShippingRecorder.Client.Interfaces { - public interface IVoyageClient + public interface IVoyageClient : IImporter, IExporter { Task GetAsync(long id); Task AddAsync(long operatorId, long vesselId, string number); diff --git a/src/ShippingRecorder.DataExchange/Entities/ExportableSighting.cs b/src/ShippingRecorder.DataExchange/Entities/ExportableSighting.cs index 2e1ea4c..927bf92 100644 --- a/src/ShippingRecorder.DataExchange/Entities/ExportableSighting.cs +++ b/src/ShippingRecorder.DataExchange/Entities/ExportableSighting.cs @@ -9,7 +9,7 @@ namespace ShippingRecorder.DataExchange.Entities [ExcludeFromCodeCoverage] public class ExportableSighting : ExportableEntityBase { - public const string CsvRecordPattern = @"^""[0-9]+-[A-Za-z]+-[0-9]+"",""(?!\s*"")[\s\S]*"",""\d{7}"",""True|False"".?$"; + public const string CsvRecordPattern = @"^""[0-9]+-[A-Za-z]+-[0-9]+"",""(?!\s*"")[\s\S]*"",""\d{7}"","".*"",""True|False"".?$"; [Export("Date", 1)] public DateTime Date { get; set; } @@ -20,7 +20,10 @@ public class ExportableSighting : ExportableEntityBase [Export("IMO", 3)] public string IMO { get; set; } - [Export("My Voyage", 4)] + [Export("Voyage", 4)] + public string VoyageNumber { get; set; } + + [Export("My Voyage", 5)] public bool IsMyVoyage { get; set; } public static ExportableSighting FromCsv(string record) @@ -31,7 +34,8 @@ public static ExportableSighting FromCsv(string record) Date = DateTime.ParseExact(words[0].Replace("\"", "").Trim(), DateFormat, CultureInfo.CurrentCulture), Location = words[1].Replace("\"", "").Trim(), IMO = words[2].Replace("\"", "").Trim().CleanCode(), - IsMyVoyage = words[3].Replace("\"", "").Trim().Equals("True", StringComparison.OrdinalIgnoreCase) + VoyageNumber = words[3].Replace("\"", "").Trim().Clean(), + IsMyVoyage = words[4].Replace("\"", "").Trim().Equals("True", StringComparison.OrdinalIgnoreCase) }; } } diff --git a/src/ShippingRecorder.DataExchange/Entities/ExportableVoyage.cs b/src/ShippingRecorder.DataExchange/Entities/ExportableVoyage.cs index 36ceae5..d4c2d2b 100644 --- a/src/ShippingRecorder.DataExchange/Entities/ExportableVoyage.cs +++ b/src/ShippingRecorder.DataExchange/Entities/ExportableVoyage.cs @@ -30,7 +30,7 @@ public class ExportableVoyage : ExportableEntityBase [Export("Event Type", 3)] public string EventType { get; set; } - [Export("Callsign", 4)] + [Export("Port", 4)] public string Port { get; set; } [Export("Date", 5)] diff --git a/src/ShippingRecorder.DataExchange/Export/VoyageExporter.cs b/src/ShippingRecorder.DataExchange/Export/VoyageExporter.cs new file mode 100644 index 0000000..1472ffb --- /dev/null +++ b/src/ShippingRecorder.DataExchange/Export/VoyageExporter.cs @@ -0,0 +1,62 @@ +using ShippingRecorder.DataExchange.Entities; +using ShippingRecorder.DataExchange.Interfaces; +using ShippingRecorder.DataExchange.Extensions; +using ShippingRecorder.Entities.Interfaces; +using System; +using System.Threading.Tasks; +using System.Linq; +using System.Collections.Generic; +using ShippingRecorder.Entities.Db; + +namespace ShippingRecorder.DataExchange.Export +{ + public class VoyageExporter : IVoyageExporter + { + private readonly IShippingRecorderFactory _factory; + + public event EventHandler> RecordExport; + + public VoyageExporter(IShippingRecorderFactory factory) + => _factory = factory; + + /// + /// Export the voyages to a CSV file + /// + /// + public async Task ExportAsync(string file) + { + var voyages = await _factory.Voyages.ListAsync(x => true, 1, int.MaxValue).ToListAsync(); + await ExportAsync(voyages, file); + } + + /// + /// Export a collection of voyages to a CSV file + /// + /// + /// +#pragma warning disable CS1998 + public async Task ExportAsync(IEnumerable voyages, string file) + { + // Convert the voyages to exportable (flattened hierarchy) voyages + var exportable = voyages.ToExportable(); + + // Configure an exporter to export them + var exporter = new CsvExporter(ExportableEntityBase.DateFormat); + exporter.RecordExport += OnRecordExported; + + // Export the records + exporter.Export(exportable, file, ','); + } +#pragma warning restore CS1998 + + /// + /// Handler for voyage export notifications + /// + /// + /// + private void OnRecordExported(object _, ExportEventArgs e) + { + RecordExport?.Invoke(this, e); + } + } +} diff --git a/src/ShippingRecorder.DataExchange/Extensions/SightingExtensions.cs b/src/ShippingRecorder.DataExchange/Extensions/SightingExtensions.cs index 33fbb76..57ef248 100644 --- a/src/ShippingRecorder.DataExchange/Extensions/SightingExtensions.cs +++ b/src/ShippingRecorder.DataExchange/Extensions/SightingExtensions.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using ShippingRecorder.DataExchange.Entities; using ShippingRecorder.Entities.Db; @@ -18,6 +17,7 @@ public static ExportableSighting ToExportable(this Sighting sighting) Date = sighting.Date, Location = sighting.Location.Name, IMO = sighting.Vessel.IMO, + VoyageNumber = sighting.Voyage?.Number, IsMyVoyage = sighting.IsMyVoyage }; diff --git a/src/ShippingRecorder.DataExchange/Extensions/VoyageExtensions.cs b/src/ShippingRecorder.DataExchange/Extensions/VoyageExtensions.cs new file mode 100644 index 0000000..23bfe75 --- /dev/null +++ b/src/ShippingRecorder.DataExchange/Extensions/VoyageExtensions.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using ShippingRecorder.DataExchange.Entities; +using ShippingRecorder.Entities.Db; + +namespace ShippingRecorder.DataExchange.Extensions +{ + public static class VoyageExtensions + { + /// + /// Return an exportable voyage from a voyage. The result is a list of records, one per voyage event + /// + /// + /// + public static List ToExportable(this Voyage voyage) + => voyage.Events + .Select(e => new ExportableVoyage + { + Operator = voyage.Operator.Name, + IMO = voyage.Vessel.IMO, + Number = voyage.Number, + EventType = e.EventType.ToString(), + Port = e.Port.Code, + Date = e.Date.ToString(ExportableEntityBase.DateFormat) + }) + .ToList(); + + /// + /// Return a collection of exportable voyages from a collection of voyages + /// + /// + /// + public static IEnumerable ToExportable(this IEnumerable voyages) + { + var exportable = new List(); + + foreach (var voyage in voyages) + { + exportable.AddRange(voyage.ToExportable()); + } + + return exportable; + } + } +} diff --git a/src/ShippingRecorder.DataExchange/Import/SightingImporter.cs b/src/ShippingRecorder.DataExchange/Import/SightingImporter.cs index ce02ad0..c893589 100644 --- a/src/ShippingRecorder.DataExchange/Import/SightingImporter.cs +++ b/src/ShippingRecorder.DataExchange/Import/SightingImporter.cs @@ -13,6 +13,7 @@ public sealed class SightingImporter : CsvImporter, ISightin { private List _locations; private List _vessels; + private List _voyages; public SightingImporter(IShippingRecorderFactory factory, string format) : base(factory, format) { @@ -27,6 +28,7 @@ protected override async Task Prepare() await base.Prepare(); _locations = await _factory.Locations.ListAsync(x => true, 1, int.MaxValue).ToListAsync(); _vessels = await _factory.Vessels.ListAsync(x => true, 1, int.MaxValue).ToListAsync(); + _voyages = await _factory.Voyages.ListAsync(x => true, 1, int.MaxValue).ToListAsync(); } /// @@ -49,6 +51,7 @@ protected override void Validate(ExportableSighting sighting, int recordCount) ValidateField(x => x <= DateTime.Now, sighting.Date, "Date", recordCount); ValidateField(x => CheckLocationExists(x), sighting.Location, "Location", recordCount); ValidateField(x => CheckVesselExists(x), sighting.IMO, "IMO", recordCount); + ValidateField(x => CheckVoyageExists(x), sighting.VoyageNumber, "VoyageNumber", recordCount); } #pragma warning restore CS1998 @@ -61,7 +64,8 @@ protected override async Task AddAsync(ExportableSighting sighting) { var location = _locations.First(x => x.Name == sighting.Location); var vessel = _vessels.First(x => x.IMO == sighting.IMO); - await _factory.Sightings.AddAsync(location.Id, null, vessel.Id, sighting.Date, sighting.IsMyVoyage); + long? voyageId = string.IsNullOrEmpty(sighting.VoyageNumber) ? null : _voyages.First(x => x.Number == sighting.VoyageNumber).Id; + await _factory.Sightings.AddAsync(location.Id, voyageId, vessel.Id, sighting.Date, sighting.IsMyVoyage); } /// @@ -79,5 +83,13 @@ private bool CheckLocationExists(string name) /// private bool CheckVesselExists(string imo) => _vessels.FirstOrDefault(x => x.IMO == imo) != null; + + /// + /// Check a voyage exists + /// + /// + /// + private bool CheckVoyageExists(string number) + => string.IsNullOrEmpty(number) || _voyages.FirstOrDefault(x => x.Number == number) != null; } } \ No newline at end of file diff --git a/src/ShippingRecorder.DataExchange/Interfaces/IVoyageExporter.cs b/src/ShippingRecorder.DataExchange/Interfaces/IVoyageExporter.cs new file mode 100644 index 0000000..08bc8a2 --- /dev/null +++ b/src/ShippingRecorder.DataExchange/Interfaces/IVoyageExporter.cs @@ -0,0 +1,9 @@ +using ShippingRecorder.DataExchange.Entities; +using ShippingRecorder.Entities.Db; + +namespace ShippingRecorder.DataExchange.Interfaces +{ + public interface IVoyageExporter : IExporter + { + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Entities/Config/CommandLineOptionType.cs b/src/ShippingRecorder.Entities/Config/CommandLineOptionType.cs index 1fd5a63..656cc37 100644 --- a/src/ShippingRecorder.Entities/Config/CommandLineOptionType.cs +++ b/src/ShippingRecorder.Entities/Config/CommandLineOptionType.cs @@ -10,6 +10,7 @@ public enum CommandLineOptionType ExportSightings, ExportVessels, ExportVesselTypes, + ExportVoyages, Help, ImportCountries, ImportLocations, @@ -18,6 +19,7 @@ public enum CommandLineOptionType ImportSightings, ImportVessels, ImportVesselTypes, + ImportVoyages, SetPassword, Update } diff --git a/src/ShippingRecorder.Manager/Logic/ExportHandler.cs b/src/ShippingRecorder.Manager/Logic/ExportHandler.cs index c5aa257..7f94dcc 100644 --- a/src/ShippingRecorder.Manager/Logic/ExportHandler.cs +++ b/src/ShippingRecorder.Manager/Logic/ExportHandler.cs @@ -105,5 +105,12 @@ public async Task HandleSightingExportAsync() /// public async Task HandleVesselExportAsync() => await HandleExport(CommandLineOptionType.ExportVessels); + + /// + /// Handle the voyage export command + /// + /// + public async Task HandleVoyageExportAsync() + => await HandleExport(CommandLineOptionType.ExportVoyages); } } \ No newline at end of file diff --git a/src/ShippingRecorder.Manager/Logic/ImportHandler.cs b/src/ShippingRecorder.Manager/Logic/ImportHandler.cs index c8f7ee9..b2be288 100644 --- a/src/ShippingRecorder.Manager/Logic/ImportHandler.cs +++ b/src/ShippingRecorder.Manager/Logic/ImportHandler.cs @@ -104,6 +104,13 @@ public async Task HandleVesselTypeImportAsync() public async Task HandleVesselImportAsync() => await HandleImport(CommandLineOptionType.ImportVessels, ExportableVessel.CsvRecordPattern); + /// + /// Handle the voyage import command + /// + /// + public async Task HandleVoyageImportAsync() + => await HandleImport(CommandLineOptionType.ImportVoyages, ExportableVoyage.CsvRecordPattern); + /// /// Handle the sightings import command /// diff --git a/src/ShippingRecorder.Manager/Program.cs b/src/ShippingRecorder.Manager/Program.cs index d7bc620..dd0ce0c 100644 --- a/src/ShippingRecorder.Manager/Program.cs +++ b/src/ShippingRecorder.Manager/Program.cs @@ -99,6 +99,12 @@ public static async Task Main(string[] args) await new ImportHandler(settings, parser, factory).HandleVesselImportAsync(); } + // If a CSV file containing voyage details has been supplied, import it + if (parser.IsPresent(CommandLineOptionType.ImportVoyages)) + { + await new ImportHandler(settings, parser, factory).HandleVoyageImportAsync(); + } + // If a CSV file containing sighting details has been supplied, import it if (parser.IsPresent(CommandLineOptionType.ImportSightings)) { @@ -141,6 +147,12 @@ public static async Task Main(string[] args) await new ExportHandler(settings, parser, factory).HandleVesselExportAsync(); } + // If a voyage export has been requested, run the export + if (parser.IsPresent(CommandLineOptionType.ExportVoyages)) + { + await new ExportHandler(settings, parser, factory).HandleVoyageExportAsync(); + } + // Handle user addition if (parser.IsPresent(CommandLineOptionType.AddUser)) { diff --git a/src/ShippingRecorder.Manager/fred.csv b/src/ShippingRecorder.Manager/fred.csv new file mode 100644 index 0000000..46b75f3 --- /dev/null +++ b/src/ShippingRecorder.Manager/fred.csv @@ -0,0 +1,60 @@ +IMO,Operator,Number,Event Type,Port,Date +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Depart","ESSCT","26-Nov-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Arrive","ESFUE","27-Nov-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Arrive","ESACE","28-Nov-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Depart","ESACE","28-Nov-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Arrive","ESLPA","29-Nov-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Depart","ESLPA","29-Nov-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Arrive","PTFNC","01-Dec-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Depart","PTFNC","01-Dec-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Arrive","ESSPC","02-Dec-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Depart","ESSPC","02-Dec-2025" +"9781877","Aida Cruises","AIDACOSMA-2025-11-26","Arrive","ESSCT","03-Dec-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Depart","PTLIS","24-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Arrive","MACAS","26-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Depart","MACAS","26-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Arrive","MAAGA","27-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Depart","MAAGA","27-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Arrive","ESACE","28-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Depart","ESACE","28-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Arrive","ESSCT","29-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Depart","ESSCT","29-Nov-2025" +"9128532","Norwegian Cruise Line","NORWEGIANSKY-2025-11-24","Arrive","ESBCN","06-Dec-2025" +"9226906","P&O Cruises","J519","Depart","GBSOU","17-Nov-2025" +"9226906","P&O Cruises","J519","Arrive","ESLKN","19-Nov-2025" +"9226906","P&O Cruises","J519","Depart","ESLKN","19-Nov-2025" +"9226906","P&O Cruises","J519","Arrive","PTFNC","22-Nov-2025" +"9226906","P&O Cruises","J519","Depart","PTFNC","23-Nov-2025" +"9226906","P&O Cruises","J519","Arrive","ESSCT","24-Nov-2025" +"9226906","P&O Cruises","J519","Depart","ESSCT","25-Nov-2025" +"9226906","P&O Cruises","J519","Arrive","ESLPA","26-Nov-2025" +"9226906","P&O Cruises","J519","Depart","ESACE","27-Nov-2025" +"9226906","P&O Cruises","J519","Arrive","ESCAD","29-Nov-2025" +"9226906","P&O Cruises","J519","Depart","ESCAD","29-Nov-2025" +"9226906","P&O Cruises","J519","Arrive","GBSOU","03-Dec-2025" +"9226906","P&O Cruises","J519","Depart","ESLPA","26-Jan-2026" +"9226906","P&O Cruises","J519","Arrive","ESACE","27-Jan-2026" +"8913162","Phoenix Reisen Gmbh","AMADEA-2025-11-15","Depart","DEEHV","15-Nov-2025" +"8913162","Phoenix Reisen Gmbh","AMADEA-2025-11-15","Arrive","ESACE","28-Nov-2025" +"8913162","Phoenix Reisen Gmbh","AMADEA-2025-11-15","Depart","ESACE","28-Nov-2025" +"8913162","Phoenix Reisen Gmbh","AMADEA-2025-11-15","Arrive","DEEHV","07-Dec-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Depart","ESLPA","25-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Arrive","ESLCR","26-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Depart","ESLCR","26-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Arrive","ESACE","27-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Depart","ESACE","27-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Arrive","ESSCT","28-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Depart","ESSCT","28-Nov-2025" +"9814040","Ponant","LEBOUGAINVILLE-2025-11-25","Arrive","SNDKR","05-Dec-2025" +"8420878","Windstar Cruises","ST20251120-10D","Depart","ESSCT","20-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Arrive","ESSSG","21-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Depart","ESSSG","21-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Arrive","ESSPC","22-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Depart","ESSPC","22-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Arrive","PTFNC","24-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Depart","PTFNC","25-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Arrive","ESACE","27-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Depart","ESACE","27-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Arrive","ESLPA","28-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Depart","ESLPA","29-Nov-2025" +"8420878","Windstar Cruises","ST20251120-10D","Arrive","ESSCT","30-Nov-2025" diff --git a/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs b/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs index 330f84c..607a115 100644 --- a/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs +++ b/src/ShippingRecorder.Mvc/Controllers/DataExchangeControllerBase.cs @@ -14,7 +14,8 @@ public abstract class DataExchangeControllerBase : ShippingRecorderControllerBas { DataExchangeType.Ports, "Port" }, { DataExchangeType.Sightings, "Sighting" }, { DataExchangeType.Vessels, "Vessel" }, - { DataExchangeType.VesselTypes, "VesselType" } + { DataExchangeType.VesselTypes, "VesselType" }, + { DataExchangeType.Voyages, "Voyage" } }; protected readonly Dictionary _importers = new(); @@ -28,6 +29,7 @@ public DataExchangeControllerBase( ISightingClient sightingClient, IVesselClient vesselClient, IVesselTypeClient vesselTypeClient, + IVoyageClient voyageClient, IPartialViewToStringRenderer renderer, ILogger logger) : base(renderer, logger) { @@ -38,6 +40,7 @@ public DataExchangeControllerBase( _importers.Add(DataExchangeType.Sightings, sightingClient); _importers.Add(DataExchangeType.Vessels, vesselClient); _importers.Add(DataExchangeType.VesselTypes, vesselTypeClient); + _importers.Add(DataExchangeType.Voyages, voyageClient); _exporters.Add(DataExchangeType.Countries, countryClient); _exporters.Add(DataExchangeType.Locations, locationClient); @@ -45,6 +48,7 @@ public DataExchangeControllerBase( _exporters.Add(DataExchangeType.Sightings, sightingClient); _exporters.Add(DataExchangeType.Vessels, vesselClient); _exporters.Add(DataExchangeType.VesselTypes, vesselTypeClient); + _exporters.Add(DataExchangeType.Voyages, voyageClient); } /// diff --git a/src/ShippingRecorder.Mvc/Controllers/ExportController.cs b/src/ShippingRecorder.Mvc/Controllers/ExportController.cs index b5c2fc4..cac041b 100644 --- a/src/ShippingRecorder.Mvc/Controllers/ExportController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/ExportController.cs @@ -18,6 +18,7 @@ public ExportController( ISightingClient sightingClient, IVesselClient vesselClient, IVesselTypeClient vesselTypeClient, + IVoyageClient voyageClient, IPartialViewToStringRenderer renderer, ILogger logger) : base( countryClient, @@ -27,6 +28,7 @@ public ExportController( sightingClient, vesselClient, vesselTypeClient, + voyageClient, renderer, logger ) diff --git a/src/ShippingRecorder.Mvc/Controllers/ImportController.cs b/src/ShippingRecorder.Mvc/Controllers/ImportController.cs index faee1bc..0cb9609 100644 --- a/src/ShippingRecorder.Mvc/Controllers/ImportController.cs +++ b/src/ShippingRecorder.Mvc/Controllers/ImportController.cs @@ -21,6 +21,7 @@ public ImportController( ISightingClient sightingClient, IVesselClient vesselClient, IVesselTypeClient vesselTypeClient, + IVoyageClient voyageClient, IPartialViewToStringRenderer renderer, ILogger logger) : base( countryClient, @@ -30,6 +31,7 @@ public ImportController( sightingClient, vesselClient, vesselTypeClient, + voyageClient, renderer, logger ) diff --git a/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs b/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs index 579b8c6..7da92ea 100644 --- a/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs +++ b/src/ShippingRecorder.Mvc/Entities/DataExchangeType.cs @@ -9,6 +9,7 @@ public enum DataExchangeType Ports, Sightings, Vessels, - VesselTypes + VesselTypes, + Voyages } } \ No newline at end of file diff --git a/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs b/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs index b195c8a..a3be9fa 100644 --- a/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs +++ b/src/ShippingRecorder.Mvc/Helpers/DataExchangeTypeExtensions.cs @@ -21,6 +21,7 @@ public static string ToName(this DataExchangeType type) DataExchangeType.Sightings => "Sightings", DataExchangeType.Vessels => "Vessels", DataExchangeType.VesselTypes => "Vessel Types", + DataExchangeType.Voyages => "Voyages", _ => "", }; } diff --git a/src/ShippingRecorder.Mvc/appsettings.json b/src/ShippingRecorder.Mvc/appsettings.json index 13bb34b..0f64ecb 100644 --- a/src/ShippingRecorder.Mvc/appsettings.json +++ b/src/ShippingRecorder.Mvc/appsettings.json @@ -107,6 +107,10 @@ "Name": "ImportVessel", "Route": "/import/vessels" }, + { + "Name": "ImportVoyage", + "Route": "/import/voyages" + }, { "Name": "ImportSighting", "Route": "/import/sightings" @@ -134,6 +138,10 @@ { "Name": "ExportCountry", "Route": "/export/countries" + }, + { + "Name": "ExportVoyage", + "Route": "/export/voyages" } ], "UseCustomErrorPageInDevelopment": true, diff --git a/src/ShippingRecorder.Payload/Program.cs b/src/ShippingRecorder.Payload/Program.cs index ab863a4..5160c6d 100644 --- a/src/ShippingRecorder.Payload/Program.cs +++ b/src/ShippingRecorder.Payload/Program.cs @@ -2,7 +2,7 @@ // Tool to create the payload for a data import job based on an existing file const string FilePath = @"data.csv"; -const string JobName = @"Port Import"; +const string JobName = @"Voyage Import"; // Construct the payload dynamic data = new{ Content = File.ReadAllText(FilePath), JobName = JobName }; diff --git a/src/ShippingRecorder.Tests/Client/VoyageClientTest.cs b/src/ShippingRecorder.Tests/Client/VoyageClientTest.cs index bc5b7c1..9298023 100644 --- a/src/ShippingRecorder.Tests/Client/VoyageClientTest.cs +++ b/src/ShippingRecorder.Tests/Client/VoyageClientTest.cs @@ -11,6 +11,8 @@ using System.Collections.Generic; using ShippingRecorder.Entities.Db; using System.Linq; +using System.IO; +using ShippingRecorder.DataExchange.Entities; namespace ShippingRecorder.Tests.Client { @@ -20,12 +22,15 @@ public class VoyageClientTest private readonly string ApiToken = "An API Token"; private readonly MockShippingRecorderHttpClient _httpClient = new(); private IVoyageClient _client; + private string _filePath; private readonly ShippingRecorderApplicationSettings _settings = new() { ApiUrl = "http://server/", ApiRoutes = [ - new() { Name = "Voyage", Route = "/voyages" } + new() { Name = "Voyage", Route = "/voyages" }, + new() { Name = "ImportVoyage", Route = "/import/voyages" }, + new() { Name = "ExportVoyage", Route = "/export/voyages" } ] }; @@ -39,6 +44,15 @@ public void Initialise() _client = new VoyageClient(_httpClient, _settings, provider.Object, cache.Object, logger.Object); } + [TestCleanup] + public void CleanUp() + { + if (!string.IsNullOrEmpty(_filePath) && File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + [TestMethod] public async Task AddTest() { @@ -139,5 +153,45 @@ public async Task ListTest() Assert.AreEqual(voyage.OperatorId, voyages[0].OperatorId); Assert.AreEqual(voyage.Number, voyages[0].Number); } + + [TestMethod] + public async Task ImportFromFileTest() + { + var voyage = DataGenerator.CreateVoyage(); + var record = $@"""{voyage.Vessel.IMO}"",""{voyage.Operator.Name}"",""{voyage.Number}"",""{voyage.Events.First().EventType}"",""{voyage.Events.First().Port.Code}"",""{voyage.Events.First().Date.ToString(ExportableVoyage.DateFormat)}"""; + _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + File.WriteAllLines(_filePath, ["", record]); + _httpClient.AddResponse(""); + + var content = File.ReadAllText(_filePath); + var json = JsonSerializer.Serialize(new { Content = content }); + var expectedRoute = _settings.ApiRoutes.First(x => x.Name == "ImportVoyage").Route; + + await _client.ImportFromFileAsync(_filePath); + + Assert.AreEqual($"Bearer {ApiToken}", _httpClient.DefaultRequestHeaders.Authorization.ToString()); + Assert.AreEqual($"{_settings.ApiUrl}", _httpClient.BaseAddress.ToString()); + Assert.AreEqual(HttpMethod.Post, _httpClient.Requests[0].Method); + Assert.AreEqual(expectedRoute, _httpClient.Requests[0].Uri); + Assert.AreEqual(json, await _httpClient.Requests[0].Content.ReadAsStringAsync()); + } + + [TestMethod] + public async Task ExportTest() + { + _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + _httpClient.AddResponse(""); + + var json = JsonSerializer.Serialize(new { FileName = _filePath }); + var expectedRoute = _settings.ApiRoutes.First(x => x.Name == "ExportVoyage").Route; + + await _client.ExportAsync(_filePath); + + Assert.AreEqual($"Bearer {ApiToken}", _httpClient.DefaultRequestHeaders.Authorization.ToString()); + Assert.AreEqual($"{_settings.ApiUrl}", _httpClient.BaseAddress.ToString()); + Assert.AreEqual(HttpMethod.Post, _httpClient.Requests[0].Method); + Assert.AreEqual(expectedRoute, _httpClient.Requests[0].Uri); + Assert.AreEqual(json, await _httpClient.Requests[0].Content.ReadAsStringAsync()); + } } } diff --git a/src/ShippingRecorder.Tests/Export/SightingExporterTest.cs b/src/ShippingRecorder.Tests/Export/SightingExporterTest.cs index adf8b7e..7814b43 100644 --- a/src/ShippingRecorder.Tests/Export/SightingExporterTest.cs +++ b/src/ShippingRecorder.Tests/Export/SightingExporterTest.cs @@ -56,7 +56,7 @@ public void ConvertCollectionToExportable() public void FromCsvRecordTest() { var isMyVoyage = _sighting.IsMyVoyage ? "True" : "False"; - var record = $@"""{_sighting.Date.ToString(ExportableEntityBase.DateFormat)}"",""{_sighting.Location.Name}"",""{_sighting.Vessel.IMO}"",""{isMyVoyage}"""; + var record = $@"""{_sighting.Date.ToString(ExportableEntityBase.DateFormat)}"",""{_sighting.Location.Name}"",""{_sighting.Vessel.IMO}"","""",""{isMyVoyage}"""; var exportable = ExportableSighting.FromCsv(record); Assert.AreEqual(_sighting.Date, exportable.Date); Assert.AreEqual(_sighting.Location.Name, exportable.Location); diff --git a/src/ShippingRecorder.Tests/Export/VoyageExporterTest.cs b/src/ShippingRecorder.Tests/Export/VoyageExporterTest.cs new file mode 100644 index 0000000..b0340b1 --- /dev/null +++ b/src/ShippingRecorder.Tests/Export/VoyageExporterTest.cs @@ -0,0 +1,113 @@ +using ShippingRecorder.Data; +using ShippingRecorder.DataExchange.Entities; +using ShippingRecorder.DataExchange.Export; +using ShippingRecorder.DataExchange.Extensions; +using ShippingRecorder.Tests.Mocks; +using Moq; +using ShippingRecorder.Entities.Db; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ShippingRecorder.BusinessLogic.Factory; + +namespace ShippingRecorder.Tests.Export +{ + [TestClass] + public class VoyageExportTest + { + private Voyage _voyage; + + private string _filePath; + + [TestInitialize] + public void Initialise() + { + _voyage = DataGenerator.CreateVoyage(); + _voyage.Events = [DataGenerator.CreateVoyageEvent(_voyage.Id)]; + } + + [TestCleanup] + public void CleanUp() + { + if (!string.IsNullOrEmpty(_filePath) && File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + + [TestMethod] + public void ConvertSingleObjectToExportable() + { + var exportable = _voyage.ToExportable(); + Assert.AreEqual(_voyage.Vessel.IMO, exportable.First().IMO); + Assert.AreEqual(_voyage.Operator.Name, exportable.First().Operator); + Assert.AreEqual(_voyage.Number, exportable.First().Number); + Assert.AreEqual(_voyage.Events.First().EventType.ToString(), exportable.First().EventType); + Assert.AreEqual(_voyage.Events.First().Port.Code, exportable.First().Port); + Assert.AreEqual(_voyage.Events.First().Date.ToString(ExportableEntityBase.DateFormat), exportable.First().Date); + } + + [TestMethod] + public void ConvertCollectionToExportable() + { + List vessels = [_voyage]; + var exportable = vessels.ToExportable(); + Assert.AreEqual(_voyage.Vessel.IMO, exportable.First().IMO); + Assert.AreEqual(_voyage.Operator.Name, exportable.First().Operator); + Assert.AreEqual(_voyage.Number, exportable.First().Number); + Assert.AreEqual(_voyage.Events.First().EventType.ToString(), exportable.First().EventType); + Assert.AreEqual(_voyage.Events.First().Port.Code, exportable.First().Port); + Assert.AreEqual(_voyage.Events.First().Date.ToString(ExportableEntityBase.DateFormat), exportable.First().Date); + + } + + [TestMethod] + public void FromCsvRecordTest() + { + var record = $@"""{_voyage.Vessel.IMO}"",""{_voyage.Operator.Name}"",""{_voyage.Number}"",""{_voyage.Events.First().EventType}"",""{_voyage.Events.First().Port.Code}"",""{_voyage.Events.First().Date.ToString(ExportableVoyage.DateFormat)}"""; + var exportable = ExportableVoyage.FromCsv(record); + Assert.AreEqual(_voyage.Vessel.IMO, exportable.IMO); + Assert.AreEqual(_voyage.Operator.Name, exportable.Operator); + Assert.AreEqual(_voyage.Number, exportable.Number); + Assert.AreEqual(_voyage.Events.First().EventType.ToString(), exportable.EventType); + Assert.AreEqual(_voyage.Events.First().Port.Code, exportable.Port); + Assert.AreEqual(_voyage.Events.First().Date.ToString(ExportableEntityBase.DateFormat), exportable.Date); + } + + [TestMethod] + public async Task ExportTest() + { + var context = ShippingRecorderDbContextFactory.CreateInMemoryDbContext(); + await context.Vessels.AddAsync(_voyage.Vessel); + await context.Operators.AddAsync(_voyage.Operator); + await context.Countries.AddAsync(_voyage.Events.First().Port.Country); + await context.Ports.AddAsync(_voyage.Events.First().Port); + await context.SaveChangesAsync(); + await context.Voyages.AddAsync(_voyage); + await context.SaveChangesAsync(); + + var factory = new ShippingRecorderFactory(context, new MockFileLogger()); + var exporter = new VoyageExporter(factory); + + _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + await exporter.ExportAsync(_filePath); + + var info = new FileInfo(_filePath); + Assert.AreEqual(info.FullName, _filePath); + Assert.IsGreaterThan(0, info.Length); + + var records = File.ReadAllLines(_filePath); + Assert.HasCount(2, records); + + var exportable = ExportableVoyage.FromCsv(records[1]); + Assert.AreEqual(_voyage.Vessel.IMO, exportable.IMO); + Assert.AreEqual(_voyage.Operator.Name, exportable.Operator); + Assert.AreEqual(_voyage.Number, exportable.Number); + Assert.AreEqual(_voyage.Events.First().EventType.ToString(), exportable.EventType); + Assert.AreEqual(_voyage.Events.First().Port.Code, exportable.Port); + Assert.AreEqual(_voyage.Events.First().Date.ToString(ExportableEntityBase.DateFormat), exportable.Date); + } + } +} \ No newline at end of file diff --git a/src/ShippingRecorder.Tests/Import/SightingImporterTest.cs b/src/ShippingRecorder.Tests/Import/SightingImporterTest.cs index 690d18f..8a7f6b4 100644 --- a/src/ShippingRecorder.Tests/Import/SightingImporterTest.cs +++ b/src/ShippingRecorder.Tests/Import/SightingImporterTest.cs @@ -36,7 +36,7 @@ public void CleanUp() } [TestMethod] - public async Task ImportTest() + public async Task ImportWithoutVoyageTest() { var date = DateTime.Today; var location = DataGenerator.CreateLocation(); @@ -47,7 +47,7 @@ public async Task ImportTest() var importer = new SightingImporter(_factory, ExportableSighting.CsvRecordPattern); - var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""{vessel.IMO}"",""False"""; + var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""{vessel.IMO}"","""",""False"""; _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); File.WriteAllLines(_filePath, ["", record]); @@ -62,6 +62,42 @@ public async Task ImportTest() Assert.AreEqual(date, sightings.First().Date); Assert.AreEqual(location.Id, sightings.First().LocationId); Assert.AreEqual(vessel.Id, sightings.First().VesselId); + Assert.IsNull(sightings.First().VoyageId); + Assert.IsFalse(sightings.First().IsMyVoyage); + } + + [TestMethod] + public async Task ImportWithVoyageTest() + { + var date = DateTime.Today; + var location = DataGenerator.CreateLocation(); + var vessel = DataGenerator.CreateVessel(); + var voyage = DataGenerator.CreateVoyage(); + var op = DataGenerator.CreateOperator(); + + location = await _factory.Locations.AddAsync(location.Name); + op = await _factory.Operators.AddAsync(op.Name); + vessel = await _factory.Vessels.AddAsync(vessel.IMO, vessel.Built, vessel.Draught, vessel.Length, vessel.Beam); + voyage = await _factory.Voyages.AddAsync(op.Id, vessel.Id, voyage.Number); + + var importer = new SightingImporter(_factory, ExportableSighting.CsvRecordPattern); + + var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""{vessel.IMO}"",""{voyage.Number}"",""False"""; + _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + File.WriteAllLines(_filePath, ["", record]); + + await importer.ImportAsync(_filePath); + + var info = new FileInfo(_filePath); + Assert.AreEqual(info.FullName, _filePath); + Assert.IsGreaterThan(0, info.Length); + + var sightings = await _factory.Sightings.ListAsync(x => true, 1, int.MaxValue).ToListAsync(); + Assert.HasCount(1, sightings); + Assert.AreEqual(date, sightings.First().Date); + Assert.AreEqual(location.Id, sightings.First().LocationId); + Assert.AreEqual(vessel.Id, sightings.First().VesselId); + Assert.AreEqual(voyage.Id, sightings.First().VoyageId); Assert.IsFalse(sightings.First().IsMyVoyage); } @@ -75,7 +111,7 @@ public async Task ImportForMissingLocationTest() var importer = new SightingImporter(_factory, ExportableSighting.CsvRecordPattern); - var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""Missing"",""{vessel.IMO}"",""False"""; + var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""Missing"",""{vessel.IMO}"","""",""False"""; _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); File.WriteAllLines(_filePath, ["", record]); @@ -92,7 +128,27 @@ public async Task ImportForMissingVesselTest() var importer = new SightingImporter(_factory, ExportableSighting.CsvRecordPattern); - var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""1234567"",""False"""; + var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""1234567"","""",""False"""; + _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); + File.WriteAllLines(_filePath, ["", record]); + + await Assert.ThrowsAsync(() => importer.ImportAsync(_filePath)); + } + + [TestMethod] + public async Task ImportForMissingVoyageTest() + { + var date = DateTime.Today; + var location = DataGenerator.CreateLocation(); + var vessel = DataGenerator.CreateVessel(); + var voyage = DataGenerator.CreateVoyage(); + + location = await _factory.Locations.AddAsync(location.Name); + vessel = await _factory.Vessels.AddAsync(vessel.IMO, vessel.Built, vessel.Draught, vessel.Length, vessel.Beam); + + var importer = new SightingImporter(_factory, ExportableSighting.CsvRecordPattern); + + var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""{vessel.IMO}"",""{voyage.Number}"",""False"""; _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); File.WriteAllLines(_filePath, ["", record]); @@ -111,7 +167,7 @@ public async Task ImportForFutureDateTest() var importer = new SightingImporter(_factory, ExportableSighting.CsvRecordPattern); - var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""{vessel.IMO}"",""False"""; + var record = $@"""{date.ToString(ExportableSighting.DateFormat)}"",""{location.Name}"",""{vessel.IMO}"","""",""False"""; _filePath = Path.ChangeExtension(Path.GetTempFileName(), "csv"); File.WriteAllLines(_filePath, ["", record]); diff --git a/src/ShippingRecorder.Tests/Mocks/DataGenerator.cs b/src/ShippingRecorder.Tests/Mocks/DataGenerator.cs index ed48e28..9f8a390 100644 --- a/src/ShippingRecorder.Tests/Mocks/DataGenerator.cs +++ b/src/ShippingRecorder.Tests/Mocks/DataGenerator.cs @@ -1,7 +1,5 @@ using System; using System.Text; -using DocumentFormat.OpenXml.ExtendedProperties; -using DocumentFormat.OpenXml.Wordprocessing; using ShippingRecorder.BusinessLogic.Extensions; using ShippingRecorder.Entities.Db; @@ -130,28 +128,65 @@ public static Country CreateCountry() /// /// public static Port CreatePort() - => new() { Id = RandomId(), CountryId = RandomId(), Code = RandomWord(2, 2), Name = RandomWord() }; + { + var country = CreateCountry(); + return new() + { + Id = RandomId(), + CountryId = country.Id, + Country = country, + Code = RandomWord(5, 5).CleanCode(), + Name = RandomWord() + }; + } /// /// Return a random voyage /// /// public static Voyage CreateVoyage() - => new() { Id = RandomId(), OperatorId = RandomId(), VesselId = RandomId(), Number = RandomWord() }; + { + var op = CreateOperator(); + var vessel = CreateVessel(); + var evt = CreateVoyageEvent(RandomId()); + return new() + { + Id = evt.VoyageId, + OperatorId = op.Id, + Operator = op, + VesselId = vessel.Id, + Vessel = vessel, + Number = RandomWord().CleanCode(), + Events = [evt] + }; + } /// - /// Return a random voyage + /// Return a random voyage event for the voyage with the specified Id /// + /// /// - public static VoyageEvent CreateVoyageEvent() - => new() + public static VoyageEvent CreateVoyageEvent(long voyageId) + { + var port = CreatePort(); + return new() { Id = RandomId(), - VoyageId = RandomId(), + VoyageId = voyageId, EventType = RandomInt(0, 100) < 50 ? VoyageEventType.Depart : VoyageEventType.Arrive, - PortId = RandomId(), + PortId = port.Id, + Port = port, Date = DateTime.Now }; + } + + /// + /// Return a random voyage event + /// + /// + /// + public static VoyageEvent CreateVoyageEvent() + => CreateVoyageEvent(RandomId()); /// /// Create a random vessel registration history