postit: persist settings save and apply ApiUrl changes without restart
All checks were successful
Forgejo Release / release (push) Successful in 11m52s

This commit is contained in:
Paul Schneider 2026-09-06 14:17:40 +01:00
commit bff7cff59f
8 changed files with 84 additions and 30 deletions

View file

@ -39,6 +39,22 @@ public class ActivitiesPageViewModelTests
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[3]);
}
[Fact]
public async Task ActivityApiClient_uses_updated_business_base_without_restart()
{
var api = new StubActivityApi();
var baseUrl = "https://business-a.example/api/v1/";
var client = new ActivityApiClient(api, () => baseUrl);
await client.GetCatalogAsync(ct: TestContext.Current.CancellationToken);
baseUrl = "https://business-b.example/api/v1/";
await client.GetUsersAsync("brush", TestContext.Current.CancellationToken);
Assert.Equal("https://business-a.example/api/v1/activity/catalog", api.Paths[0]);
Assert.Equal("https://business-b.example/api/v1/activity/brush/users", api.Paths[1]);
}
[Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
{

View file

@ -26,9 +26,9 @@ public static class ServiceCollectionHelpers
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(
api,
settings.ApiUrl,
settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, settings.ApiUrl);
() => settings.ApiUrl,
() => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient);
// Vues

View file

@ -61,16 +61,11 @@
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<!-- Sauver: bind to the RelayCommand-generated property
(SaveCommand). IsEnabled tracks IsDirty so the button
auto-disables when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding Save}"
Command="{Binding SaveCommand}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>

View file

@ -5,7 +5,7 @@
},
"RedirectUri": "postit://callback",
"DarkMode": false,
"ApiUrl": "https://blogs.pschneider.fr/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",

View file

@ -5,7 +5,7 @@
},
"RedirectUri": "postit://callback",
"DarkMode": true,
"ApiUrl": "https://blogs.pschneider.fr/api/v1/",
"ApiUrl": "https://api.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",

View file

@ -19,19 +19,29 @@ public sealed class ActivityApiClient
private const string PathPrefix = "activity";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly Uri _avatarBaseAddress;
private readonly Func<string> _businessBaseAddress;
private readonly Func<string?> _avatarBaseAddress;
public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null)
: this(
api,
() => businessBaseAddress,
() => avatarBaseAddress)
{
}
public ActivityApiClient(
IYavscApiClient api,
Func<string> businessBaseAddress,
Func<string?>? avatarBaseAddress = null)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(businessBaseAddress))
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_avatarBaseAddress = avatarBaseAddress ?? (() => null);
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
_avatarBaseAddress = string.IsNullOrWhiteSpace(avatarBaseAddress)
? _baseAddress
: new Uri(avatarBaseAddress, UriKind.Absolute);
// Validate initial values early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
_ = ResolveAvatarBaseAddress();
}
public Task<List<ActivityInfo>> GetCatalogAsync(
@ -65,7 +75,7 @@ public sealed class ActivityApiClient
public string BuildAvatarXsUrl(string? userName)
{
var siteRoot = new Uri(_avatarBaseAddress, "/");
var siteRoot = new Uri(ResolveAvatarBaseAddress(), "/");
if (string.IsNullOrWhiteSpace(userName))
{
@ -75,5 +85,22 @@ public sealed class ActivityApiClient
return new Uri(siteRoot, $"avatars/{Uri.EscapeDataString(userName)}.xs.png").ToString();
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
private Uri ResolveAvatarBaseAddress()
{
var raw = _avatarBaseAddress();
return string.IsNullOrWhiteSpace(raw)
? ResolveBusinessBaseAddress()
: new Uri(raw, UriKind.Absolute);
}
}

View file

@ -20,15 +20,20 @@ public sealed class BillingApiClient
private const string PathPrefix = "billing";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
private readonly Func<string> _businessBaseAddress;
public BillingApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public BillingApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrWhiteSpace(businessBaseAddress))
throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
// Validate initial value early to fail fast on invalid setup.
_ = ResolveBusinessBaseAddress();
}
public Task CreateAsync(string billingCode, object payload, CancellationToken ct = default)
@ -121,7 +126,16 @@ public sealed class BillingApiClient
ct: ct);
}
private string Absolute(string relativePath) => new Uri(_baseAddress, relativePath).ToString();
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
private static BillingQueryDetailsDto MapRdv(RdvQueryResponse dto, string billingCode)
{