diff --git a/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs new file mode 100644 index 0000000..e5754b5 --- /dev/null +++ b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs @@ -0,0 +1,857 @@ +namespace BinDays.Api.Collectors.Collectors.Councils; + +using BinDays.Api.Collectors.Collectors.Vendors; +using BinDays.Api.Collectors.Models; +using BinDays.Api.Collectors.Utilities; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; + +/// +/// Collector implementation for Cheltenham Borough Council. +/// +internal sealed class CheltenhamBoroughCouncil : GovUkCollectorBase, ICollector +{ + /// + public string Name => "Cheltenham Borough Council"; + + /// + public Uri WebsiteUrl => new("https://www.cheltenham.gov.uk/"); + + /// + public override string GovUkId => "cheltenham"; + + /// + /// The list of bin types for this collector. + /// + private readonly IReadOnlyCollection _binTypes = + [ + new() + { + Name = "General Waste", + Colour = BinColour.Green, + Keys = [ "Refuse" ], + }, + new() + { + Name = "Mixed Dry Recycling (Green Box)", + Colour = BinColour.Green, + Keys = [ "Recycling" ], + Type = BinType.Box, + }, + new() + { + Name = "Paper, Glass & Cardboard Recycling (Blue Bag)", + Colour = BinColour.Blue, + Keys = [ "Recycling" ], + Type = BinType.Bag, + }, + new() + { + Name = "Garden Waste", + Colour = BinColour.Brown, + Keys = [ "Garden" ], + }, + new() + { + Name = "Food Waste", + Colour = BinColour.Green, + Keys = [ "Food" ], + Type = BinType.Caddy, + }, + ]; + + /// + /// The base URL for StatMap Aurora requests. + /// + private const string _auroraBaseUrl = "https://maps.cheltenham.gov.uk/map/Aurora.svc"; + + /// + /// The script path used when requesting a new session. + /// + private const string _scriptPath = "%5CAurora%5CCBC%20Waste%20Streets.AuroraScript%24"; + + /// + /// The Google Calendar ICS feed for Cheltenham's week schedule. + /// + private const string _calendarUrl = "https://calendar.google.com/calendar/ical/v7oettki6t1pi2p7s0j6q6121k%40group.calendar.google.com/public/full.ics"; + + /// + public GetAddressesResponse GetAddresses(string postcode, ClientSideResponse? clientSideResponse) + { + // Prepare client-side request for getting addresses + if (clientSideResponse == null) + { + var clientSideRequest = new ClientSideRequest + { + RequestId = 1, + Url = $"{_auroraBaseUrl}/RequestSession?userName=guest%20CBC&password=&script={_scriptPath}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + }; + + var getAddressesResponse = new GetAddressesResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getAddressesResponse; + } + // Prepare client-side request to find locations for the postcode + else if (clientSideResponse.RequestId == 1) + { + var jsonDocument = ParseJsonp(clientSideResponse.Content); + var sessionId = jsonDocument.RootElement.GetProperty("Session").GetProperty("SessionId").GetString()!; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 2, + Url = $"{_auroraBaseUrl}/FindLocation?sessionId={sessionId}&address={postcode}&limit=100&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + }; + + var getAddressesResponse = new GetAddressesResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getAddressesResponse; + } + // Process addresses from response + else if (clientSideResponse.RequestId == 2) + { + var responseJson = ParseJsonp(clientSideResponse.Content); + var locations = responseJson.RootElement.GetProperty("Locations").EnumerateArray(); + + // Iterate through each location, and create a new address object + var addresses = new List
(); + foreach (var location in locations) + { + var description = location.GetProperty("Description").GetString()! + .Replace("", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("", string.Empty, StringComparison.OrdinalIgnoreCase) + .Trim(); + + var address = new Address + { + Property = description, + Postcode = postcode, + Uid = location.GetProperty("Id").GetString()!, + }; + + addresses.Add(address); + } + + var getAddressesResponse = new GetAddressesResponse + { + Addresses = [.. addresses], + }; + + return getAddressesResponse; + } + + // Throw exception for invalid request + throw new InvalidOperationException("Invalid client-side request."); + } + + /// + public GetBinDaysResponse GetBinDays(Address address, ClientSideResponse? clientSideResponse) + { + // Prepare client-side request for creating a session + if (clientSideResponse == null) + { + var clientSideRequest = new ClientSideRequest + { + RequestId = 1, + Url = $"{_auroraBaseUrl}/RequestSession?userName=guest%20CBC&password=&script={_scriptPath}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request for retrieving workflow tasks + else if (clientSideResponse.RequestId == 1) + { + var responseJson = ParseJsonp(clientSideResponse.Content); + var sessionId = responseJson.RootElement.GetProperty("Session").GetProperty("SessionId").GetString()!; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 2, + Url = $"{_auroraBaseUrl}/GetWorkflow?sessionId={sessionId}&workflowId=wastestreet&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = + { + { "sessionId", sessionId }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to open the script map + else if (clientSideResponse.RequestId == 2) + { + var workflowJson = ParseJsonp(clientSideResponse.Content); + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + + var taskIds = workflowJson.RootElement.GetProperty("Tasks").EnumerateArray().ToDictionary( + task => task.GetProperty("$type").GetString()!, + task => task.GetProperty("Id").GetString()! + ); + + var clientSideRequest = new ClientSideRequest + { + RequestId = 3, + Url = $"{_auroraBaseUrl}/OpenScriptMap?sessionId={sessionId}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = + { + { "sessionId", sessionId }, + { "restoreTaskId", taskIds["StatMap.Aurora.RestoreStateTask, StatMapService"] }, + { "saveTaskId", taskIds["StatMap.Aurora.SaveStateTask, StatMapService"] }, + { "clearTaskId", taskIds["StatMap.Aurora.ClearResultSetTask, StatMapService"] }, + { "visibilityTaskId", taskIds["StatMap.Aurora.ChangeLayersVisibilityTask, StatMapService"] }, + { "drillTaskId", taskIds["StatMap.Aurora.DrillDownTask, StatMapService"] }, + { "fetchTaskId", taskIds["StatMap.Aurora.FetchResultSetTask, StatMapService"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to restore state + else if (clientSideResponse.RequestId == 3) + { + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + var restoreTaskId = clientSideResponse.Options.Metadata["restoreTaskId"]; + + var job = """ +{ + "Task": { + "$type": "StatMap.Aurora.RestoreStateTask, StatMapService" + } +} +"""; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 4, + Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={restoreTaskId}&job={job}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "sessionId", sessionId }, + { "saveTaskId", clientSideResponse.Options.Metadata["saveTaskId"] }, + { "clearTaskId", clientSideResponse.Options.Metadata["clearTaskId"] }, + { "visibilityTaskId", clientSideResponse.Options.Metadata["visibilityTaskId"] }, + { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] }, + { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to save state + else if (clientSideResponse.RequestId == 4) + { + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + var saveTaskId = clientSideResponse.Options.Metadata["saveTaskId"]; + + var job = """ +{ + "Task": { + "$type": "StatMap.Aurora.SaveStateTask, StatMapService" + } +} +"""; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 5, + Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={saveTaskId}&job={job}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "sessionId", sessionId }, + { "clearTaskId", clientSideResponse.Options.Metadata["clearTaskId"] }, + { "visibilityTaskId", clientSideResponse.Options.Metadata["visibilityTaskId"] }, + { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] }, + { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to clear the result set + else if (clientSideResponse.RequestId == 5) + { + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + var clearTaskId = clientSideResponse.Options.Metadata["clearTaskId"]; + + var job = """ +{ + "Task": { + "$type": "StatMap.Aurora.ClearResultSetTask, StatMapService" + } +} +"""; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 6, + Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={clearTaskId}&job={job}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "sessionId", sessionId }, + { "visibilityTaskId", clientSideResponse.Options.Metadata["visibilityTaskId"] }, + { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] }, + { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to ensure layers are visible + else if (clientSideResponse.RequestId == 6) + { + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + var visibilityTaskId = clientSideResponse.Options.Metadata["visibilityTaskId"]; + + var job = """ +{ + "Task": { + "$type": "StatMap.Aurora.ChangeLayersVisibilityTask, StatMapService" + } +} +"""; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 7, + Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={visibilityTaskId}&job={job}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "sessionId", sessionId }, + { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] }, + { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to resolve the selected location + else if (clientSideResponse.RequestId == 7) + { + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 8, + Url = $"{_auroraBaseUrl}/FindLocation?sessionId={sessionId}&locationId={address.Uid}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "sessionId", sessionId }, + { "drillTaskId", clientSideResponse.Options.Metadata["drillTaskId"] }, + { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to drill into the selected location + else if (clientSideResponse.RequestId == 8) + { + var responseJson = ParseJsonp(clientSideResponse.Content); + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + var drillTaskId = clientSideResponse.Options.Metadata["drillTaskId"]; + + var location = responseJson.RootElement.GetProperty("Locations").EnumerateArray().First(); + var queryX = location.GetProperty("X").GetDouble(); + var queryY = location.GetProperty("Y").GetDouble(); + + var job = $$""" +{ + "QueryX": {{queryX.ToString(CultureInfo.InvariantCulture)}}, + "QueryY": {{queryY.ToString(CultureInfo.InvariantCulture)}}, + "Task": { + "Type": "StatMap.Aurora.DrillDownTask, StatMapService" + } +} +"""; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 9, + Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={drillTaskId}&job={job}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "sessionId", sessionId }, + { "fetchTaskId", clientSideResponse.Options.Metadata["fetchTaskId"] }, + { "queryX", queryX.ToString(CultureInfo.InvariantCulture) }, + { "queryY", queryY.ToString(CultureInfo.InvariantCulture) }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request to fetch the result set + else if (clientSideResponse.RequestId == 9) + { + var sessionId = clientSideResponse.Options!.Metadata["sessionId"]; + var fetchTaskId = clientSideResponse.Options.Metadata["fetchTaskId"]; + var queryX = clientSideResponse.Options.Metadata["queryX"]; + var queryY = clientSideResponse.Options.Metadata["queryY"]; + + var job = $$""" +{ + "QueryX": {{queryX}}, + "QueryY": {{queryY}}, + "Task": { + "Type": "StatMap.Aurora.FetchResultSetTask, StatMapService" + }, + "ResultSetName": "inspection" +} +"""; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 10, + Url = $"{_auroraBaseUrl}/ExecuteTaskJob?sessionId={sessionId}&taskId={fetchTaskId}&job={job}&callback=_jqjsp", + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Prepare client-side request for the calendar feed and store service configuration + else if (clientSideResponse.RequestId == 10) + { + var fetchJson = ParseJsonp(clientSideResponse.Content); + var table = fetchJson.RootElement + .GetProperty("TaskResult") + .GetProperty("DistanceOrderedSet") + .GetProperty("ResultSet") + .GetProperty("Tables") + .EnumerateArray() + .First(); + + var columnNames = table.GetProperty("ColumnDefinitions") + .EnumerateArray() + .Select(column => column.GetProperty("ColumnName").GetString()!) + .ToList(); + + var record = table.GetProperty("Records").EnumerateArray().First().EnumerateArray().ToList(); + + var recordData = columnNames.Zip(record).ToDictionary( + pair => pair.First, + pair => pair.Second.ToString().Trim() + ); + + var clientSideRequest = new ClientSideRequest + { + RequestId = 11, + Url = _calendarUrl, + Method = "GET", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + }, + Options = new ClientSideOptions + { + Metadata = new() + { + { "refuseDay", recordData["New_Refuse_Day_internal"] }, + { "refuseWeek", recordData["Refuse_Week_External"] }, + { "recyclingDay", recordData["New_Recycling_Round"] }, + { "recyclingWeek", recordData["Amended_Recycling_Round"] }, + { "foodDay", recordData["New_Food_Day"] }, + { "foodWeek", recordData["New_Food_Week_Internal"] }, + { "gardenDay", recordData["Garden_Bin_Crew"] }, + { "gardenWeek", recordData["Garden_Bin_Week"] }, + }, + }, + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Process bin days using the calendar feed and service configuration + else if (clientSideResponse.RequestId == 11) + { + var week1Start = GetWeekStartDate(clientSideResponse.Content, "Week 1"); + var week2Start = GetWeekStartDate(clientSideResponse.Content, "Week 2"); + var endDate = DateOnly.FromDateTime(DateTime.Now.AddMonths(12)); + + var refuseDates = BuildCollectionDates( + clientSideResponse.Options!.Metadata["refuseDay"], + clientSideResponse.Options.Metadata["refuseWeek"], + week1Start, + week2Start, + endDate + ); + + var recyclingDates = BuildCollectionDates( + clientSideResponse.Options.Metadata["recyclingDay"], + clientSideResponse.Options.Metadata["recyclingWeek"], + week1Start, + week2Start, + endDate + ); + + var foodDates = BuildCollectionDates( + clientSideResponse.Options.Metadata["foodDay"], + clientSideResponse.Options.Metadata["foodWeek"], + week1Start, + week2Start, + endDate + ); + + var refuseBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Refuse"); + var recyclingBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Recycling"); + var foodBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Food"); + var gardenBins = ProcessingUtilities.GetMatchingBins(_binTypes, "Garden"); + + // Iterate through each collection date, and create a bin day entry + var binDays = new List(); + foreach (var date in refuseDates) + { + binDays.Add(new BinDay + { + Address = address, + Date = date, + Bins = refuseBins, + }); + } + + // Iterate through each recycling date, and create a bin day entry + foreach (var date in recyclingDates) + { + binDays.Add(new BinDay + { + Address = address, + Date = date, + Bins = recyclingBins, + }); + } + + // Iterate through each food waste date, and create a bin day entry + foreach (var date in foodDates) + { + binDays.Add(new BinDay + { + Address = address, + Date = date, + Bins = foodBins, + }); + } + + var gardenWeek = clientSideResponse.Options.Metadata["gardenWeek"]; + if (!string.IsNullOrWhiteSpace(clientSideResponse.Options.Metadata["gardenDay"]) + && !string.IsNullOrWhiteSpace(gardenWeek)) + { + var gardenDates = BuildCollectionDates( + clientSideResponse.Options.Metadata["gardenDay"], + gardenWeek, + week1Start, + week2Start, + endDate + ); + + // Iterate through each garden waste date, and create a bin day entry + foreach (var date in gardenDates) + { + binDays.Add(new BinDay + { + Address = address, + Date = date, + Bins = gardenBins, + }); + } + } + + var getBinDaysResponse = new GetBinDaysResponse + { + BinDays = ProcessingUtilities.ProcessBinDays(binDays), + }; + + return getBinDaysResponse; + } + + // Throw exception for invalid request + throw new InvalidOperationException("Invalid client-side request."); + } + + /// + /// Parses a JSONP response into a JSON document. + /// + private static JsonDocument ParseJsonp(string content) + { + var startIndex = content.IndexOf('('); + var endIndex = content.LastIndexOf(')'); + var jsonContent = content[(startIndex + 1)..endIndex]; + + return JsonDocument.Parse(jsonContent); + } + + /// + /// Builds recurring collection dates for a service based on the week pattern. + /// + private static List BuildCollectionDates( + string dayOfWeekText, + string weekPattern, + DateOnly week1Start, + DateOnly week2Start, + DateOnly endDate + ) + { + var dayOfWeek = ParseDayOfWeek(dayOfWeekText); + string? normalisedPattern; + if (weekPattern.Contains("weekly", StringComparison.OrdinalIgnoreCase)) + { + normalisedPattern = "Weekly"; + } + else if (weekPattern.Contains('1') && weekPattern.Contains('2')) + { + normalisedPattern = "Weekly"; + } + else if (weekPattern.Contains('2')) + { + normalisedPattern = "2"; + } + else + { + normalisedPattern = "1"; + } + + var week1Date = GetFirstWeekDate(week1Start, dayOfWeek); + var week2Date = GetFirstWeekDate(week2Start, dayOfWeek); + + if (normalisedPattern.Equals("Weekly", StringComparison.OrdinalIgnoreCase)) + { + var firstDate = week1Date < week2Date ? week1Date : week2Date; + return BuildRecurringDates(firstDate, 7, endDate); + } + + var targetWeek = normalisedPattern == "2" ? week2Date : week1Date; + + return BuildRecurringDates(targetWeek, 14, endDate); + } + + /// + /// Parses a day of week string that may be abbreviated. + /// + private static DayOfWeek ParseDayOfWeek(string dayOfWeekText) + { + if (Enum.TryParse(dayOfWeekText, true, out DayOfWeek parsedDay)) + { + return parsedDay; + } + + var abbreviatedDay = CultureInfo.InvariantCulture.DateTimeFormat.AbbreviatedDayNames + .Select((day, index) => new { Day = day, Index = index }) + .FirstOrDefault(day => day.Day.Equals(dayOfWeekText, StringComparison.OrdinalIgnoreCase)); + + if (abbreviatedDay != null) + { + return (DayOfWeek)abbreviatedDay.Index; + } + + throw new InvalidOperationException("Invalid day of week provided."); + } + + /// + /// Builds a list of dates at a specified interval. + /// + private static List BuildRecurringDates(DateOnly startDate, int intervalDays, DateOnly endDate) + { + var dates = new List(); + + for (var current = startDate; current <= endDate; current = current.AddDays(intervalDays)) + { + dates.Add(current); + } + + return dates; + } + + /// + /// Gets the first date for a given day within a week. + /// + private static DateOnly GetFirstWeekDate(DateOnly weekStart, DayOfWeek dayOfWeek) + { + var offset = ((int)dayOfWeek - (int)weekStart.DayOfWeek + 7) % 7; + + return weekStart.AddDays(offset); + } + + /// + /// Extracts the week start date for a specific summary label from the ICS content. + /// + private static DateOnly GetWeekStartDate(string icsContent, string summaryLabel) + { + var events = icsContent.Split("BEGIN:VEVENT", StringSplitOptions.RemoveEmptyEntries); + + // Iterate through each calendar event to locate the matching week summary + foreach (var calendarEvent in events) + { + var lines = calendarEvent + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var summaryLine = lines.FirstOrDefault(line => line.StartsWith("SUMMARY", StringComparison.OrdinalIgnoreCase)); + + if (summaryLine == null || !summaryLine.Contains(summaryLabel, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var startLine = lines.FirstOrDefault(line => line.StartsWith("DTSTART", StringComparison.OrdinalIgnoreCase)); + + if (startLine == null) + { + continue; + } + + var dateText = startLine.Split(':', 2)[1].Trim(); + + var startDate = DateOnly.ParseExact( + dateText, + "yyyyMMdd", + CultureInfo.InvariantCulture, + DateTimeStyles.None + ); + + return startDate; + } + + throw new InvalidOperationException("Week start date not found in calendar feed."); + } +} diff --git a/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs b/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs new file mode 100644 index 0000000..1235e1d --- /dev/null +++ b/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs @@ -0,0 +1,36 @@ +namespace BinDays.Api.IntegrationTests.Collectors.Councils; + +using BinDays.Api.Collectors.Collectors; +using BinDays.Api.Collectors.Collectors.Councils; +using BinDays.Api.Collectors.Services; +using BinDays.Api.IntegrationTests.Helpers; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +public class CheltenhamBoroughCouncilTests +{ + private readonly IntegrationTestClient _client; + private static readonly ICollector _collector = new CheltenhamBoroughCouncil(); + private readonly CollectorService _collectorService = new([_collector]); + private readonly ITestOutputHelper _outputHelper; + + public CheltenhamBoroughCouncilTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + _client = new IntegrationTestClient(outputHelper); + } + + [Theory] + [InlineData("GL51 7DU")] + public async Task GetBinDaysTest(string postcode) + { + await TestSteps.EndToEnd( + _client, + _collectorService, + _collector, + postcode, + _outputHelper + ); + } +}