From 9379555e23e1027ded27be61fd1242a032c6db78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sat, 21 Feb 2026 23:26:01 +0100 Subject: [PATCH 01/23] add hjemlo integration --- .../UserSearch/IDataForsyningenClient.cs | 10 + .../GroupSearch/GroupSearchForm.razor | 28 +- .../Features/HjemGroups/HjemGroupEntry.cs | 14 + .../Features/HjemGroups/HjemGroupProvider.cs | 92 +++++++ .../HjemGroupScraperBackgroundService.cs | 34 +++ .../HjemGroups/HjemGroupScraperService.cs | 260 ++++++++++++++++++ .../WebApplicationBuilderExtensions.cs | 19 ++ src/web/Jordnaer/Jordnaer.csproj | 1 + .../Pages/GroupSearch/GroupSearch.razor | 9 +- src/web/Jordnaer/Program.cs | 2 + 10 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs create mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs create mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs create mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs create mode 100644 src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs diff --git a/src/shared/Jordnaer.Shared/UserSearch/IDataForsyningenClient.cs b/src/shared/Jordnaer.Shared/UserSearch/IDataForsyningenClient.cs index 9b4d5a5b2..445f47c8a 100644 --- a/src/shared/Jordnaer.Shared/UserSearch/IDataForsyningenClient.cs +++ b/src/shared/Jordnaer.Shared/UserSearch/IDataForsyningenClient.cs @@ -55,4 +55,14 @@ public interface IDataForsyningenClient [QueryUriFormat(UriFormat.Unescaped)] Task>> GetZipCodesWithinCircle([AliasAs("cirkel")] string? circle, CancellationToken cancellationToken = default); + + /// + /// Searches for zip codes matching the given query string. + /// + /// City or area name to search for. + /// + /// Matching zip code entries, ordered by relevance. + [Get("/postnumre")] + Task>> + SearchZipCodesAsync([AliasAs("q")] string query, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor b/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor index 7a5d328a5..7d8b47b45 100644 --- a/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor +++ b/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor @@ -54,19 +54,29 @@ [Parameter] public IEnumerable? Groups { get; set; } + /// + /// Additional markers (e.g. HJEM lokalafdelinger) merged into the map alongside regular groups. + /// Filtered client-side by the active name filter. + /// + [Parameter] + public IReadOnlyList? AdditionalMarkers { get; set; } + private List? _groupMarkers; private IEnumerable? _previousGroups; + private string? _previousNameFilter; protected override void OnParametersSet() { - // Early return if Groups reference hasn't changed - if (ReferenceEquals(Groups, _previousGroups)) + // Early return if Groups reference and name filter haven't changed + if (ReferenceEquals(Groups, _previousGroups) && Filter.Name == _previousNameFilter) { return; } + _previousNameFilter = Filter.Name; + // Only use zip-code-level coordinates to avoid exposing exact group locations - _groupMarkers = Groups? + var regularMarkers = Groups? .Where(g => g.ZipCodeLatitude.HasValue && g.ZipCodeLongitude.HasValue) .Select(g => new GroupMarkerData { @@ -80,7 +90,17 @@ Latitude = g.ZipCodeLatitude!.Value + ((HashCode.Combine(g.Id, 1) & 0x7FFFFFFF) % 10000 / 10000.0 - 0.5) * 0.003, Longitude = g.ZipCodeLongitude!.Value + ((HashCode.Combine(g.Id, 2) & 0x7FFFFFFF) % 10000 / 10000.0 - 0.5) * 0.005, }) - .ToList(); + ?? Enumerable.Empty(); + + var filteredAdditional = AdditionalMarkers ?? []; + if (!string.IsNullOrWhiteSpace(Filter.Name)) + { + filteredAdditional = filteredAdditional + .Where(m => m.Name.Contains(Filter.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + _groupMarkers = regularMarkers.Concat(filteredAdditional).ToList(); _previousGroups = Groups; } diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs new file mode 100644 index 000000000..e8fdc1ae9 --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs @@ -0,0 +1,14 @@ +namespace Jordnaer.Features.HjemGroups; + +public record HjemGroupEntry +{ + public required string Name { get; init; } + public required string WebsiteUrl { get; init; } + public string? City { get; init; } + public int? ZipCode { get; init; } + public required double Latitude { get; init; } + public required double Longitude { get; init; } + public required HjemGroupType Type { get; init; } +} + +public enum HjemGroupType { Lokalafdeling, Lokalrepresentant } diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs new file mode 100644 index 000000000..e6ac78313 --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -0,0 +1,92 @@ +using Azure.Storage.Blobs; +using Jordnaer.Features.Map; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace Jordnaer.Features.HjemGroups; + +public interface IHjemGroupProvider +{ + Task> GetMarkersAsync(CancellationToken cancellationToken = default); +} + +public class HjemGroupProvider( + BlobServiceClient blobServiceClient, + ILogger logger) : IHjemGroupProvider +{ + private const string ContainerName = "hjemlo-groups"; + private const string BlobName = "groups.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private IReadOnlyList? _cached; + + public async Task> GetMarkersAsync(CancellationToken cancellationToken = default) + { + if (_cached is not null) + return _cached; + + try + { + var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); + + if (!await containerClient.ExistsAsync(cancellationToken)) + { + _cached = []; + return _cached; + } + + var blobClient = containerClient.GetBlobClient(BlobName); + + if (!await blobClient.ExistsAsync(cancellationToken)) + { + _cached = []; + return _cached; + } + + var response = await blobClient.DownloadContentAsync(cancellationToken); + var entries = JsonSerializer.Deserialize>( + response.Value.Content.ToString(), JsonOptions); + + if (entries is null or { Count: 0 }) + { + _cached = []; + return _cached; + } + + _cached = entries.Select(MapToMarker).ToList(); + return _cached; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load HJEM group markers from blob storage."); + _cached = []; + return _cached; + } + } + + private static GroupMarkerData MapToMarker(HjemGroupEntry entry) + { + var idBytes = MD5.HashData(Encoding.UTF8.GetBytes(entry.WebsiteUrl + entry.Name)); + var id = new Guid(idBytes); + + return new GroupMarkerData + { + Id = id, + Name = entry.Name, + ProfilePictureUrl = null, + WebsiteUrl = entry.WebsiteUrl, + ShortDescription = entry.Type == HjemGroupType.Lokalafdeling + ? "HJEM lokalafdeling" + : "HJEM lokalrepræsentant", + ZipCode = entry.ZipCode, + City = entry.City, + Latitude = entry.Latitude, + Longitude = entry.Longitude, + }; + } +} diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs new file mode 100644 index 000000000..d4bd2e59a --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs @@ -0,0 +1,34 @@ +namespace Jordnaer.Features.HjemGroups; + +public class HjemGroupScraperBackgroundService( + HjemGroupScraperService scraperService, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await scraperService.ScrapeAndSaveAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception in HJEM group scraper background service."); + } + + try + { + await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } +} diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs new file mode 100644 index 000000000..6b1290522 --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs @@ -0,0 +1,260 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using HtmlAgilityPack; +using Jordnaer.Shared; +using System.Text.Json; + +namespace Jordnaer.Features.HjemGroups; + +public class HjemGroupScraperService( + HttpClient httpClient, + IDataForsyningenClient dataForsyningenClient, + BlobServiceClient blobServiceClient, + ILogger logger) +{ + private const string ContainerName = "hjemlo-groups"; + private const string BlobName = "groups.json"; + private const string LokalafdjelingerUrl = "https://www.hjemlo.dk/lokalafdelinger"; + private const string LokalreprasentanterUrl = "https://www.hjemlo.dk/lokalrepraesentanter"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task ScrapeAndSaveAsync(CancellationToken cancellationToken = default) + { + try + { + var entries = new List(); + + var lokalafdelinger = await ScrapeLokalafdelingerAsync(cancellationToken); + entries.AddRange(lokalafdelinger); + + var lokalreprasentanter = await ScrapeLokalreprasentanterAsync(cancellationToken); + entries.AddRange(lokalreprasentanter); + + if (entries.Count == 0) + { + logger.LogWarning("Scraping hjemlo.dk yielded zero results. Skipping blob upload to preserve previous version."); + return; + } + + await SaveToBlobAsync(entries, cancellationToken); + logger.LogInformation("Saved {Count} HJEM entries to blob storage.", entries.Count); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to scrape and save HJEM groups."); + } + } + + private async Task> ScrapeLokalafdelingerAsync(CancellationToken cancellationToken) + { + var results = new List(); + + string html; + try + { + html = await httpClient.GetStringAsync(LokalafdjelingerUrl, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch lokalafdelinger page."); + return results; + } + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // Look for anchor elements with hrefs like /randers, /koebenhavn, etc. + var anchors = doc.DocumentNode.SelectNodes("//a[starts-with(@href, '/') and string-length(@href) > 1]"); + if (anchors is null || anchors.Count == 0) + { + logger.LogWarning("No lokalafdeling anchors found on page — page may require JavaScript rendering."); + return results; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var anchor in anchors) + { + var href = anchor.GetAttributeValue("href", "").Trim(); + var name = HtmlEntity.DeEntitize(anchor.InnerText).Trim(); + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(href)) + continue; + + // Skip obvious navigation links (about, contact, etc.) + var skipPaths = new[] { "/lokalafdelinger", "/lokalrepraesentanter", "/om", "/kontakt", "/blog", "/login", "/signup", "/search" }; + if (skipPaths.Any(s => href.Equals(s, StringComparison.OrdinalIgnoreCase))) + continue; + + // Only single-segment relative paths like /randers + if (href.Count(c => c == '/') != 1) + continue; + + if (!seen.Add(href)) + continue; + + var geocoded = await GeocodeAsync(name, cancellationToken); + if (geocoded is null) + { + logger.LogWarning("Could not geocode lokalafdeling: {Name}", name); + continue; + } + + results.Add(new HjemGroupEntry + { + Name = name, + WebsiteUrl = $"https://www.hjemlo.dk{href}", + City = geocoded.City, + ZipCode = geocoded.ZipCode, + Latitude = geocoded.Latitude, + Longitude = geocoded.Longitude, + Type = HjemGroupType.Lokalafdeling, + }); + } + + logger.LogInformation("Scraped {Count} lokalafdelinger.", results.Count); + return results; + } + + private async Task> ScrapeLokalreprasentanterAsync(CancellationToken cancellationToken) + { + var results = new List(); + + string html; + try + { + html = await httpClient.GetStringAsync(LokalreprasentanterUrl, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch lokalrepræsentanter page."); + return results; + } + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // Look for text nodes / elements that reference city/area names. + var candidates = doc.DocumentNode.SelectNodes("//li | //p | //div[@class]"); + if (candidates is null || candidates.Count == 0) + { + logger.LogWarning("No lokalrepræsentant candidates found on page — page may require JavaScript rendering."); + return results; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var node in candidates) + { + var text = HtmlEntity.DeEntitize(node.InnerText).Trim(); + + if (string.IsNullOrWhiteSpace(text) || text.Length > 100 || text.Length < 3) + continue; + + // Skip if contains newlines or many words (likely a paragraph) + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (lines.Length > 2) + continue; + + var singleLine = lines[0]; + if (string.IsNullOrWhiteSpace(singleLine) || singleLine.Length > 60) + continue; + + if (!seen.Add(singleLine)) + continue; + + var geocoded = await GeocodeAsync(singleLine, cancellationToken); + if (geocoded is null) + continue; + + results.Add(new HjemGroupEntry + { + Name = singleLine, + WebsiteUrl = LokalreprasentanterUrl, + City = geocoded.City, + ZipCode = geocoded.ZipCode, + Latitude = geocoded.Latitude, + Longitude = geocoded.Longitude, + Type = HjemGroupType.Lokalrepresentant, + }); + } + + logger.LogInformation("Scraped {Count} lokalrepræsentanter.", results.Count); + return results; + } + + private async Task GeocodeAsync(string locationText, CancellationToken cancellationToken) + { + // Try the name as-is first, then with common suffixes stripped + var candidates = new List { locationText }; + + var suffixes = new[] { " lokalafdeling", " lokalrepræsentant", " kommune", " by" }; + foreach (var suffix in suffixes) + { + if (locationText.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + candidates.Add(locationText[..^suffix.Length].Trim()); + } + } + + foreach (var query in candidates) + { + var result = await TryGeocodeAsync(query, cancellationToken); + if (result is not null) + return result; + } + + return null; + } + + private async Task TryGeocodeAsync(string query, CancellationToken cancellationToken) + { + try + { + var response = await dataForsyningenClient.SearchZipCodesAsync(query, cancellationToken); + + if (!response.IsSuccessStatusCode || response.Content is null) + return null; + + var first = response.Content.FirstOrDefault(); + if (first.Navn is null) + return null; + + if (first.Visueltcenter is not { Length: >= 2 }) + return null; + + // GeoJSON order: [longitude, latitude] + var longitude = (double)first.Visueltcenter[0]; + var latitude = (double)first.Visueltcenter[1]; + + int? zipCode = null; + if (int.TryParse(first.Nr, out var parsedZip)) + zipCode = parsedZip; + + return new GeocodedResult(first.Navn, zipCode, latitude, longitude); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Geocoding failed for query: {Query}", query); + return null; + } + } + + private async Task SaveToBlobAsync(List entries, CancellationToken cancellationToken) + { + var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); + await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: cancellationToken); + + var blobClient = containerClient.GetBlobClient(BlobName); + var json = JsonSerializer.Serialize(entries, JsonOptions); + + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); + } + + private sealed record GeocodedResult(string? City, int? ZipCode, double Latitude, double Longitude); +} diff --git a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs new file mode 100644 index 000000000..4d859b89e --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs @@ -0,0 +1,19 @@ +namespace Jordnaer.Features.HjemGroups; + +public static class WebApplicationBuilderExtensions +{ + public static WebApplicationBuilder AddHjemGroupServices(this WebApplicationBuilder builder) + { + builder.Services.AddHttpClient(client => + { + client.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (compatible; MiniMoeder/1.0)"); + client.Timeout = TimeSpan.FromSeconds(30); + }); + + builder.Services.AddScoped(); + builder.Services.AddHostedService(); + + return builder; + } +} diff --git a/src/web/Jordnaer/Jordnaer.csproj b/src/web/Jordnaer/Jordnaer.csproj index 9fbcbe14d..a4c9d39c6 100644 --- a/src/web/Jordnaer/Jordnaer.csproj +++ b/src/web/Jordnaer/Jordnaer.csproj @@ -12,6 +12,7 @@ + diff --git a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor index 323a67246..9b888551f 100644 --- a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor +++ b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor @@ -1,8 +1,12 @@ @page "/groups/discover" +@using Jordnaer.Features.HjemGroups +@using Jordnaer.Features.Map + @inject IGroupSearchService GroupSearchService @inject NavigationManager Navigation @inject GroupSearchResultCache Cache +@inject IHjemGroupProvider HjemGroupProvider @attribute [Sitemap] @implements IDisposable @@ -11,7 +15,7 @@ - + @@ -19,6 +23,7 @@ private GroupSearchFilter _filter = new(); private GroupSearchResult _searchResult = new(); + private IReadOnlyList _hjemMarkers = []; private bool _isSearching = false; private CancellationTokenSource? _debounceCts; @@ -26,6 +31,8 @@ protected override async Task OnInitializedAsync() { + _hjemMarkers = await HjemGroupProvider.GetMarkersAsync(); + // Restore from cache if available if (Cache.SearchFilter is not null && Cache.SearchResult is not null) { diff --git a/src/web/Jordnaer/Program.cs b/src/web/Jordnaer/Program.cs index 411b1015e..a535061f6 100644 --- a/src/web/Jordnaer/Program.cs +++ b/src/web/Jordnaer/Program.cs @@ -13,6 +13,7 @@ using Jordnaer.Features.Email; using Jordnaer.Features.Groups; using Jordnaer.Features.GroupSearch; +using Jordnaer.Features.HjemGroups; using Jordnaer.Features.Images; using Jordnaer.Features.Membership; using Jordnaer.Features.GroupPosts; @@ -103,6 +104,7 @@ builder.AddSharingFeature(); builder.AddPartnerServices(); builder.AddAdServices(); +builder.AddHjemGroupServices(); builder.AddMudBlazor(); From f016fd4dad99785349fdaba8480a473a9b61b8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 08:43:18 +0100 Subject: [PATCH 02/23] tests & fixes --- .../GroupSearch/GroupSearchForm.razor | 6 +- .../Features/HjemGroups/HjemGroupEntry.cs | 2 +- .../Features/HjemGroups/HjemGroupProvider.cs | 7 +- .../HjemGroupScraperBackgroundService.cs | 5 +- .../HjemGroups/HjemGroupScraperOptions.cs | 8 + .../HjemGroups/HjemGroupScraperService.cs | 14 +- .../WebApplicationBuilderExtensions.cs | 7 + .../HjemGroups/HjemGroupIntegrationTests.cs | 280 ++++++++++++++++ .../HjemGroups/HjemGroupProviderTests.cs | 287 ++++++++++++++++ .../HjemGroupScraperServiceTests.cs | 314 ++++++++++++++++++ .../UserSearch/DataForsyningenClientTests.cs | 56 ++++ 11 files changed, 972 insertions(+), 14 deletions(-) create mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs create mode 100644 tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs create mode 100644 tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs create mode 100644 tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs diff --git a/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor b/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor index 7d8b47b45..6b56f0c26 100644 --- a/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor +++ b/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor @@ -64,16 +64,18 @@ private List? _groupMarkers; private IEnumerable? _previousGroups; private string? _previousNameFilter; + private IReadOnlyList? _previousAdditionalMarkers; protected override void OnParametersSet() { - // Early return if Groups reference and name filter haven't changed - if (ReferenceEquals(Groups, _previousGroups) && Filter.Name == _previousNameFilter) + // Early return if Groups reference, name filter, and AdditionalMarkers reference haven't changed + if (ReferenceEquals(Groups, _previousGroups) && Filter.Name == _previousNameFilter && ReferenceEquals(AdditionalMarkers, _previousAdditionalMarkers)) { return; } _previousNameFilter = Filter.Name; + _previousAdditionalMarkers = AdditionalMarkers; // Only use zip-code-level coordinates to avoid exposing exact group locations var regularMarkers = Groups? diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs index e8fdc1ae9..7c7e43c1a 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs @@ -3,7 +3,7 @@ namespace Jordnaer.Features.HjemGroups; public record HjemGroupEntry { public required string Name { get; init; } - public required string WebsiteUrl { get; init; } + public required Uri WebsiteUrl { get; init; } public string? City { get; init; } public int? ZipCode { get; init; } public required double Latitude { get; init; } diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index e6ac78313..54427ef6d 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -64,14 +64,13 @@ public async Task> GetMarkersAsync(CancellationTo catch (Exception ex) { logger.LogWarning(ex, "Failed to load HJEM group markers from blob storage."); - _cached = []; - return _cached; + return _cached ?? []; } } private static GroupMarkerData MapToMarker(HjemGroupEntry entry) { - var idBytes = MD5.HashData(Encoding.UTF8.GetBytes(entry.WebsiteUrl + entry.Name)); + var idBytes = MD5.HashData(Encoding.UTF8.GetBytes(entry.WebsiteUrl.ToString() + entry.Name)); var id = new Guid(idBytes); return new GroupMarkerData @@ -79,7 +78,7 @@ private static GroupMarkerData MapToMarker(HjemGroupEntry entry) Id = id, Name = entry.Name, ProfilePictureUrl = null, - WebsiteUrl = entry.WebsiteUrl, + WebsiteUrl = entry.WebsiteUrl.ToString(), ShortDescription = entry.Type == HjemGroupType.Lokalafdeling ? "HJEM lokalafdeling" : "HJEM lokalrepræsentant", diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs index d4bd2e59a..fa56c4a87 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs @@ -1,7 +1,10 @@ +using Microsoft.Extensions.Options; + namespace Jordnaer.Features.HjemGroups; public class HjemGroupScraperBackgroundService( HjemGroupScraperService scraperService, + IOptions options, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -23,7 +26,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) try { - await Task.Delay(TimeSpan.FromHours(24), stoppingToken); + await Task.Delay(options.Value.Interval, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs new file mode 100644 index 000000000..f523322b8 --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs @@ -0,0 +1,8 @@ +namespace Jordnaer.Features.HjemGroups; + +public class HjemGroupScraperOptions +{ + public const string SectionName = "HjemGroupScraper"; + + public TimeSpan Interval { get; set; } = TimeSpan.FromHours(24); +} diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs index 6b1290522..caaf36a6a 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs @@ -14,7 +14,7 @@ public class HjemGroupScraperService( { private const string ContainerName = "hjemlo-groups"; private const string BlobName = "groups.json"; - private const string LokalafdjelingerUrl = "https://www.hjemlo.dk/lokalafdelinger"; + private const string LokalafdelingerUrl = "https://www.hjemlo.dk/lokalafdelinger"; private const string LokalreprasentanterUrl = "https://www.hjemlo.dk/lokalrepraesentanter"; private static readonly JsonSerializerOptions JsonOptions = new() @@ -23,6 +23,9 @@ public class HjemGroupScraperService( PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; + private static readonly string[] SkipPaths = + ["/lokalafdelinger", "/lokalrepraesentanter", "/om", "/kontakt", "/blog", "/login", "/signup", "/search"]; + public async Task ScrapeAndSaveAsync(CancellationToken cancellationToken = default) { try @@ -57,7 +60,7 @@ private async Task> ScrapeLokalafdelingerAsync(Cancellation string html; try { - html = await httpClient.GetStringAsync(LokalafdjelingerUrl, cancellationToken); + html = await httpClient.GetStringAsync(LokalafdelingerUrl, cancellationToken); } catch (Exception ex) { @@ -86,8 +89,7 @@ private async Task> ScrapeLokalafdelingerAsync(Cancellation continue; // Skip obvious navigation links (about, contact, etc.) - var skipPaths = new[] { "/lokalafdelinger", "/lokalrepraesentanter", "/om", "/kontakt", "/blog", "/login", "/signup", "/search" }; - if (skipPaths.Any(s => href.Equals(s, StringComparison.OrdinalIgnoreCase))) + if (SkipPaths.Any(s => href.Equals(s, StringComparison.OrdinalIgnoreCase))) continue; // Only single-segment relative paths like /randers @@ -107,7 +109,7 @@ private async Task> ScrapeLokalafdelingerAsync(Cancellation results.Add(new HjemGroupEntry { Name = name, - WebsiteUrl = $"https://www.hjemlo.dk{href}", + WebsiteUrl = new Uri($"https://www.hjemlo.dk{href}"), City = geocoded.City, ZipCode = geocoded.ZipCode, Latitude = geocoded.Latitude, @@ -174,7 +176,7 @@ private async Task> ScrapeLokalreprasentanterAsync(Cancella results.Add(new HjemGroupEntry { Name = singleLine, - WebsiteUrl = LokalreprasentanterUrl, + WebsiteUrl = new Uri(LokalreprasentanterUrl), City = geocoded.City, ZipCode = geocoded.ZipCode, Latitude = geocoded.Latitude, diff --git a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs index 4d859b89e..feb43751a 100644 --- a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs @@ -4,6 +4,13 @@ public static class WebApplicationBuilderExtensions { public static WebApplicationBuilder AddHjemGroupServices(this WebApplicationBuilder builder) { + + builder.Services + .AddOptions() + .BindConfiguration(HjemGroupScraperOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + builder.Services.AddHttpClient(client => { client.DefaultRequestHeaders.UserAgent.ParseAdd( diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs new file mode 100644 index 000000000..2fffad185 --- /dev/null +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs @@ -0,0 +1,280 @@ +using Azure.Storage.Blobs; +using FluentAssertions; +using Jordnaer.Features.HjemGroups; +using Jordnaer.Shared; +using Microsoft.Extensions.Logging.Abstractions; +using Refit; +using System.Net; +using System.Text; +using System.Text.Json; +using Testcontainers.Azurite; +using Xunit; + +namespace Jordnaer.Tests.HjemGroups; + +/// +/// Integration tests for the HJEM scraper pipeline using a real Azurite blob container. +/// Geocoding uses a fake IDataForsyningenClient to avoid external API calls in CI. +/// +public class HjemGroupIntegrationTests : IAsyncLifetime +{ + private readonly AzuriteContainer _azurite = new AzuriteBuilder("mcr.microsoft.com/azure-storage/azurite:latest") + .WithInMemoryPersistence() + .WithCommand("--skipApiVersionCheck") + .Build(); + + private BlobServiceClient _blobServiceClient = null!; + + private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public async Task InitializeAsync() + { + await _azurite.StartAsync(); + _blobServiceClient = new BlobServiceClient(_azurite.GetConnectionString()); + } + + public async Task DisposeAsync() => await _azurite.DisposeAsync(); + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private HjemGroupScraperService CreateScraper(string lokAfdelHtml, string lokRepHtml, IDataForsyningenClient? geocoder = null) + { + var handler = new HtmlFakeHandler(lokAfdelHtml, lokRepHtml); + var httpClient = new HttpClient(handler); + geocoder ??= new AlwaysSuccessGeocoder(); + return new HjemGroupScraperService(httpClient, geocoder, _blobServiceClient, NullLogger.Instance); + } + + private HjemGroupProvider CreateProvider() => + new(_blobServiceClient, NullLogger.Instance); + + private const string SingleCityHtml = """ + Randers + """; + + private const string TwoCitiesHtml = """ + + Randers + Aarhus + + """; + + private const string Empty = ""; + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + [Fact] + public async Task FullPipeline_ScrapeSave_ThenProviderReadsMarkers() + { + // Arrange + var scraper = CreateScraper(SingleCityHtml, Empty); + var provider = CreateProvider(); + + // Act + await scraper.ScrapeAndSaveAsync(); + var markers = await provider.GetMarkersAsync(); + + // Assert + markers.Should().HaveCount(1); + markers[0].Name.Should().Be("Randers"); + markers[0].ShortDescription.Should().Be("HJEM lokalafdeling"); + markers[0].Latitude.Should().Be(56.0); + markers[0].Longitude.Should().Be(10.0); + } + + [Fact] + public async Task FullPipeline_MultipleCities_AllAppearAsMarkers() + { + // Arrange + var scraper = CreateScraper(TwoCitiesHtml, Empty); + var provider = CreateProvider(); + + // Act + await scraper.ScrapeAndSaveAsync(); + var markers = await provider.GetMarkersAsync(); + + // Assert + markers.Should().HaveCount(2); + markers.Select(m => m.Name).Should().BeEquivalentTo(["Randers", "Aarhus"]); + } + + [Fact] + public async Task FullPipeline_BlobPreserved_WhenSecondScrapeYieldsNothing() + { + // Arrange: first successful scrape + var scraper = CreateScraper(SingleCityHtml, Empty); + await scraper.ScrapeAndSaveAsync(); + + // Second scraper — geocoding always returns empty (simulates failure) + var failingScraper = CreateScraper(SingleCityHtml, Empty, geocoder: new AlwaysEmptyGeocoder()); + await failingScraper.ScrapeAndSaveAsync(); + + // Provider should still return data from the first run + var provider = CreateProvider(); + var markers = await provider.GetMarkersAsync(); + + markers.Should().HaveCount(1, "previous blob must be preserved when scrape yields zero results"); + } + + [Fact] + public async Task FullPipeline_BlobOverwritten_WhenNewScrapeSucceeds() + { + // Arrange: first scrape — one city + await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); + + // Second scrape — two cities + await CreateScraper(TwoCitiesHtml, Empty).ScrapeAndSaveAsync(); + + var markers = await CreateProvider().GetMarkersAsync(); + + markers.Should().HaveCount(2, "blob should be overwritten with the newer result"); + } + + [Fact] + public async Task Provider_ReturnsEmpty_BeforeScrapeHasEverRun() + { + // Arrange: fresh Azurite — no blob uploaded + var provider = CreateProvider(); + + // Act + var markers = await provider.GetMarkersAsync(); + + // Assert + markers.Should().BeEmpty(); + } + + [Fact] + public async Task FullPipeline_MarkersHaveStableIds_AcrossProviderInstances() + { + // Arrange + await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); + + // Act: two separate provider instances read the same blob + var markers1 = await CreateProvider().GetMarkersAsync(); + var markers2 = await CreateProvider().GetMarkersAsync(); + + // Assert + markers1[0].Id.Should().Be(markers2[0].Id); + markers1[0].Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public async Task FullPipeline_LokalreprasentanterGetCorrectShortDescription() + { + // Arrange + const string repHtml = """ +
  • Odense
+ """; + await CreateScraper(Empty, repHtml).ScrapeAndSaveAsync(); + + var markers = await CreateProvider().GetMarkersAsync(); + + // Assert + markers.Should().ContainSingle(); + markers[0].ShortDescription.Should().Be("HJEM lokalrepræsentant"); + } + + [Fact] + public async Task FullPipeline_BlobContainsValidJson() + { + // Arrange + await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); + + // Act: read raw blob content + var containerClient = _blobServiceClient.GetBlobContainerClient("hjemlo-groups"); + var blobClient = containerClient.GetBlobClient("groups.json"); + var download = await blobClient.DownloadContentAsync(); + var json = download.Value.Content.ToString(); + + // Assert + var act = () => JsonSerializer.Deserialize>(json, CamelCase); + act.Should().NotThrow(); + act()!.Should().NotBeEmpty(); + } + + [Fact] + public async Task FullPipeline_WebsiteUrl_IsFullHjemloUrl() + { + // Arrange + await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); + + var markers = await CreateProvider().GetMarkersAsync(); + + // Assert + markers[0].WebsiteUrl.Should().StartWith("https://www.hjemlo.dk/"); + } + + [Fact] + public async Task FullPipeline_Coordinates_AreFromGeocoderResult() + { + // Arrange - AlwaysSuccessGeocoder returns lat=56, lng=10 + await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); + var markers = await CreateProvider().GetMarkersAsync(); + + // Assert + markers[0].Latitude.Should().Be(56.0); + markers[0].Longitude.Should().Be(10.0); + } + + // ------------------------------------------------------------------------- + // Fake helpers + // ------------------------------------------------------------------------- + + private sealed class HtmlFakeHandler(string lokAfdelHtml, string lokRepHtml) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var html = request.RequestUri?.AbsolutePath.Contains("lokalrepraesentanter") == true + ? lokRepHtml + : lokAfdelHtml; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(html, Encoding.UTF8, "text/html") + }); + } + } + + /// Always returns a fixed geocode result for any query. + private sealed class AlwaysSuccessGeocoder : IDataForsyningenClient + { + private static readonly ZipCodeSearchResponse FakeResult = new( + Href: null, Nr: "8900", Navn: "Randers", + Stormodtageradresser: null, Bbox: null, + Visueltcenter: [10f, 56f], // GeoJSON: [lng, lat] + Kommuner: null, + Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, + Geo_Version: 1, Dagi_Id: null); + + public Task>> GetAddressesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task>> GetZipCodesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task> GetZipCodeFromCoordinates(string longitude, string latitude, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task>> GetZipCodesWithinCircle(string? circle, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Task>> SearchZipCodesAsync(string query, CancellationToken cancellationToken = default) => + Task.FromResult>>( + new ApiResponse>( + new HttpResponseMessage(HttpStatusCode.OK), + [FakeResult], + new RefitSettings())); + } + + /// Always returns no geocode results (simulates failed geocoding). + private sealed class AlwaysEmptyGeocoder : IDataForsyningenClient + { + public Task>> GetAddressesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task>> GetZipCodesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task> GetZipCodeFromCoordinates(string longitude, string latitude, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task>> GetZipCodesWithinCircle(string? circle, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + + public Task>> SearchZipCodesAsync(string query, CancellationToken cancellationToken = default) => + Task.FromResult>>( + new ApiResponse>( + new HttpResponseMessage(HttpStatusCode.OK), + [], + new RefitSettings())); + } +} diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs new file mode 100644 index 000000000..9d3bd3377 --- /dev/null +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs @@ -0,0 +1,287 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using FluentAssertions; +using Jordnaer.Features.HjemGroups; +using Jordnaer.Features.Map; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Jordnaer.Tests.HjemGroups; + +public class HjemGroupProviderTests +{ + private readonly BlobServiceClient _blobServiceClient = Substitute.For(); + private readonly BlobContainerClient _containerClient = Substitute.For(); + private readonly BlobClient _blobClient = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + + private HjemGroupProvider CreateSut() => + new(_blobServiceClient, _logger); + + public HjemGroupProviderTests() + { + _blobServiceClient + .GetBlobContainerClient("hjemlo-groups") + .Returns(_containerClient); + + _containerClient + .GetBlobClient("groups.json") + .Returns(_blobClient); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsEmpty_WhenContainerDoesNotExist() + { + // Arrange + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(false, Substitute.For())); + + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobDoesNotExist() + { + // Arrange + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(false, Substitute.For())); + + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobIsEmpty() + { + // Arrange + SetupBlobWithContent("[]"); + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalafdeling() + { + // Arrange + var entries = new[] + { + new HjemGroupEntry + { + Name = "Randers", + WebsiteUrl = new Uri("https://www.hjemlo.dk/randers"), + City = "Randers", + ZipCode = 8900, + Latitude = 56.46, + Longitude = 10.03, + Type = HjemGroupType.Lokalafdeling, + } + }; + SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().HaveCount(1); + var marker = result[0]; + marker.Name.Should().Be("Randers"); + marker.WebsiteUrl.Should().Be("https://www.hjemlo.dk/randers"); + marker.City.Should().Be("Randers"); + marker.ZipCode.Should().Be(8900); + marker.Latitude.Should().Be(56.46); + marker.Longitude.Should().Be(10.03); + marker.ShortDescription.Should().Be("HJEM lokalafdeling"); + marker.ProfilePictureUrl.Should().BeNull(); + marker.Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalrepresentant() + { + // Arrange + var entries = new[] + { + new HjemGroupEntry + { + Name = "Aalborg", + WebsiteUrl = new Uri("https://www.hjemlo.dk/lokalrepraesentanter"), + City = "Aalborg", + ZipCode = 9000, + Latitude = 57.04, + Longitude = 9.92, + Type = HjemGroupType.Lokalrepresentant, + } + }; + SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().HaveCount(1); + result[0].ShortDescription.Should().Be("HJEM lokalrepræsentant"); + } + + [Fact] + public async Task GetMarkersAsync_ProducesStableId_ForSameEntry() + { + // Arrange + var entry = new HjemGroupEntry + { + Name = "Aarhus", + WebsiteUrl = new Uri("https://www.hjemlo.dk/aarhus"), + City = "Aarhus", + ZipCode = 8000, + Latitude = 56.15, + Longitude = 10.20, + Type = HjemGroupType.Lokalafdeling, + }; + var json = JsonSerializer.Serialize(new[] { entry }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + // Call GetMarkersAsync twice with two separate provider instances + SetupBlobWithContent(json); + var sut1 = CreateSut(); + var result1 = await sut1.GetMarkersAsync(); + + SetupBlobWithContent(json); + var sut2 = CreateSut(); + var result2 = await sut2.GetMarkersAsync(); + + // Assert: same input always produces same Guid + result1[0].Id.Should().Be(result2[0].Id); + result1[0].Id.Should().NotBe(Guid.Empty); + } + + [Fact] + public async Task GetMarkersAsync_IsCached_WithinSameInstance() + { + // Arrange + SetupBlobWithContent(JsonSerializer.Serialize(new[] + { + new HjemGroupEntry + { + Name = "Odense", + WebsiteUrl = new Uri("https://www.hjemlo.dk/odense"), + City = "Odense", + ZipCode = 5000, + Latitude = 55.40, + Longitude = 10.38, + Type = HjemGroupType.Lokalafdeling, + } + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + + var sut = CreateSut(); + + // Act + await sut.GetMarkersAsync(); + await sut.GetMarkersAsync(); + + // Assert: blob was only downloaded once despite two calls + await _blobClient.Received(1).DownloadContentAsync(Arg.Any()); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobThrows() + { + // Arrange + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.DownloadContentAsync(Arg.Any()) + .ThrowsAsync(new RequestFailedException("Simulated blob error")); + + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetMarkersAsync_ReturnsAllEntries_WhenMultipleEntriesPresent() + { + // Arrange + var entries = Enumerable.Range(1, 5).Select(i => new HjemGroupEntry + { + Name = $"City{i}", + WebsiteUrl = new Uri($"https://www.hjemlo.dk/city{i}"), + City = $"City{i}", + ZipCode = 1000 + i, + Latitude = 55.0 + i * 0.1, + Longitude = 10.0 + i * 0.1, + Type = i % 2 == 0 ? HjemGroupType.Lokalrepresentant : HjemGroupType.Lokalafdeling, + }).ToArray(); + + SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result.Should().HaveCount(5); + result.Select(m => m.Name).Should().BeEquivalentTo(entries.Select(e => e.Name)); + } + + [Fact] + public async Task GetMarkersAsync_ProducesDifferentIds_ForDifferentEntries() + { + // Arrange + var entries = new[] + { + new HjemGroupEntry { Name = "A", WebsiteUrl = new Uri("https://www.hjemlo.dk/a"), Latitude = 55, Longitude = 10, Type = HjemGroupType.Lokalafdeling }, + new HjemGroupEntry { Name = "B", WebsiteUrl = new Uri("https://www.hjemlo.dk/b"), Latitude = 56, Longitude = 11, Type = HjemGroupType.Lokalafdeling }, + }; + SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + var sut = CreateSut(); + + // Act + var result = await sut.GetMarkersAsync(); + + // Assert + result[0].Id.Should().NotBe(result[1].Id); + } + + // Helper: wires up blob container + blob to return the given JSON string + private void SetupBlobWithContent(string json) + { + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + + var bytes = Encoding.UTF8.GetBytes(json); + var binaryData = BinaryData.FromBytes(bytes); + var downloadResult = BlobsModelFactory.BlobDownloadResult(content: binaryData); + _blobClient.DownloadContentAsync(Arg.Any()) + .Returns(Response.FromValue(downloadResult, Substitute.For())); + } +} diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs new file mode 100644 index 000000000..5d0a48ae6 --- /dev/null +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs @@ -0,0 +1,314 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using FluentAssertions; +using Jordnaer.Features.HjemGroups; +using Jordnaer.Shared; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Refit; +using System.Net; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Jordnaer.Tests.HjemGroups; + +public class HjemGroupScraperServiceTests +{ + private readonly IDataForsyningenClient _dataForsyningenClient = Substitute.For(); + private readonly BlobServiceClient _blobServiceClient = Substitute.For(); + private readonly BlobContainerClient _containerClient = Substitute.For(); + private readonly BlobClient _blobClient = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + + private const string LokalafdelingerHtml = """ + + +
+ Randers + Aarhus + København +
+ + """; + + private const string LokalreprasentanterHtml = """ + +
    +
  • Odense
  • +
  • Esbjerg
  • +
+ + """; + + private const string EmptyHtml = ""; + + public HjemGroupScraperServiceTests() + { + _blobServiceClient + .GetBlobContainerClient("hjemlo-groups") + .Returns(_containerClient); + _containerClient + .GetBlobClient("groups.json") + .Returns(_blobClient); + _containerClient + .CreateIfNotExistsAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + _blobClient + .UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + } + + private HjemGroupScraperService CreateSut(string lokAfdelHtml, string lokRepHtml) + { + var handler = new FakeHttpMessageHandler(lokAfdelHtml, lokRepHtml); + var httpClient = new HttpClient(handler); + return new HjemGroupScraperService(httpClient, _dataForsyningenClient, _blobServiceClient, _logger); + } + + private static IApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) => + new ApiResponse>( + new HttpResponseMessage(HttpStatusCode.OK), + results, + new RefitSettings()); + + private static IApiResponse> MakeEmptyGeoResponse() => + new ApiResponse>( + new HttpResponseMessage(HttpStatusCode.OK), + [], + new RefitSettings()); + + private static ZipCodeSearchResponse MakeZip(string navn, int nr, double lat, double lng) => + new(Href: null, Nr: nr.ToString("D4"), Navn: navn, + Stormodtageradresser: null, Bbox: null, + Visueltcenter: [(float)lng, (float)lat], + Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, + Geo_Version: 1, Dagi_Id: null); + + private void SetupGeocodeSuccessForAll(string cityName, int zip, double lat, double lng) + { + _dataForsyningenClient + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(MakeGeoResponse(MakeZip(cityName, zip, lat, lng))); + } + + private void SetupGeocodeEmpty() + { + _dataForsyningenClient + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(MakeEmptyGeoResponse()); + } + + // Captures the uploaded JSON from the blob upload call + private string CaptureUploadedJson() + { + var captured = string.Empty; + _blobClient + .UploadAsync(Arg.Do(s => + { + using var sr = new StreamReader(s, Encoding.UTF8); + captured = sr.ReadToEnd(); + }), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + return captured; // note: populated after the act + } + + [Fact] + public async Task ScrapeAndSaveAsync_SavesBlob_WhenAtLeastOneEntryGeocoded() + { + // Arrange + SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); + var sut = CreateSut(LokalafdelingerHtml, LokalreprasentanterHtml); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert + await _blobClient.Received().UploadAsync(Arg.Any(), overwrite: true, Arg.Any()); + } + + [Fact] + public async Task ScrapeAndSaveAsync_DoesNotSaveBlob_WhenGeocodeYieldsZeroResults() + { + // Arrange + SetupGeocodeEmpty(); + var sut = CreateSut(LokalafdelingerHtml, LokalreprasentanterHtml); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert: must not overwrite blob — preserves previous version + await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ScrapeAndSaveAsync_DoesNotSaveBlob_WhenHtmlIsEmpty() + { + // Arrange + SetupGeocodeSuccessForAll("SomeCity", 1234, 55.0, 10.0); + var sut = CreateSut(EmptyHtml, EmptyHtml); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert + await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ScrapeAndSaveAsync_SkipsNavLinks_FromLokalafdelingerPage() + { + // Arrange - page has only skip-listed nav links + const string navOnlyHtml = """ + + Lokalafdelinger + Om HJEM + Kontakt + + """; + SetupGeocodeSuccessForAll("City", 1000, 55.0, 10.0); + var sut = CreateSut(navOnlyHtml, EmptyHtml); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert: no content entries → no upload + await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ScrapeAndSaveAsync_DeduplicatesHrefs() + { + // Arrange - same href appears twice + const string duplicateHtml = """ + + Randers + Randers (duplicate) + + """; + SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); + var sut = CreateSut(duplicateHtml, EmptyHtml); + + string uploadedJson = string.Empty; + _blobClient + .UploadAsync(Arg.Do(s => + { + using var sr = new StreamReader(s, Encoding.UTF8); + uploadedJson = sr.ReadToEnd(); + }), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert: only one entry for /randers + var entries = JsonSerializer.Deserialize>(uploadedJson, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + entries.Should().NotBeNull(); + entries!.Count(e => e.WebsiteUrl == new Uri("https://www.hjemlo.dk/randers")).Should().Be(1); + } + + [Fact] + public async Task ScrapeAndSaveAsync_SavesLokalafdeling_WithCorrectType() + { + // Arrange + SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); + var sut = CreateSut(LokalafdelingerHtml, EmptyHtml); + + string uploadedJson = string.Empty; + _blobClient + .UploadAsync(Arg.Do(s => + { + using var sr = new StreamReader(s, Encoding.UTF8); + uploadedJson = sr.ReadToEnd(); + }), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert + var entries = JsonSerializer.Deserialize>(uploadedJson, + new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + entries.Should().NotBeNull().And.NotBeEmpty(); + entries!.Should().AllSatisfy(e => e.Type.Should().Be(HjemGroupType.Lokalafdeling)); + } + + [Fact] + public async Task ScrapeAndSaveAsync_DoesNotThrow_WhenHttpClientFails() + { + // Arrange + var httpClient = new HttpClient(new FailingHttpMessageHandler()); + var sut = new HjemGroupScraperService(httpClient, _dataForsyningenClient, _blobServiceClient, _logger); + + // Act & Assert: must swallow exceptions internally + await FluentActions.Awaiting(() => sut.ScrapeAndSaveAsync()).Should().NotThrowAsync(); + await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ScrapeAndSaveAsync_SavesValidJson_ToBlob() + { + // Arrange + SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); + var sut = CreateSut(LokalafdelingerHtml, EmptyHtml); + + string uploadedJson = string.Empty; + _blobClient + .UploadAsync(Arg.Do(s => + { + using var sr = new StreamReader(s, Encoding.UTF8); + uploadedJson = sr.ReadToEnd(); + }), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert: valid deserializable JSON + var act = () => JsonSerializer.Deserialize>( + uploadedJson, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + act.Should().NotThrow(); + act()!.Should().NotBeEmpty(); + } + + [Fact] + public async Task ScrapeAndSaveAsync_CallsGeocodeForEachUniqueHref() + { + // Arrange - three unique hrefs + SetupGeocodeSuccessForAll("City", 1000, 55.0, 10.0); + var sut = CreateSut(LokalafdelingerHtml, EmptyHtml); // has /randers, /aarhus, /koebenhavn + + // Act + await sut.ScrapeAndSaveAsync(); + + // Assert: geocoded for each unique city name + await _dataForsyningenClient.Received(3).SearchZipCodesAsync(Arg.Any(), Arg.Any()); + } + + // --- Fakes --- + + private sealed class FakeHttpMessageHandler(string lokAfdelHtml, string lokRepHtml) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var html = request.RequestUri?.AbsolutePath.Contains("lokalrepraesentanter") == true + ? lokRepHtml + : lokAfdelHtml; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(html, Encoding.UTF8, "text/html") + }); + } + } + + private sealed class FailingHttpMessageHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + throw new HttpRequestException("Simulated network failure"); + } +} diff --git a/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs b/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs index 38390af6f..b9dca85ac 100644 --- a/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs +++ b/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs @@ -65,4 +65,60 @@ public async Task Ping_Data_Forsyningen_API() response.IsSuccessful.Should().BeTrue(); response.Content.Should().NotBeNull().And.HaveCount(1); } + + [Fact] + public async Task SearchZipCodesAsync_ReturnsResults_ForKnownCity() + { + // Arrange + const string query = "Randers"; + + // Act + var response = await _dataForsyningenClient.SearchZipCodesAsync(query); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + response.Content.Should().NotBeNull().And.HaveCountGreaterThan(0); + + var first = response.Content!.First(); + first.Navn.Should().NotBeNullOrWhiteSpace(); + first.Nr.Should().NotBeNullOrWhiteSpace(); + first.Visueltcenter.Should().NotBeNull().And.HaveCountGreaterOrEqualTo(2, + "visueltcenter must contain [longitude, latitude]"); + } + + [Fact] + public async Task SearchZipCodesAsync_ReturnsEmpty_ForGibberishQuery() + { + // Arrange + const string query = "xyzxyzxyz_nonexistent_city_999"; + + // Act + var response = await _dataForsyningenClient.SearchZipCodesAsync(query); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + response.Content.Should().NotBeNull().And.BeEmpty(); + } + + [Fact] + public async Task SearchZipCodesAsync_VisueltcenterIsLngLatOrder() + { + // Arrange - Aarhus C is at approx lng 10.2, lat 56.15 + const string query = "Aarhus C"; + + // Act + var response = await _dataForsyningenClient.SearchZipCodesAsync(query); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + var first = response.Content!.First(); + first.Visueltcenter.Should().HaveCountGreaterOrEqualTo(2); + + var longitude = (double)first.Visueltcenter![0]; + var latitude = (double)first.Visueltcenter[1]; + + // Denmark: lng ≈ 8–15, lat ≈ 54–58 + longitude.Should().BeInRange(8, 15, "longitude should be in Danish range"); + latitude.Should().BeInRange(54, 58, "latitude should be in Danish range"); + } } From a68ca75f42aa8453b184eca3452224c705a88868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 09:09:02 +0100 Subject: [PATCH 03/23] simplify massively, do things manually --- .../HjemGroups/HjemGroupAdminService.cs | 85 +++++ .../HjemGroupScraperBackgroundService.cs | 37 --- .../HjemGroups/HjemGroupScraperOptions.cs | 8 - .../HjemGroups/HjemGroupScraperService.cs | 262 --------------- .../WebApplicationBuilderExtensions.cs | 16 +- src/web/Jordnaer/Jordnaer.csproj | 1 - .../Backoffice/HjemGroupManagementPage.razor | 307 ++++++++++++++++++ 7 files changed, 393 insertions(+), 323 deletions(-) create mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs delete mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs delete mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs delete mode 100644 src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs create mode 100644 src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs new file mode 100644 index 000000000..ba8bd5a81 --- /dev/null +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs @@ -0,0 +1,85 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Jordnaer.Shared; +using System.Text; +using System.Text.Json; + +namespace Jordnaer.Features.HjemGroups; + +public class HjemGroupAdminService( + BlobServiceClient blobServiceClient, + IDataForsyningenClient dataForsyningenClient, + ILogger logger) +{ + private const string ContainerName = "hjemlo-groups"; + private const string BlobName = "groups.json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public async Task> LoadAsync(CancellationToken cancellationToken = default) + { + try + { + var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); + if (!await containerClient.ExistsAsync(cancellationToken)) + return []; + + var blobClient = containerClient.GetBlobClient(BlobName); + if (!await blobClient.ExistsAsync(cancellationToken)) + return []; + + var response = await blobClient.DownloadContentAsync(cancellationToken); + return JsonSerializer.Deserialize>( + response.Value.Content.ToString(), JsonOptions) ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load HJEM group entries from blob storage."); + return []; + } + } + + public async Task SaveAsync(List entries, CancellationToken cancellationToken = default) + { + var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); + await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: cancellationToken); + + var blobClient = containerClient.GetBlobClient(BlobName); + var json = JsonSerializer.Serialize(entries, JsonOptions); + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); + } + + /// + /// Looks up coordinates and city/zip for a Danish city or area name via Dataforsyningen. + /// Returns null if not found. + /// + public async Task GeocodeAsync(string locationText, CancellationToken cancellationToken = default) + { + var response = await dataForsyningenClient.SearchZipCodesAsync(locationText, cancellationToken); + + if (!response.IsSuccessStatusCode || response.Content is null) + return null; + + var first = response.Content.FirstOrDefault(); + if (first.Navn is null || first.Visueltcenter is not { Length: >= 2 }) + return null; + + // GeoJSON order: [longitude, latitude] + var longitude = (double)first.Visueltcenter[0]; + var latitude = (double)first.Visueltcenter[1]; + + int? zipCode = null; + if (int.TryParse(first.Nr, out var parsedZip)) + zipCode = parsedZip; + + return new GeocodeResult(first.Navn, zipCode, latitude, longitude); + } + + public sealed record GeocodeResult(string City, int? ZipCode, double Latitude, double Longitude); +} diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs deleted file mode 100644 index fa56c4a87..000000000 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperBackgroundService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.Extensions.Options; - -namespace Jordnaer.Features.HjemGroups; - -public class HjemGroupScraperBackgroundService( - HjemGroupScraperService scraperService, - IOptions options, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await scraperService.ScrapeAndSaveAsync(stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogError(ex, "Unhandled exception in HJEM group scraper background service."); - } - - try - { - await Task.Delay(options.Value.Interval, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - } - } -} diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs deleted file mode 100644 index f523322b8..000000000 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Jordnaer.Features.HjemGroups; - -public class HjemGroupScraperOptions -{ - public const string SectionName = "HjemGroupScraper"; - - public TimeSpan Interval { get; set; } = TimeSpan.FromHours(24); -} diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs deleted file mode 100644 index caaf36a6a..000000000 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupScraperService.cs +++ /dev/null @@ -1,262 +0,0 @@ -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using HtmlAgilityPack; -using Jordnaer.Shared; -using System.Text.Json; - -namespace Jordnaer.Features.HjemGroups; - -public class HjemGroupScraperService( - HttpClient httpClient, - IDataForsyningenClient dataForsyningenClient, - BlobServiceClient blobServiceClient, - ILogger logger) -{ - private const string ContainerName = "hjemlo-groups"; - private const string BlobName = "groups.json"; - private const string LokalafdelingerUrl = "https://www.hjemlo.dk/lokalafdelinger"; - private const string LokalreprasentanterUrl = "https://www.hjemlo.dk/lokalrepraesentanter"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - private static readonly string[] SkipPaths = - ["/lokalafdelinger", "/lokalrepraesentanter", "/om", "/kontakt", "/blog", "/login", "/signup", "/search"]; - - public async Task ScrapeAndSaveAsync(CancellationToken cancellationToken = default) - { - try - { - var entries = new List(); - - var lokalafdelinger = await ScrapeLokalafdelingerAsync(cancellationToken); - entries.AddRange(lokalafdelinger); - - var lokalreprasentanter = await ScrapeLokalreprasentanterAsync(cancellationToken); - entries.AddRange(lokalreprasentanter); - - if (entries.Count == 0) - { - logger.LogWarning("Scraping hjemlo.dk yielded zero results. Skipping blob upload to preserve previous version."); - return; - } - - await SaveToBlobAsync(entries, cancellationToken); - logger.LogInformation("Saved {Count} HJEM entries to blob storage.", entries.Count); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to scrape and save HJEM groups."); - } - } - - private async Task> ScrapeLokalafdelingerAsync(CancellationToken cancellationToken) - { - var results = new List(); - - string html; - try - { - html = await httpClient.GetStringAsync(LokalafdelingerUrl, cancellationToken); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to fetch lokalafdelinger page."); - return results; - } - - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // Look for anchor elements with hrefs like /randers, /koebenhavn, etc. - var anchors = doc.DocumentNode.SelectNodes("//a[starts-with(@href, '/') and string-length(@href) > 1]"); - if (anchors is null || anchors.Count == 0) - { - logger.LogWarning("No lokalafdeling anchors found on page — page may require JavaScript rendering."); - return results; - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var anchor in anchors) - { - var href = anchor.GetAttributeValue("href", "").Trim(); - var name = HtmlEntity.DeEntitize(anchor.InnerText).Trim(); - - if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(href)) - continue; - - // Skip obvious navigation links (about, contact, etc.) - if (SkipPaths.Any(s => href.Equals(s, StringComparison.OrdinalIgnoreCase))) - continue; - - // Only single-segment relative paths like /randers - if (href.Count(c => c == '/') != 1) - continue; - - if (!seen.Add(href)) - continue; - - var geocoded = await GeocodeAsync(name, cancellationToken); - if (geocoded is null) - { - logger.LogWarning("Could not geocode lokalafdeling: {Name}", name); - continue; - } - - results.Add(new HjemGroupEntry - { - Name = name, - WebsiteUrl = new Uri($"https://www.hjemlo.dk{href}"), - City = geocoded.City, - ZipCode = geocoded.ZipCode, - Latitude = geocoded.Latitude, - Longitude = geocoded.Longitude, - Type = HjemGroupType.Lokalafdeling, - }); - } - - logger.LogInformation("Scraped {Count} lokalafdelinger.", results.Count); - return results; - } - - private async Task> ScrapeLokalreprasentanterAsync(CancellationToken cancellationToken) - { - var results = new List(); - - string html; - try - { - html = await httpClient.GetStringAsync(LokalreprasentanterUrl, cancellationToken); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to fetch lokalrepræsentanter page."); - return results; - } - - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // Look for text nodes / elements that reference city/area names. - var candidates = doc.DocumentNode.SelectNodes("//li | //p | //div[@class]"); - if (candidates is null || candidates.Count == 0) - { - logger.LogWarning("No lokalrepræsentant candidates found on page — page may require JavaScript rendering."); - return results; - } - - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var node in candidates) - { - var text = HtmlEntity.DeEntitize(node.InnerText).Trim(); - - if (string.IsNullOrWhiteSpace(text) || text.Length > 100 || text.Length < 3) - continue; - - // Skip if contains newlines or many words (likely a paragraph) - var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (lines.Length > 2) - continue; - - var singleLine = lines[0]; - if (string.IsNullOrWhiteSpace(singleLine) || singleLine.Length > 60) - continue; - - if (!seen.Add(singleLine)) - continue; - - var geocoded = await GeocodeAsync(singleLine, cancellationToken); - if (geocoded is null) - continue; - - results.Add(new HjemGroupEntry - { - Name = singleLine, - WebsiteUrl = new Uri(LokalreprasentanterUrl), - City = geocoded.City, - ZipCode = geocoded.ZipCode, - Latitude = geocoded.Latitude, - Longitude = geocoded.Longitude, - Type = HjemGroupType.Lokalrepresentant, - }); - } - - logger.LogInformation("Scraped {Count} lokalrepræsentanter.", results.Count); - return results; - } - - private async Task GeocodeAsync(string locationText, CancellationToken cancellationToken) - { - // Try the name as-is first, then with common suffixes stripped - var candidates = new List { locationText }; - - var suffixes = new[] { " lokalafdeling", " lokalrepræsentant", " kommune", " by" }; - foreach (var suffix in suffixes) - { - if (locationText.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - { - candidates.Add(locationText[..^suffix.Length].Trim()); - } - } - - foreach (var query in candidates) - { - var result = await TryGeocodeAsync(query, cancellationToken); - if (result is not null) - return result; - } - - return null; - } - - private async Task TryGeocodeAsync(string query, CancellationToken cancellationToken) - { - try - { - var response = await dataForsyningenClient.SearchZipCodesAsync(query, cancellationToken); - - if (!response.IsSuccessStatusCode || response.Content is null) - return null; - - var first = response.Content.FirstOrDefault(); - if (first.Navn is null) - return null; - - if (first.Visueltcenter is not { Length: >= 2 }) - return null; - - // GeoJSON order: [longitude, latitude] - var longitude = (double)first.Visueltcenter[0]; - var latitude = (double)first.Visueltcenter[1]; - - int? zipCode = null; - if (int.TryParse(first.Nr, out var parsedZip)) - zipCode = parsedZip; - - return new GeocodedResult(first.Navn, zipCode, latitude, longitude); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Geocoding failed for query: {Query}", query); - return null; - } - } - - private async Task SaveToBlobAsync(List entries, CancellationToken cancellationToken) - { - var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); - await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: cancellationToken); - - var blobClient = containerClient.GetBlobClient(BlobName); - var json = JsonSerializer.Serialize(entries, JsonOptions); - - using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); - await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); - } - - private sealed record GeocodedResult(string? City, int? ZipCode, double Latitude, double Longitude); -} diff --git a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs index feb43751a..6ab6be65d 100644 --- a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs @@ -4,22 +4,8 @@ public static class WebApplicationBuilderExtensions { public static WebApplicationBuilder AddHjemGroupServices(this WebApplicationBuilder builder) { - - builder.Services - .AddOptions() - .BindConfiguration(HjemGroupScraperOptions.SectionName) - .ValidateDataAnnotations() - .ValidateOnStart(); - - builder.Services.AddHttpClient(client => - { - client.DefaultRequestHeaders.UserAgent.ParseAdd( - "Mozilla/5.0 (compatible; MiniMoeder/1.0)"); - client.Timeout = TimeSpan.FromSeconds(30); - }); - builder.Services.AddScoped(); - builder.Services.AddHostedService(); + builder.Services.AddScoped(); return builder; } diff --git a/src/web/Jordnaer/Jordnaer.csproj b/src/web/Jordnaer/Jordnaer.csproj index a4c9d39c6..9fbcbe14d 100644 --- a/src/web/Jordnaer/Jordnaer.csproj +++ b/src/web/Jordnaer/Jordnaer.csproj @@ -12,7 +12,6 @@ - diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor new file mode 100644 index 000000000..bb8888cf8 --- /dev/null +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -0,0 +1,307 @@ +@page "/backoffice/hjem-groups" + +@using Jordnaer.Features.Authentication +@using Jordnaer.Features.HjemGroups + +@inject HjemGroupAdminService AdminService +@inject ISnackbar Snackbar +@inject ILogger Logger + +@attribute [Authorize(Policy = AuthorizationPolicies.AdminOnly)] + + + + + HJEM Grupper + + + Tilføj + + + @(_isSaving ? "Gemmer..." : "Gem alle") + + + + + +@if (_isLoading) +{ + +} +else +{ + + + @_entries.Count poster + + + Type + Navn + By + Postnr + Breddegrad + Længdegrad + URL + + + + + + @(context.Type == HjemGroupType.Lokalafdeling ? "Lokalafdeling" : "Lokalrep.") + + + @context.Name + @context.City + @context.ZipCode + @context.Latitude.ToString("F5") + @context.Longitude.ToString("F5") + + + @context.WebsiteUrl.Host + + + + + + + + +} + +@* Edit / Add dialog *@ + + + @(_editingNew ? "Tilføj HJEM gruppe" : "Rediger HJEM gruppe") + + + + + Lokalafdeling + Lokalrepræsentant + + + + + + + + + + @(_isGeocoding ? "Søger..." : "Slå op") + + + + + + + + + + + + + + + + + + + + + Annuller + Gem + + + +@code { + private List _entries = []; + private bool _isLoading = true; + private bool _isSaving = false; + + // Dialog state + private bool _dialogVisible = false; + private bool _editingNew = false; + private bool _isGeocoding = false; + private HjemGroupEntry? _editingEntry; + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + + private FormModel _form = new(); + + protected override async Task OnInitializedAsync() + { + try + { + _entries = await AdminService.LoadAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load HJEM group entries."); + Snackbar.Add("Kunne ikke hente HJEM grupper.", Severity.Error); + } + finally + { + _isLoading = false; + } + } + + private void AddEntry() + { + _editingNew = true; + _editingEntry = null; + _form = new FormModel(); + _dialogVisible = true; + } + + private void OpenEditDialog(HjemGroupEntry entry) + { + _editingNew = false; + _editingEntry = entry; + _form = new FormModel + { + Type = entry.Type, + Name = entry.Name, + WebsiteUrl = entry.WebsiteUrl.ToString(), + City = entry.City, + ZipCode = entry.ZipCode, + Latitude = entry.Latitude, + Longitude = entry.Longitude, + GeocodeName = entry.City ?? entry.Name, + }; + _dialogVisible = true; + } + + private void CloseDialog() => _dialogVisible = false; + + private async Task LookupCoordinatesAsync() + { + var query = _form.GeocodeName?.Trim(); + if (string.IsNullOrEmpty(query)) + return; + + _isGeocoding = true; + try + { + var result = await AdminService.GeocodeAsync(query); + if (result is null) + { + Snackbar.Add($"Ingen resultater for \"{query}\".", Severity.Warning); + return; + } + + _form.City = result.City; + _form.ZipCode = result.ZipCode; + _form.Latitude = result.Latitude; + _form.Longitude = result.Longitude; + Snackbar.Add($"Fandt {result.City} ({result.ZipCode})", Severity.Success); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Geocode lookup failed for {Query}", query); + Snackbar.Add("Koordinat-søgning fejlede.", Severity.Error); + } + finally + { + _isGeocoding = false; + } + } + + private void CommitEdit() + { + if (string.IsNullOrWhiteSpace(_form.Name) || string.IsNullOrWhiteSpace(_form.WebsiteUrl)) + { + Snackbar.Add("Navn og URL er påkrævet.", Severity.Warning); + return; + } + + if (!Uri.TryCreate(_form.WebsiteUrl, UriKind.Absolute, out var uri)) + { + Snackbar.Add("URL er ikke gyldig.", Severity.Warning); + return; + } + + var updated = new HjemGroupEntry + { + Type = _form.Type, + Name = _form.Name.Trim(), + WebsiteUrl = uri, + City = string.IsNullOrWhiteSpace(_form.City) ? null : _form.City.Trim(), + ZipCode = _form.ZipCode, + Latitude = _form.Latitude, + Longitude = _form.Longitude, + }; + + if (_editingNew) + { + _entries.Add(updated); + } + else if (_editingEntry is not null) + { + var idx = _entries.IndexOf(_editingEntry); + if (idx >= 0) + _entries[idx] = updated; + } + + _dialogVisible = false; + } + + private void DeleteEntry(HjemGroupEntry entry) => _entries.Remove(entry); + + private async Task SaveAsync() + { + _isSaving = true; + try + { + await AdminService.SaveAsync(_entries); + Snackbar.Add($"{_entries.Count} HJEM grupper gemt.", Severity.Success); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to save HJEM group entries."); + Snackbar.Add("Gem fejlede. Prøv igen.", Severity.Error); + } + finally + { + _isSaving = false; + } + } + + private sealed class FormModel + { + public HjemGroupType Type { get; set; } = HjemGroupType.Lokalafdeling; + public string Name { get; set; } = ""; + public string WebsiteUrl { get; set; } = ""; + public string? GeocodeName { get; set; } + public string? City { get; set; } + public int? ZipCode { get; set; } + public double Latitude { get; set; } + public double Longitude { get; set; } + } +} From d50ab84e219a626f33ff68c1c0f81aec5a671245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 09:46:21 +0100 Subject: [PATCH 04/23] Delete HjemGroupScraperServiceTests.cs --- .../HjemGroupScraperServiceTests.cs | 314 ------------------ 1 file changed, 314 deletions(-) delete mode 100644 tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs deleted file mode 100644 index 5d0a48ae6..000000000 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupScraperServiceTests.cs +++ /dev/null @@ -1,314 +0,0 @@ -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using FluentAssertions; -using Jordnaer.Features.HjemGroups; -using Jordnaer.Shared; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Refit; -using System.Net; -using System.Text; -using System.Text.Json; -using Xunit; - -namespace Jordnaer.Tests.HjemGroups; - -public class HjemGroupScraperServiceTests -{ - private readonly IDataForsyningenClient _dataForsyningenClient = Substitute.For(); - private readonly BlobServiceClient _blobServiceClient = Substitute.For(); - private readonly BlobContainerClient _containerClient = Substitute.For(); - private readonly BlobClient _blobClient = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); - - private const string LokalafdelingerHtml = """ - - -
- Randers - Aarhus - København -
- - """; - - private const string LokalreprasentanterHtml = """ - -
    -
  • Odense
  • -
  • Esbjerg
  • -
- - """; - - private const string EmptyHtml = ""; - - public HjemGroupScraperServiceTests() - { - _blobServiceClient - .GetBlobContainerClient("hjemlo-groups") - .Returns(_containerClient); - _containerClient - .GetBlobClient("groups.json") - .Returns(_blobClient); - _containerClient - .CreateIfNotExistsAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); - _blobClient - .UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); - } - - private HjemGroupScraperService CreateSut(string lokAfdelHtml, string lokRepHtml) - { - var handler = new FakeHttpMessageHandler(lokAfdelHtml, lokRepHtml); - var httpClient = new HttpClient(handler); - return new HjemGroupScraperService(httpClient, _dataForsyningenClient, _blobServiceClient, _logger); - } - - private static IApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) => - new ApiResponse>( - new HttpResponseMessage(HttpStatusCode.OK), - results, - new RefitSettings()); - - private static IApiResponse> MakeEmptyGeoResponse() => - new ApiResponse>( - new HttpResponseMessage(HttpStatusCode.OK), - [], - new RefitSettings()); - - private static ZipCodeSearchResponse MakeZip(string navn, int nr, double lat, double lng) => - new(Href: null, Nr: nr.ToString("D4"), Navn: navn, - Stormodtageradresser: null, Bbox: null, - Visueltcenter: [(float)lng, (float)lat], - Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, - Geo_Version: 1, Dagi_Id: null); - - private void SetupGeocodeSuccessForAll(string cityName, int zip, double lat, double lng) - { - _dataForsyningenClient - .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(MakeGeoResponse(MakeZip(cityName, zip, lat, lng))); - } - - private void SetupGeocodeEmpty() - { - _dataForsyningenClient - .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(MakeEmptyGeoResponse()); - } - - // Captures the uploaded JSON from the blob upload call - private string CaptureUploadedJson() - { - var captured = string.Empty; - _blobClient - .UploadAsync(Arg.Do(s => - { - using var sr = new StreamReader(s, Encoding.UTF8); - captured = sr.ReadToEnd(); - }), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); - return captured; // note: populated after the act - } - - [Fact] - public async Task ScrapeAndSaveAsync_SavesBlob_WhenAtLeastOneEntryGeocoded() - { - // Arrange - SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); - var sut = CreateSut(LokalafdelingerHtml, LokalreprasentanterHtml); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert - await _blobClient.Received().UploadAsync(Arg.Any(), overwrite: true, Arg.Any()); - } - - [Fact] - public async Task ScrapeAndSaveAsync_DoesNotSaveBlob_WhenGeocodeYieldsZeroResults() - { - // Arrange - SetupGeocodeEmpty(); - var sut = CreateSut(LokalafdelingerHtml, LokalreprasentanterHtml); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert: must not overwrite blob — preserves previous version - await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ScrapeAndSaveAsync_DoesNotSaveBlob_WhenHtmlIsEmpty() - { - // Arrange - SetupGeocodeSuccessForAll("SomeCity", 1234, 55.0, 10.0); - var sut = CreateSut(EmptyHtml, EmptyHtml); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert - await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ScrapeAndSaveAsync_SkipsNavLinks_FromLokalafdelingerPage() - { - // Arrange - page has only skip-listed nav links - const string navOnlyHtml = """ - - Lokalafdelinger - Om HJEM - Kontakt - - """; - SetupGeocodeSuccessForAll("City", 1000, 55.0, 10.0); - var sut = CreateSut(navOnlyHtml, EmptyHtml); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert: no content entries → no upload - await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ScrapeAndSaveAsync_DeduplicatesHrefs() - { - // Arrange - same href appears twice - const string duplicateHtml = """ - - Randers - Randers (duplicate) - - """; - SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); - var sut = CreateSut(duplicateHtml, EmptyHtml); - - string uploadedJson = string.Empty; - _blobClient - .UploadAsync(Arg.Do(s => - { - using var sr = new StreamReader(s, Encoding.UTF8); - uploadedJson = sr.ReadToEnd(); - }), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert: only one entry for /randers - var entries = JsonSerializer.Deserialize>(uploadedJson, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - entries.Should().NotBeNull(); - entries!.Count(e => e.WebsiteUrl == new Uri("https://www.hjemlo.dk/randers")).Should().Be(1); - } - - [Fact] - public async Task ScrapeAndSaveAsync_SavesLokalafdeling_WithCorrectType() - { - // Arrange - SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); - var sut = CreateSut(LokalafdelingerHtml, EmptyHtml); - - string uploadedJson = string.Empty; - _blobClient - .UploadAsync(Arg.Do(s => - { - using var sr = new StreamReader(s, Encoding.UTF8); - uploadedJson = sr.ReadToEnd(); - }), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert - var entries = JsonSerializer.Deserialize>(uploadedJson, - new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - entries.Should().NotBeNull().And.NotBeEmpty(); - entries!.Should().AllSatisfy(e => e.Type.Should().Be(HjemGroupType.Lokalafdeling)); - } - - [Fact] - public async Task ScrapeAndSaveAsync_DoesNotThrow_WhenHttpClientFails() - { - // Arrange - var httpClient = new HttpClient(new FailingHttpMessageHandler()); - var sut = new HjemGroupScraperService(httpClient, _dataForsyningenClient, _blobServiceClient, _logger); - - // Act & Assert: must swallow exceptions internally - await FluentActions.Awaiting(() => sut.ScrapeAndSaveAsync()).Should().NotThrowAsync(); - await _blobClient.DidNotReceive().UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ScrapeAndSaveAsync_SavesValidJson_ToBlob() - { - // Arrange - SetupGeocodeSuccessForAll("Randers", 8900, 56.46, 10.03); - var sut = CreateSut(LokalafdelingerHtml, EmptyHtml); - - string uploadedJson = string.Empty; - _blobClient - .UploadAsync(Arg.Do(s => - { - using var sr = new StreamReader(s, Encoding.UTF8); - uploadedJson = sr.ReadToEnd(); - }), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert: valid deserializable JSON - var act = () => JsonSerializer.Deserialize>( - uploadedJson, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - act.Should().NotThrow(); - act()!.Should().NotBeEmpty(); - } - - [Fact] - public async Task ScrapeAndSaveAsync_CallsGeocodeForEachUniqueHref() - { - // Arrange - three unique hrefs - SetupGeocodeSuccessForAll("City", 1000, 55.0, 10.0); - var sut = CreateSut(LokalafdelingerHtml, EmptyHtml); // has /randers, /aarhus, /koebenhavn - - // Act - await sut.ScrapeAndSaveAsync(); - - // Assert: geocoded for each unique city name - await _dataForsyningenClient.Received(3).SearchZipCodesAsync(Arg.Any(), Arg.Any()); - } - - // --- Fakes --- - - private sealed class FakeHttpMessageHandler(string lokAfdelHtml, string lokRepHtml) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var html = request.RequestUri?.AbsolutePath.Contains("lokalrepraesentanter") == true - ? lokRepHtml - : lokAfdelHtml; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(html, Encoding.UTF8, "text/html") - }); - } - } - - private sealed class FailingHttpMessageHandler : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => - throw new HttpRequestException("Simulated network failure"); - } -} From bf1a3a37fece7b7f9d3410476f1bd2d27d2acd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 09:46:22 +0100 Subject: [PATCH 05/23] Delete HjemGroupIntegrationTests.cs --- .../HjemGroups/HjemGroupIntegrationTests.cs | 280 ------------------ 1 file changed, 280 deletions(-) delete mode 100644 tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs deleted file mode 100644 index 2fffad185..000000000 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupIntegrationTests.cs +++ /dev/null @@ -1,280 +0,0 @@ -using Azure.Storage.Blobs; -using FluentAssertions; -using Jordnaer.Features.HjemGroups; -using Jordnaer.Shared; -using Microsoft.Extensions.Logging.Abstractions; -using Refit; -using System.Net; -using System.Text; -using System.Text.Json; -using Testcontainers.Azurite; -using Xunit; - -namespace Jordnaer.Tests.HjemGroups; - -/// -/// Integration tests for the HJEM scraper pipeline using a real Azurite blob container. -/// Geocoding uses a fake IDataForsyningenClient to avoid external API calls in CI. -/// -public class HjemGroupIntegrationTests : IAsyncLifetime -{ - private readonly AzuriteContainer _azurite = new AzuriteBuilder("mcr.microsoft.com/azure-storage/azurite:latest") - .WithInMemoryPersistence() - .WithCommand("--skipApiVersionCheck") - .Build(); - - private BlobServiceClient _blobServiceClient = null!; - - private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - public async Task InitializeAsync() - { - await _azurite.StartAsync(); - _blobServiceClient = new BlobServiceClient(_azurite.GetConnectionString()); - } - - public async Task DisposeAsync() => await _azurite.DisposeAsync(); - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private HjemGroupScraperService CreateScraper(string lokAfdelHtml, string lokRepHtml, IDataForsyningenClient? geocoder = null) - { - var handler = new HtmlFakeHandler(lokAfdelHtml, lokRepHtml); - var httpClient = new HttpClient(handler); - geocoder ??= new AlwaysSuccessGeocoder(); - return new HjemGroupScraperService(httpClient, geocoder, _blobServiceClient, NullLogger.Instance); - } - - private HjemGroupProvider CreateProvider() => - new(_blobServiceClient, NullLogger.Instance); - - private const string SingleCityHtml = """ - Randers - """; - - private const string TwoCitiesHtml = """ - - Randers - Aarhus - - """; - - private const string Empty = ""; - - // ------------------------------------------------------------------------- - // Tests - // ------------------------------------------------------------------------- - - [Fact] - public async Task FullPipeline_ScrapeSave_ThenProviderReadsMarkers() - { - // Arrange - var scraper = CreateScraper(SingleCityHtml, Empty); - var provider = CreateProvider(); - - // Act - await scraper.ScrapeAndSaveAsync(); - var markers = await provider.GetMarkersAsync(); - - // Assert - markers.Should().HaveCount(1); - markers[0].Name.Should().Be("Randers"); - markers[0].ShortDescription.Should().Be("HJEM lokalafdeling"); - markers[0].Latitude.Should().Be(56.0); - markers[0].Longitude.Should().Be(10.0); - } - - [Fact] - public async Task FullPipeline_MultipleCities_AllAppearAsMarkers() - { - // Arrange - var scraper = CreateScraper(TwoCitiesHtml, Empty); - var provider = CreateProvider(); - - // Act - await scraper.ScrapeAndSaveAsync(); - var markers = await provider.GetMarkersAsync(); - - // Assert - markers.Should().HaveCount(2); - markers.Select(m => m.Name).Should().BeEquivalentTo(["Randers", "Aarhus"]); - } - - [Fact] - public async Task FullPipeline_BlobPreserved_WhenSecondScrapeYieldsNothing() - { - // Arrange: first successful scrape - var scraper = CreateScraper(SingleCityHtml, Empty); - await scraper.ScrapeAndSaveAsync(); - - // Second scraper — geocoding always returns empty (simulates failure) - var failingScraper = CreateScraper(SingleCityHtml, Empty, geocoder: new AlwaysEmptyGeocoder()); - await failingScraper.ScrapeAndSaveAsync(); - - // Provider should still return data from the first run - var provider = CreateProvider(); - var markers = await provider.GetMarkersAsync(); - - markers.Should().HaveCount(1, "previous blob must be preserved when scrape yields zero results"); - } - - [Fact] - public async Task FullPipeline_BlobOverwritten_WhenNewScrapeSucceeds() - { - // Arrange: first scrape — one city - await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); - - // Second scrape — two cities - await CreateScraper(TwoCitiesHtml, Empty).ScrapeAndSaveAsync(); - - var markers = await CreateProvider().GetMarkersAsync(); - - markers.Should().HaveCount(2, "blob should be overwritten with the newer result"); - } - - [Fact] - public async Task Provider_ReturnsEmpty_BeforeScrapeHasEverRun() - { - // Arrange: fresh Azurite — no blob uploaded - var provider = CreateProvider(); - - // Act - var markers = await provider.GetMarkersAsync(); - - // Assert - markers.Should().BeEmpty(); - } - - [Fact] - public async Task FullPipeline_MarkersHaveStableIds_AcrossProviderInstances() - { - // Arrange - await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); - - // Act: two separate provider instances read the same blob - var markers1 = await CreateProvider().GetMarkersAsync(); - var markers2 = await CreateProvider().GetMarkersAsync(); - - // Assert - markers1[0].Id.Should().Be(markers2[0].Id); - markers1[0].Id.Should().NotBe(Guid.Empty); - } - - [Fact] - public async Task FullPipeline_LokalreprasentanterGetCorrectShortDescription() - { - // Arrange - const string repHtml = """ -
  • Odense
- """; - await CreateScraper(Empty, repHtml).ScrapeAndSaveAsync(); - - var markers = await CreateProvider().GetMarkersAsync(); - - // Assert - markers.Should().ContainSingle(); - markers[0].ShortDescription.Should().Be("HJEM lokalrepræsentant"); - } - - [Fact] - public async Task FullPipeline_BlobContainsValidJson() - { - // Arrange - await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); - - // Act: read raw blob content - var containerClient = _blobServiceClient.GetBlobContainerClient("hjemlo-groups"); - var blobClient = containerClient.GetBlobClient("groups.json"); - var download = await blobClient.DownloadContentAsync(); - var json = download.Value.Content.ToString(); - - // Assert - var act = () => JsonSerializer.Deserialize>(json, CamelCase); - act.Should().NotThrow(); - act()!.Should().NotBeEmpty(); - } - - [Fact] - public async Task FullPipeline_WebsiteUrl_IsFullHjemloUrl() - { - // Arrange - await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); - - var markers = await CreateProvider().GetMarkersAsync(); - - // Assert - markers[0].WebsiteUrl.Should().StartWith("https://www.hjemlo.dk/"); - } - - [Fact] - public async Task FullPipeline_Coordinates_AreFromGeocoderResult() - { - // Arrange - AlwaysSuccessGeocoder returns lat=56, lng=10 - await CreateScraper(SingleCityHtml, Empty).ScrapeAndSaveAsync(); - var markers = await CreateProvider().GetMarkersAsync(); - - // Assert - markers[0].Latitude.Should().Be(56.0); - markers[0].Longitude.Should().Be(10.0); - } - - // ------------------------------------------------------------------------- - // Fake helpers - // ------------------------------------------------------------------------- - - private sealed class HtmlFakeHandler(string lokAfdelHtml, string lokRepHtml) : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var html = request.RequestUri?.AbsolutePath.Contains("lokalrepraesentanter") == true - ? lokRepHtml - : lokAfdelHtml; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(html, Encoding.UTF8, "text/html") - }); - } - } - - /// Always returns a fixed geocode result for any query. - private sealed class AlwaysSuccessGeocoder : IDataForsyningenClient - { - private static readonly ZipCodeSearchResponse FakeResult = new( - Href: null, Nr: "8900", Navn: "Randers", - Stormodtageradresser: null, Bbox: null, - Visueltcenter: [10f, 56f], // GeoJSON: [lng, lat] - Kommuner: null, - Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, - Geo_Version: 1, Dagi_Id: null); - - public Task>> GetAddressesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task>> GetZipCodesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> GetZipCodeFromCoordinates(string longitude, string latitude, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task>> GetZipCodesWithinCircle(string? circle, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - public Task>> SearchZipCodesAsync(string query, CancellationToken cancellationToken = default) => - Task.FromResult>>( - new ApiResponse>( - new HttpResponseMessage(HttpStatusCode.OK), - [FakeResult], - new RefitSettings())); - } - - /// Always returns no geocode results (simulates failed geocoding). - private sealed class AlwaysEmptyGeocoder : IDataForsyningenClient - { - public Task>> GetAddressesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task>> GetZipCodesWithAutoComplete(string? query, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> GetZipCodeFromCoordinates(string longitude, string latitude, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task>> GetZipCodesWithinCircle(string? circle, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - - public Task>> SearchZipCodesAsync(string query, CancellationToken cancellationToken = default) => - Task.FromResult>>( - new ApiResponse>( - new HttpResponseMessage(HttpStatusCode.OK), - [], - new RefitSettings())); - } -} From c6fc028bd982da96cb203a5042e99e2cfa6e1885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 09:46:24 +0100 Subject: [PATCH 06/23] Create HjemGroupAdminServiceTests.cs --- .../HjemGroups/HjemGroupAdminServiceTests.cs | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs new file mode 100644 index 000000000..dd9625abc --- /dev/null +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -0,0 +1,320 @@ +using Azure; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using FluentAssertions; +using Jordnaer.Features.HjemGroups; +using Jordnaer.Shared; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Refit; +using System.Net; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Jordnaer.Tests.HjemGroups; + +public class HjemGroupAdminServiceTests +{ + private readonly BlobServiceClient _blobServiceClient = Substitute.For(); + private readonly BlobContainerClient _containerClient = Substitute.For(); + private readonly BlobClient _blobClient = Substitute.For(); + private readonly IDataForsyningenClient _geocoder = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + + private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public HjemGroupAdminServiceTests() + { + _blobServiceClient + .GetBlobContainerClient("hjemlo-groups") + .Returns(_containerClient); + _containerClient + .GetBlobClient("groups.json") + .Returns(_blobClient); + _containerClient + .CreateIfNotExistsAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + _blobClient + .UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + } + + private HjemGroupAdminService CreateSut() => + new(_blobServiceClient, _geocoder, _logger); + + // ------------------------------------------------------------------------- + // LoadAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task LoadAsync_ReturnsEmpty_WhenContainerDoesNotExist() + { + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(false, Substitute.For())); + + var result = await CreateSut().LoadAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task LoadAsync_ReturnsEmpty_WhenBlobDoesNotExist() + { + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(false, Substitute.For())); + + var result = await CreateSut().LoadAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task LoadAsync_ReturnsEmpty_WhenBlobContainsEmptyArray() + { + SetupBlobWithContent("[]"); + + var result = await CreateSut().LoadAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task LoadAsync_ReturnsDeserializedEntries() + { + var entries = new[] + { + MakeEntry("Randers", "https://www.hjemlo.dk/randers", HjemGroupType.Lokalafdeling), + MakeEntry("Odense", "https://www.hjemlo.dk/lokalrepraesentanter", HjemGroupType.Lokalrepresentant), + }; + SetupBlobWithContent(JsonSerializer.Serialize(entries, CamelCase)); + + var result = await CreateSut().LoadAsync(); + + result.Should().HaveCount(2); + result[0].Name.Should().Be("Randers"); + result[1].Name.Should().Be("Odense"); + } + + [Fact] + public async Task LoadAsync_ReturnsEmpty_WhenBlobThrows() + { + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.DownloadContentAsync(Arg.Any()) + .ThrowsAsync(new RequestFailedException("Simulated blob error")); + + var result = await CreateSut().LoadAsync(); + + result.Should().BeEmpty(); + } + + // ------------------------------------------------------------------------- + // SaveAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task SaveAsync_UploadsJson_WithOverwriteTrue() + { + var entries = new List { MakeEntry("Aarhus", "https://www.hjemlo.dk/aarhus", HjemGroupType.Lokalafdeling) }; + + await CreateSut().SaveAsync(entries); + + await _blobClient.Received(1).UploadAsync(Arg.Any(), overwrite: true, Arg.Any()); + } + + [Fact] + public async Task SaveAsync_CreatesContainerIfNotExists() + { + var entries = new List { MakeEntry("Aarhus", "https://www.hjemlo.dk/aarhus", HjemGroupType.Lokalafdeling) }; + + await CreateSut().SaveAsync(entries); + + await _containerClient.Received(1).CreateIfNotExistsAsync( + PublicAccessType.Blob, + Arg.Any>(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task SaveAsync_UploadsValidCamelCaseJson() + { + var entries = new List + { + MakeEntry("Randers", "https://www.hjemlo.dk/randers", HjemGroupType.Lokalafdeling), + }; + + string uploadedJson = string.Empty; + _blobClient + .UploadAsync(Arg.Do(s => + { + using var sr = new StreamReader(s, Encoding.UTF8); + uploadedJson = sr.ReadToEnd(); + }), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + + await CreateSut().SaveAsync(entries); + + var deserialized = JsonSerializer.Deserialize>(uploadedJson, CamelCase); + deserialized.Should().NotBeNull().And.HaveCount(1); + deserialized![0].Name.Should().Be("Randers"); + // camelCase: field names start lowercase + uploadedJson.Should().Contain("\"name\""); + uploadedJson.Should().NotContain("\"Name\""); + } + + [Fact] + public async Task SaveAsync_CanRoundtrip_EmptyList() + { + string uploadedJson = string.Empty; + _blobClient + .UploadAsync(Arg.Do(s => + { + using var sr = new StreamReader(s, Encoding.UTF8); + uploadedJson = sr.ReadToEnd(); + }), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Substitute.For>())); + + await CreateSut().SaveAsync([]); + + var deserialized = JsonSerializer.Deserialize>(uploadedJson, CamelCase); + deserialized.Should().NotBeNull().And.BeEmpty(); + } + + // ------------------------------------------------------------------------- + // GeocodeAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task GeocodeAsync_ReturnsNull_WhenApiReturnsNoResults() + { + _geocoder + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(MakeGeoResponse()); + + var result = await CreateSut().GeocodeAsync("UnknownPlace"); + + result.Should().BeNull(); + } + + [Fact] + public async Task GeocodeAsync_ReturnsNull_WhenApiCallFails() + { + _geocoder + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(new ApiResponse>( + new HttpResponseMessage(HttpStatusCode.InternalServerError), null, new RefitSettings())); + + var result = await CreateSut().GeocodeAsync("Randers"); + + result.Should().BeNull(); + } + + [Fact] + public async Task GeocodeAsync_ReturnsCityAndCoordinates_WhenApiSucceeds() + { + _geocoder + .SearchZipCodesAsync("Randers", Arg.Any()) + .Returns(MakeGeoResponse(MakeZip("Randers", 8900, lat: 56.46, lng: 10.03))); + + var result = await CreateSut().GeocodeAsync("Randers"); + + result.Should().NotBeNull(); + result!.City.Should().Be("Randers"); + result.ZipCode.Should().Be(8900); + result.Latitude.Should().BeApproximately(56.46, 0.001); + result.Longitude.Should().BeApproximately(10.03, 0.001); + } + + [Fact] + public async Task GeocodeAsync_ParsesZipCode_FromStringNr() + { + _geocoder + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(MakeGeoResponse(MakeZip("København", 1000, lat: 55.67, lng: 12.57))); + + var result = await CreateSut().GeocodeAsync("København"); + + result!.ZipCode.Should().Be(1000); + } + + [Fact] + public async Task GeocodeAsync_ReturnsNullZipCode_WhenNrIsNotParseable() + { + var zip = new ZipCodeSearchResponse( + Href: null, Nr: "not-a-number", Navn: "SomeCity", + Stormodtageradresser: null, Bbox: null, + Visueltcenter: [10f, 55f], + Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, + Geo_Version: 1, Dagi_Id: null); + + _geocoder + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(MakeGeoResponse(zip)); + + var result = await CreateSut().GeocodeAsync("SomeCity"); + + result.Should().NotBeNull(); + result!.ZipCode.Should().BeNull(); + } + + [Fact] + public async Task GeocodeAsync_PassesQueryDirectlyToClient() + { + _geocoder + .SearchZipCodesAsync(Arg.Any(), Arg.Any()) + .Returns(MakeGeoResponse()); + + await CreateSut().GeocodeAsync("Silkeborg"); + + await _geocoder.Received(1).SearchZipCodesAsync("Silkeborg", Arg.Any()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static HjemGroupEntry MakeEntry(string name, string url, HjemGroupType type) => new() + { + Name = name, + WebsiteUrl = new Uri(url), + City = name, + ZipCode = 8900, + Latitude = 56.0, + Longitude = 10.0, + Type = type, + }; + + private static ZipCodeSearchResponse MakeZip(string navn, int nr, double lat, double lng) => + new(Href: null, Nr: nr.ToString("D4"), Navn: navn, + Stormodtageradresser: null, Bbox: null, + Visueltcenter: [(float)lng, (float)lat], // GeoJSON order: [lng, lat] + Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, + Geo_Version: 1, Dagi_Id: null); + + private static IApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) => + new ApiResponse>( + new HttpResponseMessage(HttpStatusCode.OK), + results, + new RefitSettings()); + + private void SetupBlobWithContent(string json) + { + _containerClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + _blobClient.ExistsAsync(Arg.Any()) + .Returns(Response.FromValue(true, Substitute.For())); + + var bytes = Encoding.UTF8.GetBytes(json); + var binaryData = BinaryData.FromBytes(bytes); + var downloadResult = BlobsModelFactory.BlobDownloadResult(content: binaryData); + _blobClient.DownloadContentAsync(Arg.Any()) + .Returns(Response.FromValue(downloadResult, Substitute.For())); + } +} From 24423899e65b4b3aef6a4b53549498776b660392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 20:01:48 +0100 Subject: [PATCH 07/23] review comments --- .../Features/HjemGroups/HjemGroupAdminService.cs | 2 +- .../Features/HjemGroups/HjemGroupProvider.cs | 15 +++------------ .../HjemGroups/WebApplicationBuilderExtensions.cs | 2 +- .../Backoffice/HjemGroupManagementPage.razor | 7 +++++++ .../HjemGroups/HjemGroupAdminServiceTests.cs | 2 +- .../UserSearch/DataForsyningenClientTests.cs | 7 ++++--- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs index ba8bd5a81..b54d54d29 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs @@ -46,7 +46,7 @@ public async Task> LoadAsync(CancellationToken cancellation public async Task SaveAsync(List entries, CancellationToken cancellationToken = default) { var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); - await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob, cancellationToken: cancellationToken); + await containerClient.CreateIfNotExistsAsync(PublicAccessType.None, cancellationToken: cancellationToken); var blobClient = containerClient.GetBlobClient(BlobName); var json = JsonSerializer.Serialize(entries, JsonOptions); diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index 54427ef6d..7a95c6a32 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -35,28 +35,19 @@ public async Task> GetMarkersAsync(CancellationTo var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); if (!await containerClient.ExistsAsync(cancellationToken)) - { - _cached = []; - return _cached; - } + return []; var blobClient = containerClient.GetBlobClient(BlobName); if (!await blobClient.ExistsAsync(cancellationToken)) - { - _cached = []; - return _cached; - } + return []; var response = await blobClient.DownloadContentAsync(cancellationToken); var entries = JsonSerializer.Deserialize>( response.Value.Content.ToString(), JsonOptions); if (entries is null or { Count: 0 }) - { - _cached = []; - return _cached; - } + return []; _cached = entries.Select(MapToMarker).ToList(); return _cached; diff --git a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs index 6ab6be65d..fdf8a2c6c 100644 --- a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs @@ -4,7 +4,7 @@ public static class WebApplicationBuilderExtensions { public static WebApplicationBuilder AddHjemGroupServices(this WebApplicationBuilder builder) { - builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddScoped(); return builder; diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index bb8888cf8..d83613210 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -247,6 +247,13 @@ else return; } + if (_form.Latitude is < -90 or > 90 || _form.Longitude is < -180 or > 180 || + (_form.Latitude == 0 && _form.Longitude == 0)) + { + Snackbar.Add("Gyldige koordinater er påkrævet (brug 'Slå op' til at hente dem).", Severity.Warning); + return; + } + var updated = new HjemGroupEntry { Type = _form.Type, diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index dd9625abc..226abfd9f 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -136,7 +136,7 @@ public async Task SaveAsync_CreatesContainerIfNotExists() await CreateSut().SaveAsync(entries); await _containerClient.Received(1).CreateIfNotExistsAsync( - PublicAccessType.Blob, + PublicAccessType.None, Arg.Any>(), Arg.Any(), Arg.Any()); diff --git a/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs b/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs index b9dca85ac..df7590d1d 100644 --- a/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs +++ b/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs @@ -76,7 +76,7 @@ public async Task SearchZipCodesAsync_ReturnsResults_ForKnownCity() var response = await _dataForsyningenClient.SearchZipCodesAsync(query); // Assert - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessful.Should().BeTrue(); response.Content.Should().NotBeNull().And.HaveCountGreaterThan(0); var first = response.Content!.First(); @@ -96,7 +96,7 @@ public async Task SearchZipCodesAsync_ReturnsEmpty_ForGibberishQuery() var response = await _dataForsyningenClient.SearchZipCodesAsync(query); // Assert - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessful.Should().BeTrue(); response.Content.Should().NotBeNull().And.BeEmpty(); } @@ -110,7 +110,8 @@ public async Task SearchZipCodesAsync_VisueltcenterIsLngLatOrder() var response = await _dataForsyningenClient.SearchZipCodesAsync(query); // Assert - response.IsSuccessStatusCode.Should().BeTrue(); + response.IsSuccessful.Should().BeTrue(); + response.Content.Should().NotBeNull(); var first = response.Content!.First(); first.Visueltcenter.Should().HaveCountGreaterOrEqualTo(2); From 7efdc82892112c139f8ad1d7df456406d4422665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 20:04:18 +0100 Subject: [PATCH 08/23] Update HjemGroupAdminServiceTests.cs --- .../HjemGroups/HjemGroupAdminServiceTests.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index 226abfd9f..7b8b0afe6 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -114,6 +114,16 @@ public async Task LoadAsync_ReturnsEmpty_WhenBlobThrows() result.Should().BeEmpty(); } + [Fact] + public async Task LoadAsync_ReturnsEmpty_WhenBlobContainsInvalidJson() + { + SetupBlobWithContent("this is not valid json {{{"); + + var result = await CreateSut().LoadAsync(); + + result.Should().BeEmpty(); + } + // ------------------------------------------------------------------------- // SaveAsync // ------------------------------------------------------------------------- @@ -154,7 +164,7 @@ public async Task SaveAsync_UploadsValidCamelCaseJson() _blobClient .UploadAsync(Arg.Do(s => { - using var sr = new StreamReader(s, Encoding.UTF8); + using var sr = new StreamReader(s, Encoding.UTF8, leaveOpen: true); uploadedJson = sr.ReadToEnd(); }), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Substitute.For>())); @@ -176,7 +186,7 @@ public async Task SaveAsync_CanRoundtrip_EmptyList() _blobClient .UploadAsync(Arg.Do(s => { - using var sr = new StreamReader(s, Encoding.UTF8); + using var sr = new StreamReader(s, Encoding.UTF8, leaveOpen: true); uploadedJson = sr.ReadToEnd(); }), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Substitute.For>())); From c3902c41c4905585f1ee3727b3eaac9b5aa6ac47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 20:06:48 +0100 Subject: [PATCH 09/23] Update NotificationItem.razor --- src/web/Jordnaer/Components/NotificationItem.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/Jordnaer/Components/NotificationItem.razor b/src/web/Jordnaer/Components/NotificationItem.razor index 8c82e3061..27223f5a5 100644 --- a/src/web/Jordnaer/Components/NotificationItem.razor +++ b/src/web/Jordnaer/Components/NotificationItem.razor @@ -23,7 +23,7 @@ @if (!string.IsNullOrEmpty(Notification.Description)) { - + @Notification.Description } From dc4c9b439af1f7bc871594d80bb81c96e2711ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 20:27:40 +0100 Subject: [PATCH 10/23] fixes --- .../Jordnaer/Components/NotificationItem.razor | 4 ++-- src/web/Jordnaer/Pages/Shared/TopBar.razor | 10 ++++++++++ .../HjemGroups/HjemGroupAdminServiceTests.cs | 16 +++++++++------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/web/Jordnaer/Components/NotificationItem.razor b/src/web/Jordnaer/Components/NotificationItem.razor index 27223f5a5..bf6e2d85a 100644 --- a/src/web/Jordnaer/Components/NotificationItem.razor +++ b/src/web/Jordnaer/Components/NotificationItem.razor @@ -23,9 +23,9 @@ @if (!string.IsNullOrEmpty(Notification.Description)) { - +
@Notification.Description - +
} @Notification.CreatedUtc.Humanize(utcDate: true, dateToCompareAgainst: DateTime.UtcNow, culture: DanishCulture) diff --git a/src/web/Jordnaer/Pages/Shared/TopBar.razor b/src/web/Jordnaer/Pages/Shared/TopBar.razor index 23bc0c9a1..e93accae4 100644 --- a/src/web/Jordnaer/Pages/Shared/TopBar.razor +++ b/src/web/Jordnaer/Pages/Shared/TopBar.razor @@ -129,6 +129,11 @@ Style="margin-right: 0.75rem;" /> Emails + + + HJEM Grupper + @@ -206,6 +211,11 @@ Style="margin-right: 0.75rem;" /> Emails + + + HJEM Grupper + diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index 7b8b0afe6..2c81d66a3 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -112,6 +112,7 @@ public async Task LoadAsync_ReturnsEmpty_WhenBlobThrows() var result = await CreateSut().LoadAsync(); result.Should().BeEmpty(); + _logger.ReceivedWithAnyArgs(1).Log(LogLevel.Warning, default, default!, default, default!); } [Fact] @@ -216,10 +217,11 @@ public async Task GeocodeAsync_ReturnsNull_WhenApiReturnsNoResults() [Fact] public async Task GeocodeAsync_ReturnsNull_WhenApiCallFails() { + using var httpResponse = new HttpResponseMessage(HttpStatusCode.InternalServerError); + using var apiResponse = new ApiResponse>(httpResponse, null, new RefitSettings()); _geocoder .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(new ApiResponse>( - new HttpResponseMessage(HttpStatusCode.InternalServerError), null, new RefitSettings())); + .Returns(apiResponse); var result = await CreateSut().GeocodeAsync("Randers"); @@ -308,11 +310,11 @@ private static ZipCodeSearchResponse MakeZip(string navn, int nr, double lat, do Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, Geo_Version: 1, Dagi_Id: null); - private static IApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) => - new ApiResponse>( - new HttpResponseMessage(HttpStatusCode.OK), - results, - new RefitSettings()); + private static ApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) + { + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK); + return new ApiResponse>(httpResponse, results, new RefitSettings()); + } private void SetupBlobWithContent(string json) { From 8357277969ee8046c8914b38ed5338b4f27280a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 20:31:44 +0100 Subject: [PATCH 11/23] Update NotificationItem.razor.css --- src/web/Jordnaer/Components/NotificationItem.razor.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/Jordnaer/Components/NotificationItem.razor.css b/src/web/Jordnaer/Components/NotificationItem.razor.css index 67913417d..46a995e2f 100644 --- a/src/web/Jordnaer/Components/NotificationItem.razor.css +++ b/src/web/Jordnaer/Components/NotificationItem.razor.css @@ -1,6 +1,7 @@ .notification-title { overflow: hidden; display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; From 91602e85cbf701ddd8b5613db42cb05483282633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 20:31:46 +0100 Subject: [PATCH 12/23] Create logo-hjem.avif --- .../wwwroot/images/partners/logo-hjem.avif | Bin 0 -> 1697 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/web/Jordnaer/wwwroot/images/partners/logo-hjem.avif diff --git a/src/web/Jordnaer/wwwroot/images/partners/logo-hjem.avif b/src/web/Jordnaer/wwwroot/images/partners/logo-hjem.avif new file mode 100644 index 0000000000000000000000000000000000000000..cff428e56cfff7fb5a33d279c1a4916053d87ca6 GIT binary patch literal 1697 zcmYjQ3pkWn82-nQX%gdJR!T;MTwU>> zY)P$|LCUD)QrcWnjUo3gE^SRN)mHW&ZO?Z8=bZO@zxO-e_nq^c9{_+Av796rOn_nm zWT=o1#X8X;Fg9?rlNSJ>vgi~JsM3&OO^u(wksexdiy)OE%E=e# zNVU)To36V0_J~q!)?ZNbe2^+#eLC>zQvZkUMK)>}T7|ozr{Kn!4(HL2 z__hG-_y=YP2@`D6`L_{$cU7p=BIgWTC&)-NGTGv|8n4K@BlFLenZ+2NGWYm7PxFYI zC{p4nF+Z{=;>(_r@aKN1MXfP~L(O6^8wnZHVHQcJ zRs{|Hq<;amq;q2ptNpfSkcYfB9CbNbvnKp;LkPNfI#RdQX@4-2-(4=+n9X1C4O5sxjk8F^?cjdc~eE9Gt$ z!PFsss94E0O-vK}MFwnWZm)0nBYs>jBS^!fd+g#YLGjjmD0ztIwbD>`pv=rrKYMou zI56Ah?5|7V+hIitcl*5ucTvNEf@)qURw}{m4s7UgBqpwT+~=V!d|R$`t2$ACn>U?% z??CT5z0=MPT~ANv+?3APTCDd~g#1#r4eH#@ncMBWwn&J--udnG&;dB;~#*R)L_j~D2-F!F@|IPW~H zCw_jiD0y7RcyF7W@-{Sz^2yc2O&Pjl5q{Ww&Jmtc2rsBAUUMcO_nGynA`TkX9PC#y9uUA24DX#ad&GN;LnKfAw>Y{^(FYb(= z?8esk%uLodOAigh!hFp&M)lW$Mr|wCgz-!3?e)8IT#|WJ^(!?VrUMPOgwiIXj39-(cC9x9dfIoJ%?ibEo#p;4Di?y6M~c$$WZXCcx9@>gV$_Q9Nxj=774) zE)0MpA(qRv5+?3H={wkNCs>xpuO_rhP#Qk3axdjd&)3U;AMhZaK(mhDIvN*7 zIXk+-Y|EUo3Oy+`e7)!Dv+?AH^23u`lB34r?lyhQXjD5jbG?#s4!w4z!Ol#S)0~Wz zHRbchIF#%3BpSUw1(Mt!$Peyi)siX(yxN1buvyBx411l_gN$48NyD3nSCf+>jE)@p zplJS_7J1hPM_iO@%+tygc~lLyECSDN{ZiCOuuO+I+wjF*6b4ojJ$JK@6VI3sQ!Dyl zzeE~gidca!TPH23T1uI_)^65OL`V+6-Af5}9my-SGBBlPkhSk07s}ZC_`-gYvNU28 z2i6g8M>u$^1m&vOp;4$M$qiEFF;F#FF9FZw-64;ynOeOFqd{URCIt+MmQnDmoYjsF5& C9<*Tq literal 0 HcmV?d00001 From ee72b73f8f92cbd518e3864dedfe1c8fc4517b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 21:53:11 +0100 Subject: [PATCH 13/23] finish icon url part of the feature --- .../Features/HjemGroups/HjemGroupEntry.cs | 1 + .../Features/HjemGroups/HjemGroupProvider.cs | 8 ++++++-- .../Backoffice/HjemGroupManagementPage.razor | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs index 7c7e43c1a..458950c3f 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupEntry.cs @@ -9,6 +9,7 @@ public record HjemGroupEntry public required double Latitude { get; init; } public required double Longitude { get; init; } public required HjemGroupType Type { get; init; } + public string? IconUrl { get; init; } } public enum HjemGroupType { Lokalafdeling, Lokalrepresentant } diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index 7a95c6a32..7714f576e 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -64,12 +64,16 @@ private static GroupMarkerData MapToMarker(HjemGroupEntry entry) var idBytes = MD5.HashData(Encoding.UTF8.GetBytes(entry.WebsiteUrl.ToString() + entry.Name)); var id = new Guid(idBytes); + var websiteUrl = entry.Type == HjemGroupType.Lokalrepresentant + ? "https://www.hjemlo.dk/lokalrepraesentanter" + : entry.WebsiteUrl.ToString(); + return new GroupMarkerData { Id = id, Name = entry.Name, - ProfilePictureUrl = null, - WebsiteUrl = entry.WebsiteUrl.ToString(), + ProfilePictureUrl = entry.IconUrl ?? "/images/partners/logo-hjem.avif", + WebsiteUrl = websiteUrl, ShortDescription = entry.Type == HjemGroupType.Lokalafdeling ? "HJEM lokalafdeling" : "HJEM lokalrepræsentant", diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index d83613210..e36e79bfd 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -48,6 +48,7 @@ else Type + Ikon Navn By Postnr @@ -63,6 +64,11 @@ else @(context.Type == HjemGroupType.Lokalafdeling ? "Lokalafdeling" : "Lokalrep.") + + + + + @context.Name @context.City @context.ZipCode @@ -104,6 +110,17 @@ else + + + + + + + Date: Sun, 22 Feb 2026 22:07:07 +0100 Subject: [PATCH 14/23] update caching, fix tests --- .../HjemGroups/HjemGroupAdminService.cs | 6 ++++++ .../Features/HjemGroups/HjemGroupProvider.cs | 21 ++++++++++++------- .../Jordnaer/wwwroot/js/leaflet-interop.js | 16 ++++++++++++-- .../HjemGroups/HjemGroupProviderTests.cs | 11 +++++++--- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs index b54d54d29..e64ee0658 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs @@ -1,6 +1,7 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Jordnaer.Shared; +using MassTransit; using System.Text; using System.Text.Json; @@ -9,6 +10,7 @@ namespace Jordnaer.Features.HjemGroups; public class HjemGroupAdminService( BlobServiceClient blobServiceClient, IDataForsyningenClient dataForsyningenClient, + IPublishEndpoint publishEndpoint, ILogger logger) { private const string ContainerName = "hjemlo-groups"; @@ -53,6 +55,10 @@ public async Task SaveAsync(List entries, CancellationToken canc using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); + + await publishEndpoint.Publish( + new InvalidateCacheTags { Tags = [HjemGroupProvider.CacheTag] }, + cancellationToken); } /// diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index 7714f576e..85b2b9fe6 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using ZiggyCreatures.Caching.Fusion; namespace Jordnaer.Features.HjemGroups; @@ -13,8 +14,11 @@ public interface IHjemGroupProvider public class HjemGroupProvider( BlobServiceClient blobServiceClient, + IFusionCache fusionCache, ILogger logger) : IHjemGroupProvider { + internal const string CacheTag = "hjem-groups"; + private const string CacheKey = "HjemGroups:markers"; private const string ContainerName = "hjemlo-groups"; private const string BlobName = "groups.json"; @@ -23,13 +27,17 @@ public class HjemGroupProvider( PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - private IReadOnlyList? _cached; - public async Task> GetMarkersAsync(CancellationToken cancellationToken = default) { - if (_cached is not null) - return _cached; + return await fusionCache.GetOrSetAsync>( + CacheKey, + (_, innerToken) => LoadFromBlobAsync(innerToken), + tags: [CacheTag], + token: cancellationToken) ?? []; + } + private async Task> LoadFromBlobAsync(CancellationToken cancellationToken) + { try { var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); @@ -49,13 +57,12 @@ public async Task> GetMarkersAsync(CancellationTo if (entries is null or { Count: 0 }) return []; - _cached = entries.Select(MapToMarker).ToList(); - return _cached; + return entries.Select(MapToMarker).ToList(); } catch (Exception ex) { logger.LogWarning(ex, "Failed to load HJEM group markers from blob storage."); - return _cached ?? []; + return []; } } diff --git a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js index 9399cedab..2f0d85128 100644 --- a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js +++ b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js @@ -354,7 +354,19 @@ window.leafletInterop = { } // Build the full popup - const groupUrl = `/groups/${encodeURIComponent(group.name)}`; + // Only use websiteUrl for the primary button if it is on the hjemlo.dk domain + let isHjemUrl = false; + if (group.websiteUrl) { + try { + const parsedGroupUrl = new URL(group.websiteUrl); + isHjemUrl = parsedGroupUrl.hostname === "www.hjemlo.dk" || parsedGroupUrl.hostname === "hjemlo.dk"; + } catch (e) { + // Invalid URL — treat as not a HJEM URL + } + } + + const groupUrl = isHjemUrl ? group.websiteUrl : `/groups/${encodeURIComponent(group.name)}`; + const groupUrlTarget = isHjemUrl ? ' target="_blank" rel="noopener"' : ''; return `
@@ -368,7 +380,7 @@ window.leafletInterop = { ${locationHtml} ${descriptionHtml} ${websiteHtml} - + Se gruppe diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs index 9d3bd3377..fc057e82b 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs @@ -3,13 +3,15 @@ using Azure.Storage.Blobs.Models; using FluentAssertions; using Jordnaer.Features.HjemGroups; -using Jordnaer.Features.Map; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; using System.Text; using System.Text.Json; using Xunit; +using ZiggyCreatures.Caching.Fusion; namespace Jordnaer.Tests.HjemGroups; @@ -20,8 +22,11 @@ public class HjemGroupProviderTests private readonly BlobClient _blobClient = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); + private static IFusionCache CreateFusionCache() => + new FusionCache(new FusionCacheOptions(), new MemoryCache(new MemoryCacheOptions())); + private HjemGroupProvider CreateSut() => - new(_blobServiceClient, _logger); + new(_blobServiceClient, CreateFusionCache(), _logger); public HjemGroupProviderTests() { @@ -115,7 +120,7 @@ public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalafdeling() marker.Latitude.Should().Be(56.46); marker.Longitude.Should().Be(10.03); marker.ShortDescription.Should().Be("HJEM lokalafdeling"); - marker.ProfilePictureUrl.Should().BeNull(); + marker.ProfilePictureUrl.Should().Be("/images/partners/logo-hjem.avif"); marker.Id.Should().NotBe(Guid.Empty); } From e9a544def90407d2ba073628ad1a2d5524dfea6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 22:10:11 +0100 Subject: [PATCH 15/23] styling and tests --- src/web/Jordnaer/wwwroot/css/marker-cluster.css | 12 ++++++++++-- .../HjemGroups/HjemGroupAdminServiceTests.cs | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/web/Jordnaer/wwwroot/css/marker-cluster.css b/src/web/Jordnaer/wwwroot/css/marker-cluster.css index 9123280a0..d90379a52 100644 --- a/src/web/Jordnaer/wwwroot/css/marker-cluster.css +++ b/src/web/Jordnaer/wwwroot/css/marker-cluster.css @@ -89,7 +89,7 @@ /* Custom group marker styles */ .group-marker-icon { - background-color: var(--map-primary); + background-color: var(--neutral-white); border: 3px solid var(--neutral-white); border-radius: 50%; box-shadow: var(--shadow-marker); @@ -106,6 +106,10 @@ border-radius: 50%; } +.group-marker-icon:has(.group-marker-initial) { + background-color: var(--map-primary); +} + .group-marker-icon .group-marker-initial { color: var(--neutral-white); font-size: 14px; @@ -147,7 +151,7 @@ width: 50px; height: 50px; border-radius: 50%; - background-color: var(--map-primary); + background-color: var(--neutral-white); display: flex; align-items: center; justify-content: center; @@ -161,6 +165,10 @@ object-fit: cover; } +.group-popup-avatar:has(.group-initial) { + background-color: var(--map-primary); +} + .group-popup-avatar .group-initial { color: var(--neutral-white); font-size: 20px; diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index 2c81d66a3..a0fa72afe 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Jordnaer.Features.HjemGroups; using Jordnaer.Shared; +using MassTransit; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -21,6 +22,7 @@ public class HjemGroupAdminServiceTests private readonly BlobContainerClient _containerClient = Substitute.For(); private readonly BlobClient _blobClient = Substitute.For(); private readonly IDataForsyningenClient _geocoder = Substitute.For(); + private readonly IPublishEndpoint _publishEndpoint = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -42,7 +44,7 @@ public HjemGroupAdminServiceTests() } private HjemGroupAdminService CreateSut() => - new(_blobServiceClient, _geocoder, _logger); + new(_blobServiceClient, _geocoder, _publishEndpoint, _logger); // ------------------------------------------------------------------------- // LoadAsync @@ -153,6 +155,18 @@ await _containerClient.Received(1).CreateIfNotExistsAsync( Arg.Any()); } + [Fact] + public async Task SaveAsync_PublishesCacheInvalidation() + { + var entries = new List { MakeEntry("Aarhus", "https://www.hjemlo.dk/aarhus", HjemGroupType.Lokalafdeling) }; + + await CreateSut().SaveAsync(entries); + + await _publishEndpoint.Received(1).Publish( + Arg.Is(m => m.Tags.Contains(HjemGroupProvider.CacheTag)), + Arg.Any()); + } + [Fact] public async Task SaveAsync_UploadsValidCamelCaseJson() { From ac8e4d9270d0da89b88c1f4adc30d5601e929502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 22:13:41 +0100 Subject: [PATCH 16/23] Update HjemGroupManagementPage.razor --- .../Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index e36e79bfd..6d25223ff 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -107,7 +107,7 @@ else - @@ -323,7 +323,7 @@ else { public HjemGroupType Type { get; set; } = HjemGroupType.Lokalafdeling; public string Name { get; set; } = ""; - public string WebsiteUrl { get; set; } = ""; + public string? WebsiteUrl { get; set; } public string? IconUrl { get; set; } public string? GeocodeName { get; set; } public string? City { get; set; } From 63cdd47a02f5c0c569aa5a3b3fd4ec6963cc63e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Sun, 22 Feb 2026 22:15:21 +0100 Subject: [PATCH 17/23] Update HjemGroupAdminServiceTests.cs --- .../HjemGroups/HjemGroupAdminServiceTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index a0fa72afe..b20d19002 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -1,6 +1,9 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Response = Azure.Response; +using ResponseT = Azure.Response; +using ResponseTContent = Azure.Response; using FluentAssertions; using Jordnaer.Features.HjemGroups; using Jordnaer.Shared; @@ -37,10 +40,10 @@ public HjemGroupAdminServiceTests() .Returns(_blobClient); _containerClient .CreateIfNotExistsAsync(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); + .Returns(Task.FromResult(Substitute.For())); _blobClient .UploadAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); + .Returns(Task.FromResult(Substitute.For())); } private HjemGroupAdminService CreateSut() => @@ -182,7 +185,7 @@ public async Task SaveAsync_UploadsValidCamelCaseJson() using var sr = new StreamReader(s, Encoding.UTF8, leaveOpen: true); uploadedJson = sr.ReadToEnd(); }), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); + .Returns(Task.FromResult(Substitute.For())); await CreateSut().SaveAsync(entries); @@ -204,7 +207,7 @@ public async Task SaveAsync_CanRoundtrip_EmptyList() using var sr = new StreamReader(s, Encoding.UTF8, leaveOpen: true); uploadedJson = sr.ReadToEnd(); }), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(Substitute.For>())); + .Returns(Task.FromResult(Substitute.For())); await CreateSut().SaveAsync([]); From 2c9d77f7786e70f3e3ac75cc2c6f2ed0eb643558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Mon, 23 Feb 2026 19:43:42 +0100 Subject: [PATCH 18/23] Create 13-cheaper-infrastructure.md --- tasks/13-cheaper-infrastructure.md | 237 +++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 tasks/13-cheaper-infrastructure.md diff --git a/tasks/13-cheaper-infrastructure.md b/tasks/13-cheaper-infrastructure.md new file mode 100644 index 000000000..00af58483 --- /dev/null +++ b/tasks/13-cheaper-infrastructure.md @@ -0,0 +1,237 @@ +# current + +- azure web app (container) (20$/month) 4gb ram poor cpu +- azure sql (5$/month) +- storage storage (blob storage, close to free) +- email communication service (free) + +# requirements + +- must offer automatic deployment somehow (dokploy opensource could be an option, more options would be good) +- must be european vps/cloud provider (hetzner, scaleway) +- must be cheaper than 25$/month +- ssl certificate +- domain name (we have mini-moeder.dk we want to use that) +- comparable performance +- automated system updates (can we do this ourselves automatically?) + +## product of task: is it viable? + +**Yes, it is viable.** Estimated monthly cost: ~$10-11 (saving ~$14/month, ~56% reduction) with zero code changes required. + +--- + +## analysis + +### what runs where today + +| service | azure resource | monthly cost | +|---|---|---| +| app (blazor server + signalr) | azure web app for containers | ~$20 | +| database | azure sql (sql server) | ~$5 | +| images / data protection keys | azure blob storage | ~$1 | +| transactional email | azure communication services | free | + +### key technical constraints that shape the decision + +1. **SQL Server stays in Azure** — Azure SQL at $5/month is cheap and risk-free to keep. No database migration needed at all. + +2. **Azure Blob Storage stores data protection keys** — `AddAzureBlobStorageDataProtection()` stores ASP.NET Core data protection keys in blob. If we keep Azure Blob, auth cookies continue to work across restarts. Keeping Azure Blob is essentially free (~$0.50-1/month) and avoids any code change. + +3. **ImageService uses Azure SDK directly** — swapping blob providers requires a code change. Not worth it. + +4. **Blazor Server is stateful** — each user has a persistent SignalR circuit. This is fine on a single VPS instance; sticky sessions are only needed when load-balancing multiple instances, which we are not doing. + +5. **Already Dockerized** — the app has a Dockerfile and docker-compose.yml. Moving to a VPS with Docker is a near-zero-friction deployment model change. + +### recommended setup + +**Hetzner Cloud CX22 ARM** (Falkenstein, Germany or Helsinki, Finland) + +| spec | value | +|---|---| +| vCPU | 2 (Ampere ARM) | +| RAM | 4 GB | +| Disk | 40 GB NVMe | +| Network | 20 TB/month | +| Price | €3.79/month (~$4.20) | +| Location | EU (GDPR compliant) | + +The CX22 matches the current Azure plan's 4 GB RAM but has significantly better CPU. Since the database stays in Azure SQL, the VPS only runs the app container — 4 GB is comfortable. Upgrade to CX32 (4 vCPU, 8 GB, €7.49/mo) later if needed. + +ARM binaries: .NET 10 has first-class ARM64 support. The official `mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0` image builds and runs fine on ARM64. The existing Dockerfile works as-is (Docker BuildKit handles cross-compilation on GitHub Actions via `--platform linux/arm64`). + +**Keep Azure SQL** — $5/month, zero migration effort, no risk. + +**Keep Azure Blob Storage** — ~$1/month, zero code changes, data protection keys continue to work. + +**Keep Azure Communication Email** — free, zero changes. + +### deployment: Dokploy + +Dokploy (https://dokploy.com) is an open-source self-hosted PaaS that runs on any VPS. It manages Docker/Docker Compose deployments, Traefik-based SSL (Let's Encrypt), environment variables, and deployment webhooks. + +The existing GitHub Actions CD workflow already: +1. Runs tests +2. Builds and pushes the Docker image to `ghcr.io/nielspilgaard/minimoeder-website` +3. Deploys to Azure + +Step 3 just gets replaced with a Dokploy webhook call. The image path and all other steps stay identical. + +### ssl certificates + +Dokploy uses Traefik with automatic Let's Encrypt certificate provisioning and renewal. No manual management needed. + +### domain + +Point `mini-moeder.dk` (and `www.mini-moeder.dk`) DNS A record to the Hetzner server IP. Dokploy configures Traefik to route requests to the app container. + +### automated system updates + +Install `unattended-upgrades` on Ubuntu 24.04 — handles automatic security patches for the OS: + +```bash +apt install unattended-upgrades +dpkg-reconfigure --priority=low unattended-upgrades +``` + +App container updates are handled by the existing GitHub Actions CD pipeline (tag a release → pipeline runs → Dokploy pulls and redeploys the new image). + +### observability + +The app currently uses `openTelemetryBuilder.UseGrafana()` in production. Grafana Cloud has a free tier that covers a small app. No changes needed there. + +### cost summary + +| item | new cost | +|---|---| +| Hetzner CX22 ARM (app only) | ~$4.20/month | +| Azure SQL (keep) | ~$5/month | +| Azure Blob Storage (keep) | ~$1/month | +| Azure Communication Email (keep) | $0 | +| Dokploy | $0 (open source) | +| SSL (Let's Encrypt via Dokploy) | $0 | +| **total** | **~$10-11/month** | + +Current: ~$25/month → New: ~$10-11/month. **Savings: ~$14/month, ~56% reduction.** + +--- + +## migration plan + +### prerequisites + +- [ ] Hetzner account +- [ ] SSH key added to Hetzner +- [ ] Note all production environment variables from Azure app settings (Azure SQL connection string stays the same) + +### step 1: provision the server + +1. Create Hetzner CX22 ARM server (Helsinki or Falkenstein) running Ubuntu 24.04 +2. Add firewall rules: allow 22 (SSH), 80 (HTTP), 443 (HTTPS) +3. Set up unattended-upgrades (security patches only, runs at 3am): + ```bash + apt update && apt install -y unattended-upgrades + dpkg-reconfigure --priority=low unattended-upgrades + + # Schedule upgrades at 3:00am instead of the default ~6am + mkdir -p /etc/systemd/system/apt-daily-upgrade.timer.d + cat > /etc/systemd/system/apt-daily-upgrade.timer.d/override.conf << 'EOF' + [Timer] + OnCalendar=03:00 + RandomizedDelaySec=0 + EOF + systemctl daemon-reload && systemctl restart apt-daily-upgrade.timer + ``` + + Safe: by default this only installs `security` and `security-updates` packages, never major upgrades. Auto-reboot is off unless you uncomment `Unattended-Upgrade::Automatic-Reboot "true"` in `/etc/apt/apt.conf.d/50unattended-upgrades` (kernel patches need a reboot to take effect, so worth enabling at 3am). +4. Install Dokploy: + ```bash + curl -sSL https://dokploy.com/install.sh | sh + ``` +5. Access Dokploy at `http://:3000`, create admin account + +### step 2: configure the app service in Dokploy + +Create a new project with one service: + +**Jordnaer app** +- Image: `ghcr.io/nielspilgaard/minimoeder-website:production` +- Domain: `mini-moeder.dk` and `www.mini-moeder.dk` +- SSL: enable Let's Encrypt in Dokploy +- Environment variables: copy all from current Azure app settings as-is + - `ConnectionStrings__JordnaerDbContext` stays pointing to Azure SQL — no change + - `ConnectionStrings__AzureBlobStorage` stays pointing to Azure Blob — no change + +### step 3: wire up automated deployment + +In the current [website_cd.yml](.github/workflows/website_cd.yml), replace the Azure deployment steps with a Dokploy webhook call: + +```yaml +# Replace this block: +- name: Login to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + +- name: Deploy to Azure Web App for Containers + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ env.AZURE_WEBAPP_NAME }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production + +# With this: +- name: Deploy via Dokploy webhook + run: | + curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" \ + -H "Authorization: Bearer ${{ secrets.DOKPLOY_API_KEY }}" +``` + +Add `DOKPLOY_WEBHOOK_URL` and `DOKPLOY_API_KEY` to GitHub repository secrets. Remove `AZURE_CREDENTIALS` secret after decommission. + +### step 4: DNS cutover + +1. Reduce mini-moeder.dk TTL to 300 seconds (5 minutes) 24 hours before cutover +2. Verify the app works correctly on the Hetzner server (test via `/etc/hosts` override or Dokploy preview URL) +3. Update mini-moeder.dk DNS A record to point to the Hetzner server IP +4. Monitor for ~15 minutes to confirm traffic is routing correctly and SSL cert is issued +5. Restore TTL to a longer value (e.g., 3600) + +### step 5: decommission Azure resources + +After 48 hours of stable operation: +- Delete Azure Web App (mini-moeder) +- Keep Azure SQL (still in use) +- Keep Azure Blob Storage (still used for images + data protection keys) +- Keep Azure Communication Email (still used for transactional mail) + +--- + +## risk mitigation + +| risk | likelihood | mitigation | +|---|---|---| +| App memory pressure on VPS | low | CX22 has 4 GB — same as current Azure plan but better CPU. Upgrade to CX32 (8 GB, €7.49/mo) if needed | +| SignalR circuit drops during deploy | mitigated | Rolling deploy: new container must pass `/alive` health check before old gets SIGTERM. Old container then drains existing circuits for 30s (configured via `HostOptions.ShutdownTimeout`). `boot.js` retries every 5s; if `Blazor.reconnect()` returns false, page reloads — user is on new container, auth cookie intact. In Dokploy: set deployment strategy to **Rolling** | +| ARM compatibility issue with .NET app | low | .NET 10 has first-class ARM64 support. The existing Dockerfile works as-is | +| OAuth callback URLs break | low | Domain stays the same (mini-moeder.dk) so OAuth redirect URIs don't change | +| Let's Encrypt rate limit | low | Keep port 80 open so ACME HTTP-01 challenge works | +| VPS outage (no redundancy) | low/medium | Hetzner SLA is 99.9%. Same single-point-of-failure model as current Azure Web App | +| Docker image ARM build | low | Add `--platform linux/arm64` to docker build step in CD workflow. Or skip ARM and use Hetzner CX22 x86 (€4.49/mo, negligible cost difference) | +| Azure SQL latency from Hetzner | low | Azure SQL is in West Europe; Hetzner Helsinki/Falkenstein are both low latency to that region. Same as today since the app already calls out to Azure SQL over the internet | + +### safest rollback path + +Azure Web App stays running until the cutover is confirmed stable. If something goes wrong, DNS can be pointed back to Azure within the TTL window (~5 minutes after TTL reduction). Azure costs nothing extra during the parallel period (it was already paid for the month). Azure SQL is untouched throughout — no rollback needed for the database. + +--- + +## alternatives considered + +| option | verdict | +|---|---| +| **Scaleway** | More expensive than Hetzner for equivalent specs. DEV1-S (2 vCPU, 2 GB) is ~€8/mo for less RAM. Hetzner wins on price/performance | +| **Hetzner CX22 x86** | €4.49/mo vs €3.79/mo for ARM. Avoids any ARM build concern. Either works; ARM is cheaper and .NET 10 supports it well | +| **Migrate from SQL Server to PostgreSQL** | Would eliminate the $5/month Azure SQL cost. But requires rewriting all EF Core migrations, switching providers, and retesting spatial queries. Not worth it | +| **Switch blob storage to Hetzner Object Storage** | €3/month vs ~$1/month Azure. Costs more and requires code changes in ImageService + data protection configuration. Skip | +| **Coolify** (alternative to Dokploy) | Also open source and mature. More feature-rich but heavier. Either works; Dokploy is simpler for this use case | From 590342dd6ecf9c8b4c4c05bb850369bf391eb983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Mon, 23 Feb 2026 21:36:58 +0100 Subject: [PATCH 19/23] review comments --- .../HjemGroups/HjemGroupAdminService.cs | 21 ++++++-- .../Features/HjemGroups/HjemGroupProvider.cs | 28 ++++++---- .../Features/Partners/PartnerService.cs | 30 ++++++++--- .../Backoffice/HjemGroupManagementPage.razor | 53 ++++++++++++------- .../Pages/GroupSearch/GroupSearch.razor | 3 +- .../Jordnaer/wwwroot/js/leaflet-interop.js | 3 +- .../HjemGroups/HjemGroupAdminServiceTests.cs | 28 +++++----- .../HjemGroups/HjemGroupProviderTests.cs | 40 ++++++++------ 8 files changed, 137 insertions(+), 69 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs index e64ee0658..82ef1edb5 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs @@ -2,6 +2,7 @@ using Azure.Storage.Blobs.Models; using Jordnaer.Shared; using MassTransit; +using OneOf; using System.Text; using System.Text.Json; @@ -22,17 +23,21 @@ public class HjemGroupAdminService( PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - public async Task> LoadAsync(CancellationToken cancellationToken = default) + public async Task, HjemGroupLoadError>> LoadAsync(CancellationToken cancellationToken = default) { try { var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); if (!await containerClient.ExistsAsync(cancellationToken)) - return []; + { + return new HjemGroupLoadError($"Blob container '{ContainerName}' does not exist."); + } var blobClient = containerClient.GetBlobClient(BlobName); if (!await blobClient.ExistsAsync(cancellationToken)) - return []; + { + return new HjemGroupLoadError($"Blob '{BlobName}' does not exist in container '{ContainerName}'."); + } var response = await blobClient.DownloadContentAsync(cancellationToken); return JsonSerializer.Deserialize>( @@ -41,7 +46,7 @@ public async Task> LoadAsync(CancellationToken cancellation catch (Exception ex) { logger.LogWarning(ex, "Failed to load HJEM group entries from blob storage."); - return []; + return new HjemGroupLoadError(ex.Message); } } @@ -70,11 +75,15 @@ await publishEndpoint.Publish( var response = await dataForsyningenClient.SearchZipCodesAsync(locationText, cancellationToken); if (!response.IsSuccessStatusCode || response.Content is null) + { return null; + } var first = response.Content.FirstOrDefault(); if (first.Navn is null || first.Visueltcenter is not { Length: >= 2 }) + { return null; + } // GeoJSON order: [longitude, latitude] var longitude = (double)first.Visueltcenter[0]; @@ -82,10 +91,14 @@ await publishEndpoint.Publish( int? zipCode = null; if (int.TryParse(first.Nr, out var parsedZip)) + { zipCode = parsedZip; + } return new GeocodeResult(first.Navn, zipCode, latitude, longitude); } public sealed record GeocodeResult(string City, int? ZipCode, double Latitude, double Longitude); } + +public sealed record HjemGroupLoadError(string Message); diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index 85b2b9fe6..dc622ccca 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -1,5 +1,7 @@ using Azure.Storage.Blobs; using Jordnaer.Features.Map; +using OneOf; +using OneOf.Types; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -9,7 +11,7 @@ namespace Jordnaer.Features.HjemGroups; public interface IHjemGroupProvider { - Task> GetMarkersAsync(CancellationToken cancellationToken = default); + Task, Error>> GetMarkersAsync(CancellationToken cancellationToken = default); } public class HjemGroupProvider( @@ -27,42 +29,50 @@ public class HjemGroupProvider( PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - public async Task> GetMarkersAsync(CancellationToken cancellationToken = default) + public async Task, Error>> GetMarkersAsync(CancellationToken cancellationToken = default) { - return await fusionCache.GetOrSetAsync>( + return await fusionCache.GetOrSetAsync, Error>>( CacheKey, (_, innerToken) => LoadFromBlobAsync(innerToken), tags: [CacheTag], - token: cancellationToken) ?? []; + token: cancellationToken); } - private async Task> LoadFromBlobAsync(CancellationToken cancellationToken) + private async Task, Error>> LoadFromBlobAsync(CancellationToken cancellationToken) { try { var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); if (!await containerClient.ExistsAsync(cancellationToken)) - return []; + { + logger.LogError("Blob container '{ContainerName}' does not exist.", ContainerName); + return new Error(); + } var blobClient = containerClient.GetBlobClient(BlobName); if (!await blobClient.ExistsAsync(cancellationToken)) - return []; + { + logger.LogError("Blob '{BlobName}' does not exist in container '{ContainerName}'.", BlobName, ContainerName); + return new Error(); + } var response = await blobClient.DownloadContentAsync(cancellationToken); var entries = JsonSerializer.Deserialize>( response.Value.Content.ToString(), JsonOptions); if (entries is null or { Count: 0 }) - return []; + { + return Array.Empty(); + } return entries.Select(MapToMarker).ToList(); } catch (Exception ex) { logger.LogWarning(ex, "Failed to load HJEM group markers from blob storage."); - return []; + return new Error(); } } diff --git a/src/web/Jordnaer/Features/Partners/PartnerService.cs b/src/web/Jordnaer/Features/Partners/PartnerService.cs index afba62d74..955a3b221 100644 --- a/src/web/Jordnaer/Features/Partners/PartnerService.cs +++ b/src/web/Jordnaer/Features/Partners/PartnerService.cs @@ -442,13 +442,22 @@ public async Task>> UploadPendingChangesAsync( // Validate URLs first (before any uploads to avoid orphaned blobs) var partnerPageLinkError = ValidateUrl(partnerPageLink, "Ugyldig partnerside link URL"); - if (partnerPageLinkError is not null) return partnerPageLinkError.Value; + if (partnerPageLinkError is not null) + { + return partnerPageLinkError.Value; + } var adLinkError = ValidateUrl(adLink, "Ugyldig annonce link URL"); - if (adLinkError is not null) return adLinkError.Value; + if (adLinkError is not null) + { + return adLinkError.Value; + } var colorError = ValidateHexColor(adLabelColor); - if (colorError is not null) return colorError.Value; + if (colorError is not null) + { + return colorError.Value; + } var validatedPartnerPageLink = string.IsNullOrWhiteSpace(partnerPageLink) ? null : partnerPageLink.Trim(); var validatedAdLink = string.IsNullOrWhiteSpace(adLink) ? null : adLink.Trim(); @@ -838,13 +847,22 @@ public async Task>> UpdatePartnerAsync(Gu // Validate URLs var partnerPageLinkError = ValidateUrl(request.PartnerPageLink, "Ugyldig partnerside link URL"); - if (partnerPageLinkError is not null) return partnerPageLinkError.Value; + if (partnerPageLinkError is not null) + { + return partnerPageLinkError.Value; + } var adLinkError = ValidateUrl(request.AdLink, "Ugyldig annonce link URL"); - if (adLinkError is not null) return adLinkError.Value; + if (adLinkError is not null) + { + return adLinkError.Value; + } var colorError = ValidateHexColor(request.AdLabelColor); - if (colorError is not null) return colorError.Value; + if (colorError is not null) + { + return colorError.Value; + } await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var partner = await context.Partners.FirstOrDefaultAsync(s => s.Id == partnerId, cancellationToken); diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index 6d25223ff..80a0c9ffc 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -107,7 +107,9 @@ else - @@ -174,19 +176,15 @@ else protected override async Task OnInitializedAsync() { - try - { - _entries = await AdminService.LoadAsync(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to load HJEM group entries."); - Snackbar.Add("Kunne ikke hente HJEM grupper.", Severity.Error); - } - finally - { - _isLoading = false; - } + var result = await AdminService.LoadAsync(); + result.Switch( + entries => _entries = entries, + error => + { + Logger.LogError("Failed to load HJEM group entries: {Error}", error.Message); + Snackbar.Add("Kunne ikke hente HJEM grupper.", Severity.Error); + }); + _isLoading = false; } private void AddEntry() @@ -253,16 +251,33 @@ else private void CommitEdit() { - if (string.IsNullOrWhiteSpace(_form.Name) || string.IsNullOrWhiteSpace(_form.WebsiteUrl)) + if (string.IsNullOrWhiteSpace(_form.Name)) { - Snackbar.Add("Navn og URL er påkrævet.", Severity.Warning); + Snackbar.Add("Navn er påkrævet.", Severity.Warning); return; } - if (!Uri.TryCreate(_form.WebsiteUrl, UriKind.Absolute, out var uri)) + Uri uri; + var websiteUrlStr = _form.WebsiteUrl?.Trim(); + if (_form.Type == HjemGroupType.Lokalrepresentant && string.IsNullOrWhiteSpace(websiteUrlStr)) { - Snackbar.Add("URL er ikke gyldig.", Severity.Warning); - return; + uri = new Uri("https://www.hjemlo.dk/lokalrepraesentanter"); + } + else + { + if (string.IsNullOrWhiteSpace(websiteUrlStr)) + { + Snackbar.Add("URL er påkrævet.", Severity.Warning); + return; + } + + if (!Uri.TryCreate(websiteUrlStr, UriKind.Absolute, out var parsedUri)) + { + Snackbar.Add("URL er ikke gyldig.", Severity.Warning); + return; + } + + uri = parsedUri; } if (_form.Latitude is < -90 or > 90 || _form.Longitude is < -180 or > 180 || diff --git a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor index 9b888551f..172ed207d 100644 --- a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor +++ b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor @@ -31,7 +31,8 @@ protected override async Task OnInitializedAsync() { - _hjemMarkers = await HjemGroupProvider.GetMarkersAsync(); + var markersResult = await HjemGroupProvider.GetMarkersAsync(); + _hjemMarkers = markersResult.IsT0 ? markersResult.AsT0 : []; // Restore from cache if available if (Cache.SearchFilter is not null && Cache.SearchResult is not null) diff --git a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js index 2f0d85128..c7c9c5227 100644 --- a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js +++ b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js @@ -359,7 +359,8 @@ window.leafletInterop = { if (group.websiteUrl) { try { const parsedGroupUrl = new URL(group.websiteUrl); - isHjemUrl = parsedGroupUrl.hostname === "www.hjemlo.dk" || parsedGroupUrl.hostname === "hjemlo.dk"; + isHjemUrl = (parsedGroupUrl.protocol === "http:" || parsedGroupUrl.protocol === "https:") && + (parsedGroupUrl.hostname === "www.hjemlo.dk" || parsedGroupUrl.hostname === "hjemlo.dk"); } catch (e) { // Invalid URL — treat as not a HJEM URL } diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index b20d19002..80ffb2124 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; +using OneOf; using Refit; using System.Net; using System.Text; @@ -54,18 +55,18 @@ private HjemGroupAdminService CreateSut() => // ------------------------------------------------------------------------- [Fact] - public async Task LoadAsync_ReturnsEmpty_WhenContainerDoesNotExist() + public async Task LoadAsync_ReturnsError_WhenContainerDoesNotExist() { _containerClient.ExistsAsync(Arg.Any()) .Returns(Response.FromValue(false, Substitute.For())); var result = await CreateSut().LoadAsync(); - result.Should().BeEmpty(); + result.IsT1.Should().BeTrue(); } [Fact] - public async Task LoadAsync_ReturnsEmpty_WhenBlobDoesNotExist() + public async Task LoadAsync_ReturnsError_WhenBlobDoesNotExist() { _containerClient.ExistsAsync(Arg.Any()) .Returns(Response.FromValue(true, Substitute.For())); @@ -74,7 +75,7 @@ public async Task LoadAsync_ReturnsEmpty_WhenBlobDoesNotExist() var result = await CreateSut().LoadAsync(); - result.Should().BeEmpty(); + result.IsT1.Should().BeTrue(); } [Fact] @@ -84,7 +85,8 @@ public async Task LoadAsync_ReturnsEmpty_WhenBlobContainsEmptyArray() var result = await CreateSut().LoadAsync(); - result.Should().BeEmpty(); + result.IsT0.Should().BeTrue(); + result.AsT0.Should().BeEmpty(); } [Fact] @@ -99,13 +101,14 @@ public async Task LoadAsync_ReturnsDeserializedEntries() var result = await CreateSut().LoadAsync(); - result.Should().HaveCount(2); - result[0].Name.Should().Be("Randers"); - result[1].Name.Should().Be("Odense"); + result.IsT0.Should().BeTrue(); + result.AsT0.Should().HaveCount(2); + result.AsT0[0].Name.Should().Be("Randers"); + result.AsT0[1].Name.Should().Be("Odense"); } [Fact] - public async Task LoadAsync_ReturnsEmpty_WhenBlobThrows() + public async Task LoadAsync_ReturnsError_WhenBlobThrows() { _containerClient.ExistsAsync(Arg.Any()) .Returns(Response.FromValue(true, Substitute.For())); @@ -116,18 +119,17 @@ public async Task LoadAsync_ReturnsEmpty_WhenBlobThrows() var result = await CreateSut().LoadAsync(); - result.Should().BeEmpty(); - _logger.ReceivedWithAnyArgs(1).Log(LogLevel.Warning, default, default!, default, default!); + result.IsT1.Should().BeTrue(); } [Fact] - public async Task LoadAsync_ReturnsEmpty_WhenBlobContainsInvalidJson() + public async Task LoadAsync_ReturnsError_WhenBlobContainsInvalidJson() { SetupBlobWithContent("this is not valid json {{{"); var result = await CreateSut().LoadAsync(); - result.Should().BeEmpty(); + result.IsT1.Should().BeTrue(); } // ------------------------------------------------------------------------- diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs index fc057e82b..a4ea01cbb 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ExceptionExtensions; +using OneOf; using System.Text; using System.Text.Json; using Xunit; @@ -40,7 +41,7 @@ public HjemGroupProviderTests() } [Fact] - public async Task GetMarkersAsync_ReturnsEmpty_WhenContainerDoesNotExist() + public async Task GetMarkersAsync_ReturnsError_WhenContainerDoesNotExist() { // Arrange _containerClient.ExistsAsync(Arg.Any()) @@ -52,11 +53,11 @@ public async Task GetMarkersAsync_ReturnsEmpty_WhenContainerDoesNotExist() var result = await sut.GetMarkersAsync(); // Assert - result.Should().BeEmpty(); + result.IsT1.Should().BeTrue(); } [Fact] - public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobDoesNotExist() + public async Task GetMarkersAsync_ReturnsError_WhenBlobDoesNotExist() { // Arrange _containerClient.ExistsAsync(Arg.Any()) @@ -70,7 +71,7 @@ public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobDoesNotExist() var result = await sut.GetMarkersAsync(); // Assert - result.Should().BeEmpty(); + result.IsT1.Should().BeTrue(); } [Fact] @@ -84,7 +85,8 @@ public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobIsEmpty() var result = await sut.GetMarkersAsync(); // Assert - result.Should().BeEmpty(); + result.IsT0.Should().BeTrue(); + result.AsT0.Should().BeEmpty(); } [Fact] @@ -111,8 +113,9 @@ public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalafdeling() var result = await sut.GetMarkersAsync(); // Assert - result.Should().HaveCount(1); - var marker = result[0]; + result.IsT0.Should().BeTrue(); + result.AsT0.Should().HaveCount(1); + var marker = result.AsT0[0]; marker.Name.Should().Be("Randers"); marker.WebsiteUrl.Should().Be("https://www.hjemlo.dk/randers"); marker.City.Should().Be("Randers"); @@ -148,8 +151,9 @@ public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalrepresentant() var result = await sut.GetMarkersAsync(); // Assert - result.Should().HaveCount(1); - result[0].ShortDescription.Should().Be("HJEM lokalrepræsentant"); + result.IsT0.Should().BeTrue(); + result.AsT0.Should().HaveCount(1); + result.AsT0[0].ShortDescription.Should().Be("HJEM lokalrepræsentant"); } [Fact] @@ -178,8 +182,10 @@ public async Task GetMarkersAsync_ProducesStableId_ForSameEntry() var result2 = await sut2.GetMarkersAsync(); // Assert: same input always produces same Guid - result1[0].Id.Should().Be(result2[0].Id); - result1[0].Id.Should().NotBe(Guid.Empty); + result1.IsT0.Should().BeTrue(); + result2.IsT0.Should().BeTrue(); + result1.AsT0[0].Id.Should().Be(result2.AsT0[0].Id); + result1.AsT0[0].Id.Should().NotBe(Guid.Empty); } [Fact] @@ -211,7 +217,7 @@ public async Task GetMarkersAsync_IsCached_WithinSameInstance() } [Fact] - public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobThrows() + public async Task GetMarkersAsync_ReturnsError_WhenBlobThrows() { // Arrange _containerClient.ExistsAsync(Arg.Any()) @@ -227,7 +233,7 @@ public async Task GetMarkersAsync_ReturnsEmpty_WhenBlobThrows() var result = await sut.GetMarkersAsync(); // Assert - result.Should().BeEmpty(); + result.IsT1.Should().BeTrue(); } [Fact] @@ -252,8 +258,9 @@ public async Task GetMarkersAsync_ReturnsAllEntries_WhenMultipleEntriesPresent() var result = await sut.GetMarkersAsync(); // Assert - result.Should().HaveCount(5); - result.Select(m => m.Name).Should().BeEquivalentTo(entries.Select(e => e.Name)); + result.IsT0.Should().BeTrue(); + result.AsT0.Should().HaveCount(5); + result.AsT0.Select(m => m.Name).Should().BeEquivalentTo(entries.Select(e => e.Name)); } [Fact] @@ -272,7 +279,8 @@ public async Task GetMarkersAsync_ProducesDifferentIds_ForDifferentEntries() var result = await sut.GetMarkersAsync(); // Assert - result[0].Id.Should().NotBe(result[1].Id); + result.IsT0.Should().BeTrue(); + result.AsT0[0].Id.Should().NotBe(result.AsT0[1].Id); } // Helper: wires up blob container + blob to return the given JSON string From 0dc7aa00dc2ee2990e841c9b883c46e9c5cb19bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Mon, 23 Feb 2026 22:43:55 +0100 Subject: [PATCH 20/23] review fixes --- .../Features/HjemGroups/HjemGroupProvider.cs | 26 ++++++++--- .../WebApplicationBuilderExtensions.cs | 2 +- .../Pages/GroupSearch/GroupSearch.razor | 11 ++++- .../Jordnaer/wwwroot/css/marker-cluster.css | 16 +++++++ .../Jordnaer/wwwroot/js/leaflet-interop.js | 28 +++++++----- tasks/13-cheaper-infrastructure.md | 45 +++++++++++++------ .../HjemGroups/HjemGroupAdminServiceTests.cs | 31 ++++++++++--- .../UserSearch/DataForsyningenClientTests.cs | 1 + 8 files changed, 120 insertions(+), 40 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index dc622ccca..0a59c29bc 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -31,11 +31,25 @@ public class HjemGroupProvider( public async Task, Error>> GetMarkersAsync(CancellationToken cancellationToken = default) { - return await fusionCache.GetOrSetAsync, Error>>( - CacheKey, - (_, innerToken) => LoadFromBlobAsync(innerToken), - tags: [CacheTag], - token: cancellationToken); + try + { + var markers = await fusionCache.GetOrSetAsync>( + CacheKey, + async (_, innerToken) => + { + var result = await LoadFromBlobAsync(innerToken); + return result.IsT0 + ? result.AsT0 + : throw new InvalidOperationException("Failed to load HJEM group markers from blob storage."); + }, + tags: [CacheTag], + token: cancellationToken); + return OneOf, Error>.FromT0(markers); + } + catch (Exception) + { + return new Error(); + } } private async Task, Error>> LoadFromBlobAsync(CancellationToken cancellationToken) @@ -71,7 +85,7 @@ private async Task, Error>> LoadFromBlobAsy } catch (Exception ex) { - logger.LogWarning(ex, "Failed to load HJEM group markers from blob storage."); + logger.LogError(ex, "Failed to load HJEM group markers from blob storage."); return new Error(); } } diff --git a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs index fdf8a2c6c..6ab6be65d 100644 --- a/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/HjemGroups/WebApplicationBuilderExtensions.cs @@ -4,7 +4,7 @@ public static class WebApplicationBuilderExtensions { public static WebApplicationBuilder AddHjemGroupServices(this WebApplicationBuilder builder) { - builder.Services.AddSingleton(); + builder.Services.AddScoped(); builder.Services.AddScoped(); return builder; diff --git a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor index 172ed207d..6e3723f7e 100644 --- a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor +++ b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor @@ -31,8 +31,15 @@ protected override async Task OnInitializedAsync() { - var markersResult = await HjemGroupProvider.GetMarkersAsync(); - _hjemMarkers = markersResult.IsT0 ? markersResult.AsT0 : []; + try + { + var markersResult = await HjemGroupProvider.GetMarkersAsync(); + _hjemMarkers = markersResult.IsT0 ? markersResult.AsT0 : []; + } + catch + { + _hjemMarkers = []; + } // Restore from cache if available if (Cache.SearchFilter is not null && Cache.SearchResult is not null) diff --git a/src/web/Jordnaer/wwwroot/css/marker-cluster.css b/src/web/Jordnaer/wwwroot/css/marker-cluster.css index d90379a52..07377df3a 100644 --- a/src/web/Jordnaer/wwwroot/css/marker-cluster.css +++ b/src/web/Jordnaer/wwwroot/css/marker-cluster.css @@ -111,6 +111,14 @@ } .group-marker-icon .group-marker-initial { + /* Fallback for browsers without :has() support — provide own background */ + background-color: var(--map-primary); + border-radius: 50%; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; color: var(--neutral-white); font-size: 14px; font-weight: 600; @@ -170,6 +178,14 @@ } .group-popup-avatar .group-initial { + /* Fallback for browsers without :has() support — provide own background */ + background-color: var(--map-primary); + border-radius: 50%; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; color: var(--neutral-white); font-size: 20px; font-weight: 600; diff --git a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js index c7c9c5227..718b71900 100644 --- a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js +++ b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js @@ -335,7 +335,8 @@ window.leafletInterop = { let isValidUrl = false; try { const parsedUrl = new URL(group.websiteUrl); - isValidUrl = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:"; + isValidUrl = + parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:"; } catch (e) { // Invalid URL, skip rendering isValidUrl = false; @@ -347,7 +348,7 @@ window.leafletInterop = { - ${this.escapeHtml(group.websiteUrl)} + ${this.escapeHtml(group.websiteUrl)}
`; } @@ -359,15 +360,21 @@ window.leafletInterop = { if (group.websiteUrl) { try { const parsedGroupUrl = new URL(group.websiteUrl); - isHjemUrl = (parsedGroupUrl.protocol === "http:" || parsedGroupUrl.protocol === "https:") && - (parsedGroupUrl.hostname === "www.hjemlo.dk" || parsedGroupUrl.hostname === "hjemlo.dk"); + isHjemUrl = + (parsedGroupUrl.protocol === "http:" || + parsedGroupUrl.protocol === "https:") && + (parsedGroupUrl.hostname === "www.hjemlo.dk" || + parsedGroupUrl.hostname === "hjemlo.dk"); } catch (e) { // Invalid URL — treat as not a HJEM URL } } - const groupUrl = isHjemUrl ? group.websiteUrl : `/groups/${encodeURIComponent(group.name)}`; - const groupUrlTarget = isHjemUrl ? ' target="_blank" rel="noopener"' : ''; + const safeName = group.name != null ? encodeURIComponent(group.name) : ""; + const groupUrl = isHjemUrl ? group.websiteUrl : `/groups/${safeName}`; + const groupUrlTarget = isHjemUrl + ? ' target="_blank" rel="noopener noreferrer"' + : ""; return `
@@ -504,13 +511,13 @@ window.leafletInterop = { // Detect and offset duplicate coordinates so each marker is individually clickable const coordKey = (lat, lng) => `${lat.toFixed(5)},${lng.toFixed(5)}`; const byCoord = {}; - allMarkers.forEach(m => { + allMarkers.forEach((m) => { const ll = m.getLatLng(); const key = coordKey(ll.lat, ll.lng); (byCoord[key] = byCoord[key] || []).push(m); }); - Object.values(byCoord).forEach(bucket => { + Object.values(byCoord).forEach((bucket) => { if (bucket.length <= 1) return; const offsetMeters = 25; const angleStep = (2 * Math.PI) / bucket.length; @@ -519,8 +526,9 @@ window.leafletInterop = { const ll = marker.getLatLng(); const angle = i * angleStep; const dLat = (offsetMeters / 111320) * Math.cos(angle); - const dLng = (offsetMeters / - (111320 * Math.cos(ll.lat * Math.PI / 180))) * Math.sin(angle); + const dLng = + (offsetMeters / (111320 * Math.cos((ll.lat * Math.PI) / 180))) * + Math.sin(angle); marker.setLatLng([ll.lat + dLat, ll.lng + dLng]); }); }); diff --git a/tasks/13-cheaper-infrastructure.md b/tasks/13-cheaper-infrastructure.md index 00af58483..50a8e734b 100644 --- a/tasks/13-cheaper-infrastructure.md +++ b/tasks/13-cheaper-infrastructure.md @@ -2,7 +2,7 @@ - azure web app (container) (20$/month) 4gb ram poor cpu - azure sql (5$/month) -- storage storage (blob storage, close to free) +- azure blob storage (close to free) - email communication service (free) # requirements @@ -46,20 +46,20 @@ ### recommended setup -**Hetzner Cloud CX22 ARM** (Falkenstein, Germany or Helsinki, Finland) +**Hetzner Cloud CAX11 ARM** (Falkenstein, Germany or Helsinki, Finland) | spec | value | |---|---| -| vCPU | 2 (Ampere ARM) | +| vCPU | 2 (Ampere ARM64) | | RAM | 4 GB | | Disk | 40 GB NVMe | | Network | 20 TB/month | | Price | €3.79/month (~$4.20) | | Location | EU (GDPR compliant) | -The CX22 matches the current Azure plan's 4 GB RAM but has significantly better CPU. Since the database stays in Azure SQL, the VPS only runs the app container — 4 GB is comfortable. Upgrade to CX32 (4 vCPU, 8 GB, €7.49/mo) later if needed. +The CAX11 matches the current Azure plan's 4 GB RAM but has significantly better CPU. Since the database stays in Azure SQL, the VPS only runs the app container — 4 GB is comfortable. Upgrade to CX32 (4 vCPU, 8 GB, €6.80/mo) later if needed. -ARM binaries: .NET 10 has first-class ARM64 support. The official `mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0` image builds and runs fine on ARM64. The existing Dockerfile works as-is (Docker BuildKit handles cross-compilation on GitHub Actions via `--platform linux/arm64`). +ARM binaries: .NET 10 has first-class ARM64 support. The official `mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0` image builds and runs fine on ARM64. The existing Dockerfile works as-is, but the CD workflow's `docker/build-push-action` step needs `platforms: linux/arm64` added to produce an ARM64 image (see risk table below). **Keep Azure SQL** — $5/month, zero migration effort, no risk. @@ -147,12 +147,26 @@ Current: ~$25/month → New: ~$10-11/month. **Savings: ~$14/month, ~56% reductio Safe: by default this only installs `security` and `security-updates` packages, never major upgrades. Auto-reboot is off unless you uncomment `Unattended-Upgrade::Automatic-Reboot "true"` in `/etc/apt/apt.conf.d/50unattended-upgrades` (kernel patches need a reboot to take effect, so worth enabling at 3am). 4. Install Dokploy: ```bash - curl -sSL https://dokploy.com/install.sh | sh + # Download the installer first, verify it, then execute + curl -sSL https://dokploy.com/install.sh -o dokploy-install.sh + # Verify SHA256 checksum matches the value published on https://dokploy.com/docs/get-started/installation + sha256sum dokploy-install.sh + # Inspect the script before running + less dokploy-install.sh + sh dokploy-install.sh ``` 5. Access Dokploy at `http://:3000`, create admin account ### step 2: configure the app service in Dokploy +**Prerequisite — GHCR registry credentials in Dokploy:** +Before creating the service, go to Dokploy → **Registry** and add a custom registry: +- Registry URL: `ghcr.io` +- Username: your GitHub username +- Password: a GitHub Personal Access Token (PAT) with `read:packages` scope + +This is required so Dokploy can pull the image even if the GHCR package visibility is set to private. Do this before the first deployment attempt. + Create a new project with one service: **Jordnaer app** @@ -181,13 +195,16 @@ In the current [website_cd.yml](.github/workflows/website_cd.yml), replace the A images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production # With this: -- name: Deploy via Dokploy webhook +- name: Deploy via Dokploy API run: | - curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}" \ - -H "Authorization: Bearer ${{ secrets.DOKPLOY_API_KEY }}" + curl --fail --show-error --silent \ + -X POST "https:///api/application.deploy" \ + -H "x-api-key: ${{ secrets.DOKPLOY_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{"applicationId":"${{ secrets.DOKPLOY_APPLICATION_ID }}"}' ``` -Add `DOKPLOY_WEBHOOK_URL` and `DOKPLOY_API_KEY` to GitHub repository secrets. Remove `AZURE_CREDENTIALS` secret after decommission. +Add `DOKPLOY_API_KEY` and `DOKPLOY_APPLICATION_ID` to GitHub repository secrets. Remove `AZURE_CREDENTIALS` secret after decommission. ### step 4: DNS cutover @@ -211,13 +228,13 @@ After 48 hours of stable operation: | risk | likelihood | mitigation | |---|---|---| -| App memory pressure on VPS | low | CX22 has 4 GB — same as current Azure plan but better CPU. Upgrade to CX32 (8 GB, €7.49/mo) if needed | -| SignalR circuit drops during deploy | mitigated | Rolling deploy: new container must pass `/alive` health check before old gets SIGTERM. Old container then drains existing circuits for 30s (configured via `HostOptions.ShutdownTimeout`). `boot.js` retries every 5s; if `Blazor.reconnect()` returns false, page reloads — user is on new container, auth cookie intact. In Dokploy: set deployment strategy to **Rolling** | +| App memory pressure on VPS | low | CX22 has 4 GB — same as current Azure plan but better CPU. Upgrade to CX32 (8 GB, €6.80/mo) if needed | +| SignalR circuit drops during deploy | mitigated | Rolling deploy requires Docker Swarm mode. **Step 2 sub-steps:** (1) On the server run `docker swarm init` to enable Swarm. (2) In Dokploy → Advanced → Cluster Settings → Swarm Settings, paste the following update_config and health-check JSON (adjust port if needed): `{"updateConfig":{"parallelism":1,"delay":"10s","order":"start-first"},"healthCheck":{"test":["CMD","curl","-f","http://localhost:8080/alive"],"interval":"10s","timeout":"5s","retries":3,"startPeriod":"30s"}}`. The new container must pass `/alive` before the old receives SIGTERM. The old container drains existing SignalR circuits for 30 s — this aligns with `HostOptions.ShutdownTimeout = TimeSpan.FromSeconds(30)` already set in `Program.cs`. `boot.js` retries every 5 s; if `Blazor.reconnect()` returns false, the page reloads and the user lands on the new container with the auth cookie intact. | | ARM compatibility issue with .NET app | low | .NET 10 has first-class ARM64 support. The existing Dockerfile works as-is | | OAuth callback URLs break | low | Domain stays the same (mini-moeder.dk) so OAuth redirect URIs don't change | | Let's Encrypt rate limit | low | Keep port 80 open so ACME HTTP-01 challenge works | | VPS outage (no redundancy) | low/medium | Hetzner SLA is 99.9%. Same single-point-of-failure model as current Azure Web App | -| Docker image ARM build | low | Add `--platform linux/arm64` to docker build step in CD workflow. Or skip ARM and use Hetzner CX22 x86 (€4.49/mo, negligible cost difference) | +| Docker image ARM build | low | Add `platforms: linux/arm64` to the `docker/build-push-action` step in `website_cd.yml`. Or skip ARM and use Hetzner CX22 x86 (€4.49/mo, negligible cost difference) | | Azure SQL latency from Hetzner | low | Azure SQL is in West Europe; Hetzner Helsinki/Falkenstein are both low latency to that region. Same as today since the app already calls out to Azure SQL over the internet | ### safest rollback path @@ -231,7 +248,7 @@ Azure Web App stays running until the cutover is confirmed stable. If something | option | verdict | |---|---| | **Scaleway** | More expensive than Hetzner for equivalent specs. DEV1-S (2 vCPU, 2 GB) is ~€8/mo for less RAM. Hetzner wins on price/performance | -| **Hetzner CX22 x86** | €4.49/mo vs €3.79/mo for ARM. Avoids any ARM build concern. Either works; ARM is cheaper and .NET 10 supports it well | +| **Hetzner CX22 x86** | €4.49/mo vs €3.79/mo for CAX11 ARM. Avoids any ARM build concern. Either works; ARM is cheaper and .NET 10 supports it well | | **Migrate from SQL Server to PostgreSQL** | Would eliminate the $5/month Azure SQL cost. But requires rewriting all EF Core migrations, switching providers, and retesting spatial queries. Not worth it | | **Switch blob storage to Hetzner Object Storage** | €3/month vs ~$1/month Azure. Costs more and requires code changes in ImageService + data protection configuration. Skip | | **Coolify** (alternative to Dokploy) | Also open source and mature. More feature-rich but heavier. Either works; Dokploy is simpler for this use case | diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs index 80ffb2124..d05afe0f9 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupAdminServiceTests.cs @@ -224,9 +224,10 @@ public async Task SaveAsync_CanRoundtrip_EmptyList() [Fact] public async Task GeocodeAsync_ReturnsNull_WhenApiReturnsNoResults() { + using var response = MakeGeoResponse(); _geocoder .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(MakeGeoResponse()); + .Returns(response.ApiResponse); var result = await CreateSut().GeocodeAsync("UnknownPlace"); @@ -250,9 +251,10 @@ public async Task GeocodeAsync_ReturnsNull_WhenApiCallFails() [Fact] public async Task GeocodeAsync_ReturnsCityAndCoordinates_WhenApiSucceeds() { + using var response = MakeGeoResponse(MakeZip("Randers", 8900, lat: 56.46, lng: 10.03)); _geocoder .SearchZipCodesAsync("Randers", Arg.Any()) - .Returns(MakeGeoResponse(MakeZip("Randers", 8900, lat: 56.46, lng: 10.03))); + .Returns(response.ApiResponse); var result = await CreateSut().GeocodeAsync("Randers"); @@ -266,9 +268,10 @@ public async Task GeocodeAsync_ReturnsCityAndCoordinates_WhenApiSucceeds() [Fact] public async Task GeocodeAsync_ParsesZipCode_FromStringNr() { + using var response = MakeGeoResponse(MakeZip("København", 1000, lat: 55.67, lng: 12.57)); _geocoder .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(MakeGeoResponse(MakeZip("København", 1000, lat: 55.67, lng: 12.57))); + .Returns(response.ApiResponse); var result = await CreateSut().GeocodeAsync("København"); @@ -285,9 +288,10 @@ public async Task GeocodeAsync_ReturnsNullZipCode_WhenNrIsNotParseable() Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, Geo_Version: 1, Dagi_Id: null); + using var response = MakeGeoResponse(zip); _geocoder .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(MakeGeoResponse(zip)); + .Returns(response.ApiResponse); var result = await CreateSut().GeocodeAsync("SomeCity"); @@ -298,9 +302,10 @@ public async Task GeocodeAsync_ReturnsNullZipCode_WhenNrIsNotParseable() [Fact] public async Task GeocodeAsync_PassesQueryDirectlyToClient() { + using var response = MakeGeoResponse(); _geocoder .SearchZipCodesAsync(Arg.Any(), Arg.Any()) - .Returns(MakeGeoResponse()); + .Returns(response.ApiResponse); await CreateSut().GeocodeAsync("Silkeborg"); @@ -329,10 +334,22 @@ private static ZipCodeSearchResponse MakeZip(string navn, int nr, double lat, do Kommuner: null, Ændret: DateTime.UtcNow, Geo_Ændret: DateTime.UtcNow, Geo_Version: 1, Dagi_Id: null); - private static ApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) + private static DisposableApiResponse> MakeGeoResponse(params ZipCodeSearchResponse[] results) { var httpResponse = new HttpResponseMessage(HttpStatusCode.OK); - return new ApiResponse>(httpResponse, results, new RefitSettings()); + var apiResponse = new ApiResponse>(httpResponse, results, new RefitSettings()); + return new DisposableApiResponse>(apiResponse, httpResponse); + } + + private sealed class DisposableApiResponse(ApiResponse apiResponse, HttpResponseMessage httpResponse) : IDisposable + { + public ApiResponse ApiResponse { get; } = apiResponse; + + public void Dispose() + { + apiResponse.Dispose(); + httpResponse.Dispose(); + } } private void SetupBlobWithContent(string json) diff --git a/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs b/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs index df7590d1d..376e55415 100644 --- a/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs +++ b/tests/web/Jordnaer.Tests/UserSearch/DataForsyningenClientTests.cs @@ -112,6 +112,7 @@ public async Task SearchZipCodesAsync_VisueltcenterIsLngLatOrder() // Assert response.IsSuccessful.Should().BeTrue(); response.Content.Should().NotBeNull(); + response.Content.Should().HaveCountGreaterThan(0); var first = response.Content!.First(); first.Visueltcenter.Should().HaveCountGreaterOrEqualTo(2); From 6229e8ab6ff113701836cf6de8d69ee4f3bc3f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Wed, 25 Feb 2026 20:36:56 +0100 Subject: [PATCH 21/23] review comments --- .../HjemGroups/HjemGroupAdminService.cs | 32 +++++++++++++------ .../Backoffice/HjemGroupManagementPage.razor | 31 ++++++++++++------ .../HjemGroups/HjemGroupProviderTests.cs | 14 ++++---- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs index 82ef1edb5..73d10f34c 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupAdminService.cs @@ -3,6 +3,7 @@ using Jordnaer.Shared; using MassTransit; using OneOf; +using OneOf.Types; using System.Text; using System.Text.Json; @@ -50,20 +51,30 @@ public async Task, HjemGroupLoadError>> LoadAsync(Can } } - public async Task SaveAsync(List entries, CancellationToken cancellationToken = default) + public async Task> SaveAsync(List entries, CancellationToken cancellationToken = default) { - var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); - await containerClient.CreateIfNotExistsAsync(PublicAccessType.None, cancellationToken: cancellationToken); + try + { + var containerClient = blobServiceClient.GetBlobContainerClient(ContainerName); + await containerClient.CreateIfNotExistsAsync(PublicAccessType.None, cancellationToken: cancellationToken); + + var blobClient = containerClient.GetBlobClient(BlobName); + var json = JsonSerializer.Serialize(entries, JsonOptions); - var blobClient = containerClient.GetBlobClient(BlobName); - var json = JsonSerializer.Serialize(entries, JsonOptions); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); - using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - await blobClient.UploadAsync(stream, overwrite: true, cancellationToken: cancellationToken); + await publishEndpoint.Publish( + new InvalidateCacheTags { Tags = [HjemGroupProvider.CacheTag] }, + cancellationToken); - await publishEndpoint.Publish( - new InvalidateCacheTags { Tags = [HjemGroupProvider.CacheTag] }, - cancellationToken); + return new Success(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to save HJEM group entries."); + return new HjemGroupSaveError(ex.Message); + } } /// @@ -102,3 +113,4 @@ public sealed record GeocodeResult(string City, int? ZipCode, double Latitude, d } public sealed record HjemGroupLoadError(string Message); +public sealed record HjemGroupSaveError(string Message); diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index 80a0c9ffc..eb52d888a 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -5,6 +5,7 @@ @inject HjemGroupAdminService AdminService @inject ISnackbar Snackbar +@inject IDialogService DialogService @inject ILogger Logger @attribute [Authorize(Policy = AuthorizationPolicies.AdminOnly)] @@ -86,7 +87,7 @@ else + OnClick="@(async () => await DeleteEntryAsync(context))" /> @@ -313,24 +314,36 @@ else _dialogVisible = false; } - private void DeleteEntry(HjemGroupEntry entry) => _entries.Remove(entry); + private async Task DeleteEntryAsync(HjemGroupEntry entry) + { + bool? confirmed = await DialogService.ShowMessageBox( + "Bekræft sletning", + $"Er du sikker på, at du vil slette \"{entry.Name}\"?", + yesText: "Slet", cancelText: "Annuller"); + if (confirmed == true) + _entries.Remove(entry); + } private async Task SaveAsync() { + if (_isSaving) return; _isSaving = true; + StateHasChanged(); try { - await AdminService.SaveAsync(_entries); - Snackbar.Add($"{_entries.Count} HJEM grupper gemt.", Severity.Success); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to save HJEM group entries."); - Snackbar.Add("Gem fejlede. Prøv igen.", Severity.Error); + var result = await AdminService.SaveAsync(_entries); + result.Switch( + _ => Snackbar.Add($"{_entries.Count} HJEM grupper gemt.", Severity.Success), + error => + { + Logger.LogError("Failed to save HJEM group entries: {Error}", error.Message); + Snackbar.Add("Gem fejlede. Prøv igen.", Severity.Error); + }); } finally { _isSaving = false; + StateHasChanged(); } } diff --git a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs index a4ea01cbb..593390f2a 100644 --- a/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs +++ b/tests/web/Jordnaer.Tests/HjemGroups/HjemGroupProviderTests.cs @@ -23,6 +23,8 @@ public class HjemGroupProviderTests private readonly BlobClient _blobClient = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); + private static readonly JsonSerializerOptions CamelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private static IFusionCache CreateFusionCache() => new FusionCache(new FusionCacheOptions(), new MemoryCache(new MemoryCacheOptions())); @@ -106,7 +108,7 @@ public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalafdeling() Type = HjemGroupType.Lokalafdeling, } }; - SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + SetupBlobWithContent(JsonSerializer.Serialize(entries, CamelCaseOptions)); var sut = CreateSut(); // Act @@ -144,7 +146,7 @@ public async Task GetMarkersAsync_ReturnsMappedMarkers_ForLokalrepresentant() Type = HjemGroupType.Lokalrepresentant, } }; - SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + SetupBlobWithContent(JsonSerializer.Serialize(entries, CamelCaseOptions)); var sut = CreateSut(); // Act @@ -170,7 +172,7 @@ public async Task GetMarkersAsync_ProducesStableId_ForSameEntry() Longitude = 10.20, Type = HjemGroupType.Lokalafdeling, }; - var json = JsonSerializer.Serialize(new[] { entry }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + var json = JsonSerializer.Serialize(new[] { entry }, CamelCaseOptions); // Call GetMarkersAsync twice with two separate provider instances SetupBlobWithContent(json); @@ -204,7 +206,7 @@ public async Task GetMarkersAsync_IsCached_WithinSameInstance() Longitude = 10.38, Type = HjemGroupType.Lokalafdeling, } - }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + }, CamelCaseOptions)); var sut = CreateSut(); @@ -251,7 +253,7 @@ public async Task GetMarkersAsync_ReturnsAllEntries_WhenMultipleEntriesPresent() Type = i % 2 == 0 ? HjemGroupType.Lokalrepresentant : HjemGroupType.Lokalafdeling, }).ToArray(); - SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + SetupBlobWithContent(JsonSerializer.Serialize(entries, CamelCaseOptions)); var sut = CreateSut(); // Act @@ -272,7 +274,7 @@ public async Task GetMarkersAsync_ProducesDifferentIds_ForDifferentEntries() new HjemGroupEntry { Name = "A", WebsiteUrl = new Uri("https://www.hjemlo.dk/a"), Latitude = 55, Longitude = 10, Type = HjemGroupType.Lokalafdeling }, new HjemGroupEntry { Name = "B", WebsiteUrl = new Uri("https://www.hjemlo.dk/b"), Latitude = 56, Longitude = 11, Type = HjemGroupType.Lokalafdeling }, }; - SetupBlobWithContent(JsonSerializer.Serialize(entries, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + SetupBlobWithContent(JsonSerializer.Serialize(entries, CamelCaseOptions)); var sut = CreateSut(); // Act From a2a3e3f7bde310a89ee89f0c77faffc31b52ba80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Wed, 25 Feb 2026 20:40:54 +0100 Subject: [PATCH 22/23] review comments --- src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs | 4 ++++ .../Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor | 3 ++- tasks/13-cheaper-infrastructure.md | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index 0a59c29bc..8115f8bb8 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -46,6 +46,10 @@ public async Task, Error>> GetMarkersAsync( token: cancellationToken); return OneOf, Error>.FromT0(markers); } + catch (OperationCanceledException) + { + throw; + } catch (Exception) { return new Error(); diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index eb52d888a..767103ad9 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -76,7 +76,8 @@ else @context.Latitude.ToString("F5") @context.Longitude.ToString("F5") - + @context.WebsiteUrl.Host diff --git a/tasks/13-cheaper-infrastructure.md b/tasks/13-cheaper-infrastructure.md index 50a8e734b..6995605de 100644 --- a/tasks/13-cheaper-infrastructure.md +++ b/tasks/13-cheaper-infrastructure.md @@ -105,7 +105,7 @@ The app currently uses `openTelemetryBuilder.UseGrafana()` in production. Grafan | item | new cost | |---|---| -| Hetzner CX22 ARM (app only) | ~$4.20/month | +| Hetzner CAX11 ARM (app only) | ~$4.20/month | | Azure SQL (keep) | ~$5/month | | Azure Blob Storage (keep) | ~$1/month | | Azure Communication Email (keep) | $0 | @@ -127,7 +127,7 @@ Current: ~$25/month → New: ~$10-11/month. **Savings: ~$14/month, ~56% reductio ### step 1: provision the server -1. Create Hetzner CX22 ARM server (Helsinki or Falkenstein) running Ubuntu 24.04 +1. Create Hetzner CAX11 ARM server (Helsinki or Falkenstein) running Ubuntu 24.04 2. Add firewall rules: allow 22 (SSH), 80 (HTTP), 443 (HTTPS) 3. Set up unattended-upgrades (security patches only, runs at 3am): ```bash @@ -228,7 +228,7 @@ After 48 hours of stable operation: | risk | likelihood | mitigation | |---|---|---| -| App memory pressure on VPS | low | CX22 has 4 GB — same as current Azure plan but better CPU. Upgrade to CX32 (8 GB, €6.80/mo) if needed | +| App memory pressure on VPS | low | CAX11 has 4 GB — same as current Azure plan but better CPU. Upgrade to CX32 (8 GB, €6.80/mo) if needed | | SignalR circuit drops during deploy | mitigated | Rolling deploy requires Docker Swarm mode. **Step 2 sub-steps:** (1) On the server run `docker swarm init` to enable Swarm. (2) In Dokploy → Advanced → Cluster Settings → Swarm Settings, paste the following update_config and health-check JSON (adjust port if needed): `{"updateConfig":{"parallelism":1,"delay":"10s","order":"start-first"},"healthCheck":{"test":["CMD","curl","-f","http://localhost:8080/alive"],"interval":"10s","timeout":"5s","retries":3,"startPeriod":"30s"}}`. The new container must pass `/alive` before the old receives SIGTERM. The old container drains existing SignalR circuits for 30 s — this aligns with `HostOptions.ShutdownTimeout = TimeSpan.FromSeconds(30)` already set in `Program.cs`. `boot.js` retries every 5 s; if `Blazor.reconnect()` returns false, the page reloads and the user lands on the new container with the auth cookie intact. | | ARM compatibility issue with .NET app | low | .NET 10 has first-class ARM64 support. The existing Dockerfile works as-is | | OAuth callback URLs break | low | Domain stays the same (mini-moeder.dk) so OAuth redirect URIs don't change | From 036bc26049a85fa4280690c2bcda3bb50e5693d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Wed, 25 Feb 2026 21:01:32 +0100 Subject: [PATCH 23/23] fixes --- src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs | 4 ++++ .../Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor | 4 +++- tasks/13-cheaper-infrastructure.md | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs index 8115f8bb8..292549e09 100644 --- a/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs +++ b/src/web/Jordnaer/Features/HjemGroups/HjemGroupProvider.cs @@ -87,6 +87,10 @@ private async Task, Error>> LoadFromBlobAsy return entries.Select(MapToMarker).ToList(); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Failed to load HJEM group markers from blob storage."); diff --git a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor index 767103ad9..0a0aab3bb 100644 --- a/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor +++ b/src/web/Jordnaer/Pages/Backoffice/HjemGroupManagementPage.razor @@ -238,7 +238,9 @@ else _form.ZipCode = result.ZipCode; _form.Latitude = result.Latitude; _form.Longitude = result.Longitude; - Snackbar.Add($"Fandt {result.City} ({result.ZipCode})", Severity.Success); + Snackbar.Add(string.IsNullOrWhiteSpace(result.ZipCode?.ToString()) + ? $"Fandt {result.City}" + : $"Fandt {result.City} ({result.ZipCode})", Severity.Success); } catch (Exception ex) { diff --git a/tasks/13-cheaper-infrastructure.md b/tasks/13-cheaper-infrastructure.md index 6995605de..97ab2ac0d 100644 --- a/tasks/13-cheaper-infrastructure.md +++ b/tasks/13-cheaper-infrastructure.md @@ -17,7 +17,7 @@ ## product of task: is it viable? -**Yes, it is viable.** Estimated monthly cost: ~$10-11 (saving ~$14/month, ~56% reduction) with zero code changes required. +**Yes, it is viable.** Estimated monthly cost: ~$10-11 (saving ~$14/month, ~56% reduction) with zero application code changes required. ---