Adds a publish-release job that triggers only on tag pushes (refs/tags/v*).
It reuses the APK artifact uploaded by apk-deploy, publishes a GitHub
release via softprops/action-gh-release, and attaches the APK.
Result: a stable permalink to the latest APK at
https://github.com/<owner>/<repo>/releases/latest/download/PostIt.Android.apk
The Yavsc.Org integration tests were failing 'Internal Server Error' on
the OIDC discovery document when run as part of the full test suite.
Root cause: WebHostFixture bound Kestrel to IPAddress.Loopback on a
dynamically-allocated port and exposed it via IServerAddressesFeature.
But the OIDC issuer URLs (and the issuer claim) come from Site:Authority,
which was left at the production value (mercure.pschneider.fr). So
IdentityServer8's discovery document advertised URLs unreachable from
the test process, and the discovery call returned a 500.
Fix:
- WebServerFixture now overrides Site:Authority and Site:ExternalUrl
in AddInMemoryCollection to 'https://localhost:44300' (the ASP.NET
Core dev HTTPS convention).
- WebHostFixture reads Site:Authority from configuration and binds
Kestrel to that fixed URL. The exposed Addresses list is sourced
from the same configuration value instead of the
IServerAddressesFeature, so the listen URL and the OIDC issuer
URLs always match.
Remoting.cs (Mandatory/Remoting.cs): add 'using
Microsoft.Extensions.DependencyInjection;' so the existing OIDC/DB
diagnostic block (capture raw HTTP response + dump OIDC-related DB
state on discovery failure) compiles. The diagnostic itself is left
in place — it's what surfaced the 500 in the first place.
LoadClientAsync(id) on ClientController used to trip an
IndexOutOfRangeException at the InMemory shaper for any
.Include() of one of three Client navs: RedirectUris,
AllowedScopes, AllowedGrantTypes. Five other Client navs (with
the same EF shape and the same application-level config) worked
fine.
Bisection pointed at the InMemory provider; that hypothesis was
wrong. The real cause is in ApplicationDbContext.OnModelCreating:
yavsc was redeclaring the HasOne<Client>().WithMany(...).
HasForeignKey(e => e.ClientId) for all eight Client* navs. The
same relation is already declared (more completely, with
.IsRequired().OnDelete(DeleteBehavior.Cascade)) by
IdentityServer8's ConfigureClientStore via ModelBuilderExtensions.
The redundant mapping on three specific entities — ClientScope,
ClientRedirectUri, ClientGrantType — interacts with the InMemory
provider's shaper in a way that throws IndexOutOfRange. Removing
the redundancy fixes it.
This commit also walks back b12c272d:
- Drops .AsSplitQuery() from LoadClientAsync (no longer needed
for the InMemory shaper, and the Postgres path it was a hedge
against was a false alarm — there is no Postgres production
bug here, only an InMemory shaper quirk surfaced by the
redundant mapping).
- Removes the 9 Bisect_*_alone tests that were the artefact of
the provider-hypothesis phase. They pointed at the right
entities but for the wrong reason.
- Keeps EditRedirectUris_GET_after_add_lists_both_uris as the
end-to-end regression sentinel: with the fix in place, it
loads a Client with two RedirectUris and asserts both are
rendered. Without the fix, it fails with IndexOutOfRange.
LoadClientAsync chains 9 .Include() calls on dbContext.Clients.
On Postgres (and InMemory for some IdentityServer8 nav types), the
resulting cartesian product trips the query shaper with
IndexOutOfRangeException at IncludeCollection materialisation time.
Bug reproduces in production on the Blog admin pages that load a
Client by id.
AsSplitQuery() rewrites the load as 9 separate SELECTs joined by
client id, which sidesteps the cartesian explosion and any shaper
ambiguity between Claims/Properties/ClientSecrets (which share
Type/Value column names across some IdentityServer8 versions).
Tests:
- EditRedirectUris_GET_after_add_lists_both_uris: end-to-end
reproducer that adds a second RedirectUri via POST then re-GETs
the editor. Guards the fix on the integration path.
- Bisect_*_alone: nine unit tests that exercise the same
.SingleOrDefaultAsync(c => c.Id == id).Include(nav) on the
InMemory provider, one nav at a time. Pinpointed three
problematic navs (RedirectUris, AllowedScopes, AllowedGrantTypes)
on InMemory; kept as a regression net for any future shaper
regressions on the InMemory provider (not the Postgres path).
TestWebApplicationFactory used ASPNETCORE_ENVIRONMENT=Development, which
caused Program.Main's AddConfiguration("org") to load the tracked
appsettings-org.json (the reference file with the
'*** via dotnet user-secrets ou variable d'environnement ***'
placeholder connection string). Npgsql then failed to parse that
placeholder during host startup, failing six integration tests
(observed 2026-07-11: System.ArgumentException on
NpgsqlConnectionStringBuilder.set_Item).
Switching the test host to a dedicated Testing environment makes
AddConfiguration("org") pick up the new optional
appsettings-org.Testing.json file as the last source in the chain
(JSON → env vars), which overrides YavscConnection with the
InMemory marker and the Smtp section with the test stub values.
The .gitignore exception whitelists this file explicitly: it is a
configuration source for the test host, not a secrets file.
The WebServerFixture path is unchanged — it owns its
WebApplicationBuilder and adds the same in-memory override via its
BuildApp hook.
Le commit 2 a fixé la NPE du /BlogSpot/Details/{id} en passant
le display template par UserDisplayHelpers.AvatarSrc, qui
défend contre un UserName null. Ce commit complète le filet
de non-régression et pose la doc d'architecture des tests.
- ApplicationUserDisplayTemplateTests : assert que le cshtml ne
concatène plus directement Model.UserName (ancien code fautif)
et qu'il utilise bien le helper. Si quelqu'un revert la ligne
4 du cshtml, les tests cassent. Les autres usages de
Model.UserName (alt, title, asp-route-id) sont autorisés : ils
ne sont pas la cause du 500, juste laids si null.
- doc/testing.md : vue d'ensemble de la stratégie de test
(conventions NonRegression/Mandatory/Smoke/Controllers, EF
in-memory via InMemoryDatabaseRoot partagé, auth stubs,
quand ne pas écrire de test).
- src/Yavsc.Tests.Shared/README.md : détails du scaffold partagé
(WebHostFixture + son cycle de vie et ses hooks,
TestAuthPolicyProvider, TestTokenIssuer) et des deux
spécialisations dans le repo
(Yavsc.Org.Tests.WebServerFixture et
Yavsc.Blogs.Tests.BlogsWebServerFixture).
- doc/README.md : entrée vers testing.md dans l'index.
GET /BlogSpot/Details/1 retournait 500 (avec un 500-sur-500 sur
la page d'erreur elle-même) parce que DisplayTemplates
/ApplicationUser.cshtml faisait `var avuri = "/Avatars/" +
Model.UserName + ".s.png"` : avec <Nullable>enable</Nullable>,
Razor émet un null-check implicite sur Model.UserName et lève
NullReferenceException quand l'auteur n'a pas de UserName
posé (donnée héritée, user partiellement initialisé).
- UserDisplayHelpers.AvatarSrc : helper statique pur dans
Yavsc.Abstract.Identity qui retourne
YavscConstants.DefaultAvatar pour user null / UserName vide
ou whitespace, et un path /avatars/<name>.s.png sinon.
- ApplicationUser.cshtml : utilise le helper.
- Tests : 4 cas (null, vide, whitespace, valide) dans
Yavsc.Org.Tests/NonRegression.
Bonus : le path d'avatar passe de "/Avatars/" (S majuscule,
ne résolvait pas dans le middleware de fichiers statiques) à
YavscConstants.AvatarsPath ("/avatars" minuscule), pour fermer
l'autre trou que centraliser le calcul permettait de fixer
proprement.
Tout billet a un auteur, tout commentaire a un auteur. On aligne la
base sur ce contrat (Postgres) en droppant les orphelins existants
puis en remplaçant les FK en cascade par des FK Restrict.
- ApplicationDbContext: fluent pour BlogPost.Author et Comment.Author
en DeleteBehavior.Restrict.
- ApplicationUser: ajoute la nav inverse BlogComments (manquait,
EF aurait sinon créé une shadow FK).
- Migration 20260711173717_EnforceBlogAuthorFKs: Up purge les
Comment/BlogSpot dont l'AuthorId n'existe plus, log le volume,
puis drop+add des FK. Down laisse la cascade (état pré-migration).
Le code applicatif (BlogSpotService.Details) s'appuiera sur cette
contrainte dans un commit séparé.
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.
Three pieces here make the regression diagnosable and pin a
test for the fix:
1. YavscApiClient: replace EnsureSuccessStatusCode() at both
call sites with a small helper that reads the response
body and embeds it in the thrown HttpRequestException. The
VM's existing catch (Exception) in ExecuteAsync forwards
ex.Message to the status bar, so the next 'click Save'
tells the user exactly which field the server rejected.
2. Yavsc.Blogs.Tests: two integration tests on the real
controller (no HTTP mock) — one pins that a well-formed
PostIt-shaped payload (Title + Article + AuthorId + dates,
Id=0) is accepted with 201, the other pins that a payload
with Title=string.Empty is rejected with 400. Together they
pin the contract the VM has to honour.
3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
MainPage inside a headless Window, types a title into the
TextBox without first selecting a post in the list, taps
Save, and asserts the body of the first POST contains
the typed title. Today this test fails with Title='',
reproducing the production 400. The matching fix (a
Title/Article buffer on MainPageViewModel that the XAML
binds to, and that Save uses to build the outgoing
BlogPost) is the next commit; the test is the safety net.
Three related changes that close the loop on the DarkMode
field and lay the first stone of a UI test scaffold for
PostIt.
1. Settings.DarkMode was previously a dead field. It round-
tripped through postit-settings.json and the SettingsPage
CheckBox, OnDarkModeChanged flipped IsDirty, and that was
it — no consumer ever read the value, so toggling the
CheckBox had no visible effect. The fix is in
App.OnFrameworkInitializationCompleted: read the value
Load() just populated and set
Application.Current.RequestedThemeVariant accordingly
(so a dark-mode user lands on a dark window on first
launch, not on a default-light window that flips after
the user touches the toggle), then subscribe to
settings.PropertyChanged and update the theme on every
DarkMode change. The consumer lives in App.axaml.cs, not
in Settings, so the Settings model stays free of any
Avalonia.Application dependency and the SettingsLoadTests
(which construct Settings outside an Avalonia host)
still pass unchanged.
2. MainPageViewModel had a vestigial [ObservableProperty]
ThemeVariant themeVariant = ThemeVariant.Default that no
XAML, no code, and no test ever read. It was the start of
a half-finished attempt to expose the theme variant on
the page VM. The dark-mode wiring above makes it
irrelevant: the theme is now driven by Application, not
by a VM property. The field is removed, along with the
using Avalonia.Styling; it pulled in (now unused).
3. SessionStatusBannerTests adds the first set of UI tests
for PostIt. They mount a real MainWindow via the headless
Avalonia host declared in TestApp.cs, attach a
SessionStatusViewModel as the banner's DataContext, and
assert the actual visual tree contents: three buttons
render (Se déconnecter, Se connecter, Paramètres), the
Login button is visible when logged out, the Logout
button is hidden when logged out, the Paramètres button
is visible regardless of session, and the session label
text reflects the VM. The pattern follows what
UnitTest1.MainPage_Should_Load already established:
[AvaloniaFact] (from Avalonia.Headless.XUnit) plus
new MainWindow() / window.Show(). A plain [Fact] cannot
drive Window..ctor() because the headless platform's
PlatformManager.CreateWindow() has no service registered
outside a dispatcher-aware test context; the
AvaloniaFact attribute provides that context. The
DataContext is set on the banner directly because
App.OnFrameworkInitializationCompleted is not called in
a unit test (production wiring is exercised by the
manual launch, not here).
Build: 0 errors. Tests: 5/5 SessionStatusBannerTests,
3/3 SettingsLoadTests, 1/1 MainPageTests (the existing
scaffold test, unchanged). The other PostIt.Tests suites
depend on the OIDC stub WebApplicationFactory and time out
on this network-restricted host.
Two related changes that close the loop on the SettingsPage
push semantics.
1. The SettingsPage used to be registered as Transient. Each
click on the Paramètres button resolved a fresh instance,
re-bound it to the Settings singleton, and pushed it onto
the navigation stack. Repeated clicks accumulated stacked
instances, each fully bound, and the user had to tap Back
N times to leave. The fix is to register the page as a
Singleton in the DI container. There is now one and only
one SettingsPage ContentPage for the lifetime of the app:
- its DataContext is wired once, at composition time
(just after the ViewLocator is added to DataTemplates),
not on every push;
- the OpenSettingsRequested handler is a pure navigation
concern, with no DI resolution and no rebinding;
- the in-memory Settings state is preserved across visits
(any in-flight edit stays in the same instance).
2. The OpenSettingsRequested handler is guarded so that if the
SettingsPage is already at the top of NavigationStack, the
push is a no-op. NavigationPage.PushAsync does not
deduplicate; without the guard, calling it twice with the
same instance pushes it a second time, and the user has to
tap Back twice to leave. The guard is a reference comparison
on NavigationStack[Count - 1] against the singleton
instance, which is correct precisely because the page is
a singleton.
doc/architecture/postit.md is updated to match: the DI table
reflects the new lifetime, and the 'Garde anti-empilement'
section is rewritten from 'to be implemented' to the actual
implementation, including the rationale for reference
comparison and the cross-dependency between the singleton
lifetime and the guard.
The Settings-singleton invariant (in the same doc) is
unchanged: Settings is still a singleton, and adding a
transient override would still be the bug it always was.
The new SettingsPage singleton sits alongside it cleanly.
Build: 0 errors. Tests: 3/3 SettingsLoadTests green.