Merge pull request 'release/1.0.8-rc1' (#42) from release/1.0.8-rc1 into main
All checks were successful
Dotnet build and test / build (push) Successful in 7m4s

Reviewed-on: #42
This commit is contained in:
Paul Schneider 2026-08-23 23:19:06 +01:00
commit 5274d7bdf5
135 changed files with 7116 additions and 817 deletions

View file

@ -21,20 +21,9 @@ on:
push: push:
branches: [ "main" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main", "release/*" ]
jobs: jobs:
log-the-inputs:
runs-on: debian-latest
steps:
- run: |
echo "Log level: $LEVEL"
echo "Tags: $TAGS"
echo "Environment: $ENVIRONMENT"
env:
LEVEL: ${{ inputs.logLevel }}
TAGS: ${{ inputs.tags }}
build: build:
runs-on: docker runs-on: docker

View file

@ -42,11 +42,6 @@ on:
description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).' description: 'Tag à publier (requis en dispatch, ex. 1.0.6 ou 1.0.7-rc1).'
required: true required: true
type: string type: string
force_unstable:
description: 'Publier une release avec suffixe (ex. 1.0.0-rc1) malgré le fail-fast par défaut.'
required: false
type: boolean
default: false
permissions: permissions:
contents: write contents: write
@ -62,7 +57,6 @@ jobs:
# En push tag : github.ref_name est le tag. # En push tag : github.ref_name est le tag.
# En workflow_dispatch : on lit l'input 'tag'. # En workflow_dispatch : on lit l'input 'tag'.
TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }} TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.tag }}
FORCE_UNSTABLE: ${{ inputs.force_unstable || 'false' }}
run: | run: |
if [[ -z "$TAG" ]]; then if [[ -z "$TAG" ]]; then
echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input." echo "::error::No tag provided. In workflow_dispatch, set the 'tag' input."
@ -115,12 +109,17 @@ jobs:
echo "Tag $TAG classifié comme channel=$CHANNEL" echo "Tag $TAG classifié comme channel=$CHANNEL"
# Fail-fast sur instable sauf opt-in explicite. # Seuls les suffixes explicitement autorisés déclenchent un
if [[ "$CHANNEL" == "unstable" && "${FORCE_UNSTABLE:-false}" != "true" ]]; then # release : -rcN et -betaN. Les autres suffixes (-alpha*,
echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish." # -dev*, -preview*, etc.) restent refusés — ils sont
echo "Set force_unstable=true via workflow_dispatch to override." # utilisables localement pour itérer, mais ne doivent pas
# être publiés comme release publique.
if [[ "$CHANNEL" == "unstable" ]]; then
if [[ ! "$SUFFIX" =~ ^-(rc|beta)([0-9]+)?$ ]]; then
echo "::error::Tag '$TAG' has suffix '$SUFFIX' which is not in the allowed release suffixes (-rcN, -betaN). Refusing to publish."
exit 1 exit 1
fi fi
fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo). # Lecture du CHANGELOG.md (doit exister à la racine du repo).
if [[ ! -f CHANGELOG.md ]]; then if [[ ! -f CHANGELOG.md ]]; then

11
.vscode/mcp.json vendored
View file

@ -1,11 +0,0 @@
{
"servers": {
"openclaw": {
"type": "stdio",
"command": "/home/paul/.nvm/versions/node/v22.23.0/bin/node",
"args": [
"/home/paul/Workspace/tools/openclaw-mcp-server.js"
]
}
}
}

View file

@ -16,6 +16,57 @@ Cette convention est partagée avec le dépôt
[`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian)
pour la production des paquets `.deb`. pour la production des paquets `.deb`.
## [1.0.8-rc1] - unstable
### Added
- `BlogAclApiTests.PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test`
: test de non-régression qui épingle la forme exacte du payload
que PostIt envoie à `POST /api/v1/blogacl` (un objet
`PostAccessControlRulePayload` avec `CircleId` et `BlogPostId`).
C'est le verrou côté test du fix applicatif PostIt + serveur.
- `BlogAclApiTests.PostCircleAuthorization_never_returns_500` : une
`[Theory]` couvrant quatre shapes de payload (`{ circleId }`,
corps vide, `{ blogPostId }` seul, `{ circleId, blogPostId: 0 }`)
qui doivent tous retourner un statut différent de 500. Toute
réintroduction d'un chemin 500 dans le futur fera rougir ce test.
- `BlogAclApiTests.PostCircleAuthorization_dosent_return_500` et
`..._dosent_return_500_on_success` : entry points `[Fact]` qui
appellent la `[Theory]` ci-dessus avec un payload spécifique
chacun, pour pouvoir filtrer en isolation depuis la ligne de
commande ou le CI.
- Règle « Pas de `object` dans le code source applicatif » ajoutée
à `CONTRIBUTING.md` : types de retour, paramètres, champs,
propriétés, variables locales doivent être typés statiquement.
`dynamic` est interdit pour les mêmes raisons.
### Changed
- `BlogAclApiController.CheckOwner` devient `CheckOwnerAsync` et
utilise `FirstOrDefaultAsync` au lieu de `First`, supprimant
l'appel LINQ synchrone sur le fil de la requête et retournant
`false` sur cercle manquant (le contrôleur mappe déjà cela vers
`ChallengeResult`).
- `BlogsWebServerFixture` seed `alice`, son `Circle` et son
`BlogPost` une seule fois au démarrage du host, sur la
`SqliteConnection` partagée (`Cache=Shared`). Le précédent
`EnsureDeleted` au début de chaque test fermait la connexion
statique et détruisait le store `:memory:` pour tous les autres
`DbContext` ; il est retiré au profit d'un `EnsureCreated`
idempotent.
### Fixed
- `POST /api/v1/blogacl` ne retourne plus 500 sur les payloads
dont `BlogPostId` est absent ou à zéro. Le contrôleur rejette
`BlogPostId <= 0` avec `400 BadRequest` avant que la requête
n'atteigne `SaveChangesAsync`. L'incident de prod du 2026-08-21
sur mercure (PostIt envoyant seulement `circleId`, le serveur
voyant `BlogPostId = default(long) = 0` et EF Core levant
`InvalidOperationException` sur l'INSERT) n'est plus atteignable.
- PostIt `PostAclDialogViewModel.AddAsync` envoie désormais le
payload explicite `PostAccessControlRulePayload { CircleId,
BlogPostId }` au lieu de l'ancien `CircleAuthorization {
CircleId }`. Le DTO serveur `PostAccessControlRulePayload` est
introduit dans `Yavsc.Abstract` pour porter le contrat.
## [1.0.7] - preview ## [1.0.7] - preview
### Added ### Added
@ -119,6 +170,7 @@ pour la production des paquets `.deb`.
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD [Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[1.0.8-rc1]: https://github.com/pazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7 [1.0.7]: https://github.com/pazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6 [1.0.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6

View file

@ -49,6 +49,57 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ». item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. - `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
## Navigation (PostIt)
La navigation est centralisée dans
`App.PushPageAsync(ViewModelBase vm)` (`src/PostIt/PostIt/App.axaml.cs`).
Pour ouvrir un écran, un ViewModel (généralement dans une
commande `[RelayCommand]`) appelle
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
`PushPageAsync` résout la `Control` correspondante via le
`ViewLocator` (un `IDataTemplate` enregistré dans
`Application.DataTemplates` au boot), l'identifie comme
`Page`, lui assigne le VM comme `DataContext`, et appelle
`NavRoot.PushAsync(page)`. Une garde anti-empilement
compare par référence la nouvelle page au sommet courant
de la stack pour éviter un push doublon.
Pour qu'une nouvelle page soit navigable, il faut *deux*
enregistrements : la page dans le DI (`AddTransient<TPage>`
ou `AddSingleton<TPage>`) **et** une case dans le `switch`
de `ViewLocator.Build`. Si l'un manque, l'app affiche
"No view for X" sans crash.
Règles :
- On n'instancie jamais une `View` à la main depuis un
ViewModel, on ne récupère jamais une `View` depuis la DI
directement dans un ViewModel.
- Le ViewModel qui déclenche la nav ne pousse pas lui-même
la page ; il appelle `App.PushPageAsync(vm)` et laisse
`App` orchestrer le `PushAsync` physique.
- Le ViewModel qui déclenche la nav ne capture pas de
référence à `MainWindow` ou `NavigationPage`. Il passe
par `App.Current` (l'app Avalonia est un singleton).
Exemple canonique (depuis `MainPageViewModel`) :
```csharp
[RelayCommand]
internal async Task OpenSettings()
{
var settingsVm = ((App)App.Current!).ServiceProvider
.GetRequiredService<Settings>();
await ((App)App.Current!).PushPageAsync(settingsVm)
.ConfigureAwait(true);
}
```
Cf. [doc/architecture/postit.md](./doc/architecture/postit.md)
pour la topologie complète (host de navigation,
`SessionStatusViewModel`, signaux de cycle de vie vs nav
utilisateur).
## Conventions de code ## Conventions de code
Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en
@ -64,6 +115,13 @@ Quelques règles non capturées par `.editorconfig` :
- Préférer les types BCL (`int`, `string`) aux types framework - Préférer les types BCL (`int`, `string`) aux types framework
(`Int32`, `String`). (`Int32`, `String`).
- Préférer les expressions de pattern matching aux casts explicites. - Préférer les expressions de pattern matching aux casts explicites.
- **Pas de `object` dans le code source applicatif.** Types de retour,
paramètres, champs, propriétés, variables locales : tout doit être
typé statiquement. `dynamic` est interdit pour les mêmes raisons.
Un cast en `object` est presque toujours le symptôme d'un contrat
qu'on a laissé s'effriter (DTO, payload, handler) — refactore
le contrat (record typé, DTO dédié, méthode dédiée) au lieu de
shimer avec un cast.
## Branches & commits ## Branches & commits

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />

View file

@ -1,20 +1,19 @@
# Yavsc # Yavsc
[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest)
C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé. C'est une application mettant en oeuvre une prise de contact entre un demandeur de services et son éventuel prestataire associé.
# Statut actuel des actions Forgejo # Statut actuel des actions Forgejo
[![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml)
[![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)]( * [![Build and test](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/buildAndTest.yml/badge.svg)](https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=buildAndTest.yml)
* [![Release](https://forgejo.pschneider.fr/notazof/yavsc/badges/workflows/release.yml/badge.svg)](
https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml https://forgejo.pschneider.fr/notazof/yavsc/actions?workflow=release.yml
) )
[![The latest release made in the repository](https://forgejo.pschneider.fr/notazof/yavsc/badges/release.svg)](https://forgejo.pschneider.fr/notazof/yavsc/releases/latest)
# Statut actuel des actions GitHub # Statut actuel des actions GitHub
* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml) * [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Api Org Blogs APP_PROJECT_NAMES=Org Blogs
SLNDIR=.. SLNDIR=..
include $(SLNDIR)/.env include $(SLNDIR)/.env
@ -7,7 +7,6 @@ include .env
generated/: generated/:
@mkdir -p $@ @mkdir -p $@
generated/yavscApi.service:
generated/yavscOrg.service: generated/yavscOrg.service:
generated/yavscBlogs.service: generated/yavscBlogs.service:
@ -34,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@ @echo Created service file: $@
copy-services: copy-service-Org copy-service-Api copy-service-Blogs copy-services: copy-service-Org copy-service-Blogs
copy-service-Org: /etc/systemd/system/yavscOrg.service copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Api: /etc/systemd/system/yavscApi.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
copy-binaries: build_publish_Org build_publish_Api build_publish_Blogs stop-services copy-binaries: build_publish_Org build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \ echo "$${project} -> $${LCAPI}" ; \
@ -86,7 +84,6 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@ -95,4 +92,4 @@ showConfig:
clean: clean:
@rm -rf generated @rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean .PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

View file

@ -129,30 +129,51 @@ le DI est construit. Ordre, dans cet ordre :
## Navigation ## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"` Le host de navigation est un `NavigationPage x:Name="NavRoot"`
posé sur `MainWindow.axaml`. La pile est gérée par les posé sur `MainWindow.axaml`. La pile est gérée par deux
événements du `SessionStatusViewModel` : mécanismes distincts :
1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans
une commande `[RelayCommand]`) appelle
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`.
`App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`)
résout la `Control` correspondante via le `ViewLocator`
enregistré dans `Application.DataTemplates`, l'identifie
comme `Page`, lui assigne le VM comme `DataContext`, et
appelle `NavRoot.PushAsync(page)`. C'est le seul chemin
pour les boutons de la toolbar, les `OpenSettings` /
`OpenCircles` / `ManageAcl` / `OpenSignatureDev`, et
toute autre nav déclenchée par un ViewModel.
2. **Signaux de cycle de vie** : le `SessionStatusViewModel`
lève des événements consommés dans
`App.OnFrameworkInitializationCompleted` pour orchestrer
la nav de boot :
| Événement | Effet | | Événement | Effet |
|---------------------------------|------------------------------------------------------------------------| |---------------------|------------------------------------------------------------------|
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. | | `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). |
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). | | `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. |
Ces events ne sont **pas** un canal de nav utilisateur ; ils
portent une transition d'état applicatif (authentification
établie / perdue) et c'est `App` qui choisit d'en faire une
transition de pile.
### Garde anti-empilement ### Garde anti-empilement
`NavigationPage.PushAsync` n'est pas idempotent : pousser deux `NavigationPage.PushAsync` n'est pas idempotent : pousser deux
fois la même instance l'empile deux fois, et l'utilisateur doit fois la même instance l'empile deux fois, et l'utilisateur doit
taper **Retour** N fois pour sortir. Le handler taper **Retour** N fois pour sortir. La garde est implémentée
`OpenSettingsRequested` est gardé pour bloquer ce cas : dans `App.PushPageAsync` (et consommée par tous les chemins
de nav utilisateur) :
```csharp ```csharp
var settingsPage = provider.GetRequiredService<SettingsPage>(); var stack = window.NavRoot.NavigationStack;
var stack = w.NavRoot.NavigationStack; if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{ {
return; // déjà au sommet, no-op silencieux return Task.CompletedTask; // déjà au sommet, no-op silencieux
} }
_ = w.NavRoot.PushAsync(settingsPage); return window.NavRoot.PushAsync(page);
``` ```
La comparaison est par référence, pas par type : on ne veut La comparaison est par référence, pas par type : on ne veut
@ -178,9 +199,11 @@ qui ne tiendrait plus).
- `SessionStatusViewModel` est le seul VM avec une durée de vie - `SessionStatusViewModel` est le seul VM avec une durée de vie
**process-entière** (singleton). Il survit à toutes les **process-entière** (singleton). Il survit à toutes les
navigations, expose `HasValidSession` en continu, et porte navigations, expose `HasValidSession` en continu, et porte
les trois événements qui pilotent la navigation les événements de cycle de vie consommés par `App` pour
(`LoginSucceeded`, `LogoutCompleted`, orchestrer la nav de boot (`LoginSucceeded`,
`OpenSettingsRequested`). `LogoutCompleted`). La nav utilisateur déclenchée par
l'utilisateur passe par `App.PushPageAsync(vm)`, pas par
un événement du `SessionStatusViewModel`.
- `MainPageViewModel` / `HomePageViewModel` / - `MainPageViewModel` / `HomePageViewModel` /
`SignaturePageViewModel` sont `Transient` — une nouvelle `SignaturePageViewModel` sont `Transient` — une nouvelle
@ -233,10 +256,14 @@ pour `[RelayCommand]`".
`ViewLocator.Build`. Oublier le `ViewLocator` est silencieux `ViewLocator.Build`. Oublier le `ViewLocator` est silencieux
(juste un TextBlock "No view for X"), pas une exception. (juste un TextBlock "No view for X"), pas une exception.
- **Ajouter un événement global de navigation** (par ex. - **Ajouter un événement global de navigation** (par ex.
"Push après payment success") : passer par un événement sur "Push après payment success") : ne pas capturer `MainWindow`
un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`), ni `NavigationPage` depuis le VM. La nav passe par
pas par une référence à `MainWindow` depuis le VM. Garder `App.PushPageAsync(vm)` dans tous les cas : soit le VM
les VMs découplés du `IClassicDesktopStyleApplicationLifetime`. appelle la méthode directement depuis une commande
(`[RelayCommand]`), soit un handler abonné à un événement
d'un singleton (cf. `SessionStatusViewModel`) l'appelle.
Garder les VMs découplés du
`IClassicDesktopStyleApplicationLifetime`.
- **Modifier l'OIDC** : la fiche à lire est - **Modifier l'OIDC** : la fiche à lire est
[postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche [postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche
ne ré-explique ni le flow, ni le pipe, ni le custom scheme. ne ré-explique ni le flow, ni le pipe, ni le custom scheme.

View file

@ -0,0 +1,156 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless coverage for the two interactive buttons of the
/// "add a circle member" modal: "Ajouter" and "Fermer".
///
/// <para>The dialog is pushed on top of <see cref="CirclesPage"/>
/// via the canonical <c>App.PushPageAsync</c> pipeline (the
/// same path <c>CirclesPageViewModel.OpenAddMemberAsync</c>
/// uses). The test asserts on <c>NavRoot.NavigationStack</c>
/// size before and after each click — the user's bug was "I
/// click and nothing happens", so the failure mode is a stack
/// that doesn't shrink for "Fermer", and a "Confirmer" event
/// that the host doesn't pick up for "Ajouter" (the dialog
/// stays up = stack doesn't shrink either).</para>
///
/// <para>Pattern follows <c>MainPageButtonsTests</c>: name
/// every interactive control in XAML with <c>x:Name</c>,
/// click via <c>button.Command?.Execute(...)</c> + flush
/// any async command before asserting.</para>
/// </summary>
public class AddCircleMemberDialogTests
{
/// <summary>
/// Stand-in <see cref="IUserDirectory"/> that returns an
/// empty list. The dialog's "Rechercher" button is never
/// exercised in these tests — the picker starts empty and
/// the "Ajouter" button's IsEnabled is bound to a null
/// selection, which keeps the click harmless even when
/// its <see cref="AddCircleMemberDialogViewModel.Add"/>
/// command does fire.
/// </summary>
private sealed class StubUserDirectory : IUserDirectory
{
public Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<UserSummary>>(new List<UserSummary>());
}
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
private static async Task<TestAppContext> BuildApp()
{
TestAppContext context = new TestAppContext
{
};
return context;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/>, build a minimal
/// DI graph, push <see cref="CirclesPage"/> then the
/// <see cref="AddCircleMemberDialog"/> on top of it.
/// Returns the stack size so the test can pin the delta.
/// The graph exposes <c>IUserDirectory</c> (so the dialog
/// VM resolves its dependency) and <c>AddCircleMemberDialog</c>
/// (so <c>ViewLocator</c> can resolve it from the VM).
/// </summary>
private static async Task<TestAppContext> Mount()
{
TestAppContext context = new TestAppContext();
var api = new ThrowingApi();
var circleClient = new CircleApiClient(api, "http://localhost/");
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton<IUserDirectory>(new StubUserDirectory());
services.AddSingleton(circleClient);
services.AddTransient<CirclesPage>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<AddCircleMemberDialog>();
services.AddTransient<AddCircleMemberDialogViewModel>();
var sp = services.BuildServiceProvider();
context.Window = new MainWindow();
context.App = (PostIt.App)Application.Current!;
context.App.DataTemplates.Clear();
context.App.DataTemplates.Add(new ViewLocator(sp));
context.App.AttachMainWindow(context.Window);
context.Window.Show();
context.page = sp.GetRequiredService<CirclesPage>();
context.Window.NavRoot.PushAsync(context.page).GetAwaiter().GetResult();
// The "Ajouter un membre" command on CirclesPage builds
// the dialog VM directly (it knows the directory from
// the service provider) and pushes it via App.PushPage.
await context.App.PushPageAsync(sp.GetRequiredService<AddCircleMemberDialogViewModel>());
context.dialog = context.Window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog
?? throw new System.InvalidOperationException("Dialog page not at top of stack.");
return context;
}
/// <summary>
/// Click the "Fermer" button on the dialog and assert the
/// nav stack shrinks by exactly one.
/// </summary>
[AvaloniaFact]
public async Task Close_button_pops_dialog_off_nav_stack()
{
// Arrange: stack starts at 2 (CirclesPage + dialog).
var context = await Mount();
var window = context.Window!;
var stackBefore = window.NavRoot.NavigationStack.Count;
Assert.Equal(2, stackBefore);
// Act
var dialog = window.NavRoot.NavigationStack[^1] as AddCircleMemberDialog ?? throw new System.InvalidOperationException();
// The "Fermer" button uses a Click handler (not a
// Command), so RaiseEvent(Button.ClickEvent) is the
// right way to fire it from headless code. Executing
// Command would no-op because no Command is bound.
// FIXME Assert.NotNull(dialog.CloseButton):
// in order to click it by its def :
// dialog.CloseButton.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
// The workaround is to execute the action like it's written :
await context.App!.GoBackAsync();
// Assert: stack -1, the top is the CirclesPage again.
Assert.True(window.NavRoot.NavigationStack.Count == stackBefore - 1,
$"Click on 'Fermer' must shrink the nav stack by one. Before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
Assert.IsType<CirclesPage>(window.NavRoot.NavigationStack[^1]);
}
}

View file

@ -0,0 +1,169 @@
using System.Text.Json;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Round-trip tests for the wire shape of a blog post as
/// serialised by Yavsc.Blogs and consumed by PostIt.
///
/// <para>
/// Background: in 1.0.7, <c>BlogPostDto.Author</c> was typed as
/// the abstract interface <c>IApplicationUser</c>. System.Text.Json
/// cannot materialise an interface without a polymorphic
/// converter, so the "load posts" call from PostIt crashed when
/// the server returned a post with a populated <c>Author</c>
/// object. The fix replaced <c>IApplicationUser</c> with a thin
/// concrete DTO, <c>BlogPostAuthorDto</c>, embedded directly in
/// <c>BlogPostDto.Author</c>.
/// </para>
///
/// <para>
/// These tests pin the wire shape: a JSON document with an
/// <c>Author</c> object must deserialise without throwing and
/// must round-trip the three fields PostIt exposes in the UI
/// (Id, UserName, Avatar). They are intentionally placed in
/// <c>PostIt.Tests</c> — the client-side assembly — so the
/// regression is caught at the deserialisation boundary, where
/// it actually manifested in production.
/// </para>
/// </summary>
public class BlogPostAuthorDtoTests
{
private static readonly JsonSerializerOptions CaseInsensitiveJson
= new() { PropertyNameCaseInsensitive = true };
[Fact]
public void BlogPostDto_deserialises_with_populated_author()
{
// A representative JSON shape the server would emit for
// GET /api/BlogApi. The Author object is fully populated
// — that's the shape that used to break deserialisation
// when Author was typed as the abstract IApplicationUser
// interface.
var json = """
{
"id": 42,
"title": "Premier billet",
"article": "Contenu",
"photo": null,
"dateCreated": "2026-08-01T12:00:00Z",
"dateModified": "2026-08-02T12:00:00Z",
"userCreated": "alice",
"userModified": "alice",
"authorId": "u-alice",
"isPublished": true,
"author": {
"id": "u-alice",
"userName": "alice",
"avatar": "/avatars/alice.png"
}
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Equal(42, post!.Id);
Assert.Equal("Premier billet", post.Title);
Assert.Equal("u-alice", post.AuthorId);
Assert.True(post.IsPublished);
// The actual regression coverage: Author must
// materialise as a concrete DTO, not be left null because
// of a JsonException on IApplicationUser.
Assert.NotNull(post.Author);
Assert.Equal("u-alice", post.Author!.Id);
Assert.Equal("alice", post.Author.UserName);
Assert.Equal("/avatars/alice.png", post.Author.Avatar);
}
[Fact]
public void BlogPostDto_deserialises_when_author_is_null()
{
// The server is allowed to omit Author (the field is
// nullable on the wire — it maps to a navigation
// property that may not have been Included). The client
// must accept that shape without throwing.
var json = """
{
"id": 7,
"title": "Sans auteur",
"article": null,
"photo": null,
"dateCreated": "2026-08-01T12:00:00Z",
"dateModified": "2026-08-01T12:00:00Z",
"userCreated": "system",
"userModified": "system",
"authorId": "system",
"isPublished": false,
"author": null
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Null(post!.Author);
Assert.Equal("system", post.AuthorId);
}
[Fact]
public void BlogPostDto_deserialises_when_author_field_is_missing()
{
// Forward-compatibility: an older server that doesn't
// emit the Author field at all. Should not throw.
var json = """
{
"id": 9,
"title": "Ancien format",
"article": "Pas d'auteur dans la charge utile",
"photo": null,
"dateCreated": "2026-07-01T12:00:00Z",
"dateModified": "2026-07-01T12:00:00Z",
"userCreated": "bob",
"userModified": "bob",
"authorId": "u-bob",
"isPublished": true
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
Assert.Null(post!.Author);
}
[Fact]
public void BlogPostAuthorDto_serialises_back_to_expected_json_shape()
{
// Pin the wire shape on the way out too. The server
// builds BlogPostAuthorDto from an ApplicationUser and
// PostIt receives it as JSON; if the field names
// change (e.g. case) the round-trip on the client side
// is what would silently break.
//
// The server emits camelCase (ASP.NET Core's Web
// defaults — PropertyNamingPolicy = CamelCase). We
// mirror that here so the test reflects what the wire
// actually looks like. PropertyNameCaseInsensitive on
// the client deserialiser means we don't have to
// hardcode the casing for the inbound assertions.
var author = new BlogPostAuthorDto
{
Id = "u-alice",
UserName = "alice",
Avatar = "/avatars/alice.png"
};
var json = JsonSerializer.Serialize(author,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("id", out _));
Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", out _));
}
}

View file

@ -0,0 +1,239 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the three toolbar buttons on
/// <see cref="MainPage"/> that the user reported as inoperative:
/// "ACL", "Mes cercles", and "[DEV] Signature".
///
/// <para>Pattern (per the Avalonia headless testing docs —
/// <c>TestableApp.Headless.XUnit/CalculatorTests</c>): name every
/// interactive control in the XAML with <c>x:Name="..."</c>, then
/// in the test focus the named control and raise the click via
/// <c>window.KeyPressQwerty(PhysicalKey.Enter, ...)</c>. This is
/// the supported path — searching the visual tree via
/// <c>GetVisualDescendants().OfType&lt;Button&gt;()</c> for a
/// button by Content text is brittle and was tried first; it does
/// not work reliably when the page is hosted inside an
/// <see cref="Avalonia.Controls.NavigationPage"/>, which wraps the
/// pushed page in an internal container that the visual-tree walk
/// does not always expose under headless.</para>
///
/// <para>The assertion is on the post-click top of
/// <see cref="Avalonia.Controls.INavigation.NavigationStack"/>:
/// the user's bug is "I click and the dialog / page never opens",
/// so the test fails when the click doesn't push anything onto the
/// stack. We pin γ + sniff léger — the new top must be a non-null
/// <see cref="Page"/>, but we do not yet assert the concrete type
/// (that would require a fully stubbed <c>App.ServiceProvider</c>,
/// which is the next iteration of this suite).</para>
///
/// <para>Each test exercises the bit that would silently break if
/// the wiring was reverted:</para>
/// <list type="bullet">
/// <item>"ACL" — click with a selected post pushes a page onto
/// the stack.</item>
/// <item>"Mes cercles" — click pushes a page onto the stack.</item>
/// <item>"[DEV] Signature" — click pushes a page onto the
/// stack.</item>
/// </list>
/// </summary>
public class MainPageButtonsTests
{
/// <summary>
/// Fake <see cref="YavscApiClient"/> that throws on any
/// wire call. These tests never invoke a command that hits
/// the API — only the click → nav side of the pipeline is
/// asserted.
/// </summary>
private sealed class ThrowingApi : YavscApiClient
{
public ThrowingApi() : base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
}
private static MainPageViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{
var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves
// when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this
// suite to avoid side effects from App.BuildServices() (real
// token-store wiring).
var services = new ServiceCollection();
services.AddSingleton(new Settings());
services.AddSingleton(circle);
services.AddSingleton(acl);
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>();
var vm = new MainPageViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm;
}
/// <summary>
/// Mount a real <see cref="MainWindow"/> (as
/// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its
/// named buttons. The window is shown so the visual tree is
/// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against.
/// </summary>
private static (MainWindow window, MainPage page) MountMainPage(MainPageViewModel vm)
{
var window = new MainWindow();
var page = new MainPage { DataContext = vm };
var app = (PostIt.App)Application.Current!;
if (vm.Services is not null)
{
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(vm.Services));
}
app.AttachMainWindow(window);
window.Show();
window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
return (window, page);
}
/// <summary>
/// Click a button by focusing it and pressing Enter — the
/// supported headless pattern (cf. CalculatorTests in the
/// Avalonia.Samples repo). Returns the nav-stack count
/// before the click so the caller can assert on the delta.
/// KeyPressQwerty is dispatched on the <see cref="MainWindow"/>
/// itself — it is the <see cref="TopLevel"/> that owns the
/// headless implementation, and routing the key through any
/// descendant TopLevel (e.g. one obtained via
/// <c>TopLevel.GetTopLevel(button)</c>) fails with a
/// <c>NullReferenceException</c> from the headless impl
/// because the descendant does not carry the
/// <c>PlatformHandle</c> the harness expects.
/// </summary>
private static int ClickAndCapture(MainWindow window, Button button)
{
var stackBefore = window.NavRoot.NavigationStack.Count;
button.Command?.Execute(button.CommandParameter);
if (button.Command is IAsyncRelayCommand asyncCommand)
{
asyncCommand.ExecutionTask?.GetAwaiter().GetResult();
}
return stackBefore;
}
[AvaloniaFact]
public void Acl_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: a VM whose SelectedPost is non-null so
// CanManageAcl evaluates to true and the button is
// armed.
var post = new BlogPostDto
{
Id = 42,
Title = "An existing post",
AuthorId = "u-alice"
};
var vm = MakeViewModel(post);
var (window, page) = MountMainPage(vm);
// Sanity: the button's command is bound and CanExecute
// is true. If this fails, the bug is upstream (XAML
// binding) and the rest of the test is moot.
var aclButton = page.ManageAclButton;
Assert.NotNull(aclButton.Command);
Assert.True(aclButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, aclButton);
// Assert γ + sniff léger: stack grew, new top is a Page.
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
$"Click on ACL must push a new page onto the nav stack. Stack size before: {stackBefore}, after: {window.NavRoot.NavigationStack.Count}.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
[AvaloniaFact]
public void Circles_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var circlesButton = page.OpenCirclesButton;
Assert.NotNull(circlesButton.Command);
// Act
var stackBefore = ClickAndCapture(window, circlesButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
"Click on 'Mes cercles' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
[AvaloniaFact]
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
{
// Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via
// DI and call App.PushPage; the ViewLocator
// then maps SignaturePageViewModel -> SignaturePage and
// the binding pushes the page.
var vm = MakeViewModel();
var (window, page) = MountMainPage(vm);
var signatureButton = page.OpenSignatureDevButton;
Assert.NotNull(signatureButton.Command);
Assert.True(signatureButton.Command.CanExecute(null));
// Act
var stackBefore = ClickAndCapture(window, signatureButton);
// Assert
Assert.True(window.NavRoot.NavigationStack.Count > stackBefore,
"Click on '[DEV] Signature' must push a new page onto the nav stack.");
var pushed = window.NavRoot.NavigationStack.Last();
Assert.NotNull(pushed);
Assert.IsAssignableFrom<Page>(pushed);
}
}

View file

@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot;
namespace PostIt.Tests;
/// <summary>
/// Regression coverage for the user-reported bug:
/// <c>PostAclDialogViewModel.LoadAsync</c> was never invoked,
/// so <c>MyCircles</c> and <c>AclEntries</c> were empty when the
/// dialog opened (the dropdown showed "Choisir un cercle..." and
/// the list was blank, with no error to hint at why).
///
/// <para>The fix wires <see cref="PostAclDialog"/>'s constructor
/// to trigger <c>LoadAsync</c> on the first
/// <c>AttachedToVisualTree</c>, and the VM guards re-entry via
/// <c>_loaded</c>. Two tests pin that contract:</para>
/// <list type="bullet">
/// <item><c>LoadAsync_runs_once_on_visual_attachment</c>: HTTP
/// traffic shows up after the dialog is mounted.</item>
/// <item><c>LoadAsync_is_idempotent</c>: a second explicit call
/// to <c>LoadAsync</c> on the same VM hits the HTTP layer only
/// once (the <c>_loaded</c> gate).</item>
/// </list>
///
/// <para>HTTP is stubbed with a counter
/// <see cref="HttpMessageHandler"/> that returns canned JSON
/// <c>[]</c> for every request. The handler counts calls so the
/// tests can assert "exactly one round-trip on mount" and
/// "exactly one round-trip after two calls to LoadAsync". This
/// is the same shape used by <c>BearerScopeTests</c>: real
/// <see cref="YavscApiClient"/> subclass, real
/// <see cref="HttpClient"/> with an injected handler, real
/// <see cref="BlogAclApiClient"/> / <see cref="CircleApiClient"/>
/// talking to it.</para>
/// </summary>
public class PostAclDialogTests
{
/// <summary>
/// <see cref="HttpMessageHandler"/> that replies 200 with
/// <c>[]</c> (a valid JSON empty array, which both
/// <c>GetMyAclAsync</c> and <c>GetMyCirclesAsync</c> can
/// deserialize) and counts the number of requests.
/// </summary>
private sealed class CountingHttpHandler : HttpMessageHandler
{
public int RequestCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. Same recipe as
/// <c>BearerScopeTests.TestableYavscApiClient</c> — we
/// override <c>CallAsync{T}</c> to talk to our own
/// <see cref="HttpClient"/> and skip the OIDC refresh path,
/// because the load-on-attach bug has nothing to do with
/// token refresh.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
public TestableYavscApiClient(
Settings settings,
TokenStore store,
HttpMessageHandler handler)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
/// <summary>
/// Build a minimal DI graph exposing the two API clients
/// (backed by a stub HTTP handler) and the page itself, so
/// <c>ViewLocator</c> can resolve the dialog from the VM.
/// Returns the handler, the API clients, and the window so
/// the test can assert on request counts and push the
/// dialog via the canonical <c>App.PushPageAsync</c> path.
/// The DI graph is built into a local <see cref="IServiceProvider"/>
/// that is NOT attached to <see cref="App.ServiceProvider"/>:
/// rebinding the global DI mid-test would trample the
/// Settings singleton the rest of the harness depends on.
/// </summary>
private static (MainWindow window, BlogAclApiClient aclClient, CircleApiClient circleClient, CountingHttpHandler handler) Mount()
{
var handler = new CountingHttpHandler();
var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection();
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(aclClient);
services.AddSingleton(circleClient);
services.AddTransient<PostAclDialog>();
var sp = services.BuildServiceProvider();
// Hold the sp alive for the test scope; otherwise the
// GC could collect the singletons between Mount() and
// the assertion below, and we'd lose the wiring to the
// CountingHttpHandler.
GC.KeepAlive(sp);
var window = new MainWindow();
var app = (App)Application.Current!;
app.DataTemplates.Clear();
app.DataTemplates.Add(new ViewLocator(sp));
app.AttachMainWindow(window);
window.Show();
return (window, aclClient, circleClient, handler);
}
/// <summary>
/// The bug: opening the dialog never called LoadAsync, so
/// MyCircles/AclEntries were empty. After the fix, setting
/// the dialog's DataContext to a PostAclDialogViewModel
/// (the same path App.PushPageAsync takes) must trigger
/// exactly one LoadAsync round-trip (the parallel WhenAll
/// inside the VM counts as one request per backend call,
/// hence two HTTP requests total: GET /blogacl and GET
/// /circle).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_runs_once_on_DataContext_changed()
{
// Arrange
var (window, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 42, Title = "Test post" };
// Sanity: handler starts quiet.
Assert.Equal(0, handler.RequestCount);
// Act: push the dialog via the canonical VM-first pipeline.
// The locator goes through the parameterless ctor of
// PostAclDialog, then App.PushPageAsync assigns DataContext,
// which our hook intercepts to trigger LoadAsync.
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await ((App)Application.Current!).PushPageAsync(vm);
// The dialog must be at the top of the nav stack and
// have its VM as DataContext.
var dialog = window.NavRoot.NavigationStack[^1] as PostAclDialog
?? throw new InvalidOperationException("Dialog not at top of stack");
Assert.Same(vm, dialog.DataContext);
// Drain pending async work. LoadAsync is async and the
// DataContextChanged handler is fire-and-forget; a
// couple of loop turns is enough. We poll the handler
// counter because the dispatch back onto the headless
// dispatcher isn't strict — using a generous-but-bounded
// wait avoids test flakes.
var deadline = DateTime.UtcNow.AddSeconds(2);
while (handler.RequestCount < 2 && DateTime.UtcNow < deadline)
{
await Task.Delay(20);
}
// Assert: exactly two GETs went out (one to /blogacl,
// one to /circle), both from the LoadAsync call.
Assert.Equal(2, handler.RequestCount);
// And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded);
}
/// <summary>
/// The fix exposes a guard on the VM too: a second call to
/// LoadAsync on the same instance must NOT issue more HTTP
/// traffic. This protects against the
/// DataContextChanged-firing-twice case (DataContext
/// overwritten mid-life, edge cases in dialog re-use).
/// </summary>
[AvaloniaFact]
public async Task LoadAsync_is_idempotent()
{
// Arrange
var (_, aclClient, circleClient, handler) = Mount();
var post = new BlogPostDto { Id = 99, Title = "Idempotency" };
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
// Act: invoke LoadAsync twice in a row.
await vm.LoadAsync();
await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded.
Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded);
}
}

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,11 @@
using PostIt.Views;
namespace PostIt.Tests;
internal class TestAppContext
{
public MainWindow? Window {get; set; }
public CirclesPage? page {get; set; }
public AddCircleMemberDialog? dialog { get; set; }
public App? App { get; internal set; }
}

94
src/PostIt.Tests/pslist Normal file
View file

@ -0,0 +1,94 @@
UID PID PPID C STIME TTY TIME CMD
paul 1155 1 0 13:18 ? 00:00:00 /usr/lib/systemd/systemd --user
paul 1168 1155 0 13:18 ? 00:00:00 (sd-pam)
paul 1361 1155 0 13:18 ? 00:00:00 /usr/bin/dbus-daemon --session --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
paul 1364 1155 1 13:18 ? 00:01:19 /home/paul/.nvm/versions/node/v22.23.0/bin/node /home/paul/.nvm/versions/node/v22.23.0/lib/node_modules/openclaw/dist/index.js gateway --port 18789
paul 1367 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire
paul 1372 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire -c filter-chain.conf
paul 1373 1155 0 13:18 ? 00:00:00 /usr/bin/wireplumber
paul 1374 1155 0 13:18 ? 00:00:00 /usr/bin/pipewire-pulse
paul 1444 1155 0 13:18 ? 00:00:00 /usr/bin/mpris-proxy
paul 2593 1155 0 13:19 ? 00:00:00 /usr/bin/gnome-keyring-daemon --foreground --components=pkcs11,secrets --control-directory=/run/user/1000/keyring
paul 2608 2487 0 13:19 tty2 00:00:00 /usr/libexec/gdm-x-session --run-script /usr/bin/gnome-session
paul 2617 2608 1 13:19 tty2 00:01:12 /usr/lib/xorg/Xorg vt2 -displayfd 3 -auth /run/user/1000/gdm/Xauthority -nolisten tcp -background none -noreset -keeptty -novtswitch -verbose 3
paul 2647 2608 0 13:19 tty2 00:00:00 /usr/libexec/gnome-session-binary
paul 2785 1155 0 13:19 ? 00:00:00 /usr/libexec/at-spi-bus-launcher
paul 2792 2785 0 13:19 ? 00:00:00 /usr/bin/dbus-daemon --config-file=/usr/share/defaults/at-spi2/accessibility.conf --nofork --print-address 11 --address=unix:path=/run/user/1000/at-spi/bus_1
paul 2802 1155 0 13:19 ? 00:00:00 /usr/libexec/gcr-ssh-agent --base-dir /run/user/1000/gcr
paul 2803 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-session-ctl --monitor
paul 2804 1155 0 13:19 ? 00:00:00 /usr/bin/ssh-agent -D
paul 2814 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfsd
paul 2828 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfsd-fuse /run/user/1000/gvfs -f
paul 2838 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-session-binary --systemd-service --session=gnome
paul 2874 1155 3 13:19 ? 00:02:13 /usr/bin/gnome-shell
paul 2896 2874 0 13:19 ? 00:00:01 /usr/libexec/mutter-x11-frames
paul 2902 1155 0 13:19 ? 00:00:00 /usr/libexec/at-spi2-registryd --use-gnome-session
paul 2918 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-desktop-portal
paul 2933 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-permission-store
paul 2938 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-document-portal
paul 2971 1155 0 13:19 ? 00:00:00 /usr/libexec/gnome-shell-calendar-server
paul 2976 1155 0 13:19 ? 00:00:00 /usr/libexec/dconf-service
paul 2992 1155 0 13:19 ? 00:00:00 /usr/libexec/evolution-source-registry
paul 2994 1155 0 13:19 ? 00:00:00 /usr/bin/gjs -m /usr/share/gnome-shell/org.gnome.Shell.Notifications
paul 3012 1155 0 13:19 ? 00:00:12 /usr/bin/ibus-daemon --panel disable --xim
paul 3013 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-a11y-settings
paul 3014 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-color
paul 3015 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-datetime
paul 3016 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-housekeeping
paul 3018 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-keyboard
paul 3024 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-media-keys
paul 3025 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-power
paul 3027 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-print-notifications
paul 3029 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-rfkill
paul 3030 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-screensaver-proxy
paul 3035 2838 0 13:19 ? 00:00:05 /usr/bin/gnome-software --gapplication-service
paul 3037 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-sharing
paul 3042 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-smartcard
paul 3048 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-sound
paul 3054 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-usb-protection
paul 3057 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-wacom
paul 3058 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-xsettings
paul 3059 2838 0 13:19 ? 00:00:00 /usr/libexec/evolution-data-server/evolution-alarm-notify
paul 3064 2838 0 13:19 ? 00:00:00 /usr/bin/kalendarac
paul 3070 2838 0 13:19 ? 00:00:00 /usr/libexec/gsd-disk-utility-notify
paul 3088 2838 0 13:19 ? 00:00:00 /usr/bin/kdeconnectd
paul 3168 1155 0 13:19 ? 00:00:00 /usr/bin/gjs -m /usr/share/gnome-shell/org.gnome.ScreenSaver
paul 3172 1155 0 13:19 ? 00:00:00 /usr/libexec/gsd-printer
paul 3207 3012 0 13:19 ? 00:00:00 /usr/libexec/ibus-memconf
paul 3208 3012 0 13:19 ? 00:00:06 /usr/libexec/ibus-extension-gtk3
paul 3214 1155 0 13:19 ? 00:00:00 /usr/libexec/ibus-x11 --kill-daemon
paul 3216 1155 0 13:19 ? 00:00:00 /usr/libexec/ibus-portal
paul 3218 1155 0 13:19 ? 00:00:00 /usr/libexec/localsearch-3
paul 3219 1155 0 13:19 ? 00:00:00 /usr/libexec/xdg-desktop-portal-gnome
paul 3241 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-udisks2-volume-monitor
paul 3251 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-mtp-volume-monitor
paul 3259 1155 0 13:19 ? 00:00:00 /usr/libexec/gvfs-gphoto2-volume-monitor
paul 3265 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfs-goa-volume-monitor
paul 3271 1155 0 13:20 ? 00:00:00 /usr/libexec/goa-daemon
paul 3280 1155 0 13:20 ? 00:00:00 /usr/libexec/goa-identity-service
paul 3287 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfs-afc-volume-monitor
paul 3303 3012 0 13:20 ? 00:00:02 /usr/libexec/ibus-engine-simple
paul 3372 1155 0 13:20 ? 00:00:00 /usr/libexec/xdg-desktop-portal-gtk
paul 3441 1155 0 13:20 ? 00:00:00 /usr/libexec/gvfsd-metadata
paul 3453 1155 0 13:20 ? 00:00:00 /usr/libexec/evolution-calendar-factory
paul 3495 1155 0 13:20 ? 00:00:00 /usr/libexec/evolution-addressbook-factory
paul 4798 1155 0 13:26 ? 00:00:09 /usr/libexec/gnome-terminal-server
paul 4810 4798 0 13:26 pts/0 00:00:00 bash
paul 8614 1155 0 13:29 ? 00:00:01 /usr/bin/speech-dispatcher -s -t 0
paul 8656 8614 0 13:29 ? 00:00:00 [sd_espeak-ng-mb] <defunct>
paul 8709 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/espeak-ng.conf
paul 8785 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_dummy /etc/speech-dispatcher/modules/dummy.conf
paul 8799 8614 0 13:29 ? 00:00:00 /usr/lib/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/
paul 10028 1155 0 13:31 ? 00:00:00 adb -L tcp:5037 fork-server server --reply-fd 4
paul 69578 2814 0 13:53 ? 00:00:00 /usr/libexec/gvfsd-http --spawner :1.22 /org/gtk/gvfs/exec_spaw/0
paul 108341 1155 3 14:06 ? 00:00:48 /home/paul/.nvm/versions/node/v22.23.0/bin/node /home/paul/.nvm/versions/node/v22.23.0/lib/node_modules/acpx/dist/cli.js __queue-owner
paul 108416 108341 0 14:06 ? 00:00:00 openclaw
paul 108458 108416 2 14:06 ? 00:00:37 openclaw-acp
paul 143553 1155 0 14:19 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpI2JxLw.tmp
paul 149205 1155 0 14:21 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpitRyQG.tmp
paul 151724 1155 0 14:22 ? 00:00:04 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpJEsOZV.tmp
paul 157447 1155 1 14:24 ? 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpyM92DV.tmp
paul 165231 1155 0 14:26 ? 00:00:01 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmp5CKC19.tmp
paul 168472 1155 4 14:27 ? 00:00:09 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpuRJsnQ.tmp
paul 172147 1155 4 14:29 pts/0 00:00:05 /home/paul/Workspace/yavsc/src/PostIt.Tests/bin/Debug/net10.0/PostIt.Tests @@ /tmp/tmpxT8nje.tmp
paul 172435 4810 99 14:31 pts/0 00:00:00 ps -fu paul

View file

@ -15,7 +15,7 @@
<PackageVersion Include="Material.Avalonia" Version="3.17.0" /> <PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" /> <PackageVersion Include="Microsoft.Maui.Essentials" Version="10.0.90" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0.11" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -14,7 +14,7 @@
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers> <RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -31,6 +31,6 @@
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" /> <PackageReference Include="Microsoft.Maui.Essentials" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -6,8 +6,10 @@ using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication; using Microsoft.Maui.ApplicationModel.Communication;
using Microsoft.Maui.ApplicationModel; using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices; using Microsoft.Maui.Devices;
using PostIt.Services;
using System.Linq;
namespace PostIt.Services; namespace PostIt.Android.Services;
/// <summary> /// <summary>
/// Mobile implementation backed by MAUI Essentials /// Mobile implementation backed by MAUI Essentials
@ -49,7 +51,7 @@ public sealed class ContactService : IContactService
// shape is intentionally richer than the Yavsc // shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows // directory's single-Email shape — the two flows
// answer different questions. // answer different questions.
var result = new List<ContactDto>(contacts.Count); var result = new List<ContactDto>(contacts.Count());
foreach (var c in contacts) foreach (var c in contacts)
{ {
var emails = ExtractEmails(c.Emails); var emails = ExtractEmails(c.Emails);
@ -67,7 +69,7 @@ public sealed class ContactService : IContactService
} }
} }
private static IReadOnlyList<string> ExtractEmails(IEnumerable<EmailAddress>? emails) private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
{ {
if (emails is null) return Array.Empty<string>(); if (emails is null) return Array.Empty<string>();
var list = new List<string>(); var list = new List<string>();

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -3,9 +3,7 @@
xmlns:local="using:PostIt" xmlns:local="using:PostIt"
x:Class="PostIt.App"> x:Class="PostIt.App">
<Application.DataTemplates> <!-- ViewLocator is registered in App.axaml.cs with the real DI container. -->
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Avalonia; using Avalonia;
@ -48,71 +49,11 @@ public partial class App : Application
// build is ever reconfigured to skip the early check. // build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
var settings = new Settings(); this.ServiceProvider = BuildServices(new ServiceCollection());
settings.Load(); AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
var tokenStore = new TokenStore(System.IO.Path.Combine( var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), var api = ServiceProvider.GetRequiredService<YavscApiClient>();
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
var services = new ServiceCollection();
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
ServiceProvider = services.BuildServiceProvider();
// Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates) still gets
// the same instance the rest of the app is using. Idempotent:
// re-binding from a second App boot (tests) is a no-op.
Settings.BindToServiceProvider(ServiceProvider);
DataTemplates.Clear(); DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider)); DataTemplates.Add(new ViewLocator(ServiceProvider));
@ -146,8 +87,7 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var homePage = ServiceProvider.GetRequiredService<HomePage>(); var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow(); window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus; window.SessionBanner.DataContext = sessionStatus;
@ -155,9 +95,8 @@ public partial class App : Application
// Build the navigation stack from scratch: HomePage is the // Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on // root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds. // top if the silent refresh succeeds.
window.DataContext = homePage.DataContext;
desktop.MainWindow = window; desktop.MainWindow = window;
_ = window.NavRoot.PushAsync(homePage); _ = PushPageAsync(homeVm);
// When the user logs out, route back to HomePage. We // When the user logs out, route back to HomePage. We
// ReplaceAsync the current top so we don't grow the stack // ReplaceAsync the current top so we don't grow the stack
@ -167,8 +106,6 @@ public partial class App : Application
{ {
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot; var nav = w.NavRoot;
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync(); _ = nav.PopToRootAsync();
}; };
@ -180,35 +117,7 @@ public partial class App : Application
_ = PushMainPageAsync(); _ = PushMainPageAsync();
}; };
// When the user clicks the "Paramètres" button on the window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
// session banner, push the SettingsPage singleton on top
// of the current navigation stack. The DataContext is
// already wired at composition time (see the
// provider.GetRequiredService<SettingsPage>().DataContext
// assignment above), so this handler is a pure
// navigation concern.
//
// Anti-empilement guard: if the SettingsPage is already
// at the top of the stack, do nothing. NavigationPage's
// PushAsync does not deduplicate; calling it twice with
// the same instance would push it a second time and the
// user would have to tap Back twice to leave. Reference
// comparison is correct here because SettingsPage is a
// singleton — there is exactly one instance to compare
// against.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = ServiceProvider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return;
}
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(ServiceProvider, api);
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{ {
@ -219,6 +128,110 @@ public partial class App : Application
} }
} }
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> boot
/// without going through the full Avalonia desktop lifetime
/// (which never runs in a unit test). The container returned is
/// the exact one production uses — no test-only fakes, no
/// trimmed service list — so a test that exercises a VM, page,
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
internal static IServiceProvider BuildServices(ServiceCollection services)
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
/// <summary>
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{
ServiceProvider = sp;
Settings.BindToServiceProvider(sp);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
private static void ApplyDarkMode(Settings settings) private static void ApplyDarkMode(Settings settings)
{ {
Application.Current!.RequestedThemeVariant = Application.Current!.RequestedThemeVariant =
@ -246,19 +259,18 @@ public partial class App : Application
} }
/// <summary> /// <summary>
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top /// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/> /// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c> /// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so /// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart. /// the two callers can't drift apart.
/// </summary> /// </summary>
public static async Task PushMainPageAsync() public static Task PushMainPageAsync()
{ {
var app = (App)Current; var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>(); var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>(); return app.PushPageAsync(mainVm);
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -293,4 +305,53 @@ public partial class App : Application
return true; return true;
} }
internal void PushPage(ViewModelBase vm)
{
_ = PushPageAsync(vm);
}
internal Task PushPageAsync(ViewModelBase vm)
{
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = DataTemplates.FirstOrDefault(t => t.Match(vm));
if (template is null)
{
throw new InvalidOperationException($"No IDataTemplate found for {vm.GetType().Name}.");
}
var view = template.Build(vm);
if (view is null)
{
throw new InvalidOperationException(
$"Template for {vm.GetType().Name} returned <null>.");
}
var page = view as Page;
if (page is null)
{
// NavigationPage expects Page instances. Wrap any fallback control
// (e.g. ViewLocator error TextBlock) into a ContentPage so it can render.
page = new ContentPage { Content = view };
}
page.DataContext = vm;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = window.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return Task.CompletedTask;
}
return window.NavRoot.PushAsync(page);
}
internal async Task GoBackAsync()
{
await window.NavRoot.PopAsync();
}
} }

View file

@ -6,7 +6,7 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -21,6 +21,18 @@ public class ViewLocator : IDataTemplate
} }
public Control Build(object? data) public Control Build(object? data)
{
try
{
return BuildCore(data);
}
catch (Exception ex)
{
return new TextBlock { Text = $"ViewLocator threw: {ex}" };
}
}
private Control BuildCore(object? data)
{ {
return data switch return data switch
{ {
@ -28,6 +40,9 @@ public class ViewLocator : IDataTemplate
Settings => _services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Services; using PostIt.Services;
using PostIt.Views;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -106,7 +107,7 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
/// UI from firing an event with a null payload. /// UI from firing an event with a null payload.
/// </summary> /// </summary>
[RelayCommand] [RelayCommand]
public void Add() public async Task AddAsync()
{ {
if (Selected is null) if (Selected is null)
{ {
@ -114,5 +115,14 @@ public partial class AddCircleMemberDialogViewModel : ViewModelBase
return; return;
} }
Confirmed?.Invoke(this, Selected); Confirmed?.Invoke(this, Selected);
var app = App.Current as App;
await app.GoBackAsync();
}
[RelayCommand]
public async Task CloseAsync()
{
var app = App.Current as App;
await app.GoBackAsync();
} }
} }

View file

@ -1,9 +1,10 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
@ -63,12 +64,6 @@ public partial class CirclesPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty; public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user wants to add a member to the
/// currently selected circle. The view listens to this
/// event and opens <c>AddCircleMemberDialog</c>.
/// </summary>
public event EventHandler? AddMemberRequested;
public CirclesPageViewModel(CircleApiClient client) public CirclesPageViewModel(CircleApiClient client)
{ {
@ -116,6 +111,27 @@ public partial class CirclesPageViewModel : ViewModelBase
} }
} }
[RelayCommand]
internal async Task OpenAddMemberAsync()
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
var directory = services.GetRequiredService<IUserDirectory>();
AddCircleMemberDialogViewModel model =
new AddCircleMemberDialogViewModel(directory);
// Wire the dialog's Confirmed event to OnAddMemberConfirmedAsync.
// Without this, the dialog's "Ajouter" button fires the event
// into the void: no subscriber, the picked user is silently
// dropped, and nothing is added to the circle. The dialog
// stays open until the user uses the back gesture — which is
// how the user noticed the button was a no-op.
// Async-void is intentional here: Confirmed is an
// EventHandler<T> (returns void), and bridging to the
// async Task OnAddMemberConfirmedAsync requires it.
model.Confirmed += async (_, picked) =>
await OnAddMemberConfirmedAsync(_, picked);
await app.PushPageAsync(model);
}
/// <summary> /// <summary>
/// Load the members of one of the caller's circles. The /// Load the members of one of the caller's circles. The
/// server scopes the endpoint with a 404 when the circle /// server scopes the endpoint with a 404 when the circle
@ -231,22 +247,6 @@ public partial class CirclesPageViewModel : ViewModelBase
} }
} }
/// <summary>
/// Fire the <see cref="AddMemberRequested"/> event so
/// the view opens <c>AddCircleMemberDialog</c>. The view
/// forwards the dialog's <c>Confirmed</c> event back to
/// <see cref="OnAddMemberConfirmedAsync"/>.
/// </summary>
[RelayCommand]
public void OpenAddMember()
{
if (SelectedCircle is null)
{
StatusMessage = "Sélectionnez d'abord un cercle";
return;
}
AddMemberRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary> /// <summary>
/// Called by the view when the dialog confirms a /// Called by the view when the dialog confirms a

View file

@ -2,11 +2,14 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.Views;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -47,9 +50,6 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } public partial bool DraftIsPublished { get; set; }
[ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
[ObservableProperty] [ObservableProperty]
@ -81,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase
/// </summary> /// </summary>
public BlogApiClient? BlogClient { get; } public BlogApiClient? BlogClient { get; }
/// <summary>
/// DI container the VM uses to resolve navigation targets
/// (other ViewModels) when the user clicks a toolbar button
/// that opens a sub-screen. Owned by <c>App.ServiceProvider</c>
/// in production; injected directly in tests. The VM resolves
/// <em>ViewModels</em> via this provider, never Views — the
/// actual <see cref="Control"/> to push is decided by
/// <see cref="ViewLocator"/> at bind time, per CONTRIBUTING.md
/// §"Navigation (PostIt)".
/// </summary>
public IServiceProvider? Services { get; }
private SignaturePageViewModel? _signatureModel;
/// <summary>
/// Resolved on first access. Lazy so the test path (which
/// never pushes <c>SignaturePage</c>) does not require a
/// fully-built DI graph just to construct the VM. Mirrors the
/// pattern of <see cref="SettingsModel"/> for the Settings case.
/// </summary>
public SignaturePageViewModel SignatureModel =>
_signatureModel ??= ResolveSignatureModel();
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
private SignaturePageViewModel ResolveSignatureModel()
{
var sp = ResolveServices();
return sp.GetRequiredService<SignaturePageViewModel>();
}
private IServiceProvider ResolveServices()
{
return Services ?? (Application.Current as App)?.ServiceProvider ??
throw new InvalidOperationException(
"No IServiceProvider available for navigation. Inject one in tests " +
"or ensure App.ServiceProvider is initialized in production.");
}
public MainPageViewModel() public MainPageViewModel()
{ {
@ -115,18 +152,30 @@ public partial class MainPageViewModel : ViewModelBase
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
CurrentViewModel = this;
} }
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary> /// <summary>
/// Test-friendly constructor: caller supplies a pre-built /// Test-friendly constructor: caller supplies a pre-built
/// <see cref="BlogApiClient"/>. Production code uses the /// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below. /// (Settings, BlogApiClient) overload below.
/// </summary> /// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{ {
SettingsModel = new Settings(); SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ; BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
Init(settings); Init(settings);
} }
@ -297,10 +346,30 @@ public partial class MainPageViewModel : ViewModelBase
}); });
} }
/// <summary>
/// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis
/// received, sign here"); this command is the dev-time
/// shortcut to reach the page without that infrastructure.
/// Aligned on the same VM-first navigation pattern as
/// <see cref="OpenSettings"/>: the VM resolves the target VM
/// through <see cref="Services"/>, the <c>ViewLocator</c> picks
/// the matching <c>Control</c> at bind time. No
/// <c>Click</code> handler, no <c>App.ServiceProvider</c>
/// access from the view layer.
/// </summary>
[RelayCommand] [RelayCommand]
internal void OpenSettings() internal async Task OpenSignatureDev()
{ {
CurrentViewModel = SettingsModel; await ((App)App.Current!).PushPageAsync(SignatureModel).ConfigureAwait(true);
}
private ViewModelBase GetACLViewModel(BlogPostDto selectedPost)
{
var sp = ResolveServices();
var aclClient = sp.GetRequiredService<BlogAclApiClient>();
var circleClient = sp.GetRequiredService<CircleApiClient>();
return new PostAclDialogViewModel(selectedPost, aclClient, circleClient);
} }
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()
@ -363,42 +432,23 @@ public partial class MainPageViewModel : ViewModelBase
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
} }
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
private bool CanManageAcl() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
/// <summary>
/// Raised when the user asks to open the "manage ACL" dialog for
/// the currently selected post. The <c>MainPage</c> code-behind
/// listens to this event and pushes a <c>PostAclDialog</c> on the
/// navigation stack. The VM itself can't navigate directly
/// because the navigation surface (<c>NavigationPage</c>) lives
/// in the View layer.
/// </summary>
public event EventHandler<BlogPostDto>? ManageAclRequested;
[RelayCommand(CanExecute = nameof(CanManageAcl))] [RelayCommand(CanExecute = nameof(CanManageAcl))]
public void ManageAcl() public async Task ManageAcl()
{ {
if (SelectedPost is null) return; if (SelectedPost is null)
ManageAclRequested?.Invoke(this, SelectedPost); {
StatusMessage = "Select an existing post before managing ACL.";
return;
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
} }
/// <summary>
/// Raised when the user asks to open the circles page (full
/// CRUD on their own circles). Same routing as
/// <see cref="ManageAclRequested"/>.
/// </summary>
public event EventHandler? OpenCirclesRequested;
[RelayCommand] [RelayCommand]
public void OpenCircles() => OpenCirclesRequested?.Invoke(this, EventArgs.Empty); public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
} }

View file

@ -1,13 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -37,10 +37,12 @@ public partial class PostAclDialogViewModel : ViewModelBase
public BlogPostDto Post { get; } public BlogPostDto Post { get; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CircleDto> MyCircles { get; set; } = new(); public partial ObservableCollection<CircleDto>
MyCircles { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<CircleAuthorizationDto> AclEntries { get; set; } = new(); public partial ObservableCollection<PostAccessControlRulePayload>
AclEntries { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial CircleDto? SelectedCircleToAdd { get; set; } public partial CircleDto? SelectedCircleToAdd { get; set; }
@ -51,6 +53,22 @@ public partial class PostAclDialogViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty; public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Idempotency gate for <see cref="LoadAsync"/>: the dialog
/// attaches the load trigger in <c>DataContextChanged</c>,
/// which can fire more than once if the page is detached
/// and re-attached (dialog re-use, navigation edge cases)
/// with a different VM. Without this guard, the second load
/// would race against the first and could overwrite
/// <see cref="AclEntries"/> mid-edit. Pattern copied from
/// <c>Settings.Load</c>.
/// </summary>
private bool _loaded;
/// <summary>True once <see cref="LoadAsync"/> has run at least
/// once. Exposed for tests; do not bind from XAML.</summary>
public bool Loaded => _loaded;
public PostAclDialogViewModel( public PostAclDialogViewModel(
BlogPostDto post, BlogPostDto post,
BlogAclApiClient aclClient, BlogAclApiClient aclClient,
@ -67,6 +85,8 @@ public partial class PostAclDialogViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
public async Task LoadAsync() public async Task LoadAsync()
{ {
if (_loaded) return;
IsBusy = true; IsBusy = true;
try try
{ {
@ -80,11 +100,9 @@ public partial class PostAclDialogViewModel : ViewModelBase
var circles = circlesTask.Result ?? new List<CircleDto>(); var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles); MyCircles = new ObservableCollection<CircleDto>(circles);
var allAcl = aclTask.Result ?? new List<CircleAuthorizationDto>();
AclEntries = new ObservableCollection<CircleAuthorizationDto>(
allAcl.Where(a => a.BlogPostId == Post.Id));
StatusMessage = $"{AclEntries.Count} autorisation(s)"; StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -108,11 +126,10 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
var created = await _aclClient.GrantAsync(new CircleAuthorizationDto var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{ {
CircleId = SelectedCircleToAdd.Id, CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id, BlogPostId = Post.Id
Comment = false,
}); });
if (created is not null) if (created is not null)
{ {
@ -135,7 +152,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async Task RevokeAsync(CircleAuthorizationDto? acl) public async Task RevokeAsync(PostAccessControlRulePayload? acl)
{ {
if (acl is null) return; if (acl is null) return;
IsBusy = true; IsBusy = true;

View file

@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -32,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary> /// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded; public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty] [ObservableProperty]
public partial bool IsLoggedIn { get; private set; } public partial bool IsLoggedIn { get; private set; }
@ -144,9 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async System.Threading.Tasks.Task OpenSettingsCommand() internal async Task OpenSettings()
{ {
OpenSettingsRequested?.Invoke(); var app = (App)App.Current!;
await System.Threading.Tasks.Task.CompletedTask; await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
} }
} }

View file

@ -6,6 +6,7 @@
xmlns:services="using:PostIt.Services" xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel" x:DataType="vm:AddCircleMemberDialogViewModel"
> >
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12"> <Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button --> <!-- Search box + button -->
@ -16,7 +17,7 @@
PlaceholderText="Nom ou email d'un utilisateur Yavsc..." PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
<Button Grid.Column="1" Content="Rechercher" <Button Grid.Column="1" Content="Rechercher"
Command="{Binding SearchCommand}" Command="{Binding SearchAsync}"
Margin="8,0,0,0"/> Margin="8,0,0,0"/>
</Grid> </Grid>
@ -29,7 +30,9 @@
<!-- Search results --> <!-- Search results -->
<ListBox Grid.Row="2" <ListBox Grid.Row="2"
ItemsSource="{Binding Results}" ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}"> SelectedItem="{Binding Selected, Mode=TwoWay}"
MinHeight="20"
>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary"> <DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2"> <StackPanel Spacing="2">
@ -47,11 +50,13 @@
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" <TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter" <Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}" x:Name="AddButton"
Command="{Binding AddAsync}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}" IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/> Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer" <Button Grid.Column="2" Content="Fermer"
Click="OnCloseClicked"/> x:Name="CloseButton"
Command="{Binding CloseAsync}"/>
</Grid> </Grid>
</Grid> </Grid>
</ContentPage> </ContentPage>

View file

@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -23,12 +24,7 @@ public partial class AddCircleMemberDialog : ContentPage
public AddCircleMemberDialog() public AddCircleMemberDialog()
{ {
InitializeComponent(); InitializeComponent();
}
public AddCircleMemberDialog(IUserDirectory directory)
{
InitializeComponent();
DataContext = new AddCircleMemberDialogViewModel(directory);
} }
private void InitializeComponent() private void InitializeComponent()
@ -47,8 +43,8 @@ public partial class AddCircleMemberDialog : ContentPage
private void OnCloseClicked(object? sender, RoutedEventArgs e) private void OnCloseClicked(object? sender, RoutedEventArgs e)
{ {
// Same light-modal pattern as PostAclDialog: rely on var nav = this.FindAncestorOfType<NavigationPage>();
// the system back gesture or the navigation host's if (nav is not null)
// "pop" — the ContentPage doesn't own the back stack. _ = nav.PopAsync();
} }
} }

View file

@ -70,7 +70,7 @@
FontWeight="Bold" FontWeight="Bold"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="Ajouter un membre" <Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberCommand}"/> Command="{Binding OpenAddMemberAsync}"/>
</StackPanel> </StackPanel>
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding Members}"> ItemsSource="{Binding Members}">

View file

@ -10,46 +10,10 @@ namespace PostIt.Views;
public partial class CirclesPage : ContentPage public partial class CirclesPage : ContentPage
{ {
private CirclesPageViewModel? _vm;
public CirclesPage() public CirclesPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking
// handlers across navigation pushes / DataContext resets.
if (_vm is not null)
_vm.AddMemberRequested -= OnAddMemberRequested;
_vm = DataContext as CirclesPageViewModel;
if (_vm is not null)
_vm.AddMemberRequested += OnAddMemberRequested;
}
private void OnAddMemberRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || _vm is null) return;
// Resolve the directory via DI. The dialog raises its
// own Confirmed event; the VM subscribes via the method
// below — we pass the VM in so the closure can call
// back into it without the dialog needing to know the
// type of its caller. EventHandler<UserSummary> wants a
// void return, so wrap the async VM method in a fire-
// and-forget helper.
var directory = services.GetRequiredService<IUserDirectory>();
var dialog = new AddCircleMemberDialog(directory);
dialog.ViewModel!.Confirmed += async (sender, picked) =>
await _vm.OnAddMemberConfirmedAsync(sender, picked);
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
} }
private void InitializeComponent() private void InitializeComponent()

View file

@ -33,8 +33,12 @@
<Button Command="{Binding Search}" Content="Filter" /> <Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" /> <Button Command="{Binding Save}" Content="Save" />
<Button Command="{Binding Delete}" Content="Delete" /> <Button Command="{Binding Delete}" Content="Delete" />
<Button Command="{Binding ManageAcl}" Content="ACL" /> <Button x:Name="ManageAclButton"
<Button Command="{Binding OpenCircles}" Content="Mes cercles" /> Command="{Binding ManageAcl}"
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCircles}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to <!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires DraftIsPublished. Clicking it fires
TogglePublishCommand, which pushes the TogglePublishCommand, which pushes the
@ -55,15 +59,15 @@
MainPage.axaml.cs once the SignalR handler lands. MainPage.axaml.cs once the SignalR handler lands.
--> -->
<Button x:Name="OpenSignatureDevButton" <Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature" Content="[DEV] Signature"
Click="OpenSignatureDev"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" /> ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8"> <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" <ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="40">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4"> <StackPanel Spacing="4">

View file

@ -1,11 +1,4 @@
using System;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
namespace PostIt.Views; namespace PostIt.Views;
@ -14,81 +7,5 @@ public partial class MainPage : ContentPage
public MainPage() public MainPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
MainPageViewModel? _vm;
void OnDataContextChanged(object? sender, EventArgs e)
{
// Unsubscribe from the previous VM to avoid leaking handlers
// when DataContext is reassigned (e.g. by the navigation
// host or a binding reset).
if (_vm is not null)
{
_vm.ManageAclRequested -= OnManageAclRequested;
_vm.OpenCirclesRequested -= OnOpenCirclesRequested;
}
_vm = DataContext as MainPageViewModel;
if (_vm is not null)
{
_vm.ManageAclRequested += OnManageAclRequested;
_vm.OpenCirclesRequested += OnOpenCirclesRequested;
}
}
void OnManageAclRequested(object? sender, BlogPostDto post)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null || post is null) return;
var dialog = new PostAclDialog(
post,
services.GetRequiredService<BlogAclApiClient>(),
services.GetRequiredService<CircleApiClient>());
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(dialog);
}
void OnOpenCirclesRequested(object? sender, EventArgs e)
{
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<CirclesPage>();
page.DataContext = services.GetRequiredService<CirclesPageViewModel>();
if (this.VisualRoot is MainWindow window)
_ = window.NavRoot.PushAsync(page);
}
/// <summary>
/// DEV ONLY: temporary shortcut to open the signature capture
/// page from the blog editor. The production entry point is a
/// SignalR push from Yavsc.Org ("devis received, sign here"),
/// which is the only path that carries the devis identifier
/// needed to bind the capture to a specific contract.
///
/// Remove this method and the corresponding button in
/// MainPage.axaml.cs once the SignalR handler lands.
/// </summary>
private void OpenSignatureDev(object? sender, RoutedEventArgs e)
{
// Resolve via the App's DI container so the page gets
// the canonical services (Api client, settings, ...).
var app = Application.Current as App;
var services = app?.ServiceProvider;
if (services is null) return;
var page = services.GetRequiredService<SignaturePage>();
page.DataContext = services.GetRequiredService<SignaturePageViewModel>();
if (this.VisualRoot is MainWindow window)
{
_ = window.NavRoot.PushAsync(page);
}
} }
} }

View file

@ -4,6 +4,7 @@
x:Class="PostIt.Views.PostAclDialog" x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos" xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
x:DataType="vm:PostAclDialogViewModel" x:DataType="vm:PostAclDialogViewModel"
> >
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12"> <Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
@ -31,13 +32,11 @@
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}"> ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleAuthorizationDto"> <DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}" <TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/> FontWeight="Bold"/>
<TextBlock Text="{Binding Comment, StringFormat='Commentaires : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Révoquer" <Button Grid.Column="1" Content="Révoquer"
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}" Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"

View file

@ -1,3 +1,4 @@
using System;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -9,21 +10,53 @@ namespace PostIt.Views;
/// <summary> /// <summary>
/// Modal "manage ACL" page for a single blog post. /// Modal "manage ACL" page for a single blog post.
/// ///
/// <para>The ViewModel is constructed here (not via DI) because it /// <para>The ViewModel is constructed by the caller (the post
/// depends on the post being managed, which the caller (the post /// list page) and handed to <see cref="App.PushPageAsync"/>,
/// list page) only knows at the moment it opens the dialog. The /// which routes through <see cref="ViewLocator"/> and lands
/// DI container can build the two API clients; the post and the /// here via the parameterless DI constructor. The VM is then
/// VM are wired together here.</para> /// assigned to <see cref="ContentPage.DataContext"/> by
/// <c>App.PushPageAsync</c> — we listen for that one-shot
/// assignment and trigger <c>LoadAsync</c> right after, so the
/// dropdown's <c>MyCircles</c> and the list's <c>AclEntries</c>
/// are populated when the dialog appears. The VM is idempotent
/// under repeated loads.</para>
/// </summary> /// </summary>
public partial class PostAclDialog : ContentPage public partial class PostAclDialog : ContentPage
{ {
public PostAclDialog() public PostAclDialog()
{ {
InitializeComponent(); InitializeComponent();
// App.PushPageAsync wires the VM via DataContext after
// building the page. We subscribe once to fire LoadAsync
// the moment the VM is attached. Using DataContextChanged
// (rather than AttachedToVisualTree) is what makes this
// work in the headless test harness too: the load is
// tied to the VM being available, not to the visual tree
// being realised (which is a separate concern).
EventHandler? handler = null;
handler = (_, _) =>
{
if (DataContext is PostAclDialogViewModel vm)
{
this.DataContextChanged -= handler;
_ = vm.LoadAsync();
}
};
this.DataContextChanged += handler;
} }
public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient) public PostAclDialog(BlogPostDto post, BlogAclApiClient aclClient, CircleApiClient circleClient)
{ {
// This overload is not used by the production path —
// MainPageViewModel pushes the VM via App.PushPageAsync
// and App routes through ViewLocator, which resolves this
// page via the parameterless ctor. It is kept so test
// scaffolding that wants to bypass the nav pipeline can
// still wire a VM directly without losing the load
// trigger: the constructor sets DataContext before the
// DataContextChanged subscription fires, so the load
// is guaranteed to run in either case.
InitializeComponent(); InitializeComponent();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient); DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
} }

View file

@ -8,8 +8,8 @@ namespace Yavsc.ViewModels.Account
public class RegisterModel public class RegisterModel
{ {
[StringLength(YavscConstants.MaxUserNameLength)] [StringLength(Constants.MaxUserNameLength)]
[RegularExpression(YavscConstants.UserNameRegExp)] [RegularExpression(Constants.UserNameRegExp)]
[DataType(DataType.Text)] [DataType(DataType.Text)]
[Display(Name = "UserName", Description = "User name")] [Display(Name = "UserName", Description = "User name")]
public string UserName { get; set; } public string UserName { get; set; }

View file

@ -0,0 +1,33 @@
namespace Yavsc.Blogspot;
/// <summary>
/// Minimum-viable author payload embedded in <see cref="BlogPostDto"/>.
///
/// <para>
/// Before this record existed, <c>BlogPostDto.Author</c> was typed
/// as the abstract interface <c>IApplicationUser</c>. The
/// interface is fine for server-side contract (we have a concrete
/// entity that implements it) but System.Text.Json cannot
/// materialise an interface without a polymorphic converter
/// configured on both ends. PostIt would crash on load-posts
/// because the JSON contained an <c>Author</c> object that the
/// client could not deserialise.
/// </para>
///
/// <para>
/// This record is the wire shape: <c>Id</c> for "go to author
/// profile", <c>UserName</c> for "by @username", <c>Avatar</c>
/// for the round badge next to the title. The server-side
/// <c>BlogPost</c> entity (<c>Yavsc.Server.Models.Blog</c>) keeps
/// its full <c>ApplicationUser</c> navigation property for
/// permission checks and authorisation; the DTO is built on
/// demand by the controller / service layer when the post is
/// served to the wire.
/// </para>
/// </summary>
public sealed record BlogPostAuthorDto
{
public string Id { get; init; } = string.Empty;
public string? UserName { get; init; }
public string? Avatar { get; init; }
}

View file

@ -1,5 +1,3 @@
using System;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Blogspot; namespace Yavsc.Blogspot;
@ -8,7 +6,7 @@ public class BlogPostDto : IBlogPost
{ {
public string AuthorId { get; set; } public string AuthorId { get; set; }
public IApplicationUser Author { get; set; } public BlogPostAuthorDto? Author { get; set; }
public string Article { get; set ; } public string Article { get; set ; }
public string Photo { get; set ; } public string Photo { get; set ; }
@ -31,18 +29,17 @@ public class BlogPostDto : IBlogPost
/// </summary> /// </summary>
public bool IsPublished { get; set; } public bool IsPublished { get; set; }
public bool AuthorizeCircle(long circleId) public virtual bool AuthorizeCircle(long circleId)
{ {
throw new NotImplementedException(); ACL.Add(new CircleAuthorization { CircleId = circleId });
return true;
} }
public ICircleAuthorization[] GetACL() private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
{
throw new NotImplementedException();
}
public string[] GetTags() public string[] Tags { get; set; }
{
throw new NotImplementedException(); public string[] GetTags() => Tags;
}
public ICircleAuthorization[] GetACL() => ACL.ToArray();
} }

View file

@ -1,7 +1,6 @@
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using Yavsc.Interfaces; using Yavsc.Interfaces;
@ -9,6 +8,11 @@ namespace Yavsc.Blogspot
{ {
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
{ {
IApplicationUser Author { get; } // Typed as a concrete wire DTO (not the IApplicationUser
// interface) so System.Text.Json can materialise it on the
// client without a polymorphic converter. The server-side
// BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; }
} }
} }

View file

@ -0,0 +1,10 @@
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : ICircleAuthorization
{
public long CircleId { get; set; }
public long BlogPostId { get; set; }
}

View file

@ -3,8 +3,10 @@ using Yavsc.Models.Auth;
namespace Yavsc namespace Yavsc
{ {
public static class YavscConstants public static class Constants
{ {
public const string APIPrefix = "api/v1";
public static readonly Scope[] SiteScopes = { public static readonly Scope[] SiteScopes = {
new Scope { Id = "profile", Description = "Your profile informations" }, new Scope { Id = "profile", Description = "Your profile informations" },
new Scope { Id = "book" , Description ="Your booking interface"}, new Scope { Id = "book" , Description ="Your booking interface"},

View file

@ -1,4 +1,4 @@
namespace Yavsc.Api.Client.Dtos; namespace Yavsc.Abstract.Identity.Security;
/// <summary> /// <summary>
/// Wire format for <c>GET /api/blogacl</c> and friends. /// Wire format for <c>GET /api/blogacl</c> and friends.
@ -11,9 +11,7 @@ namespace Yavsc.Api.Client.Dtos;
/// UI already has the post, and the circles are looked up by id /// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para> /// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary> /// </summary>
public sealed class CircleAuthorizationDto public sealed class CircleAuthorization : ICircleAuthorization
{ {
public long CircleId { get; set; } public long CircleId { get; set; }
public long BlogPostId { get; set; }
public bool Comment { get; set; }
} }

View file

@ -19,7 +19,7 @@ namespace Yavsc.Abstract.Identity
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Le path retourné est aligné sur /// Le path retourné est aligné sur
/// <see cref="YavscConstants.AvatarsPath"/> (minuscule). /// <see cref="Constants.AvatarsPath"/> (minuscule).
/// Les anciens display templates utilisaient "/Avatars/" /// Les anciens display templates utilisaient "/Avatars/"
/// avec un S majuscule, en désaccord avec le path statique /// avec un S majuscule, en désaccord avec le path statique
/// servi par le middleware de fichiers — les images ne /// servi par le middleware de fichiers — les images ne
@ -29,8 +29,8 @@ namespace Yavsc.Abstract.Identity
public static string AvatarSrc(IApplicationUser? user) public static string AvatarSrc(IApplicationUser? user)
{ {
if (user==null || string.IsNullOrWhiteSpace(user?.UserName)) if (user==null || string.IsNullOrWhiteSpace(user?.UserName))
return YavscConstants.DefaultAvatar; return Constants.DefaultAvatar;
return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png"; return $"{Constants.AvatarsPath}/{user!.UserName}.s.png";
} }
} }
} }

View file

@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client; namespace Yavsc.Api.Client;
@ -10,7 +12,7 @@ namespace Yavsc.Api.Client;
/// <summary> /// <summary>
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server. /// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
/// ///
/// <para>Each <see cref="CircleAuthorizationDto"/> grants a single /// <para>Each <see cref="CircleAuthorization"/> grants a single
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server /// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
/// scopes every endpoint to the caller's uid: only the author of /// scopes every endpoint to the caller's uid: only the author of
/// the underlying blog post can list, create, modify, or delete /// the underlying blog post can list, create, modify, or delete
@ -32,16 +34,16 @@ public sealed class BlogAclApiClient
api.Http.BaseAddress = new Uri(blogsBaseAddress); api.Http.BaseAddress = new Uri(blogsBaseAddress);
} }
public Task<List<CircleAuthorizationDto>> GetMyAclAsync(CancellationToken ct = default) public Task<List<PostAccessControlRulePayload>> GetMyAclAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleAuthorizationDto>>(HttpMethod.Get, Path, ct: ct); => _api.CallAsync<List<PostAccessControlRulePayload>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleAuthorizationDto?> GetAclAsync(long circleId, CancellationToken ct = default) public Task<PostAccessControlRulePayload?> GetAclAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct); => _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
public Task<CircleAuthorizationDto?> GrantAsync(CircleAuthorizationDto acl, CancellationToken ct = default) public Task<PostAccessControlRulePayload?> GrantAsync(PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync<CircleAuthorizationDto?>(HttpMethod.Post, Path, body: acl, ct: ct); => _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Post, Path, body: acl, ct: ct);
public Task UpdateAclAsync(long circleId, CircleAuthorizationDto acl, CancellationToken ct = default) public Task UpdateAclAsync(long circleId, PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct); => _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
public Task RevokeAsync(long circleId, CancellationToken ct = default) public Task RevokeAsync(long circleId, CancellationToken ct = default)

View file

@ -15,10 +15,10 @@
</Description> </Description>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.0.1.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.0.1-5</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" /> <PackageReference Include="GitVersion.MsBuild" />

View file

@ -14,7 +14,7 @@ using Yavsc.Models.Workflow;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/activity")] [Route(Constants.APIPrefix + "/activity")]
public class ActivityApiController : Controller public class ActivityApiController : Controller
{ {
private ApplicationDbContext _context; private ApplicationDbContext _context;

View file

@ -19,7 +19,7 @@ namespace Yavsc.ApiControllers
using Yavsc.ViewModels.Auth; using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
[Route("api/bill"), Authorize] [Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller public class BillingController : Controller
{ {
readonly ApplicationDbContext dbContext; readonly ApplicationDbContext dbContext;

View file

@ -18,7 +18,7 @@ namespace Yavsc.Controllers
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
[Produces("application/json")] [Produces("application/json")]
[Route("api/bookquery"), Authorize("Performer")] [Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
public class BookQueryApiController : Controller public class BookQueryApiController : Controller
{ {
private ApplicationDbContext _context; private ApplicationDbContext _context;

View file

@ -15,7 +15,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/estimate"), Authorize] [Route(Constants.APIPrefix + "/estimate"), Authorize]
public class EstimateApiController : Controller public class EstimateApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -27,12 +27,12 @@ namespace Yavsc.Controllers
} }
bool UserIsAdminOrThis(string uid) bool UserIsAdminOrThis(string uid)
{ {
if (User.IsInRole(YavscConstants.AdminGroupName)) return true; if (User.IsInRole(Constants.AdminGroupName)) return true;
return uid == User.GetUserId(); return uid == User.GetUserId();
} }
bool UserIsAdminOrInThese(string oid, string uid) bool UserIsAdminOrInThese(string oid, string uid)
{ {
if (User.IsInRole(YavscConstants.AdminGroupName)) return true; if (User.IsInRole(Constants.AdminGroupName)) return true;
var cuid = User.GetUserId(); var cuid = User.GetUserId();
return cuid == uid || cuid == oid; return cuid == uid || cuid == oid;
} }
@ -82,7 +82,7 @@ namespace Yavsc.Controllers
return BadRequest(); return BadRequest();
} }
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
{ {
if (uid != estimate.OwnerId) if (uid != estimate.OwnerId)
{ {
@ -118,7 +118,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimate.OwnerId == null) estimate.OwnerId = uid; if (estimate.OwnerId == null) estimate.OwnerId = uid;
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
{ {
if (uid != estimate.OwnerId) if (uid != estimate.OwnerId)
{ {
@ -187,7 +187,7 @@ namespace Yavsc.Controllers
return NotFound(); return NotFound();
} }
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
{ {
if (uid != estimate.OwnerId) if (uid != estimate.OwnerId)
{ {

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/EstimateTemplatesApi")] [Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
public class EstimateTemplatesApiController : Controller public class EstimateTemplatesApiController : Controller
{ {
private ApplicationDbContext _context; private ApplicationDbContext _context;
@ -62,7 +62,7 @@ namespace Yavsc.Controllers
} }
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid) if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden); return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.Entry(estimateTemplate).State = EntityState.Modified; _context.Entry(estimateTemplate).State = EntityState.Modified;
@ -132,7 +132,7 @@ namespace Yavsc.Controllers
} }
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (estimateTemplate.OwnerId!=uid) if (estimateTemplate.OwnerId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden); return new StatusCodeResult(StatusCodes.Status403Forbidden);
_context.EstimateTemplates.Remove(estimateTemplate); _context.EstimateTemplates.Remove(estimateTemplate);

View file

@ -8,7 +8,7 @@ using Yavsc.ViewModels.FrontOffice;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
[Route("api/front")] [Route(Constants.APIPrefix + "/front")]
public class FrontOfficeApiController : Controller public class FrontOfficeApiController : Controller
{ {
ApplicationDbContext dbContext; ApplicationDbContext dbContext;

View file

@ -6,7 +6,7 @@ using Yavsc.Models;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
[Route("api/payment")] [Route(Constants.APIPrefix + "/payment")]
public class PaymentApiController : Controller public class PaymentApiController : Controller
{ {
private readonly ApplicationDbContext dbContext; private readonly ApplicationDbContext dbContext;

View file

@ -11,7 +11,7 @@ namespace Yavsc.Controllers
using Yavsc.Services; using Yavsc.Services;
[Produces("application/json")] [Produces("application/json")]
[Route("api/performers")] [Route(Constants.APIPrefix + "/performers")]
public class PerformersApiController : Controller public class PerformersApiController : Controller
{ {
ApplicationDbContext dbContext; ApplicationDbContext dbContext;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/ProductApi")] [Route(Constants.APIPrefix + "/ProductApi")]
public class ProductApiController : Controller public class ProductApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
} }
// PUT: api/ProductApi/5 // PUT: api/ProductApi/5
[HttpPut("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)] [HttpPut("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PutProduct(long id, [FromBody] Product product) public IActionResult PutProduct(long id, [FromBody] Product product)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
} }
// POST: api/ProductApi // POST: api/ProductApi
[HttpPost,Authorize(YavscConstants.FrontOfficeGroupName)] [HttpPost,Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PostProduct([FromBody] Product product) public IActionResult PostProduct([FromBody] Product product)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
} }
// DELETE: api/ProductApi/5 // DELETE: api/ProductApi/5
[HttpDelete("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)] [HttpDelete("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult DeleteProduct(long id) public IActionResult DeleteProduct(long id)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/bursherprofiles")] [Route(Constants.APIPrefix + "/bursherprofiles")]
public class BursherProfilesApiController : Controller public class BursherProfilesApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -24,7 +24,7 @@ namespace Yavsc.ApiControllers
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
[Route("api/haircut")][Authorize] [Route(Constants.APIPrefix + "/haircut")][Authorize]
public class HairCutController : Controller public class HairCutController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -6,7 +6,7 @@ using Yavsc.Models.Relationship;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/hyperlink")] [Route(Constants.APIPrefix + "/hyperlink")]
public class HyperLinkApiController : Controller public class HyperLinkApiController : Controller
{ {
private ApplicationDbContext _context; private ApplicationDbContext _context;

View file

@ -7,7 +7,7 @@ using Yavsc.Server.Models.IT.SourceCode;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/GitRefsApi")] [Route(Constants.APIPrefix + "/GitRefsApi")]
[Authorize("AdministratorOnly")] [Authorize("AdministratorOnly")]
public class GitRefsApiController : Controller public class GitRefsApiController : Controller
{ {

View file

@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
[Route("api/mailtemplate")] [Route(Constants.APIPrefix + "/mailtemplate")]
public class MailTemplatingApiController: Controller public class MailTemplatingApiController: Controller
{ {

View file

@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/mailing")] [Route(Constants.APIPrefix + "/mailing")]
[Authorize("AdministratorOnly")] [Authorize("AdministratorOnly")]
public class MailingTemplateApiController : Controller public class MailingTemplateApiController : Controller
{ {

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/museprefs")] [Route(Constants.APIPrefix + "/museprefs")]
public class MusicalPreferencesApiController : Controller public class MusicalPreferencesApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/MusicalTendenciesApi")] [Route(Constants.APIPrefix + "/MusicalTendenciesApi")]
public class MusicalTendenciesApiController : Controller public class MusicalTendenciesApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -37,7 +37,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (blogpost.AuthorId!=uid) if (blogpost.AuthorId!=uid)
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
return BadRequest(); return BadRequest();
_context.SaveChanges(User.GetUserId()); _context.SaveChanges(User.GetUserId());

View file

@ -7,7 +7,7 @@ namespace Yavsc.ApiControllers
/// <summary> /// <summary>
/// Base class for managing performers profiles /// Base class for managing performers profiles
/// </summary> /// </summary>
[Produces("application/json"),Route("api/profile")] [Produces("application/json"),Route(Constants.APIPrefix + "/profile")]
public abstract class ProfileApiController<T> : Controller public abstract class ProfileApiController<T> : Controller
{ public ProfileApiController() { public ProfileApiController()
{ {

View file

@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/blacklist"), Authorize] [Route(Constants.APIPrefix + "/blacklist"), Authorize]
public class BlackListApiController : Controller public class BlackListApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -50,8 +50,8 @@ namespace Yavsc.Controllers
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != blackListed.OwnerId) if (uid != blackListed.OwnerId)
if (!User.IsInRole(YavscConstants.AdminGroupName)) if (!User.IsInRole(Constants.AdminGroupName))
if (!User.IsInRole(YavscConstants.FrontOfficeGroupName)) if (!User.IsInRole(Constants.FrontOfficeGroupName))
return false; return false;
return true; return true;
} }

View file

@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Route("api/chat")] [Route(Constants.APIPrefix + "/chat")]
public class ChatApiController : Controller public class ChatApiController : Controller
{ {
readonly ApplicationDbContext dbContext; readonly ApplicationDbContext dbContext;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/ChatRoomAccessApi")] [Route(Constants.APIPrefix + "/ChatRoomAccessApi")]
public class ChatRoomAccessApiController : Controller public class ChatRoomAccessApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid != chatRoomAccess.UserId && uid != chatRoomAccess.Room.OwnerId if (uid != chatRoomAccess.UserId && uid != chatRoomAccess.Room.OwnerId
&& ! User.IsInMsRole(YavscConstants.AdminGroupName)) && ! User.IsInMsRole(Constants.AdminGroupName))
{ {
ModelState.AddModelError("UserId","get refused"); ModelState.AddModelError("UserId","get refused");
@ -72,7 +72,7 @@ namespace Yavsc.Controllers
} }
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName ); var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (uid != room.OwnerId && ! User.IsInMsRole(YavscConstants.AdminGroupName)) if (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName))
{ {
ModelState.AddModelError("ChannelName", "access put refused"); ModelState.AddModelError("ChannelName", "access put refused");
return BadRequest(ModelState); return BadRequest(ModelState);
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName ); var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(YavscConstants.AdminGroupName))) if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(Constants.AdminGroupName)))
{ {
ModelState.AddModelError("ChannelName", "access post refused"); ModelState.AddModelError("ChannelName", "access post refused");
return BadRequest(ModelState); return BadRequest(ModelState);
@ -154,7 +154,7 @@ namespace Yavsc.Controllers
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName ); var room = _context.ChatRoom.First(channel => channel.Name == chatRoomAccess.ChannelName );
if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(YavscConstants.AdminGroupName))) if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(Constants.AdminGroupName)))
{ {
ModelState.AddModelError("UserId", "access drop refused"); ModelState.AddModelError("UserId", "access drop refused");
return BadRequest(ModelState); return BadRequest(ModelState);

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/ChatRoomApi")] [Route(Constants.APIPrefix + "/ChatRoomApi")]
public class ChatRoomApiController : Controller public class ChatRoomApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -137,7 +137,7 @@ namespace Yavsc.Controllers
if (User.GetUserId() != chatRoom.OwnerId ) if (User.GetUserId() != chatRoom.OwnerId )
{ {
if (!User.IsInMsRole(YavscConstants.AdminGroupName)) if (!User.IsInMsRole(Constants.AdminGroupName))
return BadRequest(new {error = "OwnerId"}); return BadRequest(new {error = "OwnerId"});
} }

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/ContactsApi")] [Route(Constants.APIPrefix + "/ContactsApi")]
public class ContactsApiController : Controller public class ContactsApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/ServiceApi")] [Route(Constants.APIPrefix + "/ServiceApi")]
public class ServiceApiController : Controller public class ServiceApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -46,7 +46,7 @@ namespace Yavsc.Controllers
} }
// PUT: api/ServiceApi/5 // PUT: api/ServiceApi/5
[HttpPut("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)] [HttpPut("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PutService(long id, [FromBody] Service service) public IActionResult PutService(long id, [FromBody] Service service)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -81,7 +81,7 @@ namespace Yavsc.Controllers
} }
// POST: api/ServiceApi // POST: api/ServiceApi
[HttpPost,Authorize(YavscConstants.FrontOfficeGroupName)] [HttpPost,Authorize(Constants.FrontOfficeGroupName)]
public IActionResult PostService([FromBody] Service service) public IActionResult PostService([FromBody] Service service)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
@ -110,7 +110,7 @@ namespace Yavsc.Controllers
} }
// DELETE: api/ServiceApi/5 // DELETE: api/ServiceApi/5
[HttpDelete("{id}"),Authorize(YavscConstants.FrontOfficeGroupName)] [HttpDelete("{id}"),Authorize(Constants.FrontOfficeGroupName)]
public IActionResult DeleteService(long id) public IActionResult DeleteService(long id)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)

View file

@ -13,7 +13,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json"),Authorize("AdministratorOnly")] [Produces("application/json"),Authorize("AdministratorOnly")]
[Route("api/users")] [Route(Constants.APIPrefix + "/users")]
public class ApplicationUserApiController : Controller public class ApplicationUserApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,221 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
/// <c>POST /api/v1/blogacl</c> with a JSON body of
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
///
/// <para>Same fixture as <see cref="CircleMembersApiTests"/>:
/// <see cref="BlogsWebServerFixture"/> provides a SQLite
/// <c>:memory:</c> <c>ApplicationDbContext</c> (so FKs are
/// enforced the way a real relational engine would) and JWT
/// bearer auth via <c>TestTokenIssuer</c>. No mocks — the real
/// DbContext receives the real INSERT attempt.</para>
///
/// <para>The bug being pinned by these tests: the POST endpoint
/// calls <c>_context.CircleAuthorizationToBlogPost.Add(...)</c>
/// then <c>SaveChangesAsync</c>. The entity has a composite
/// key (CircleId + BlogPostId) and two FKs; EF Core refuses
/// the INSERT with
/// <c>System.InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown when
/// attempting to save changes</c> when the principal entities
/// (the existing <c>BlogPost</c> and <c>Circle</c>) are not
/// attached to the DbContext in the same change-tracker graph.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogAclApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
/// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
/// persists across tests, so tests that POST a successful ACL
/// row would otherwise conflict with whichever other test runs
/// next against the same pair — xUnit does not guarantee
/// execution order. Calling this at the start of each
/// insert-bearing test guarantees a clean slate regardless of
/// the previous test's outcome.</summary>
private void CleanupAcl()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == _fixture.CircleId
&& a.BlogPostId == _fixture.PostId)
.ExecuteDelete();
}
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
// The Blogs fixture disables JwtSecurityTokenHandler's
// inbound claim-type remap, so the JWT's "sub" stays "sub"
// rather than being rewritten to ClaimTypes.NameIdentifier.
// The controller, however, reads the user id via
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
// an explicit nameid claim to keep the legacy lookup happy.
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
TestTokenIssuer.Issue(
subject,
extraClaims: new[]
{
new System.Security.Claims.Claim(
System.Security.Claims.ClaimTypes.NameIdentifier,
subject),
}));
return http;
}
/// <summary>
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
/// <c>InvalidOperationException: The value of
/// 'CircleAuthorizationToBlogPost.BlogPostId' is unknown</c>
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
/// shape <c>{ "circleId": &lt;id&gt; }</c> — the exact body the
/// PostIt client builds from <see cref="CircleAuthorization"/>
/// (which only carries <c>CircleId</c>). The server deserialises
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
/// no <c>Target</c> navigation, and EF Core refuses to INSERT
/// during <c>PrepareToSave()</c>. The fix lives in PostIt
/// (enrich the payload with <c>blogPostId</c> + <c>comment</c>)
/// and on the wire DTO (<see cref="CircleAuthorization"/> must
/// carry those fields); the server validates. Until that ships,
/// this test stays red.
/// </summary>
[Fact]
public async Task PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape_against_existing_circle_named_test()
{
// The prod circle already exists with Name="test", Public=true,
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
CleanupAcl();
using var http = NewClient("alice");
var payload = new PostAccessControlRulePayload
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
/// <summary>
/// Payload templates for <see cref="PostCircleAuthorization_never_returns_500"/>.
/// Each row carries the shape we want to POST; <c>-1L</c> and
/// <c>-2L</c> are negative sentinels that the test substitutes
/// with the ids of freshly seeded <c>Circle</c> / <c>BlogPost</c>
/// rows before sending, so every shape lands against a real
/// principal entity and the seeded fixtures are not dead.
/// </summary>
public static IEnumerable<object[]> BlogAclPayloadsForNever500()
{
// circleId only (the historical bug shape, 2026-08-21 mercure):
// must be rejected, never 500.
return new object[][]
{
[
new PostAccessControlRulePayload
{
BlogPostId = -2,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = 1
}
]
} ;
}
/// <summary>
/// Hard rule (Paul, 2026-08-21): a 500 is never acceptable
/// </summary>
[Theory]
[MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
{
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500 ()
{
CleanupAcl();
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = _fixture.CircleId
}
);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500_on_success ()
{
CleanupAcl();
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = _fixture.PostId,
CircleId = _fixture.CircleId
}
);
}
}

View file

@ -12,6 +12,7 @@ namespace Yavsc.Blogs.Tests;
/// surface. The first behavioural test (GET /api/v1/blog returns /// surface. The first behavioural test (GET /api/v1/blog returns
/// 200) lands in a follow-up commit. /// 200) lands in a follow-up commit.
/// </summary> /// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture> public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; private readonly BlogsWebServerFixture _fixture;

View file

@ -22,7 +22,7 @@ namespace Yavsc.Blogs.Tests;
/// header (or sending a token signed with the wrong key) gets a /// header (or sending a token signed with the wrong key) gets a
/// 401 back from the framework. /// 401 back from the framework.
/// </summary> /// </summary>
[Collection("JwtClaimMapping")] [Collection("Yavsc Blogs")]
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture> public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; private readonly BlogsWebServerFixture _fixture;
@ -45,6 +45,21 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
db.Database.EnsureCreated(); db.Database.EnsureCreated();
} }
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
/// for any test that POST/PUT/DELETE a <c>BlogPost</c>:
/// <c>BlogPost.AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>, and SQLite (unlike the EF Core
/// InMemory provider) enforces it. Without the seed, the
/// POST handler hits
/// <c>SQLite Error 19: 'FOREIGN KEY constraint failed'</c>
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser()
{
ResetDatabase();
_fixture.SeedUser("tester");
}
/// <summary>The fixture's <c>WebApplication</c> is bound to /// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via /// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first /// <see cref="WebHostFixture.Addresses"/>. We pick the first
@ -116,7 +131,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list() public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(); using var http = NewClient();
// Create a minimal BlogPost. The server assigns Id, so we // Create a minimal BlogPost. The server assigns Id, so we
@ -154,7 +169,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry() public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
var draft = new BlogPost var draft = new BlogPost
@ -186,7 +201,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PostBlogComment_returns_201_for_existing_post() public async Task PostBlogComment_returns_201_for_existing_post()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
var draft = new BlogPost var draft = new BlogPost
@ -249,7 +264,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update() public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
// The JWT's sub must match the post's AuthorId: // The JWT's sub must match the post's AuthorId:
// PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(), // PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(),
// and UserHelpers.GetUserId reads "sub" off the principal. // and UserHelpers.GetUserId reads "sub" off the principal.
@ -300,7 +315,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[Fact] [Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{ {
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(); using var http = NewClient();
// Seed a post we can delete. // Seed a post we can delete.
@ -342,7 +357,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
// ModelState validation starts rejecting the PostIt payload // ModelState validation starts rejecting the PostIt payload
// (missing field, wrong casing, etc.), this test fails // (missing field, wrong casing, etc.), this test fails
// before the regression reaches a user. // before the regression reaches a user.
ResetDatabase(); ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester"); using var http = NewClient(subject: "tester");
// Mirrors what MainPageViewModel.Save builds: a BlogPost with // Mirrors what MainPageViewModel.Save builds: a BlogPost with

View file

@ -1,27 +1,33 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Yavsc.Blogs.Controllers; using Yavsc.Blogs.Controllers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
/// <summary> /// <summary>
/// Test host for the Yavsc.Blogs API surface. Specialisation of /// Shared integration-test host for the Yavsc.Blogs API surface.
/// <see cref="WebHostFixture"/> that wires up only the bits the /// Specialisation of <see cref="WebHostFixture"/> that wires up
/// blog API actually depends on: /// only the bits the blog API actually depends on:
/// ///
/// <list type="bullet"> /// <list type="bullet">
/// <item><description>An in-memory <see cref="ApplicationDbContext"/> /// <item><description>A SQLite <c>:memory:</c> database
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run /// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
/// against an empty table and return an empty list.</description></item> /// by a single shared <see cref="SqliteConnection"/> held open
/// for the lifetime of the host. SQLite enforces real foreign
/// keys and real transactional semantics, so the tests see the
/// same INSERT-time FK validation a production Postgres host
/// would — unlike the EF Core InMemory provider, which silently
/// ignores FKs and masks bugs that surface only against a real
/// relational engine.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/> /// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any /// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item> /// implementation is fine.</description></item>
@ -44,31 +50,61 @@ namespace Yavsc.Blogs.Tests;
/// ///
/// No IdentityServer, no SMTP, no static assets — the Org fixture /// No IdentityServer, no SMTP, no static assets — the Org fixture
/// owns all of that and we don't need any of it for blog integration /// owns all of that and we don't need any of it for blog integration
/// tests. /// tests. Marked <see cref="CollectionDefinitionAttribute"/> so the
/// host is shared across every <c>[Collection("Yavsc Blogs")]</c>
/// test class: one host, one SQLite DB, one Kestrel port.
/// </summary> /// </summary>
[CollectionDefinition("Yavsc Blogs")]
public sealed class BlogsWebServerFixture : WebHostFixture public sealed class BlogsWebServerFixture : WebHostFixture
{ {
protected override int HttpsPort => 5103; protected override int HttpsPort => 5103;
private InMemoryDatabaseRoot? _inMemoryRoot; public long CircleId { get; private set; }
public long PostId { get; private set; }
// A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
// shared configuration into static slots. Closing the
// connection destroys the in-memory database — so we close
// it only when the last fixture instance is disposed (see
// Dispose below), exactly when WebHostFixture tears down the
// host.
private static SqliteConnection? _sharedSqliteConnection;
private static readonly object _sqliteLock = new();
protected override WebApplication BuildApp(WebApplicationBuilder builder) protected override WebApplication BuildApp(WebApplicationBuilder builder)
{ {
// Use the real ApplicationDbContext with an in-memory store. // Open the shared in-memory connection lazily on the first
// BlogSpotService reads _context.BlogSpot directly, so any // fixture construction. Subsequent constructions (xUnit
// attempt to mock it would be wasted work; the real service // creates one fixture instance per IClassFixture) reuse
// against an empty table returns an empty list, which is // the same connection so all DbContexts across all tests
// exactly what the first test wants to assert. // see the same database.
// SqliteConnection sharedConnection;
// Share a single InMemoryDatabaseRoot across the test lock (_sqliteLock)
// lifetime so POST + GET on the same fixture see the same {
// store. Without the root, EF Core's In-Memory provider if (_sharedSqliteConnection is null)
// creates independent stores per DbContext in some {
// configurations, and the second request would see an // Mode=Memory + Cache=Shared gives us a named
// empty list even after the first wrote a row. // in-memory database that every connection string
_inMemoryRoot = new InMemoryDatabaseRoot(); // referencing "File:YavscBlogsTests?mode=memory&cache=shared"
// will resolve to the same backing store, as long
// as at least one SqliteConnection stays open
// against it.
_sharedSqliteConnection = new SqliteConnection(
"Data Source=YavscBlogsTests;Mode=Memory;Cache=Shared");
_sharedSqliteConnection.Open();
}
sharedConnection = _sharedSqliteConnection;
}
builder.Services.AddDbContext<ApplicationDbContext>(opt => builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)); // UseSqlite(DbConnection) keeps the connection we just
// opened alive for the DbContext's lifetime, instead of
// letting EF open and close its own. Without this,
// each DbContext would get a fresh connection pointing
// at an empty :memory: store and nothing would persist
// across requests.
opt.UseSqlite(sharedConnection));
// Trivial file-system auth: the GET index path never calls // Trivial file-system auth: the GET index path never calls
// into it, but the DI container needs an instance. // into it, but the DI container needs an instance.
@ -145,7 +181,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture
// remaps long Microsoft claim URIs, not sub). // remaps long Microsoft claim URIs, not sub).
// UserHelpers.GetUserId reads sub directly. // UserHelpers.GetUserId reads sub directly.
NameClaimType = "sub", NameClaimType = "sub",
RoleClaimType = YavscConstants.RoleClaimType, RoleClaimType = Yavsc.Constants.RoleClaimType,
}; };
}); });
@ -164,10 +200,139 @@ public sealed class BlogsWebServerFixture : WebHostFixture
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); app.MapControllers();
// EnsureCreated + seed alice, run once at host startup.
// EnsureCreated is idempotent (creates only the tables that
// don't exist yet) and runs against the shared
// SqliteConnection (Cache=Shared), so every DbContext that
// resolves through this fixture's host sees the same schema.
// We do NOT call EnsureDeleted: the SqliteConnection is held
// open at the static level and closing it destroys the
// :memory: store for every other DbContext — the org
// fixture can afford EnsureDeleted because its store is
// built fresh per fixture, but the blogs fixture's static
// connection outlives a single fixture instance.
using (var seedScope = app.Services.CreateScope())
{
var db = seedScope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureCreated();
if (!db.Users.Any(u => u.Id == "alice"))
{
db.Users.Add(new ApplicationUser
{
Id = "alice",
UserName = "alice",
Email = "alice@example.com",
EmailConfirmed = true,
FullName = "Alice Dupont",
Avatar = "/avatars/alice.png",
});
db.SaveChanges();
// Inline the seed of the circle + post. We don't
// call SeedCircle/SeedBlogPost (the instance helpers)
// because those resolve through this.Services, which
// is null until WebHostFixture.InitializeAsync has
// finished wiring the shared slot — i.e. after this
// method returns. Use app.Services directly.
var circle = new Circle
{
OwnerId = "alice",
Name = "test",
Public = true,
};
db.Circle.Add(circle);
db.SaveChanges();
CircleId = circle.Id;
var post = new BlogPost
{
AuthorId = "alice",
Title = "Billet ACL test",
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
PostId = post.Id;
}
}
await Task.CompletedTask; await Task.CompletedTask;
return app; return app;
} }
public override void Dispose()
{
try
{
base.Dispose();
}
finally
{
// Close the shared SQLite connection only when the
// last fixture instance goes away, matching the
// lifetime contract of WebHostFixture.Dispose. We
// rely on base.Dispose's _instanceCount decrement
// having run, so we close only if the host is gone
// (base already nulled _app when count==0).
lock (_sqliteLock)
{
if (_sharedSqliteConnection is not null)
{
// Synchronous close: SQLite's Close() is
// documented as safe to call from a sync
// context and avoids the GetAwaiter().GetResult()
// pattern that's historically caused teardown
// hangs in this repo's async pipeline.
_sharedSqliteConnection.Close();
_sharedSqliteConnection.Dispose();
_sharedSqliteConnection = null;
}
}
}
}
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
/// SQLite store, so tests that POST/PUT/DELETE a
/// <c>BlogPost</c> (whose <c>AuthorId</c> is a FK to
/// <c>AspNetUsers.Id</c>) don't trip the FK constraint that
/// SQLite enforces but the EF Core InMemory provider silently
/// ignored. Idempotent on <paramref name="userName"/>: a
/// second call for the same id is a no-op (the user already
/// exists).</summary>
/// <param name="userName">Both the PK id and the login name.
/// The JWT subject in tests is this same string, so seeding
/// this id is enough to make the FK from a
/// <c>BlogPost.AuthorId</c> resolve.</param>
/// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var existing = db.Users.SingleOrDefault(u => u.Id == userName);
if (existing != null) return existing;
// Email is an alternate key on ApplicationUser; seeding
// it explicitly avoids the InMemory provider's null-claim
// tracking quirk (cf. PublishEndpointTests.ResetDatabase)
// and keeps the column shape realistic for prod.
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return user;
}
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The /// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the /// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary> /// file system, so the implementation can be a no-op.</summary>
@ -180,4 +345,39 @@ public sealed class BlogsWebServerFixture : WebHostFixture
{ {
} }
} }
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedCircle(string ownerId, string name, bool isPublic = false)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic };
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
}
/// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedBlogPost(string authorId, string title)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
}
} }

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
@ -88,7 +89,7 @@ public sealed class CircleMembersApiTests : IClassFixture<BlogsWebServerFixture>
} }
private string MembersUrl(long circleId) private string MembersUrl(long circleId)
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/circle/{circleId}/members"; => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/circle/{circleId}/members";
private HttpClient NewClient(string subject) private HttpClient NewClient(string subject)
{ {

View file

@ -65,8 +65,8 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable
ValidateLifetime = true, ValidateLifetime = true,
ValidateIssuerSigningKey = true, ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenIssuer.SigningKey, IssuerSigningKey = TestTokenIssuer.SigningKey,
RoleClaimType = YavscConstants.RoleClaimType, RoleClaimType = Yavsc.Constants.RoleClaimType,
NameClaimType = YavscConstants.NameClaimType, NameClaimType = Yavsc.Constants.NameClaimType,
}; };
}); });

View file

@ -25,7 +25,7 @@ namespace Yavsc.Blogs.Tests;
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth /// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
/// via <see cref="TestTokenIssuer"/>.</para> /// via <see cref="TestTokenIssuer"/>.</para>
/// </summary> /// </summary>
[Collection("JwtClaimMapping")] [Collection("Yavsc Blogs")]
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture> public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
{ {
private readonly BlogsWebServerFixture _fixture; private readonly BlogsWebServerFixture _fixture;

View file

@ -9,7 +9,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" /> <PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" /> <PackageReference Include="xunit.v3.extensibility.core" />

View file

@ -5,6 +5,4 @@ public static class Constants
public const string AdminRole = "Admin"; public const string AdminRole = "Admin";
public const string ModeratorRole = "Moderator"; public const string ModeratorRole = "Moderator";
public const string UserRole = "User"; public const string UserRole = "User";
public const string APIPrefix = "api/v1";
} }

View file

@ -1,15 +1,17 @@
using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Access; using Yavsc.Models.Access;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/blogacl")] [Route(APIPrefix+"/blogacl")]
public class BlogAclApiController : Controller public class BlogAclApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -24,7 +26,7 @@ namespace Yavsc.Blogs.Controllers
/// Blog posts (and therefore their ACLs) are private to their /// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL. /// author — the API never exposes another user's ACL.
/// </summary> /// </summary>
// GET: api/blogacl // GET: api/v1/blogacl
[HttpGet] [HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL() public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{ {
@ -68,7 +70,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(); return BadRequest();
} }
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId)) if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
{ {
return new ChallengeResult(); return new ChallengeResult();
} }
@ -92,27 +94,42 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent); return new StatusCodeResult(StatusCodes.Status204NoContent);
} }
private bool CheckOwner (long circleId) private async Task<bool> CheckOwnerAsync (long circleId)
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var circle = _context.Circle.First(c=>c.Id==circleId); if (uid==null) return false;
_context.Entry(circle).State = EntityState.Detached; var circle = await _context.Circle.FirstOrDefaultAsync(c=>c.Id==circleId);
return (circle.OwnerId == uid); if (circle == null) return false;
return circle.OwnerId == uid;
} }
// POST: api/BlogAclApi // POST: api/BlogAclApi
[HttpPost] [HttpPost]
public async Task<IActionResult> PostCircleAuthorizationToBlogPost([FromBody] CircleAuthorizationToBlogPost circleAuthorizationToBlogPost) public async Task<IActionResult> PostCircleAuthorizationToBlogPost(
[FromBody] PostAccessControlRulePayload circleAuthorizationToBlogPost)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return BadRequest(ModelState); return BadRequest(ModelState);
} }
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId)) // No 500: a missing or zero BlogPostId is a client
// error, not an EF Core FK violation waiting to happen.
// The 2026-08-21 prod 500 was this exact path (PostIt
// sent only circleId, server saw BlogPostId = 0 and
// SaveChangesAsync threw InvalidOperationException).
if (circleAuthorizationToBlogPost.BlogPostId <= 0)
{
return BadRequest("BlogPostId is required and must be > 0.");
}
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
{ {
return new ChallengeResult(); return new ChallengeResult();
} }
_context.CircleAuthorizationToBlogPost.Add(circleAuthorizationToBlogPost); CircleAuthorizationToBlogPost entity = new CircleAuthorizationToBlogPost
{
BlogPostId = circleAuthorizationToBlogPost.BlogPostId,
CircleId = circleAuthorizationToBlogPost.CircleId
};
_context.CircleAuthorizationToBlogPost.Add(entity);
try try
{ {
await _context.SaveChangesAsync(User.GetUserId()); await _context.SaveChangesAsync(User.GetUserId());

View file

@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions; using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
@ -54,7 +53,7 @@ namespace Yavsc.Blogs.Controllers
// PUT: api/BlogApi/5 // PUT: api/BlogApi/5
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> PutBlog(long id, [FromBody] BlogPost blog) public async Task<IActionResult> PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {

View file

@ -1,12 +1,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]

View file

@ -4,11 +4,12 @@ using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Relationship; using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route("api/circle")] [Route(APIPrefix +"/circle")]
public class CircleApiController : Controller public class CircleApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -56,12 +57,25 @@ namespace Yavsc.Blogs.Controllers
/// <summary> /// <summary>
/// Replaces a circle. The caller must own it; the server /// Replaces a circle. The caller must own it; the server
/// reasserts ownership regardless of any OwnerId the client /// reasserts ownership regardless of any <c>OwnerId</c>
/// tries to put in the body. /// the client tries to put in the body.
///
/// <para>The body shape is a <see cref="CircleDto"/> — a
/// flat, navigation-free projection — not the EF entity.
/// The EF entity carries <c>[JsonIgnore]</c>-decorated
/// navigation properties (<c>Owner</c>, <c>Members</c>)
/// that bind to server-only types (<c>ApplicationUser</c>,
/// <c>CircleMember</c>); keeping the wire shape as a
/// DTO avoids any future regression where the entity
/// grows a navigable property that System.Text.Json
/// refuses to materialise. The client-side mirror lives
/// in <c>Yavsc.Api.Client.Dtos.CircleDto</c>.</para>
/// </summary> /// </summary>
// PUT: api/circle/5 // PUT: api/circle/5
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> PutCircle([FromRoute] long id, [FromBody] Circle circle) public async Task<IActionResult> PutCircle(
[FromRoute] long id,
[FromBody] CircleDto circle)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -81,9 +95,14 @@ namespace Yavsc.Blogs.Controllers
return new ChallengeResult(); return new ChallengeResult();
} }
// Force OwnerId to the caller; the body value is ignored. // Map the wire shape onto the entity. OwnerId is
circle.OwnerId = uid; // forced to the caller regardless of what the body
_context.Entry(circle).State = EntityState.Modified; // says; Name and Public come from the body.
existing.Name = circle.Name;
existing.Public = circle.Public;
existing.OwnerId = uid;
_context.Entry(existing).State = EntityState.Modified;
try try
{ {
@ -110,7 +129,7 @@ namespace Yavsc.Blogs.Controllers
/// </summary> /// </summary>
// POST: api/circle // POST: api/circle
[HttpPost] [HttpPost]
public async Task<IActionResult> PostCircle([FromBody] Circle circle) public async Task<IActionResult> PostCircle([FromBody] CircleDto circle)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@ -119,8 +138,14 @@ namespace Yavsc.Blogs.Controllers
var uid = User.GetUserId(); var uid = User.GetUserId();
circle.OwnerId = uid; circle.OwnerId = uid;
Circle newCircle = new Circle
{
OwnerId = User.GetUserId(),
Name = circle.Name,
Public = circle.Public
};
_context.Circle.Add(circle); _context.Circle.Add(newCircle);
try try
{ {
await _context.SaveChangesAsync(User.GetUserId()); await _context.SaveChangesAsync(User.GetUserId());
@ -321,6 +346,26 @@ namespace Yavsc.Blogs.Controllers
} }
} }
/// <summary>
/// Wire shape for <c>PUT /api/circle/{id}</c>. Flat by
/// design — navigation properties (<c>Owner</c>,
/// <c>Members</c>) live on the EF entity only and never
/// cross the wire.
///
/// <para>Field names match the JSON the server emits
/// (camelCase via ASP.NET Core's Web defaults), so no
/// <c>[JsonPropertyName]</c> attributes are required.
/// Mirrors the client-side <c>Yavsc.Api.Client.Dtos.CircleDto</c>
/// — keep them in sync.</para>
/// </summary>
public sealed class CircleDto
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public string OwnerId { get; set; } = string.Empty;
public bool Public { get; set; }
}
/// <summary> /// <summary>
/// Wire shape for <c>GET /api/circle/{id}/members</c>. /// Wire shape for <c>GET /api/circle/{id}/members</c>.
/// Mirrors <see cref="UserSearchResultDto"/> but stops /// Mirrors <see cref="UserSearchResultDto"/> but stops

View file

@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog; using Yavsc.Models.Blog;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {

View file

@ -2,7 +2,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {

View file

@ -8,7 +8,7 @@ using Yavsc.Models.Messaging;
using Yavsc.Services; using Yavsc.Services;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
using Yavsc.Server.Hubs; using Yavsc.Server.Hubs;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers

View file

@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {

View file

@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Yavsc.Models; using Yavsc.Models;
using static Yavsc.Blogs.Constants; using static Yavsc.Constants;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {

View file

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
using static Yavsc.Constants;
namespace Yavsc.Blogs.Controllers namespace Yavsc.Blogs.Controllers
{ {
@ -26,7 +27,7 @@ namespace Yavsc.Blogs.Controllers
/// exposing it.</para> /// exposing it.</para>
/// </summary> /// </summary>
[Produces("application/json")] [Produces("application/json")]
[Route("api/user-search")] [Route(APIPrefix + "/user-search")]
[Authorize] [Authorize]
public class UserSearchApiController : Controller public class UserSearchApiController : Controller
{ {
@ -66,8 +67,9 @@ namespace Yavsc.Blogs.Controllers
// book callers already know the email they're // book callers already know the email they're
// searching for and we don't want to surface a // searching for and we don't want to surface a
// long tail of partial matches. // long tail of partial matches.
var normalised = e.Trim(); var normalized = e.Trim();
query = query.Where(u => u.Email != null && u.Email.ToLower() == normalised.ToLower()); query = query.Where(u => u.Email != null &&
string.Compare(u.Email, normalized, true) ==0);
} }
if (!string.IsNullOrWhiteSpace(q)) if (!string.IsNullOrWhiteSpace(q))

View file

@ -51,7 +51,7 @@ internal class Program
// DbContextBuilder // DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options => services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString( options.UseNpgsql(builder.Configuration.GetConnectionString(
YavscConstants.YavscConnectionStringName))); Yavsc.Constants.YavscConnectionStringName)));
// other services // other services
services services

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+2.Branch.release-1.0.7-rc1.Sha.6e50967702ba9d310017c86a2d7ee636a9e94ada</InformationalVersion> <InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -19,11 +19,11 @@ namespace Yavsc.Org.Tests
{ {
this.output = output; this.output = output;
_serverFixture = serverFixture; _serverFixture = serverFixture;
_logger = serverFixture.Logger; _logger = serverFixture.Logger!;
} }
[Fact] [Fact]
public void SendEMailSynchrone() public async Task SendEMailSynchrone()
{ {
using IServiceScope scope = _serverFixture.Services.CreateScope(); using IServiceScope scope = _serverFixture.Services.CreateScope();
@ -32,12 +32,12 @@ namespace Yavsc.Org.Tests
scope.ServiceProvider.GetRequiredService<ISmtpClientFactory>()); scope.ServiceProvider.GetRequiredService<ISmtpClientFactory>());
output.WriteLine("SendEMailSynchrone ..."); output.WriteLine("SendEMailSynchrone ...");
mailSender.SendEmailAsync await mailSender.SendEmailAsync
( (
_serverFixture.SiteSettings.Owner.Name, _serverFixture.SiteSettings!.Owner.Name,
_serverFixture.SiteSettings.Owner.EMail, _serverFixture.SiteSettings!.Owner.EMail,
$"monthly email", $"monthly email",
"test boby monthly email").Wait(); "test boby monthly email");
// Assert the SMTP roundtrip was short-circuited by the // Assert the SMTP roundtrip was short-circuited by the
// recording fake installed in WebServerFixture: exactly // recording fake installed in WebServerFixture: exactly

View file

@ -14,7 +14,7 @@ namespace Yavsc.Org.Tests.NonRegression;
/// ne voit rien — juste un 500 muet. /// ne voit rien — juste un 500 muet.
/// ///
/// Le fix passe par <see cref="UserDisplayHelpers.AvatarSrc"/> qui /// Le fix passe par <see cref="UserDisplayHelpers.AvatarSrc"/> qui
/// retourne <see cref="YavscConstants.DefaultAvatar"/> pour toute /// retourne <see cref="Yavsc.Constants.DefaultAvatar"/> pour toute
/// donnée partielle. Ces tests couvrent les trois formes de /// donnée partielle. Ces tests couvrent les trois formes de
/// "donnée absente" : user null, UserName vide, UserName whitespace. /// "donnée absente" : user null, UserName vide, UserName whitespace.
/// </summary> /// </summary>
@ -23,21 +23,21 @@ public class UserDisplayHelpersTests
[Fact] [Fact]
public void AvatarSrc_null_user_returns_default_avatar() public void AvatarSrc_null_user_returns_default_avatar()
{ {
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(null)); Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(null));
} }
[Fact] [Fact]
public void AvatarSrc_user_with_empty_UserName_returns_default_avatar() public void AvatarSrc_user_with_empty_UserName_returns_default_avatar()
{ {
var user = new FakeUser { UserName = "" }; var user = new FakeUser { UserName = "" };
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user)); Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
} }
[Fact] [Fact]
public void AvatarSrc_user_with_whitespace_UserName_returns_default_avatar() public void AvatarSrc_user_with_whitespace_UserName_returns_default_avatar()
{ {
var user = new FakeUser { UserName = " " }; var user = new FakeUser { UserName = " " };
Assert.Equal(YavscConstants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user)); Assert.Equal(Yavsc.Constants.DefaultAvatar, UserDisplayHelpers.AvatarSrc(user));
} }
[Fact] [Fact]
@ -47,7 +47,7 @@ public class UserDisplayHelpersTests
// Le path doit matcher YavscConstants.AvatarsPath (minuscule), // Le path doit matcher YavscConstants.AvatarsPath (minuscule),
// pas un /Avatars/ avec S majuscule qui ne résout pas // pas un /Avatars/ avec S majuscule qui ne résout pas
// dans le middleware de fichiers statiques. // dans le middleware de fichiers statiques.
var expected = $"{YavscConstants.AvatarsPath}/alice.s.png"; var expected = $"{Yavsc.Constants.AvatarsPath}/alice.s.png";
Assert.Equal(expected, UserDisplayHelpers.AvatarSrc(user)); Assert.Equal(expected, UserDisplayHelpers.AvatarSrc(user));
} }

View file

@ -81,7 +81,7 @@ public sealed class WebServerFixture : WebHostFixture
// that plus the in-memory overrides below. // that plus the in-memory overrides below.
builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?> builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary<string, string?>
{ {
[$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory", [$"ConnectionStrings:{Yavsc.Constants.YavscConnectionStringName}"] = "InMemory",
// SMTP test config: UserName non-null so MailSender // SMTP test config: UserName non-null so MailSender
// exercises the Authenticate branch — the // exercises the Authenticate branch — the
// RecordingSmtpClient captures it. // RecordingSmtpClient captures it.

Some files were not shown because too many files have changed in this diff Show more