From e1d69ea4236c3ef48ef2389fba84f5721fff1d5c Mon Sep 17 00:00:00 2001 From: Moley-Bot Date: Sat, 31 Jan 2026 11:04:49 +0000 Subject: [PATCH 1/2] Add collector for CheltenhamBoroughCouncil Closes #131 Generated with Codex CLI by Moley-Bot --- .../Councils/CheltenhamBoroughCouncil.cs | 858 ++++++++++++++++++ .../Councils/CheltenhamBoroughCouncilTests.cs | 36 + 2 files changed, 894 insertions(+) create mode 100644 BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs create mode 100644 BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs diff --git a/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs new file mode 100644 index 0000000..ed243ce --- /dev/null +++ b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs @@ -0,0 +1,858 @@ +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); + var normalisedPattern = weekPattern; + + 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..456c7f0 --- /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 + ); + } +} From 46dd180735405c5ca6f4927736d8be84f783f805 Mon Sep 17 00:00:00 2001 From: Moley-Bot Date: Sat, 31 Jan 2026 11:05:32 +0000 Subject: [PATCH 2/2] Auto-format code with dotnet format Formatted by Moley-Bot --- .../Councils/CheltenhamBoroughCouncil.cs | 1715 ++++++++--------- .../Councils/CheltenhamBoroughCouncilTests.cs | 72 +- 2 files changed, 893 insertions(+), 894 deletions(-) diff --git a/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs index ed243ce..e5754b5 100644 --- a/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs +++ b/BinDays.Api.Collectors/Collectors/Councils/CheltenhamBoroughCouncil.cs @@ -1,858 +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); - var normalisedPattern = weekPattern; - - 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."); - } -} +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 index 456c7f0..1235e1d 100644 --- a/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs +++ b/BinDays.Api.IntegrationTests/Collectors/Councils/CheltenhamBoroughCouncilTests.cs @@ -1,36 +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 - ); - } -} +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 + ); + } +}