Compare commits

..

No commits in common. "main" and "1.0.6" have entirely different histories.

157 changed files with 874 additions and 10089 deletions

View file

@ -21,9 +21,20 @@ on:
push: push:
branches: [ "main" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main", "release/*" ] branches: [ "main" ]
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,6 +42,11 @@ 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
@ -57,6 +62,7 @@ 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."
@ -109,16 +115,11 @@ jobs:
echo "Tag $TAG classifié comme channel=$CHANNEL" echo "Tag $TAG classifié comme channel=$CHANNEL"
# Seuls les suffixes explicitement autorisés déclenchent un # Fail-fast sur instable sauf opt-in explicite.
# release : -rcN et -betaN. Les autres suffixes (-alpha*, if [[ "$CHANNEL" == "unstable" && "${FORCE_UNSTABLE:-false}" != "true" ]]; then
# -dev*, -preview*, etc.) restent refusés — ils sont echo "::error::Tag '$TAG' is unstable (suffix '$SUFFIX'). Refusing to publish."
# utilisables localement pour itérer, mais ne doivent pas echo "Set force_unstable=true via workflow_dispatch to override."
# être publiés comme release publique. exit 1
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
fi
fi fi
# Lecture du CHANGELOG.md (doit exister à la racine du repo). # Lecture du CHANGELOG.md (doit exister à la racine du repo).
@ -327,4 +328,4 @@ jobs:
exit 1 exit 1
fi fi
echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG" echo "Release publiée: $API_BASE/$GITHUB_REPOSITORY/releases/tag/$TAG"

11
.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"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,163 +16,15 @@ 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 ## [Unreleased]
### Added ### 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 ### 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 ### 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
### Added
- Per-post ACL in PostIt: a new “Manage ACL” page, opened from the ACL
button on a selected post, lets the post author grant or revoke
grants for individuals or circles. The server scopes each grant
operation to `caller == post.AuthorId` and returns `404` (not `403`)
for posts the caller does not own, so the existence of another
user's post is not leaked.
- Circle membership API + UI: three new REST endpoints under
`/api/circle/{id}/members` (`GET` list, `POST` add, `DELETE`
remove) and a new “Members” column on the *My Circles* page with an
“Add a member” button that opens a search modal. The search modal
reuses `IUserDirectory` (introduced by the `IContactService` split
in this same release) — exactly the use case the abstraction was
carved out for.
- Publish toggle for blog posts: a new `PUT /api/BlogApi/{id}/publish`
endpoint, and a `Published` checkbox in the post toolbar that
toggles a `BlogSpotPublication` row for the post. The publish
signal flows through the pre-existing `PermissionHandler.IsPublic`
path, so no new column was needed and the server-side authorisation
logic is unchanged.
- `UserSearchApiController` in `Yavsc.Blogs`:
`GET /api/user-search?q=...&e=...&take=...`. Any-authenticated-
caller endpoint that exposes the user's email under a closed-
community assumption (documented in the controller's XML doc).
Wired to the PostIt Desktop address book so the user search modal
picks it up.
- `IYavscApiClient` abstraction in `Yavsc.Api.Client`. The transport
for the blog/circle/blog-acl/user-search clients is now accessed
through this interface, so `PostIt.Tests` can stub the HTTP layer
without spinning up a real WebAPI host.
- Forgejo Actions release workflow: a `.forgejo/workflows/release.yml`
pipeline that builds and publishes a release with the PostIt APK
on tag push. Written in pure bash (the runner image has no Node),
uses `jq` for JSON body construction and response parsing, uses the
runner-provided `GITHUB_TOKEN` (no repo-level secret needed),
validates the CHANGELOG section heading before allowing the tag
to ship.
- `make release V=<version>` target: creates a `release/<V>` branch
from `main`, bumps the `<Version>` property in every `.csproj` via
`dotnet-gitversion /updateprojectfiles`, commits the bump on the
release branch, and pushes to `origin`. Fails fast if the working
tree is dirty or if `HEAD` is not on `main`.
- Forgejo status badges in the README.
### Changed
- The new Publish toggle replaces the “Visibility enum” approach
originally drafted in this branch: the existing `BlogSpotPublication`
table already carried enough information to expose a publish
switch, so no schema change was needed. The original `feat(blog):
add Visibility { Private, Public }` commit and its EF migration
were reverted in favour of the endpoint-only toggle.
- `BlogPost` DTO and `IBlogPost` moved from `PostIt.Models` to
`Yavsc.Abstract.Blogspot`, the shared assembly where the server-side
entity and the wire DTO both live. Renamed `Yavsc.Blogspot.BlogPost`
to `BlogPostDto` to make the wire/entity distinction explicit.
- `BlogAclApiController` and `CircleApiController` moved from
`Yavsc.Api` (not yet enabled in production) to `Yavsc.Blogs`, where
they belong next to the `BlogSpotService` they depend on.
- `IContactService` split from `IUserDirectory`: the two interfaces
previously conflated the local address-book access (mobile-only,
via `Contacts.Default`) and the Yavsc user-search access
(Desktop-only, via `/api/user-search`) behind a single facade. The
split restores the `ContactDto.Emails` multi-value shape that was
being silently flattened to a single string before.
- CI: the Forgejo Actions build now compiles `.csproj` projects
directly inside the runner container (which ships the .NET SDK +
Android workload), instead of relying on a separate Docker build
step. Node-based third-party actions were replaced with bash + curl
+ `jq`. The validate-release job parses the CHANGELOG section
heading to derive the channel (`stable` / `preview` / `unstable`)
rather than the patch-version parity alone.
### Fixed
- `CircleApiController` used to read the caller's user id via
`FindFirstValue(ClaimTypes.NameIdentifier)`, which does not match
when JWT Bearer middleware has `MapInboundClaims = false`. Switched
to `User.GetUserId()` (tries `sub` first, then
`ClaimTypes.NameIdentifier`, then `nameid`). This was a latent
bug visible in tests but easy to ship to production if a host
ever disabled the remap.
- `CircleApiController` and `BlogAclApiController` reads and writes
were not always scoped to the caller's own data. Tightened the
authorisation checks: cross-user reads now return `404`, not the
raw record.
- `validate-release` CHANGELOG channel check used to parse the
patch-version parity only, which disagreed with the channel
suffix in the section heading (e.g. `## [1.0.7] - preview`
would be flagged as `stable` from the parity alone). The job now
inspects the heading line and trusts the suffix when present.
- `.forgejo/workflows/release.yml`: the asset-upload URL now carries
the asset name as a query-string parameter instead of a `curl`
positional argument. The previous shape triggered Forgejo's
“Missing `name` parameter” 400 in some cases.
### Removed ### Removed
- The `## [Unreleased]` block has been moved into this section.
- The abandoned `Visibility { Private, Public }` enum and its EF
migration, reverted in this release. The publish toggle covers
the same user-visible switch without a schema change.
[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.6]: https://github.com/pazof/yavsc/compare/1.0.5...1.0.6
## [1.0.6] - stable ## [1.0.6] - stable
@ -206,4 +58,5 @@ pour la production des paquets `.deb`.
actual release id. Switched to `jq` for both body construction and actual release id. Switched to `jq` for both body construction and
field extraction. field extraction.
[Unreleased]: https://github.com/pazof/yavsc/compare/HEAD
[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,57 +49,6 @@ 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
@ -115,13 +64,6 @@ 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,7 +18,6 @@
<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

@ -48,77 +48,5 @@ docker-build:
docker-run: docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc docker run -d -p 5000:5000 --name yavsc yavsc
# Crée une branche release/<V> depuis main, met à jour les
# `<Version>` des .csproj via dotnet-gitversion, et la
# pousse sur origin.
#
# Usage : make release V=1.0.7-rc1
#
# Pré-requis : être sur main, working tree clean. La cible
# vérifie les deux et refuse sinon — elle ne fait JAMAIS
# de checkout automatique, c'est à l'opérateur de s'être
# positionné sur la bonne branche au préalable (sinon le
# bump pourrait partir sur une branche tierce par accident).
#
# Notes :
# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1
# donne release/1.0.7-rc1). C'est une étiquette d'intention,
# pas la version assembly.
# - La version dans les .csproj vient de GitVersion qui la
# calcule depuis l'historique git (tag le plus proche +
# nombre de commits). C'est la version assembly réelle.
# - L'ordre (fetch → branche → bump → push) garantit qu'on
# part d'un main synchro et qu'on ne pollue pas main avec
# le bump (qui vit sur la branche release).
# - Fail-fast si la branche existe déjà en local ou sur origin.
release:
@if [ -z "$(V)" ]; then \
echo "Usage: make release V=<version>"; \
echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
exit 1; \
fi
@CURRENT=$$(git branch --show-current); \
if [ "$$CURRENT" != "main" ]; then \
echo "Refus : la cible doit être lancée depuis main."; \
echo " Branche courante : $$CURRENT"; \
echo " Fais : git checkout main && git pull --ff-only origin main"; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \
git status --short; \
exit 1; \
fi
@BRANCH="release/$(V)"; \
if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
echo "La branche $$BRANCH existe déjà en local."; \
echo " Pour la supprimer : git branch -D $$BRANCH"; \
exit 1; \
fi; \
if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
echo "La branche $$BRANCH existe déjà sur origin."; \
exit 1; \
fi; \
echo "==> Fetch + vérification synchro main"; \
git fetch origin main; \
if ! git merge-base --is-ancestor origin/main HEAD; then \
echo "main a avancé plus loin que HEAD. Fais :"; \
echo " git pull --ff-only origin main"; \
exit 1; \
fi; \
echo "==> Création de $$BRANCH depuis main"; \
git checkout -b "$$BRANCH"; \
echo "==> dotnet-gitversion /updateprojectfiles"; \
dotnet-gitversion /updateprojectfiles; \
echo "==> Commit du bump"; \
git add .; \
if git diff --cached --quiet; then \
echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
else \
git commit -m "chore(release): bump version via gitversion for $(V)"; \
fi; \
echo "==> Push de $$BRANCH sur origin"; \
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
.PHONY: test release .PHONY: test

View file

@ -1,19 +1,9 @@
# 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
* [![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
)
# 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=Org Blogs APP_PROJECT_NAMES=Api Org Blogs
SLNDIR=.. SLNDIR=..
include $(SLNDIR)/.env include $(SLNDIR)/.env
@ -7,6 +7,7 @@ include .env
generated/: generated/:
@mkdir -p $@ @mkdir -p $@
generated/yavscApi.service:
generated/yavscOrg.service: generated/yavscOrg.service:
generated/yavscBlogs.service: generated/yavscBlogs.service:
@ -33,11 +34,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@ @echo Created service file: $@
copy-services: copy-service-Org copy-service-Blogs copy-services: copy-service-Org copy-service-Api 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_Blogs stop-services copy-binaries: build_publish_Org build_publish_Api 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}" ; \
@ -53,7 +55,7 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services
done done
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) @sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR)
/etc/systemd/system/yavsc%.service: generated/yavsc%.service /etc/systemd/system/yavsc%.service: generated/yavsc%.service
sudo cp $^ $@ sudo cp $^ $@
sudo chown root:root $@ sudo chown root:root $@
@ -63,14 +65,14 @@ build_publish_%: clean_publish_dir_%
clean_publish_dir_%: clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
install: build_publish copy-binaries copy-services install: build_publish copy-binaries copy-services
@sudo systemctl daemon-reload @sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
sudo systemctl enable yavsc$${project} ; \ sudo systemctl enable yavsc$${project} ; \
sudo systemctl start yavsc$${project} ; \ sudo systemctl start yavsc$${project} ; \
done done
reinstall: copy-binaries reinstall: copy-binaries
@sync @sync
@for project in $(APP_PROJECT_NAMES); do \ @for project in $(APP_PROJECT_NAMES); do \
@ -84,12 +86,13 @@ 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)
@echo BASEAPPDIR: $(BASEAPPDIR) @echo BASEAPPDIR: $(BASEAPPDIR)
clean: clean:
@rm -rf generated @rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean .PHONY: build_publish mep showConfig copy-service-Api copy-service-Org copy-service-Blogs reinstall clean

View file

@ -129,51 +129,30 @@ 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 deux posé sur `MainWindow.axaml`. La pile est gérée par les
mécanismes distincts : événements du `SessionStatusViewModel` :
1. **Nav utilisateur (VM-first)** : un ViewModel (souvent dans | Événement | Effet |
une commande `[RelayCommand]`) appelle |---------------------------------|------------------------------------------------------------------------|
`await ((App)App.Current!).PushPageAsync(targetVm).ConfigureAwait(true);`. | `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. |
`App.PushPageAsync` (`src/PostIt/PostIt/App.axaml.cs`) | `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
résout la `Control` correspondante via le `ViewLocator` | `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. |
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 |
|---------------------|------------------------------------------------------------------|
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage` (post-login). |
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
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. La garde est implémentée taper **Retour** N fois pour sortir. Le handler
dans `App.PushPageAsync` (et consommée par tous les chemins `OpenSettingsRequested` est gardé pour bloquer ce cas :
de nav utilisateur) :
```csharp ```csharp
var stack = window.NavRoot.NavigationStack; var settingsPage = provider.GetRequiredService<SettingsPage>();
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page)) var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{ {
return Task.CompletedTask; // déjà au sommet, no-op silencieux return; // déjà au sommet, no-op silencieux
} }
return window.NavRoot.PushAsync(page); _ = w.NavRoot.PushAsync(settingsPage);
``` ```
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
@ -199,11 +178,9 @@ 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 événements de cycle de vie consommés par `App` pour les trois événements qui pilotent la navigation
orchestrer la nav de boot (`LoginSucceeded`, (`LoginSucceeded`, `LogoutCompleted`,
`LogoutCompleted`). La nav utilisateur déclenchée par `OpenSettingsRequested`).
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
@ -256,14 +233,10 @@ 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") : ne pas capturer `MainWindow` "Push après payment success") : passer par un événement sur
ni `NavigationPage` depuis le VM. La nav passe par un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`),
`App.PushPageAsync(vm)` dans tous les cas : soit le VM pas par une référence à `MainWindow` depuis le VM. Garder
appelle la méthode directement depuis une commande les VMs découplés du `IClassicDesktopStyleApplicationLifetime`.
(`[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

@ -1,156 +0,0 @@
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

@ -8,9 +8,6 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.Services; using PostIt.Services;
using Xunit; using Xunit;
@ -97,7 +94,7 @@ public class BearerScopeTests
// CapturingHttpHandler is the assertion point. It // CapturingHttpHandler is the assertion point. It
// records the first request's Authorization header and // records the first request's Authorization header and
// returns 200 with an empty array (BlogApiClient // returns 200 with an empty array (BlogApiClient
// deserialises to List<BlogPostDto>). // deserialises to List<BlogPost>).
var captured = new CapturingHttpHandler(); var captured = new CapturingHttpHandler();
var client = new YavscApiClient( var client = new YavscApiClient(
settings, settings,
@ -122,7 +119,7 @@ public class BearerScopeTests
// Resolve a BlogApiClient on top. We don't need real // Resolve a BlogApiClient on top. We don't need real
// posts; we just need the outbound HTTP request to be // posts; we just need the outbound HTTP request to be
// the one we capture. // the one we capture.
var blog = new BlogApiClient(subClient, "http://localhost/"); var blog = new BlogApiClient(subClient);
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken); await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);

View file

@ -1,4 +1,4 @@
using Yavsc.Blogspot; using PostIt.Models;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc.Models; using Yavsc.Models;
@ -18,7 +18,7 @@ internal sealed class CallRecorder
/// <summary>Test fake that records every CallAsync invocation /// <summary>Test fake that records every CallAsync invocation
/// and answers them with a canned sequence: the first call gets /// and answers them with a canned sequence: the first call gets
/// a server-issued BlogPostDto (Id=42), the second call gets a /// a server-issued BlogPost (Id=42), the second call gets a
/// single-element list containing that post. Used by the ViewModel /// single-element list containing that post. Used by the ViewModel
/// tests and the headless UI test to capture exactly what the /// tests and the headless UI test to capture exactly what the
/// Save button posts to the server.</summary> /// Save button posts to the server.</summary>
@ -44,20 +44,20 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
_recorder.Calls.Add((method, path, body)); _recorder.Calls.Add((method, path, body));
// BlogPostDto? boxes to BlogPostDto at runtime, so we test the // BlogPost? boxes to BlogPost at runtime, so we test the
// non-nullable type — typeof(BlogPostDto?) is a C# error // non-nullable type — typeof(BlogPost?) is a C# error
// (CS8639: "typeof cannot be used on a nullable reference // (CS8639: "typeof cannot be used on a nullable reference
// type"). // type").
if (typeof(T) == typeof(BlogPostDto)) if (typeof(T) == typeof(BlogPost))
return Task.FromResult((T)(object)new BlogPostDto return Task.FromResult((T)(object)new BlogPost
{ {
Id = 42, Id = 42,
Title = "Mon premier billet", Title = "Mon premier billet",
AuthorId = "tester", AuthorId = "tester",
Article = "Contenu du billet de test.", Article = "Contenu du billet de test.",
}); });
if (typeof(T) == typeof(List<BlogPostDto>)) if (typeof(T) == typeof(List<BlogPost>))
return Task.FromResult((T)(object)new List<BlogPostDto> return Task.FromResult((T)(object)new List<BlogPost>
{ {
new() { Id = 42, Title = "Mon premier billet" } new() { Id = 42, Title = "Mon premier billet" }
}); });

View file

@ -1,169 +0,0 @@
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

@ -1,239 +0,0 @@
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

@ -2,8 +2,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Yavsc.Blogspot; using PostIt.Models;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -25,7 +24,7 @@ namespace PostIt.Tests;
/// in which a brand-new post can be created), the binding has /// in which a brand-new post can be created), the binding has
/// no target and the user's keystrokes are silently dropped. /// no target and the user's keystrokes are silently dropped.
/// Clicking "Save" then routes to the VM branch /// Clicking "Save" then routes to the VM branch
/// <c>if (SelectedPost is null) { new BlogPostDto { Title = string.Empty, ... } }</c> /// <c>if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }</c>
/// which the controller rejects with 400 "The Title field is /// which the controller rejects with 400 "The Title field is
/// required." This test fails on that branch today and will /// required." This test fails on that branch today and will
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c> /// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
@ -41,7 +40,7 @@ public class MainPageSaveTests
// not a Control, so it needs a navigation host). // not a Control, so it needs a navigation host).
var recorder = new CallRecorder(); var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder); var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api);
var viewModel = new MainPageViewModel(blog); var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel }; var page = new MainPage { DataContext = viewModel };
@ -77,14 +76,14 @@ public class MainPageSaveTests
// we inspect the recorder. // we inspect the recorder.
await Task.Delay(200); await Task.Delay(200);
// Assert: the first POST to "blog" carried a BlogPostDto // Assert: the first POST to "blog" carried a BlogPost
// whose Title is exactly what the user typed. The bug // whose Title is exactly what the user typed. The bug
// fails this assertion with Title == string.Empty. // fails this assertion with Title == string.Empty.
Assert.NotEmpty(recorder.Calls); Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.FirstCall; var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method); Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blog", path); Assert.Equal("blog", path);
var sent = Assert.IsType<BlogPostDto>(body); var sent = Assert.IsType<BlogPost>(body);
Assert.Equal(typed, sent.Title); Assert.Equal(typed, sent.Title);
} }
} }

View file

@ -1,234 +0,0 @@
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

@ -6,10 +6,10 @@
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>PostIt.Tests</RootNamespace> <RootNamespace>PostIt.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />

View file

@ -1,5 +1,4 @@
using Yavsc.Blogspot; using PostIt.Models;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -15,12 +14,12 @@ public class PostItViewModelTests
// default; tests construct one with a fake YavscApiClient that // default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test). // throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient(); var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/"); var blog = new BlogApiClient(fakeApi);
var viewModel = new MainPageViewModel(blog); var viewModel = new MainPageViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPostDto { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); viewModel.Posts.Add(new BlogPost { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search"; viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null); viewModel.SearchCommand.Execute(null);
@ -41,13 +40,13 @@ public class PostItViewModelTests
// The new BlogApiClient delegates transport to YavscApiClient. // The new BlogApiClient delegates transport to YavscApiClient.
// We feed it a fake YavscApiClient that returns the expected // We feed it a fake YavscApiClient that returns the expected
// list straight from CallAsync. // list straight from CallAsync.
var expected = new List<BlogPostDto> var expected = new List<BlogPost>
{ {
new() { Id = 1, Title = "Hello" }, new() { Id = 1, Title = "Hello" },
new() { Id = 2, Title = "World" } new() { Id = 2, Title = "World" }
}; };
var api = new StubYavscApiClient(expected); var api = new StubYavscApiClient(expected);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api);
var posts = await blog.GetPostsAsync(); var posts = await blog.GetPostsAsync();
@ -77,8 +76,8 @@ public class PostItViewModelTests
/// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary> /// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary>
private sealed class StubYavscApiClient : YavscApiClient private sealed class StubYavscApiClient : YavscApiClient
{ {
private readonly List<BlogPostDto> _posts; private readonly List<BlogPost> _posts;
public StubYavscApiClient(List<BlogPostDto> posts) public StubYavscApiClient(List<BlogPost> posts)
: base( : base(
new Settings new Settings
{ {
@ -98,7 +97,7 @@ public class PostItViewModelTests
{ {
// The canned fake only knows about a list of posts; the // The canned fake only knows about a list of posts; the
// BlogApiClient test asserts on that list directly. // BlogApiClient test asserts on that list directly.
if (typeof(T) == typeof(List<BlogPostDto>)) if (typeof(T) == typeof(List<BlogPost>))
return Task.FromResult((T)(object)_posts); return Task.FromResult((T)(object)_posts);
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }

View file

@ -1,11 +0,0 @@
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; }
}

View file

@ -8,9 +8,6 @@ using System.Net.Sockets;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;

View file

@ -1,94 +0,0 @@
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

@ -14,8 +14,7 @@
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<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="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

@ -12,10 +12,10 @@
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<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.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
@ -31,6 +31,6 @@
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Maui.Essentials" /> <PackageReference Include="GitVersion.MsBuild" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,84 +0,0 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Maui.ApplicationModel.Communication;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Devices;
using PostIt.Services;
using System.Linq;
namespace PostIt.Android.Services;
/// <summary>
/// Mobile implementation backed by MAUI Essentials
/// <c>Contacts.Default</c>.
///
/// <para>Compiled only for ANDROID and IOS. On desktop targets,
/// see <c>ContactService.Desktop.cs</c> (the stub that wins at
/// compile time).</para>
///
/// <para>Note: at runtime, this class throws
/// <c>NotImplementedInReferenceAssemblyException</c> unless
/// the host application project also references the
/// platform-specific Microsoft.Maui.Essentials implementation
/// (typically <c>PostIt.Android</c>). On iOS the same is
/// required via <c>PostIt.iOS</c>. On desktop the stub is used
/// and this file is excluded.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public async Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
{
if (DeviceInfo.Current.Platform == DevicePlatform.Unknown)
return Array.Empty<ContactDto>();
try
{
var status = await Permissions.RequestAsync<Permissions.ContactsRead>();
if (status != PermissionStatus.Granted)
return Array.Empty<ContactDto>();
var contacts = await Contacts.Default.GetAllAsync();
if (contacts is null) return Array.Empty<ContactDto>();
// Carry the per-contact email list as-is. A real
// device contact can carry several addresses (home /
// work / other); the UI use case ("invite / add to a
// circle") can then decide which address to use, or
// let the user pick. The platform-neutral ContactDto
// shape is intentionally richer than the Yavsc
// directory's single-Email shape — the two flows
// answer different questions.
var result = new List<ContactDto>(contacts.Count());
foreach (var c in contacts)
{
var emails = ExtractEmails(c.Emails);
result.Add(new ContactDto(
c.Id,
c.DisplayName ?? string.Empty,
emails));
}
return result;
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"ContactService: {ex.Message}");
return Array.Empty<ContactDto>();
}
}
private static IReadOnlyList<string> ExtractEmails(IEnumerable<ContactEmail>? emails)
{
if (emails is null) return Array.Empty<string>();
var list = new List<string>();
foreach (var e in emails)
{
if (!string.IsNullOrEmpty(e.EmailAddress))
list.Add(e.EmailAddress);
}
return list;
}
}
#endif

View file

@ -4,10 +4,10 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser" /> <PackageReference Include="Avalonia.Browser" />

View file

@ -5,10 +5,10 @@
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.--> See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -2,8 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:PostIt" xmlns:local="using:PostIt"
x:Class="PostIt.App"> x:Class="PostIt.App">
<!-- ViewLocator is registered in App.axaml.cs with the real DI container. --> <Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />

View file

@ -1,5 +1,4 @@
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;
@ -8,7 +7,6 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -49,11 +47,58 @@ 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;
this.ServiceProvider = BuildServices(new ServiceCollection()); var settings = new Settings();
AttachServiceProvider(ServiceProvider); settings.Load();
var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>(); var tokenStore = new TokenStore(System.IO.Path.Combine(
var api = ServiceProvider.GetRequiredService<YavscApiClient>(); System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api);
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>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton(api);
services.AddSingleton(client);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
// 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));
@ -87,7 +132,8 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>(); var homePage = ServiceProvider.GetRequiredService<HomePage>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow(); window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus; window.SessionBanner.DataContext = sessionStatus;
@ -95,8 +141,9 @@ 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;
_ = PushPageAsync(homeVm); _ = window.NavRoot.PushAsync(homePage);
// 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
@ -106,6 +153,8 @@ 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();
}; };
@ -117,7 +166,35 @@ public partial class App : Application
_ = PushMainPageAsync(); _ = PushMainPageAsync();
}; };
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api); // When the user clicks the "Paramètres" button on the
// 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)
{ {
@ -128,110 +205,6 @@ 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 =
@ -259,18 +232,19 @@ public partial class App : Application
} }
/// <summary> /// <summary>
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its /// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
/// 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 Task PushMainPageAsync() public static async Task PushMainPageAsync()
{ {
var app = (App)Current; var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>(); var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
return app.PushPageAsync(mainVm); var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -305,53 +279,4 @@ 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

@ -0,0 +1,37 @@
using System;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Blogspot;
namespace PostIt.Models;
public class BlogPost : IBlogPost
{
public string AuthorId { get; set; }
public IApplicationUser Author { get; set; }
public string Article { get; set ; }
public string Photo { get; set ; }
public long Id { get; set ; }
public DateTime DateCreated { get; set ; }
public string UserCreated { get; set ; }
public DateTime DateModified { get; set ; }
public string UserModified { get; set ; }
public string Title { get; set ; }
public bool AuthorizeCircle(long circleId)
{
throw new NotImplementedException();
}
public ICircleAuthorization[] GetACL()
{
throw new NotImplementedException();
}
public string[] GetTags()
{
throw new NotImplementedException();
}
}

View file

@ -4,10 +4,10 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -25,7 +25,6 @@
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="postit-settings.json"> <Content Include="postit-settings.json">

View file

@ -3,18 +3,17 @@ 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.Blogspot; using PostIt.Models;
namespace Yavsc.Api.Client; namespace PostIt.Services;
/// <summary> /// <summary>
/// High-level client for the Blog subsystem of the Yavsc API /// High-level client for the Blog subsystem of the Yavsc API
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport /// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
/// concerns — base URL, JSON serialisation, Bearer auth, silent /// concerns — base URL, JSON serialisation, Bearer auth, silent
/// refresh on 401, request body shaping — are delegated to /// refresh on 401, request body shaping — are delegated to
/// <see cref="YavscApiClient"/>, which lives in the consuming /// <see cref="YavscApiClient"/>. This class is a thin DTO↔path
/// application (PostIt). This class is a thin DTO↔path mapper, /// mapper, nothing more.
/// nothing more.
/// ///
/// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s /// <para><b>URL convention.</b> <see cref="YavscApiClient"/>'s
/// <c>BaseAddress</c> already terminates with <c>/api/v1/</c> /// <c>BaseAddress</c> already terminates with <c>/api/v1/</c>
@ -35,50 +34,35 @@ public sealed class BlogApiClient
{ {
private const string DefaultPathPrefix = "blog"; private const string DefaultPathPrefix = "blog";
private readonly IYavscApiClient _api; private readonly YavscApiClient _api;
private readonly Uri _baseAddress;
private readonly string _pathPrefix; private readonly string _pathPrefix;
public BlogApiClient(IYavscApiClient api, string blogsBaseAddress, string pathPrefix = DefaultPathPrefix) public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{ {
_api = api ?? throw new ArgumentNullException(nameof(api)); _api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
// e.g. "https://blogs.pschneider.fr/api/v1/" — keep the // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly. // trailing slash so relative paths ("posts") resolve correctly.
_baseAddress = new Uri(blogsBaseAddress); api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl);
api.Http.BaseAddress = _baseAddress;
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
} }
public Task<List<BlogPostDto>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) public Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPostDto>>( => _api.CallAsync<List<BlogPost>>(
HttpMethod.Get, HttpMethod.Get,
$"{_pathPrefix}?start={start}&take={take}", $"{_pathPrefix}?start={start}&take={take}",
ct: ct); ct: ct);
public Task<BlogPostDto?> GetPostAsync(long id, CancellationToken ct = default) public Task<BlogPost?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); => _api.CallAsync<BlogPost?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public Task<BlogPostDto?> CreatePostAsync(BlogPostDto post, CancellationToken ct = default) public Task<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
=> _api.CallAsync<BlogPostDto?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct); => _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default) public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct); => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
public Task DeletePostAsync(long id, CancellationToken ct = default) public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct); => _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
/// <summary>
/// Set a post's publication state. <c>true</c> publishes
/// it (visible to anonymous readers via
/// <c>PermissionHandler.IsPublic</c>); <c>false</c> takes
/// it back to draft. Idempotent: the resulting state
/// matches the call, regardless of the previous state.
/// </summary>
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
} }

View file

@ -1,36 +0,0 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Desktop stub for <see cref="IContactService"/>.
///
/// <para>The desktop has no equivalent of the mobile address
/// book (no <c>Contacts.Default</c>, no CardDAV out of the
/// box). Rather than synthesise a list from a different
/// source, this provider returns an empty list and lets the
/// UI render an honest "no local contacts on this platform"
/// message.</para>
///
/// <para>If desktop users want to invite people who aren't
/// Yavsc members, that flow goes through a separate path
/// (manual email entry + invitation endpoint) — not through
/// <see cref="IContactService"/>. Finding existing Yavsc
/// members is <see cref="IUserDirectory"/>'s job, not this
/// one's.</para>
///
/// <para>Future CardDAV / Google Contacts / Exchange
/// providers can plug in here as additional
/// <see cref="IContactService"/> implementations selected
/// from DI by configuration.</para>
/// </summary>
public sealed class ContactService : IContactService
{
public Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ContactDto>>(Array.Empty<ContactDto>());
}
#endif

View file

@ -1,57 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Abstraction over the device-local address book. Used by
/// the "invite someone" flow to enumerate people the user
/// already has in their phone — including people who have
/// never heard of Yavsc.
///
/// <para>Distinct from <see cref="IUserDirectory"/>, which
/// reads the central Yavsc user table. A device contact may
/// not have a Yavsc account; a directory entry always does.
/// The two are exposed as separate interfaces so a UI that
/// needs both can take both by constructor injection and
/// present them under separate sections (e.g. "Contacts from
/// your phone" vs "Yavsc members").</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>ContactService.Mobile.cs</c> (ANDROID/IOS) and
/// <c>ContactService.Desktop.cs</c> (everything else). On
/// desktop the implementation is a stub that returns an
/// empty list: the desktop has no equivalent of the mobile
/// address book, and inviting from a desktop is a separate
/// flow.</para>
/// </summary>
public interface IContactService
{
/// <summary>
/// Read the device address book. Returns the contacts
/// known to the local provider; on desktop (no local
/// provider) this is always an empty list.
/// </summary>
Task<IReadOnlyList<ContactDto>> GetDeviceContactsAsync(CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral contact DTO. Source-of-truth shape for
/// the UI layer; concrete providers (MAUI Essentials on
/// mobile) map to this type.
///
/// <para><c>Emails</c> is a list on purpose: a real device
/// contact may carry several addresses (home / work / other).
/// The UI use case ("invite / add to a circle") can then
/// decide which address to use, or let the user pick. This
/// is intentionally richer than the Yavsc directory's
/// single-<c>Email</c> shape — the two flows answer different
/// questions and shouldn't be flattened onto the same
/// wire.</para>
/// </summary>
public sealed record ContactDto(
string Id,
string DisplayName,
IReadOnlyList<string> Emails);

View file

@ -1,67 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services;
/// <summary>
/// Abstraction over the central Yavsc user directory. Used by
/// the "add to a circle" flow to find Yavsc users by display
/// name or email.
///
/// <para>Distinct from <see cref="IContactService"/>, which
/// reads the device-local address book. A Yavsc user
/// directory entry is always a registered account; a device
/// contact may be anyone in the user's phone — including
/// people who have never heard of Yavsc.</para>
///
/// <para>Implementations live next to this file in
/// platform-conditional source files:
/// <c>UserDirectory.Desktop.cs</c> and
/// <c>UserDirectory.Mobile.cs</c>. Both currently delegate to
/// <c>UserSearchClient</c> (the central <c>/api/user-search</c>
/// endpoint); the split exists so future platform-specific
/// sources (offline cache, directory-scoped providers) can be
/// plugged in without disturbing the consumer.</para>
/// </summary>
public interface IUserDirectory
{
/// <summary>
/// Search the directory by display name (substring) and/or
/// email (exact).
/// </summary>
/// <param name="query">Substring filter on the user's
/// display name. Empty or whitespace short-circuits to an
/// empty list (matches the client UX of "type to search",
/// not "show me a directory").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A flat list of matching directory entries.
/// Never null; may be empty.</returns>
Task<IReadOnlyList<UserSummary>> SearchAsync(string query, CancellationToken ct = default);
}
/// <summary>
/// Platform-neutral summary of a Yavsc directory entry. Mirrors
/// the wire shape of <c>/api/user-search</c> (see
/// <c>UserSearchResultDto</c>) but expressed in terms that
/// don't leak transport concerns.
///
/// <para>Kept as a record on purpose: directory entries are
/// immutable snapshots from the server, so structural equality
/// makes "did the user already pick this one?" trivial.</para>
/// </summary>
public sealed record UserSummary(
string Id,
string UserName,
string? FullName,
string? Avatar,
string? Email)
{
/// <summary>
/// Convenience for "what to show in a picker". Falls back
/// to <see cref="UserName"/> when <see cref="FullName"/>
/// is null or empty.
/// </summary>
public string DisplayName =>
string.IsNullOrWhiteSpace(FullName) ? UserName : FullName;
}

View file

@ -1,52 +0,0 @@
#if !ANDROID && !IOS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client;
namespace PostIt.Services;
/// <summary>
/// Desktop implementation of <see cref="IUserDirectory"/>.
/// Delegates to the central <c>/api/user-search</c> endpoint
/// via <see cref="UserSearchClient"/>.
///
/// <para>The desktop has no device-local address book, so the
/// "add to a circle" flow on desktop is Yavsc-users-only.
/// Inviting someone who doesn't have a Yavsc account from
/// desktop is a separate feature (manual email entry +
/// invitation endpoint) and lives outside this interface.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
string query, CancellationToken ct = default)
{
// UserSearchClient already short-circuits on empty
// queries, but do it here too so the contract is
// obvious to anyone reading IUserDirectory alone
// without having to chase the client wrapper.
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -1,49 +0,0 @@
#if ANDROID || IOS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client;
namespace PostIt.Services;
/// <summary>
/// Mobile implementation of <see cref="IUserDirectory"/>.
/// Same backing as the desktop provider (the central
/// <c>/api/user-search</c> endpoint via
/// <see cref="UserSearchClient"/>) — mobile devices have the
/// network too, and "add to a circle" needs the same directory
/// regardless of platform.
///
/// <para>The split exists so a future mobile-only provider
/// (offline cache, device-local mirror of the user's own
/// circles) can be plugged in without touching consumers.</para>
/// </summary>
public sealed class UserDirectory : IUserDirectory
{
private readonly UserSearchClient _client;
public UserDirectory(UserSearchClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public async Task<IReadOnlyList<UserSummary>> SearchAsync(
string query, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(query))
return Array.Empty<UserSummary>();
var results = await _client.SearchAsync(query: query, ct: ct).ConfigureAwait(false);
if (results is null) return Array.Empty<UserSummary>();
return results.Select(u => new UserSummary(
Id: u.Id,
UserName: u.UserName,
FullName: u.FullName,
Avatar: u.Avatar,
Email: u.Email)).ToList();
}
}
#endif

View file

@ -9,7 +9,6 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using PostIt.ViewModels; using PostIt.ViewModels;
using Yavsc.Api.Client;
namespace PostIt.Services; namespace PostIt.Services;
@ -25,7 +24,7 @@ namespace PostIt.Services;
/// <see cref="BearerTokenHandler"/> only refreshes once even if many /// <see cref="BearerTokenHandler"/> only refreshes once even if many
/// concurrent requests are in flight. /// concurrent requests are in flight.
/// </summary> /// </summary>
public class YavscApiClient : IYavscApiClient, IAsyncDisposable public class YavscApiClient : IAsyncDisposable
{ {
// 60s of slack before the access_token's nominal expiry. Covers // 60s of slack before the access_token's nominal expiry. Covers
// network latency + JWT validation on the server side. // network latency + JWT validation on the server side.

View file

@ -21,18 +21,6 @@ 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
{ {
@ -40,11 +28,8 @@ 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

@ -1,128 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "add a Yavsc user to a circle" modal.
///
/// <para>Resolves users through <see cref="IUserDirectory"/>
/// (which delegates to <c>/api/user-search</c>); the caller
/// (CirclesPage) decides whether to add the picked user to
/// the circle by calling
/// <see cref="AddCircleMemberDialogViewModel.AddCommand"/>
/// (which is bound to the dialog's "Ajouter" button).</para>
///
/// <para>The dialog itself doesn't know the target
/// <c>CircleId</c>: that's set by the caller via the
/// constructor and the dialog only triggers
/// <see cref="IUserDirectory.SearchAsync"/> against the
/// <see cref="SearchQuery"/> string. The "Add" command
/// returns the picked <see cref="UserSummary"/> via the
/// <see cref="Confirmed"/> event, and the hosting
/// <c>CirclesPage</c> then calls
/// <see cref="CircleApiClient.AddMemberAsync"/>.</para>
/// </summary>
public partial class AddCircleMemberDialogViewModel : ViewModelBase
{
private readonly IUserDirectory _directory;
[ObservableProperty]
public partial string SearchQuery { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<UserSummary> Results { get; set; } = new();
[ObservableProperty]
public partial UserSummary? Selected { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
/// <summary>
/// Raised when the user confirms a selection. The hosting
/// <c>CirclesPage</c> subscribes to this event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id + the picked user's id. The dialog itself
/// does not know the circle id by design: separation of
/// concerns — the modal is a user picker, not a
/// "circle joiner" form.
/// </summary>
public event EventHandler<UserSummary>? Confirmed;
public AddCircleMemberDialogViewModel(IUserDirectory directory)
{
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
}
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(); }
/// <summary>
/// Search the directory for users matching the current
/// <see cref="SearchQuery"/>. Triggered explicitly via the
/// "Rechercher" button — no debouncing, so the caller
/// stays in control of how often the network is hit.
/// </summary>
[RelayCommand]
public async Task SearchAsync()
{
if (string.IsNullOrWhiteSpace(SearchQuery))
{
Results.Clear();
StatusMessage = "Tapez un nom ou un email";
return;
}
IsBusy = true;
try
{
var hits = await _directory.SearchAsync(SearchQuery, CancellationToken.None).ConfigureAwait(true);
Results = new ObservableCollection<UserSummary>(hits ?? Array.Empty<UserSummary>());
StatusMessage = $"{Results.Count} résultat(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Raise <see cref="Confirmed"/> for the currently selected
/// user. No-op when no selection has been made — keeps the
/// UI from firing an event with a null payload.
/// </summary>
[RelayCommand]
public async Task AddAsync()
{
if (Selected is null)
{
StatusMessage = "Sélectionnez un utilisateur";
return;
}
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,310 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Mes cercles" page. CRUD on the caller's own
/// circles (the server scopes every endpoint to the caller's uid
/// since the BlogAcl fix on this branch), plus membership
/// management on the currently selected circle.
///
/// <para>The view lists circles in <see cref="Circles"/>, supports
/// create / edit via <see cref="DraftName"/>, and exposes
/// per-item Delete and per-item edit commands. <see cref="IsBusy"/>
/// drives a progress overlay during API calls; <see cref="StatusMessage"/>
/// surfaces success / error feedback in the view footer.</para>
///
/// <para>When the user selects a circle in the list,
/// <see cref="LoadMembersAsync"/> fetches its members into
/// <see cref="Members"/>. The "Add a member" command
/// (<see cref="OpenAddMemberAsync"/>) is a UI event the view
/// raises to open <c>AddCircleMemberDialog</c>; the dialog
/// raises a <c>Confirmed</c> event back, which the page's
/// code-behind forwards here via
/// <see cref="OnAddMemberConfirmedAsync"/>. The "remove"
/// command is per-row and runs inline.</para>
/// </summary>
public partial class CirclesPageViewModel : ViewModelBase
{
private readonly CircleApiClient _client;
[ObservableProperty]
public partial ObservableCollection<CircleDto> Circles { get; set; } = new();
[ObservableProperty]
public partial CircleDto? SelectedCircle { get; set; }
/// <summary>Editor buffer for the new / edited circle's name.</summary>
[ObservableProperty]
public partial string DraftName { get; set; } = string.Empty;
/// <summary>Editor buffer for the new / edited circle's visibility flag.</summary>
[ObservableProperty]
public partial bool DraftPublic { get; set; }
/// <summary>Members of the currently selected circle. Empty
/// when no circle is selected or after a refresh that
/// produced an empty list. Updated by
/// <see cref="LoadMembersAsync"/>.</summary>
[ObservableProperty]
public partial ObservableCollection<CircleMemberDto> Members { get; set; } = new();
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = string.Empty;
public CirclesPageViewModel(CircleApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
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(); }
/// <summary>
/// Partial property setter: when the selected circle
/// changes, refresh the members list. The setter is
/// invoked by the [ObservableProperty] source generator
/// for both user selections and programmatic resets.
/// </summary>
partial void OnSelectedCircleChanged(CircleDto? value)
{
Members = new ObservableCollection<CircleMemberDto>();
if (value is not null)
{
// Fire-and-forget: load members in the background.
// Errors are routed to StatusMessage inside
// LoadMembersAsync.
_ = LoadMembersAsync(value.Id);
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetMyCirclesAsync();
Circles = new ObservableCollection<CircleDto>(list ?? new());
StatusMessage = $"{Circles.Count} cercle(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[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>
/// Load the members of one of the caller's circles. The
/// server scopes the endpoint with a 404 when the circle
/// doesn't belong to the caller (mirroring the rest of the
/// circle API); that case flattens to an empty list here.
/// </summary>
[RelayCommand]
public async Task LoadMembersAsync(long circleId)
{
IsBusy = true;
try
{
var list = await _client.GetMembersAsync(circleId);
Members = new ObservableCollection<CircleMemberDto>(list ?? new());
StatusMessage = $"{Members.Count} membre(s)";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
Members = new ObservableCollection<CircleMemberDto>();
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void StartCreate()
{
SelectedCircle = null;
DraftName = string.Empty;
DraftPublic = false;
StatusMessage = "Nouveau cercle";
}
[RelayCommand]
public void StartEdit(CircleDto? circle)
{
if (circle is null) return;
SelectedCircle = circle;
DraftName = circle.Name;
DraftPublic = circle.Public;
StatusMessage = $"Édition de « {circle.Name} »";
}
[RelayCommand]
public async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(DraftName))
{
StatusMessage = "Le nom est obligatoire";
return;
}
IsBusy = true;
try
{
if (SelectedCircle is null)
{
var created = await _client.CreateCircleAsync(new CircleDto
{
Name = DraftName.Trim(),
Public = DraftPublic,
});
StatusMessage = created is null
? "Création échouée"
: $"Cercle « {created.Name} » créé";
}
else
{
SelectedCircle.Name = DraftName.Trim();
SelectedCircle.Public = DraftPublic;
await _client.UpdateCircleAsync(SelectedCircle.Id, SelectedCircle);
StatusMessage = $"Cercle « {SelectedCircle.Name} » mis à jour";
}
await RefreshAsync();
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task DeleteAsync(CircleDto? circle)
{
if (circle is null) return;
IsBusy = true;
try
{
await _client.DeleteCircleAsync(circle.Id);
StatusMessage = $"Cercle « {circle.Name} » supprimé";
// If the deleted circle was the selected one,
// clear the selection so the Members view goes
// empty too (the partial setter on
// SelectedCircle will reset Members).
if (SelectedCircle?.Id == circle.Id)
SelectedCircle = null;
await RefreshAsync();
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Called by the view when the dialog confirms a
/// selection. Adds the picked user to the currently
/// selected circle and refreshes the members list.
/// </summary>
public async Task OnAddMemberConfirmedAsync(object? sender, UserSummary picked)
{
if (SelectedCircle is null || picked is null) return;
IsBusy = true;
try
{
await _client.AddMemberAsync(SelectedCircle.Id, picked.Id);
StatusMessage = $"« {picked.DisplayName} » ajouté au cercle";
await LoadMembersAsync(SelectedCircle.Id);
}
catch (Exception ex)
{
// 409 (already a member) is a likely race — surface
// it as a friendly status, not an error. The
// server returns 409 for "already a member";
// YavscApiClient surfaces that as an exception
// today; future refactors could route 409 into a
// typed result, but for now the message string is
// distinctive enough.
var msg = ex.Message.Contains("409") || ex.Message.Contains("Conflict")
? "Déjà membre du cercle"
: $"Erreur: {ex.Message}";
StatusMessage = msg;
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Per-row "remove" command. Updates the local
/// collection in place so the UI doesn't flash.
/// </summary>
[RelayCommand]
public async Task RemoveMemberAsync(CircleMemberDto? member)
{
if (member is null || SelectedCircle is null) return;
IsBusy = true;
try
{
await _client.RemoveMemberAsync(SelectedCircle.Id, member.Id);
Members.Remove(member);
StatusMessage = $"« {member.UserName} » retiré du cercle";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -2,14 +2,10 @@ 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 PostIt.Models;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.Views;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -28,7 +24,7 @@ public partial class MainPageViewModel : ViewModelBase
/// previous "{Binding SelectedPost.Title}" binding, the user's /// previous "{Binding SelectedPost.Title}" binding, the user's
/// keystrokes were silently dropped whenever /// keystrokes were silently dropped whenever
/// <c>SelectedPost was null</c>, which made the editor a trap /// <c>SelectedPost was null</c>, which made the editor a trap
/// and caused Save to POST a <c>BlogPostDto</c> with an empty /// and caused Save to POST a <c>BlogPost</c> with an empty
/// title — hence the 400 "The Title field is required".</summary> /// title — hence the 400 "The Title field is required".</summary>
[ObservableProperty] [ObservableProperty]
public partial string DraftTitle { get; set; } public partial string DraftTitle { get; set; }
@ -38,17 +34,8 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial string DraftArticle { get; set; } public partial string DraftArticle { get; set; }
/// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the
/// existence of a row in <c>BlogSpotPublication</c>) and
/// is pushed to the server via
/// <see cref="BlogApiClient.SetPublishAsync"/> on explicit
/// toggle — it is NOT included in the regular Save
/// payload, mirroring the wire contract where
/// <c>BlogPostDto</c> doesn't carry <c>Publish</c> as a
/// mutable field. Toggling is its own action.</summary>
[ObservableProperty] [ObservableProperty]
public partial bool DraftIsPublished { get; set; } public partial ViewModelBase? CurrentViewModel { get; set; }
public Settings SettingsModel { get; } public Settings SettingsModel { get; }
@ -59,13 +46,13 @@ public partial class MainPageViewModel : ViewModelBase
public partial string SearchText { get; set; } public partial string SearchText { get; set; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<BlogPostDto> Posts { get; set; } public partial ObservableCollection<BlogPost> Posts { get; set; }
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; } public partial ObservableCollection<BlogPost> FilteredPosts { get; set; }
[ObservableProperty] [ObservableProperty]
public partial BlogPostDto? SelectedPost { get; set; } public partial BlogPost? SelectedPost { get; set; }
[ObservableProperty] [ObservableProperty]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
@ -81,46 +68,9 @@ 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()
{ {
@ -132,8 +82,8 @@ public partial class MainPageViewModel : ViewModelBase
private void Init(Settings? settings) private void Init(Settings? settings)
{ {
SearchText = string.Empty; SearchText = string.Empty;
Posts = new ObservableCollection<BlogPostDto>(); Posts = new ObservableCollection<BlogPost>();
FilteredPosts = new ObservableCollection<BlogPostDto>(); FilteredPosts = new ObservableCollection<BlogPost>();
SelectedPost = null; SelectedPost = null;
IsBusy = false; IsBusy = false;
StatusMessage = "Ready"; StatusMessage = "Ready";
@ -151,38 +101,25 @@ public partial class MainPageViewModel : ViewModelBase
WindowTitle = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
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, IServiceProvider? services = null) public MainPageViewModel(BlogApiClient blogClient, Settings? settings = 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);
} }
partial void OnSearchTextChanged(string value) => ApplyFilter(); partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPostDto? value) partial void OnSelectedPostChanged(BlogPost? value)
{ {
// Mirror the selection into the editor buffer so the // Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content // XAML-bound TextBox/TextEditor show the right content
@ -193,9 +130,6 @@ public partial class MainPageViewModel : ViewModelBase
// doesn't show stale content. // doesn't show stale content.
DraftTitle = value?.Title ?? string.Empty; DraftTitle = value?.Title ?? string.Empty;
DraftArticle = value?.Article ?? string.Empty; DraftArticle = value?.Article ?? string.Empty;
// Mirror publication state too. Defaults to false on
// null selection so a fresh draft starts unpublished.
DraftIsPublished = value?.IsPublished ?? false;
UpdateCommandStates(); UpdateCommandStates();
} }
@ -242,7 +176,7 @@ public partial class MainPageViewModel : ViewModelBase
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
// Build a fresh BlogPostDto from the editor buffer on // Build a fresh BlogPost from the editor buffer on
// every Save — we no longer mutate SelectedPost in // every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer // place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null) // (which was a no-op when SelectedPost was null)
@ -254,7 +188,7 @@ public partial class MainPageViewModel : ViewModelBase
// the update path. // the update path.
if (SelectedPost is null || SelectedPost.Id == 0) if (SelectedPost is null || SelectedPost.Id == 0)
{ {
var draft = new BlogPostDto var draft = new BlogPost
{ {
Title = DraftTitle, Title = DraftTitle,
Article = DraftArticle ?? string.Empty, Article = DraftArticle ?? string.Empty,
@ -270,7 +204,7 @@ public partial class MainPageViewModel : ViewModelBase
} }
else else
{ {
var update = new BlogPostDto var update = new BlogPost
{ {
Id = SelectedPost.Id, Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId, AuthorId = SelectedPost.AuthorId,
@ -306,70 +240,10 @@ public partial class MainPageViewModel : ViewModelBase
}); });
} }
/// <summary>
/// Toggle the publication state of the currently selected
/// post. Pushes the new state to
/// <c>PUT /api/BlogApi/{id}/publish</c> and reflects it
/// locally in <see cref="DraftIsPublished"/> + the
/// selected post so the UI updates without a full
/// refresh.
///
/// <para>The toggle is its own action — separate from Save
/// — because <c>Publish</c> is not part of the
/// <c>BlogPostDto</c> payload. Bundling it into Save
/// would require a wire-shape change and a second server
/// overload; the dedicated endpoint keeps the wire
/// contract clean.</para>
/// </summary>
[RelayCommand] [RelayCommand]
internal async Task TogglePublish() internal void OpenSettings()
{ {
if (SelectedPost is null || SelectedPost.Id == 0) CurrentViewModel = SettingsModel;
{
StatusMessage = "Sélectionnez un billet existant pour changer sa publication.";
return;
}
await ExecuteAsync(async () =>
{
var desired = !DraftIsPublished;
await BlogClient.SetPublishAsync(SelectedPost.Id, desired);
DraftIsPublished = desired;
// Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip
// re-hydrates it.
SelectedPost.IsPublished = desired;
StatusMessage = desired
? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon.";
});
}
/// <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]
internal async Task OpenSignatureDev()
{
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()
@ -432,23 +306,14 @@ 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
[RelayCommand(CanExecute = nameof(CanManageAcl))] /// whether a post is selected. The "no selection" case is
public async Task ManageAcl() /// the create-new-post path; the "with selection" case is
{ /// the update path. Both read from the editor buffer.
if (SelectedPost is null) /// Previously this also required <c>SelectedPost is not null</c>
{ /// — which contradicted the create-new-post intent and
StatusMessage = "Select an existing post before managing ACL."; /// forced the buggy "draft with empty title" branch.</summary>
return; private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
} private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
}
[RelayCommand]
public async Task OpenCircles()
{
var circlesVm = ResolveServices().GetRequiredService<CirclesPageViewModel>();
await ((App)App.Current!).PushPageAsync(circlesVm).ConfigureAwait(true);
}
} }

View file

@ -1,174 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot;
namespace PostIt.ViewModels;
/// <summary>
/// View model for the "Gérer l'ACL" modal of a single blog post.
///
/// <para>Loads the caller's circles once on construct (the dropdown
/// only shows circles the user owns), then keeps an in-memory list
/// of the ACL entries for the post. <see cref="AddAsync"/> /
/// <see cref="RevokeAsync"/> are the only mutating verbs; both
/// refresh the list afterwards so the UI stays in sync with the
/// server.</para>
///
/// <para>The server is the source of truth: it scopes every
/// endpoint to the caller's uid and rejects ACL grants on posts
/// the caller doesn't own. This VM does not re-validate that —
/// any 403 / 404 will surface as an exception caught by the
/// command and routed to <see cref="StatusMessage"/>.</para>
/// </summary>
public partial class PostAclDialogViewModel : ViewModelBase
{
private readonly BlogAclApiClient _aclClient;
private readonly CircleApiClient _circleClient;
/// <summary>The post whose ACL is being edited. Set by the
/// caller (MainPage) when opening the dialog.</summary>
public BlogPostDto Post { get; }
[ObservableProperty]
public partial ObservableCollection<CircleDto>
MyCircles { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<PostAccessControlRulePayload>
AclEntries { get; set; } = new();
[ObservableProperty]
public partial CircleDto? SelectedCircleToAdd { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
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(
BlogPostDto post,
BlogAclApiClient aclClient,
CircleApiClient circleClient)
{
Post = post ?? throw new ArgumentNullException(nameof(post));
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
}
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(); }
[RelayCommand]
public async Task LoadAsync()
{
if (_loaded) return;
IsBusy = true;
try
{
// Load circles and ACL entries in parallel — both are
// independent reads on the same host. The caller's uid
// is implicit in both endpoints.
var circlesTask = _circleClient.GetMyCirclesAsync();
var aclTask = _aclClient.GetMyAclAsync();
await Task.WhenAll(circlesTask, aclTask);
var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles);
StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true;
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task AddAsync()
{
if (SelectedCircleToAdd is null)
{
StatusMessage = "Sélectionnez un cercle à ajouter";
return;
}
IsBusy = true;
try
{
var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{
CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id
});
if (created is not null)
{
AclEntries.Add(created);
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
}
else
{
StatusMessage = "Autorisation refusée par le serveur";
}
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public async Task RevokeAsync(PostAccessControlRulePayload? acl)
{
if (acl is null) return;
IsBusy = true;
try
{
await _aclClient.RevokeAsync(acl.CircleId);
AclEntries.Remove(acl);
StatusMessage = "Autorisation révoquée";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -2,7 +2,6 @@ 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;
@ -33,6 +32,15 @@ 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; }
@ -136,10 +144,9 @@ public partial class SessionStatusViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
internal async Task OpenSettings() public async System.Threading.Tasks.Task OpenSettingsCommand()
{ {
var app = (App)App.Current!; OpenSettingsRequested?.Invoke();
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true); await System.Threading.Tasks.Task.CompletedTask;
} }
} }

View file

@ -1,62 +0,0 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.AddCircleMemberDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:services="using:PostIt.Services"
x:DataType="vm:AddCircleMemberDialogViewModel"
>
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<!-- Search box + button -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<TextBox Grid.Column="0"
Text="{Binding SearchQuery, Mode=TwoWay}"
PlaceholderText="Nom ou email d'un utilisateur Yavsc..."
HorizontalAlignment="Stretch"/>
<Button Grid.Column="1" Content="Rechercher"
Command="{Binding SearchAsync}"
Margin="8,0,0,0"/>
</Grid>
<!-- Selection hint -->
<TextBlock Grid.Row="1"
Text="Sélectionnez un résultat puis cliquez Ajouter."
FontSize="11" Opacity="0.6"
Margin="0,0,0,8"/>
<!-- Search results -->
<ListBox Grid.Row="2"
ItemsSource="{Binding Results}"
SelectedItem="{Binding Selected, Mode=TwoWay}"
MinHeight="20"
>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="services:UserSummary">
<StackPanel Spacing="2">
<TextBlock Text="{Binding DisplayName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto,Auto" Margin="0,8,0,0">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Ajouter"
x:Name="AddButton"
Command="{Binding AddAsync}"
IsEnabled="{Binding Selected, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,0,8,0"/>
<Button Grid.Column="2" Content="Fermer"
x:Name="CloseButton"
Command="{Binding CloseAsync}"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,50 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
/// <summary>
/// Modal "add a member to a circle" page. Hosted by
/// <c>CirclesPage</c>; the caller passes the resolved
/// <see cref="IUserDirectory"/> via the constructor.
///
/// <para>The dialog raises <c>Confirmed</c> on its ViewModel
/// when the user picks a result and clicks "Ajouter"; the
/// hosting page subscribes to that event and calls
/// <c>CircleApiClient.AddMemberAsync</c> with the target
/// circle id. The dialog itself does not know the circle id
/// by design.</para>
/// </summary>
public partial class AddCircleMemberDialog : ContentPage
{
public AddCircleMemberDialog()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
/// <summary>
/// Subscribe a handler to be notified when the user
/// confirms a selection. Returns the underlying VM so
/// the caller can also drive further state (clear the
/// selection, close the dialog, refresh its own list).
/// </summary>
public AddCircleMemberDialogViewModel? ViewModel
=> DataContext as AddCircleMemberDialogViewModel;
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
var nav = this.FindAncestorOfType<NavigationPage>();
if (nav is not null)
_ = nav.PopAsync();
}
}

View file

@ -1,111 +0,0 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.CirclesPage"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
x:DataType="vm:CirclesPageViewModel"
>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Toolbar: refresh + new -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="12">
<Button Content="Rafraîchir"
Command="{Binding RefreshCommand}"/>
<Button Content="Nouveau"
Command="{Binding StartCreateCommand}"/>
</StackPanel>
<!-- Two-pane body: circles (left) + members (right) -->
<Grid Grid.Row="1" Margin="12,0,12,12"
ColumnDefinitions="*,16,*"
RowDefinitions="*,Auto">
<!-- Left column: list of circles + editor -->
<Grid Grid.Row="0" Grid.Column="0"
RowDefinitions="*,Auto">
<ListBox Grid.Row="0"
ItemsSource="{Binding Circles}"
SelectedItem="{Binding SelectedCircle, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontWeight="Bold"/>
<TextBlock Text="{Binding Public, StringFormat='Public : {0}'}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Éditer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).StartEditCommand}"
CommandParameter="{Binding}"/>
<Button Grid.Column="2" Content="Supprimer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).DeleteCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Editor -->
<Grid Grid.Row="1" Margin="0,12,0,0" RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*" IsEnabled="{Binding !IsBusy}">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Nom :"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding DraftName, Mode=TwoWay}"/>
<CheckBox Grid.Row="1" Grid.Column="1"
Content="Public"
IsChecked="{Binding DraftPublic, Mode=TwoWay}"/>
<Button Grid.Row="2" Grid.Column="1" Content="Enregistrer"
Command="{Binding SaveCommand}"
HorizontalAlignment="Right" Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Right column: members of the selected circle -->
<Grid Grid.Row="0" Grid.Column="2"
RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8" Margin="0,0,0,8">
<TextBlock Text="Membres"
FontWeight="Bold"
VerticalAlignment="Center"/>
<Button Content="Ajouter un membre"
Command="{Binding OpenAddMemberAsync}"/>
</StackPanel>
<ListBox Grid.Row="1"
ItemsSource="{Binding Members}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleMemberDto">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding FullName}"
FontWeight="Bold"/>
<TextBlock Text="{Binding UserName}"
FontSize="11" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="Retirer"
Command="{Binding $parent[ContentPage].((vm:CirclesPageViewModel)DataContext).RemoveMemberCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state hint -->
<TextBlock Grid.Row="2"
Text="Sélectionnez un cercle pour voir ses membres."
IsVisible="{Binding SelectedCircle, Converter={x:Static ObjectConverters.IsNull}}"
FontSize="11" Opacity="0.6"
Margin="0,8,0,0"/>
</Grid>
</Grid>
<!-- Status bar -->
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,0,12,12">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,23 +0,0 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class CirclesPage : ContentPage
{
public CirclesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:models="using:Yavsc.Blogspot" xmlns:models="using:PostIt.Models"
xmlns:views="using:PostIt.Views" xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit" xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d" mc:Ignorable="d"
@ -33,24 +33,6 @@
<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 x:Name="ManageAclButton"
Command="{Binding ManageAcl}"
Content="ACL" />
<Button x:Name="OpenCirclesButton"
Command="{Binding OpenCircles}"
Content="Mes cercles" />
<!-- Publication toggle: a CheckBox wired to
DraftIsPublished. Clicking it fires
TogglePublishCommand, which pushes the
new state to /api/blog/{id}/publish.
The CheckBox is the canonical
AvaloniaXaml 'toggle' surface; binding
IsChecked TwoWay keeps the visual state
and the buffer in sync. -->
<CheckBox Content="Publié"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"
Command="{Binding TogglePublishCommand}"
VerticalAlignment="Center"/>
<!-- <!--
DEV ONLY: temporary shortcut to open the signature DEV ONLY: temporary shortcut to open the signature
capture page. Production entry point is a SignalR capture page. Production entry point is a SignalR
@ -59,17 +41,17 @@
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" MinHeight="40"> HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPostDto"> <DataTemplate x:DataType="models:BlogPost">
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" /> <TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" /> <TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />

View file

@ -1,4 +1,8 @@
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
@ -8,4 +12,31 @@ public partial class MainPage : ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
}
/// <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

@ -1,64 +0,0 @@
<ContentPage
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
x:DataType="vm:PostAclDialogViewModel"
>
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
<!-- Add a new authorisation -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsEnabled="{Binding !IsBusy}">
<ComboBox Grid.Column="0"
ItemsSource="{Binding MyCircles}"
SelectedItem="{Binding SelectedCircleToAdd, Mode=TwoWay}"
PlaceholderText="Choisir un cercle..."
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="dtos:CircleDto">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="1" Content="Ajouter"
Command="{Binding AddCommand}"
Margin="8,0,0,0"/>
</Grid>
<!-- Current ACL entries -->
<ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/>
</StackPanel>
<Button Grid.Column="1" Content="Révoquer"
Command="{Binding $parent[ContentPage].((vm:PostAclDialogViewModel)DataContext).RevokeCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Action buttons: close -->
<Button Grid.Row="2" Content="Fermer"
Click="OnCloseClicked"
HorizontalAlignment="Right"
Margin="0,8,0,8"/>
<!-- Status bar -->
<Grid Grid.Row="3" ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}"
VerticalAlignment="Center"/>
<ProgressBar Grid.Column="1" IsIndeterminate="True"
IsVisible="{Binding IsBusy}"
Width="120"/>
</Grid>
</Grid>
</ContentPage>

View file

@ -1,87 +0,0 @@
using System;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
namespace PostIt.Views;
/// <summary>
/// Modal "manage ACL" page for a single blog post.
///
/// <para>The ViewModel is constructed by the caller (the post
/// list page) and handed to <see cref="App.PushPageAsync"/>,
/// which routes through <see cref="ViewLocator"/> and lands
/// here via the parameterless DI constructor. The VM is then
/// 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>
public partial class PostAclDialog : ContentPage
{
public PostAclDialog()
{
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)
{
// 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();
DataContext = new PostAclDialogViewModel(post, aclClient, circleClient);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
// Pop this page off the navigation stack. Avalonia's
// NavigationPage doesn't have a typed "Close" — the
// hosting control (a NavigationPage in MainWindow.axaml)
// is the one that owns the back stack, but the
// ContentPage itself doesn't know about it. A simpler
// contract: fire an event the host listens to, or rely
// on the system back gesture. We do the latter — the
// dialog is intentionally modal-light.
if (this.VisualRoot is NavigationPage nav)
{
// The actual API varies between Avalonia 11.x
// versions; the safest call is the equivalent of
// "go back", which lives on the host. For now, hide
// the page and let the host decide.
}
}
}

View file

@ -8,8 +8,8 @@ namespace Yavsc.ViewModels.Account
public class RegisterModel public class RegisterModel
{ {
[StringLength(Constants.MaxUserNameLength)] [StringLength(YavscConstants.MaxUserNameLength)]
[RegularExpression(Constants.UserNameRegExp)] [RegularExpression(YavscConstants.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

@ -1,33 +0,0 @@
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,45 +0,0 @@
using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Blogspot;
public class BlogPostDto : IBlogPost
{
public string AuthorId { get; set; }
public BlogPostAuthorDto? Author { get; set; }
public string Article { get; set ; }
public string Photo { get; set ; }
public long Id { get; set; }
public DateTime DateCreated { get; set; }
public string UserCreated { get; set; }
public DateTime DateModified { get; set; }
public string UserModified { get; set; }
public string Title { get; set; }
/// <summary>
/// Whether this post is published. Derived server-side from
/// the existence of a row in <c>BlogSpotPublication</c>
/// (a row means published, no row means draft). Not stored
/// on <c>BlogPost</c> — it's a computed projection of the
/// publication table, surfaced through the wire DTO so
/// clients can render the current state without a
/// follow-up request. Toggled via
/// <c>PUT /api/BlogApi/{id}/publish</c>.
/// </summary>
public bool IsPublished { get; set; }
public virtual bool AuthorizeCircle(long circleId)
{
ACL.Add(new CircleAuthorization { CircleId = circleId });
return true;
}
private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
public string[] Tags { get; set; }
public string[] GetTags() => Tags;
public ICircleAuthorization[] GetACL() => ACL.ToArray();
}

View file

@ -1,6 +1,7 @@
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using Yavsc.Interfaces; using Yavsc.Interfaces;
@ -8,11 +9,6 @@ namespace Yavsc.Blogspot
{ {
public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle
{ {
// Typed as a concrete wire DTO (not the IApplicationUser IApplicationUser Author { get; }
// 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

@ -1,10 +0,0 @@
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,10 +3,8 @@ using Yavsc.Models.Auth;
namespace Yavsc namespace Yavsc
{ {
public static class Constants public static class YavscConstants
{ {
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,17 +0,0 @@
namespace Yavsc.Abstract.Identity.Security;
/// <summary>
/// Wire format for <c>GET /api/blogacl</c> and friends.
///
/// <para>The server-side
/// <c>Yavsc.Models.Access.CircleAuthorizationToBlogPost</c> EF entity
/// carries virtual navigation properties (<c>Target</c>,
/// <c>Allowed</c>) that pull in the full BlogPost and Circle graphs.
/// The client never needs them: when showing the ACL of a post, the
/// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary>
public sealed class CircleAuthorization : ICircleAuthorization
{
public long CircleId { 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="Constants.AvatarsPath"/> (minuscule). /// <see cref="YavscConstants.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 Constants.DefaultAvatar; return YavscConstants.DefaultAvatar;
return $"{Constants.AvatarsPath}/{user!.UserName}.s.png"; return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png";
} }
} }
} }

View file

@ -9,10 +9,10 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library> <Library>true</Library>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GitVersion.MsBuild" /> <PackageReference Include="GitVersion.MsBuild" />

View file

@ -1,51 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/blogacl</c> on the Yavsc Blogs server.
///
/// <para>Each <see cref="CircleAuthorization"/> grants a single
/// <c>Circle</c> access to a single <c>BlogPostDto</c>. The server
/// scopes every endpoint to the caller's uid: only the author of
/// the underlying blog post can list, create, modify, or delete
/// its ACL entries.</para>
/// </summary>
public sealed class BlogAclApiClient
{
private const string Path = "blogacl";
private readonly IYavscApiClient _api;
public BlogAclApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
public Task<List<PostAccessControlRulePayload>> GetMyAclAsync(CancellationToken ct = default)
=> _api.CallAsync<List<PostAccessControlRulePayload>>(HttpMethod.Get, Path, ct: ct);
public Task<PostAccessControlRulePayload?> GetAclAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Get, $"{Path}/{circleId}", ct: ct);
public Task<PostAccessControlRulePayload?> GrantAsync(PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync<PostAccessControlRulePayload?>(HttpMethod.Post, Path, body: acl, ct: ct);
public Task UpdateAclAsync(long circleId, PostAccessControlRulePayload acl, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{circleId}", body: acl, ct: ct);
public Task RevokeAsync(long circleId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{circleId}", ct: ct);
}

View file

@ -1,85 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/circle</c> on the Yavsc Blogs server.
///
/// <para>Same conventions as <see cref="BlogApiClient"/>: all
/// transport is delegated to <see cref="YavscApiClient"/>; this
/// class only maps paths to DTOs.</para>
///
/// <para>The server now (since the BlogAcl fix on this branch)
/// scopes every read and write to the caller's uid. There is no
/// way for the client to read or modify another user's circles
/// — the route will return 404 (not 403) when the circle exists
/// but belongs to someone else, to avoid leaking its existence.</para>
/// </summary>
public sealed class CircleApiClient
{
private const string Path = "circle";
private readonly IYavscApiClient _api;
public CircleApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
public Task<List<CircleDto>> GetMyCirclesAsync(CancellationToken ct = default)
=> _api.CallAsync<List<CircleDto>>(HttpMethod.Get, Path, ct: ct);
public Task<CircleDto?> GetCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<CircleDto?>(HttpMethod.Get, $"{Path}/{id}", ct: ct);
public Task<CircleDto?> CreateCircleAsync(CircleDto circle, CancellationToken ct = default)
=> _api.CallAsync<CircleDto?>(HttpMethod.Post, Path, body: circle, ct: ct);
public Task UpdateCircleAsync(long id, CircleDto circle, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{Path}/{id}", body: circle, ct: ct);
public Task DeleteCircleAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}", ct: ct);
/// <summary>
/// Returns the members of one of the caller's circles.
/// Returns null when the circle does not exist or is not
/// owned by the caller (the server scopes the endpoint
/// with a 404 in either case to avoid leaking existence
/// — this client flattens that into a null result).
/// </summary>
public Task<List<CircleMemberDto>?> GetMembersAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<List<CircleMemberDto>?>(HttpMethod.Get, $"{Path}/{id}/members", ct: ct);
/// <summary>
/// Adds a Yavsc user (resolved client-side via
/// <c>/api/user-search</c>) to one of the caller's
/// circles. Returns null when the circle does not exist
/// or is not owned by the caller, or when the target
/// user does not exist. Throws on 409 (already a
/// member) — callers that want idempotent behaviour
/// can swallow the exception or dedupe beforehand.
/// </summary>
public Task AddMemberAsync(long id, string userId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Post, $"{Path}/{id}/members",
body: new { userId }, ct: ct);
/// <summary>
/// Removes a user from one of the caller's circles.
/// Returns null on success (the server returns 200 OK
/// with no body) or when the membership does not
/// exist — both treated as success by the caller.
/// </summary>
public Task RemoveMemberAsync(long id, string userId, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{Path}/{id}/members/{userId}", ct: ct);
}

View file

@ -1,23 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle</c> and friends.
///
/// <para>Field names match the JSON the server emits (camelCase via
/// the default <see cref="System.Text.Json"/> policy), so no
/// <c>[JsonPropertyName]</c> attributes are required.</para>
///
/// <para>Mirrors the server-side <c>Yavsc.Models.Relationship.Circle</c>
/// EF entity but stops short of the navigation properties
/// (<c>Owner</c>, <c>Members</c>) which depend on
/// <c>ApplicationUser</c> and other server-only types. The client
/// only ever needs the id, name, and owner of a circle to drive
/// the UI.</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; }
}

View file

@ -1,21 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/circle/{id}/members</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.CircleMemberDto</c>. Intentionally
/// stops short of the Email field that
/// <see cref="UserSearchResultDto"/> carries — the circle
/// membership UI only needs a name and an avatar to render the
/// list. If the future ACL UI wants contact details, it can
/// fall back to <see cref="IYavscApiClient"/>'s other
/// endpoints rather than widening this shape.</para>
/// </summary>
public sealed class CircleMemberDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
}

View file

@ -1,23 +0,0 @@
namespace Yavsc.Api.Client.Dtos;
/// <summary>
/// Wire format for <c>GET /api/user-search</c>.
///
/// <para>Mirrors the server-side
/// <c>Yavsc.Blogs.Controllers.UserSearchResultDto</c> but stops
/// short of any entity navigation properties. Only the fields
/// a client address book needs (id, name, avatar, email) are
/// included.</para>
///
/// <para>Field names match the JSON the server emits (camelCase
/// via the default <see cref="System.Text.Json"/> policy), so
/// no <c>[JsonPropertyName]</c> attributes are required.</para>
/// </summary>
public sealed class UserSearchResultDto
{
public string Id { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string? FullName { get; set; }
public string? Avatar { get; set; }
public string? Email { get; set; }
}

View file

@ -1,62 +0,0 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// Transport surface that the high-level clients
/// (<see cref="BlogApiClient"/>, <see cref="CircleApiClient"/>,
/// <see cref="BlogAclApiClient"/>) need to do their work.
///
/// <para>This is intentionally a thin, transport-only contract. It
/// does not include the OIDC login / refresh / logout surface —
/// that lives on the concrete <c>YavscApiClient</c> in the
/// consuming application and is wired by the application
/// composition root. Splitting the two keeps <c>Yavsc.Api.Client</c>
/// usable from any host (a CLI, a unit test, a future iOS
/// client) without dragging OIDC, identity, and a <c>Settings</c>
/// POMVO everywhere.</para>
///
/// <para>Implementations are expected to:</para>
/// <list type="bullet">
/// <item>Attach a Bearer access token to every outbound request.</item>
/// <item>Silently refresh the token on a 401 and retry once.</item>
/// <item>Serialise the request body as JSON and deserialise the
/// response body with case-insensitive property matching.</item>
/// </list>
///
/// The exception contract on non-2xx responses is
/// <see cref="HttpRequestException"/> with a message that includes
/// the response body (capped), so callers can surface the
/// server-side validation problem to the UI without losing
/// context.
/// </summary>
public interface IYavscApiClient : IAsyncDisposable
{
/// <summary>
/// The configured <see cref="HttpClient"/>. Clients set its
/// <c>BaseAddress</c> in their constructors to point at the
/// API host they target.
/// </summary>
HttpClient Http { get; }
/// <summary>Call a JSON endpoint with a typed return value.</summary>
/// <param name="method">HTTP verb.</param>
/// <param name="path">Path relative to <see cref="HttpClient.BaseAddress"/>.</param>
/// <param name="body">Optional request body, serialised as JSON.</param>
/// <param name="ct">Cancellation token.</param>
Task<T> CallAsync<T>(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
/// <summary>Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).</summary>
Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
}

View file

@ -1,79 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Api.Client.Dtos;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for <c>/api/user-search</c> on the Yavsc Blogs
/// server. Used by client-side address books (PostIt.Desktop,
/// future PostIt.Browser CLI, …) to look up Yavsc users by
/// display name or email.
///
/// <para>The server scopes every endpoint to the authenticated
/// caller; any authenticated user can search the user table of
/// the instance. There is no per-user filtering on the response
/// side — this is by design on single-tenant deployments
/// (closed community). Multi-tenant deployments should gate
/// this controller behind a tenant-scoped policy before
/// exposing it; see the server-side
/// <c>UserSearchApiController</c> doc for details.</para>
/// </summary>
public sealed class UserSearchClient
{
private const string Path = "user-search";
private readonly IYavscApiClient _api;
public UserSearchClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
if (api.Http.BaseAddress is null)
api.Http.BaseAddress = new Uri(blogsBaseAddress);
}
/// <summary>
/// Search users by display name (substring) or email (exact).
/// </summary>
/// <param name="query">Substring filter on FullName or
/// UserName. Empty or null returns an empty list (the server
/// would return all users, which we don't want by
/// default).</param>
/// <param name="email">Optional exact-match filter on
/// Email.</param>
/// <param name="take">Maximum results, capped at 100.
/// Default 25.</param>
public Task<List<UserSearchResultDto>> SearchAsync(
string? query = null,
string? email = null,
int take = 25,
CancellationToken ct = default)
{
// Match the server's contract: at least one filter is
// expected. The server doesn't enforce this (an empty
// query + empty email returns the first `take` users
// alphabetically), but the address-book UX is "type
// something to search", so we short-circuit empty
// queries client-side.
if (string.IsNullOrWhiteSpace(query) && string.IsNullOrWhiteSpace(email))
return Task.FromResult(new List<UserSearchResultDto>());
var qs = new List<string>();
if (!string.IsNullOrWhiteSpace(query))
qs.Add($"q={Uri.EscapeDataString(query)}");
if (!string.IsNullOrWhiteSpace(email))
qs.Add($"e={Uri.EscapeDataString(email)}");
qs.Add($"take={Math.Clamp(take, 1, 100)}");
return _api.CallAsync<List<UserSearchResultDto>>(
HttpMethod.Get,
$"{Path}?{string.Join('&', qs)}",
ct: ct);
}
}

View file

@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Yavsc.Api.Client</RootNamespace>
<AssemblyName>Yavsc.Api.Client</AssemblyName>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Description>
Thin HTTP clients for the Yavsc API. Each client is a DTO↔path
mapper; all transport concerns (base URL, JSON, Bearer auth,
silent refresh on 401) are delegated to YavscApiClient, which
lives in the consuming application (PostIt).
</Description>
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion>
<Version>1.1.0-beta.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />
</ItemGroup>
</Project>

View file

@ -14,7 +14,7 @@ using Yavsc.Models.Workflow;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/activity")] [Route("api/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(Constants.APIPrefix + "/bill"), Authorize] [Route("api/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(Constants.APIPrefix + "/bookquery"), Authorize("Performer")] [Route("api/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(Constants.APIPrefix + "/estimate"), Authorize] [Route("api/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(Constants.AdminGroupName)) return true; if (User.IsInRole(YavscConstants.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(Constants.AdminGroupName)) return true; if (User.IsInRole(YavscConstants.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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.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(Constants.APIPrefix + "/EstimateTemplatesApi")] [Route("api/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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.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(Constants.APIPrefix + "/front")] [Route("api/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(Constants.APIPrefix + "/payment")] [Route("api/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(Constants.APIPrefix + "/performers")] [Route("api/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(Constants.APIPrefix + "/ProductApi")] [Route("api/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(Constants.FrontOfficeGroupName)] [HttpPut("{id}"),Authorize(YavscConstants.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(Constants.FrontOfficeGroupName)] [HttpPost,Authorize(YavscConstants.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(Constants.FrontOfficeGroupName)] [HttpDelete("{id}"),Authorize(YavscConstants.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(Constants.APIPrefix + "/bursherprofiles")] [Route("api/bursherprofiles")]
public class BursherProfilesApiController : Controller public class BursherProfilesApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -57,7 +57,7 @@ namespace Yavsc.Controllers
{ {
return BadRequest(); return BadRequest();
} }
if (id != User.GetUserId()) if (id != User.GetUserId())
{ {
return BadRequest(); return BadRequest();

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(Constants.APIPrefix + "/haircut")][Authorize] [Route("api/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(Constants.APIPrefix + "/hyperlink")] [Route("api/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(Constants.APIPrefix + "/GitRefsApi")] [Route("api/GitRefsApi")]
[Authorize("AdministratorOnly")] [Authorize("AdministratorOnly")]
public class GitRefsApiController : Controller public class GitRefsApiController : Controller
{ {

View file

@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Mvc;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
[Route(Constants.APIPrefix + "/mailtemplate")] [Route("api/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(Constants.APIPrefix + "/mailing")] [Route("api/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(Constants.APIPrefix + "/museprefs")] [Route("api/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(Constants.APIPrefix + "/MusicalTendenciesApi")] [Route("api/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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.AdminGroupName))
return BadRequest(); return BadRequest();
_context.SaveChanges(User.GetUserId()); _context.SaveChanges(User.GetUserId());

View file

@ -7,8 +7,8 @@ namespace Yavsc.ApiControllers
/// <summary> /// <summary>
/// Base class for managing performers profiles /// Base class for managing performers profiles
/// </summary> /// </summary>
[Produces("application/json"),Route(Constants.APIPrefix + "/profile")] [Produces("application/json"),Route("api/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(Constants.APIPrefix + "/blacklist"), Authorize] [Route("api/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(Constants.AdminGroupName)) if (!User.IsInRole(YavscConstants.AdminGroupName))
if (!User.IsInRole(Constants.FrontOfficeGroupName)) if (!User.IsInRole(YavscConstants.FrontOfficeGroupName))
return false; return false;
return true; return true;
} }
@ -140,7 +140,7 @@ namespace Yavsc.Controllers
if (!CheckPermission(blackListed)) if (!CheckPermission(blackListed))
return BadRequest(); return BadRequest();
_context.BlackListed.Remove(blackListed); _context.BlackListed.Remove(blackListed);
_context.SaveChanges(User.GetUserId()); _context.SaveChanges(User.GetUserId());

View file

@ -1,17 +1,15 @@
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.Helpers;
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.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(APIPrefix+"/blogacl")] [Route("api/blogacl")]
public class BlogAclApiController : Controller public class BlogAclApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -21,19 +19,11 @@ namespace Yavsc.Blogs.Controllers
_context = context; _context = context;
} }
/// <summary> // GET: api/BlogAclApi
/// Returns the ACL entries for the caller's own blog posts.
/// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL.
/// </summary>
// GET: api/v1/blogacl
[HttpGet] [HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL() public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); return _context.CircleAuthorizationToBlogPost;
return _context.CircleAuthorizationToBlogPost
.Include(a => a.Allowed)
.Where(a => a.Allowed.OwnerId == uid);
} }
// GET: api/BlogAclApi/5 // GET: api/BlogAclApi/5
@ -70,7 +60,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(); return BadRequest();
} }
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId)) if (!CheckOwner(circleAuthorizationToBlogPost.CircleId))
{ {
return new ChallengeResult(); return new ChallengeResult();
} }
@ -94,42 +84,27 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent); return new StatusCodeResult(StatusCodes.Status204NoContent);
} }
private async Task<bool> CheckOwnerAsync (long circleId) private bool CheckOwner (long circleId)
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (uid==null) return false; var circle = _context.Circle.First(c=>c.Id==circleId);
var circle = await _context.Circle.FirstOrDefaultAsync(c=>c.Id==circleId); _context.Entry(circle).State = EntityState.Detached;
if (circle == null) return false; return (circle.OwnerId == uid);
return circle.OwnerId == uid;
} }
// POST: api/BlogAclApi // POST: api/BlogAclApi
[HttpPost] [HttpPost]
public async Task<IActionResult> PostCircleAuthorizationToBlogPost( public async Task<IActionResult> PostCircleAuthorizationToBlogPost([FromBody] CircleAuthorizationToBlogPost circleAuthorizationToBlogPost)
[FromBody] PostAccessControlRulePayload circleAuthorizationToBlogPost)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return BadRequest(ModelState); return BadRequest(ModelState);
} }
// No 500: a missing or zero BlogPostId is a client if (!CheckOwner(circleAuthorizationToBlogPost.CircleId))
// 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();
} }
CircleAuthorizationToBlogPost entity = new CircleAuthorizationToBlogPost _context.CircleAuthorizationToBlogPost.Add(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

@ -9,14 +9,14 @@ using Microsoft.EntityFrameworkCore;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Route(Constants.APIPrefix + "/chat")] [Route("api/chat")]
public class ChatApiController : Controller public class ChatApiController : Controller
{ {
readonly ApplicationDbContext dbContext; readonly ApplicationDbContext dbContext;
readonly UserManager<ApplicationUser> userManager; readonly UserManager<ApplicationUser> userManager;
private readonly IConnexionManager _cxManager; private readonly IConnexionManager _cxManager;
public ChatApiController(ApplicationDbContext dbContext, public ChatApiController(ApplicationDbContext dbContext,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IConnexionManager cxManager) IConnexionManager cxManager)
{ {
this.dbContext = dbContext; this.dbContext = dbContext;

View file

@ -9,7 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/ChatRoomAccessApi")] [Route("api/ChatRoomAccessApi")]
public class ChatRoomAccessApiController : Controller public class ChatRoomAccessApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -37,7 +37,7 @@ namespace Yavsc.Controllers
ChatRoomAccess chatRoomAccess = await _context.ChatRoomAccess.SingleAsync(m => m.ChannelName == id); ChatRoomAccess chatRoomAccess = await _context.ChatRoomAccess.SingleAsync(m => m.ChannelName == id);
if (chatRoomAccess == null) if (chatRoomAccess == null)
{ {
@ -46,13 +46,13 @@ 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(Constants.AdminGroupName)) && ! User.IsInMsRole(YavscConstants.AdminGroupName))
{ {
ModelState.AddModelError("UserId","get refused"); ModelState.AddModelError("UserId","get refused");
return BadRequest(ModelState); return BadRequest(ModelState);
} }
return Ok(chatRoomAccess); return Ok(chatRoomAccess);
} }
@ -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(Constants.AdminGroupName)) if (uid != room.OwnerId && ! User.IsInMsRole(YavscConstants.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(Constants.AdminGroupName))) if (room == null || (uid != room.OwnerId && ! User.IsInMsRole(YavscConstants.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(Constants.AdminGroupName))) if (room == null || (uid != room.OwnerId && chatRoomAccess.UserId != uid && ! User.IsInMsRole(YavscConstants.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(Constants.APIPrefix + "/ChatRoomApi")] [Route("api/ChatRoomApi")]
public class ChatRoomApiController : Controller public class ChatRoomApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -128,7 +128,7 @@ namespace Yavsc.Controllers
} }
ChatRoom chatRoom = await _context.ChatRoom.SingleAsync(m => m.Name == id); ChatRoom chatRoom = await _context.ChatRoom.SingleAsync(m => m.Name == id);
if (chatRoom == null) if (chatRoom == null)
{ {
@ -137,7 +137,7 @@ namespace Yavsc.Controllers
if (User.GetUserId() != chatRoom.OwnerId ) if (User.GetUserId() != chatRoom.OwnerId )
{ {
if (!User.IsInMsRole(Constants.AdminGroupName)) if (!User.IsInMsRole(YavscConstants.AdminGroupName))
return BadRequest(new {error = "OwnerId"}); return BadRequest(new {error = "OwnerId"});
} }

View file

@ -0,0 +1,146 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Relationship;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
[Produces("application/json")]
[Route("api/cirle")]
public class CircleApiController : Controller
{
private readonly ApplicationDbContext _context;
public CircleApiController(ApplicationDbContext context)
{
_context = context;
}
// GET: api/CircleApi
[HttpGet]
public IEnumerable<Circle> GetCircle()
{
return _context.Circle;
}
// GET: api/CircleApi/5
[HttpGet("{id}", Name = "GetCircle")]
public async Task<IActionResult> GetCircle([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Circle circle = await _context.Circle.SingleAsync(m => m.Id == id);
if (circle == null)
{
return NotFound();
}
return Ok(circle);
}
// PUT: api/CircleApi/5
[HttpPut("{id}")]
public async Task<IActionResult> PutCircle([FromRoute] long id, [FromBody] Circle circle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (id != circle.Id)
{
return BadRequest();
}
_context.Entry(circle).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!CircleExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
// POST: api/CircleApi
[HttpPost]
public async Task<IActionResult> PostCircle([FromBody] Circle circle)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
_context.Circle.Add(circle);
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateException)
{
if (CircleExists(circle.Id))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetCircle", new { id = circle.Id }, circle);
}
// DELETE: api/CircleApi/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteCircle([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Circle circle = await _context.Circle.SingleAsync(m => m.Id == id);
if (circle == null)
{
return NotFound();
}
_context.Circle.Remove(circle);
await _context.SaveChangesAsync(User.GetUserId());
return Ok(circle);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool CircleExists(long id)
{
return _context.Circle.Count(e => e.Id == id) > 0;
}
}
}

View file

@ -8,7 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
{ {
[Produces("application/json")] [Produces("application/json")]
[Route(Constants.APIPrefix + "/ContactsApi")] [Route("api/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(Constants.APIPrefix + "/ServiceApi")] [Route("api/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(Constants.FrontOfficeGroupName)] [HttpPut("{id}"),Authorize(YavscConstants.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(Constants.FrontOfficeGroupName)] [HttpPost,Authorize(YavscConstants.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(Constants.FrontOfficeGroupName)] [HttpDelete("{id}"),Authorize(YavscConstants.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(Constants.APIPrefix + "/users")] [Route("api/users")]
public class ApplicationUserApiController : Controller public class ApplicationUserApiController : Controller
{ {
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
@ -28,7 +28,7 @@ namespace Yavsc.Controllers
public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25) public IEnumerable<UserInfo> GetApplicationUser(int skip=0, int take = 25)
{ {
return _context.Users.Skip(skip).Take(take) return _context.Users.Skip(skip).Take(take)
.Select(u=> new UserInfo{ .Select(u=> new UserInfo{
UserId = u.Id, UserId = u.Id,
UserName = u.UserName, UserName = u.UserName,
Avatar = u.Avatar}); Avatar = u.Avatar});
@ -39,7 +39,7 @@ namespace Yavsc.Controllers
{ {
return _context.Users.Where(u => u.UserName.Contains(pattern)) return _context.Users.Where(u => u.UserName.Contains(pattern))
.Skip(skip).Take(take) .Skip(skip).Take(take)
.Select(u=> new UserInfo { .Select(u=> new UserInfo {
UserId = u.Id, UserId = u.Id,
UserName = u.UserName, UserName = u.UserName,
Avatar = u.Avatar }); Avatar = u.Avatar });

View file

@ -5,10 +5,10 @@
<UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId> <UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId>
<RootNamespace>Yavsc.Api</RootNamespace> <RootNamespace>Yavsc.Api</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+1.Branch.release-1.0.8-rc1.Sha.1167169aa89e1bf25290e9a152d27b357a500ab3</InformationalVersion> <InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.0.1-5</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -1,221 +0,0 @@
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,7 +12,6 @@ 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;

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