Compare commits

...

91 commits

Author SHA1 Message Date
5274d7bdf5 Merge pull request 'release/1.0.8-rc1' (#42) from release/1.0.8-rc1 into main
All checks were successful
Dotnet build and test / build (push) Successful in 7m4s
Reviewed-on: #42
2026-08-23 23:19:06 +01:00
48feb27277
changelog: align 1.0.8-rc1 section title with release workflow
All checks were successful
Dotnet build and test / build (pull_request) Successful in 5m31s
Forgejo Release / release (push) Successful in 7m20s
The Forgejo release workflow validates the section title against the
channel derived from tag parity: '[TAG] - stable', '[TAG] - preview',
or '[TAG] - unstable'. The 2026-08-21 entry broke the validation with
'## [1.0.8-rc1] - 2026-08-21' (date suffix instead of channel). Move
the date into the body of the section (it is already mentioned in the
'Fixed' subsection) and use '## [1.0.8-rc1] - unstable' to match the
1.0.6 / 1.0.7 convention.
2026-08-21 23:07:34 +01:00
cec482625d
release unstables
Some checks failed
Dotnet build and test / build (pull_request) Successful in 7m37s
Forgejo Release / release (push) Failing after 15s
2026-08-21 22:51:25 +01:00
39ec739eab
changelog: section for 1.0.8-rc1
Some checks failed
Forgejo Release / release (push) Failing after 15s
Dotnet build and test / build (pull_request) Has been cancelled
Ajoute la section [1.0.8-rc1] au CHANGELOG.md, en français, au format
Keep a Changelog (### Added / ### Changed / ### Fixed). Couvre :
- le fix backend du 500 sur POST /api/v1/blogacl (commit d2a0c263)
- le fix client PostIt (commit b82b6722)
- le passage de CheckOwner en async (commit e48ede1e)
- le seed one-shot de la fixture BlogsWebServerFixture
- les tests de non-régression (sentinelles 'never 500' et 'shape PostIt')
- la règle 'Pas de object' dans CONTRIBUTING.md

Met aussi à jour le bloc de liens de comparaison en bas du fichier
pour pointer [1.0.8-rc1] vers 1.0.7...1.0.8-rc1.
2026-08-21 22:43:31 +01:00
9da6888e03 Merge pull request 'feat/postit-acl-members' (#41) from feat/postit-acl-members into release/1.0.8-rc1
Reviewed-on: #41
2026-08-21 22:31:58 +01:00
d2a0c263dd
acl post: reject BlogPostId <= 0 with 400, no 500
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m18s
The 2026-08-21 prod 500 on POST /api/v1/blogacl was caused by the
PostIt client sending { circleId } only — the server deserialised
into CircleAuthorizationToBlogPost with BlogPostId = default(long) = 0,
and EF Core refused the INSERT with InvalidOperationException.

The PostIt-side fix lives in b82b6722 (enrich the payload with
blogPostId). This commit is the server-side guard: validate
BlogPostId > 0 in the controller and return 400 BadRequest instead
of letting the request reach SaveChangesAsync. The same shape that
crashed on 2026-08-21 now fails fast at the validation layer.

Verified by BlogAclApiTests.PostCircleAuthorization_dosent_return_500:
sentinel that asserts 'never 500' on a payload with BlogPostId = -1.
Previously red (500 from EF Core), now green (400 from the new guard).
2026-08-21 22:08:05 +01:00
e48ede1e84
acl post: never 500 regression sentinel + async CheckOwner + fixture seed
The hard rule on POST /api/v1/blogacl is: a 500 is never acceptable,
regardless of the payload shape. The prod 500 logged on 2026-08-21 on
mercure was caused by the PostIt client sending { circleId } only, which
the server deserialised into CircleAuthorizationToBlogPost with
BlogPostId = default(long) = 0; EF Core refused the INSERT with
InvalidOperationException: The value of
'CircleAuthorizationToBlogPost.BlogPostId' is unknown. The PostIt fix
lives in b82b6722 (enrich the payload with blogPostId). The server-side
guard lives in this commit:

- BlogAclApiController.CheckOwner is now async and uses FirstOrDefaultAsync
  instead of First, so it does not deadlock the request thread and
  returns false on a missing circle (which the controller already maps
  to ChallengeResult).
- BlogsWebServerFixture now seeds Alice, her Circle and her BlogPost
  in ConfigurePipelineAsync, once at host startup, against the shared
  SqliteConnection (Cache=Shared). EnsureCreated is idempotent and
  runs against the connection that every DbContext resolves through,
  so the test theory can POST payloads with real FK ids against a
  schema that actually has the Circle / BlogSpot tables.
- BlogAclApiTests:
    - PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape
      is the regression sentinel for the prod fix.
    - PostCircleAuthorization_never_returns_500 is a [Theory] over
      several payload shapes; any future commit that reintroduces a
      500 path turns it red. CleanupAcl at the start of each insert-
      bearing test isolates against xUnit's no-guarantee-of-order
      execution: a successful POST in test N would otherwise conflict
      with test N+1 against the same (CircleId, BlogPostId) pair.
2026-08-21 22:00:27 +01:00
4956890236
refacto seed test db
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m8s
2026-08-21 20:45:46 +01:00
c645973b52
no object
Some checks failed
Dotnet build and test / build (pull_request) Failing after 9m2s
2026-08-21 20:33:30 +01:00
34c7b153ff
warnings 2026-08-21 20:33:15 +01:00
4b9b8d5e78
fixes the compile
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-21 20:25:13 +01:00
107c4d0b00
test some failling pathes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 4m37s
2026-08-21 19:37:22 +01:00
76a3660dcf
workaround on testing the null CloseButton
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m41s
2026-08-21 18:46:26 +01:00
404d406931
Testing circle was authorized
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-21 17:21:54 +01:00
b82b6722c7
fixes a 500 in prod and the associated test 2026-08-21 16:58:27 +01:00
8f91cbed02
A Circle must pre-exist before beeing used by an authorization 2026-08-21 16:40:24 +01:00
88461786ee
Roll back refacto on Posit.Tests 2026-08-21 16:18:20 +01:00
06672c4c90
remove dead 'Comment' field from CircleAuthorizationToBlogPost
The bool Comment on CircleAuthorizationToBlogPost was dead code:
never read or written by any caller in src/, no UI exposure, no
behavioural semantics. The wire DTO (CircleAuthorization in
Yavsc.Abstract) doesn't carry it, no reader consumes it, and the
PostIt client builds its payload without it.

What changes:
- src/Yavsc.Server/Models/Access/CircleAuthorizationToBlogPost.cs:
  remove the property.
- src/Yavsc.Blogs.Tests/BlogAclApiTests.cs: drop 'Comment = true'
  from the existing test payload and trim the now-inaccurate XML
  doc comment ('CircleId + BlogPostId + Comment' -> 'CircleId +
  BlogPostId'). Also adds a new [Fact] pinning the prod bug
  reported on 2026-08-21 (HTTP 500 'BlogPostId is unknown' when
  PostIt POSTs the bare { circleId } shape). That test stays red:
  the real fix for the 500 is in PostIt (payload needs blogPostId)
  + on the wire DTO + server-side validation, and lives in a
  follow-up commit.

Migration:
- src/Yavsc.Org/Migrations/20260820232152_DropCommentFromCircleAuthorizationToBlogPost
  drops the boolean 'Comment' column on CircleAuthorizationToBlogPost.
  The generated scaffold also wanted to drop three 'ClientId1'
  shadow FK columns on ClientScopes / ClientRedirectUris /
  ClientGrantTypes (from leftover HasOne<Client>() overrides in
  ApplicationDbContext.OnModelCreating); those were removed from
  the .cs to keep the migration scoped to this fix. Cleaning up the
  shadow property declarations themselves is left as a separate
  task.

The ModelSnapshot still reflects the shadow 'ClientId1' columns
intentionally: they exist in the prod database today (all NULL),
and EF will rescaffold a drop migration for them on the next
'migrations add' regardless. No data loss.
2026-08-21 00:27:27 +01:00
a44c04ad77
feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user
Bundled end-of-branch commit on feat/postit-acl-members.

PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
  (Bearer/OIDC scope tests vs. blog API fakes live where they
  belong) and introduces PostItHeadlessCollection so the
  Avalonia.Headless tests share a single xUnit collection
  instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
  POST /api/v1/blogacl) and the fakes it relies on
  (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
  DialogTests); pulls UserId-through-OIDC-sub path into
  BearerScopeTests / FakeAuthorizingBrowser /
  OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
  tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).

Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
  Microsoft.EntityFrameworkCore.Sqlite and rewrites
  BlogsWebServerFixture to hold a single shared
  SqliteConnection (Cache=Shared) for the fixture lifetime,
  with a sync Dispose close to dodge async teardown hangs.
  Reason: the EF Core InMemory provider silently ignores FKs,
  which masked the kind of bug we are about to pin in the
  ACL tests. SQLite enforces them, so any future INSERT that
  forgets to seed its parent rows fails loudly here instead
  of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
  tweak to follow the new connection lifecycle.

Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
  SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
  test that POST/PUT/DELETE a BlogPost and sends AuthorId=
  'tester' in the payload needs an AspNetUsers row to satisfy
  the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
  ResetAndSeedDefaultUser helper for the six mutating tests;
  the four GET-only and ModelState-only tests keep the bare
  ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
  cleanly instead of hanging at teardown — previously a stuck
  test held the shared SqliteConnection open and the next
  tests waited indefinitely.

Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00
6825f74308
refacto API prefix + nav.back 2026-08-20 20:50:52 +01:00
995a02197d Merge pull request 'fix(postit): repair Circles bindings + align UserSearchApi route' (#40) from feat/postit-acl-members into release/1.0.8-rc1
Reviewed-on: #40
2026-08-20 07:24:19 +01:00
bd6ca9d11f
fix(postit): repair Circles bindings + align UserSearchApi route
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m51s
- CirclesPage: drop VisualRoot/MainWindow hack, switch to App.PushPageAsync(vm)
- CirclesPageViewModel: make OpenAddMemberAsync public so Avalonia XAML trampoline can call it
- AddCircleMemberDialog: bind SearchAsync/Add (drop Command suffix)
- UserSearchApiController: route under Constants.APIPrefix (= api/v1/user-search), matching the rest of Yavsc.Blogs controllers and the PostIt client's BlogsApiUrl default
2026-08-20 07:22:07 +01:00
e75993ea36 Merge pull request 'fix/postit-acl' (#39) from fix/postit-acl into release/1.0.8-rc1
Reviewed-on: #39
2026-08-20 01:24:42 +01:00
46a9a84fbc
fixes the cirle POST
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m37s
2026-08-20 01:23:14 +01:00
ecfdca8f01
gixes the path to circles API 2026-08-19 21:02:28 +01:00
803e778208
access the post selector 2026-08-19 20:30:34 +01:00
a5ccfde7e1 Merge pull request 'fix/inactive-toolbar-buttons' (#38) from fix/inactive-toolbar-buttons into release/1.0.8-rc1
Reviewed-on: #38
2026-08-19 20:02:48 +01:00
e88920485a
chore(vscode): remove local mcp.json (openclaw moved to global User config).
Some checks failed
Dotnet build and test / build (pull_request) Failing after 17m50s
2026-08-19 19:59:50 +01:00
21a79074cc
chore(vscode): add PATH env to openclaw MCP server config
Lets the openclaw MCP server spawned by VS Code locate dotnet, adb
and other SDK tools (android-sdk, .dotnet/tools) used during
integration sessions.
2026-08-19 19:55:23 +01:00
6d9bb82b61
PostIt: register dialog pages in DI for ViewLocator resolution 2026-08-19 19:53:38 +01:00
600bb81be1
PostIt: show ViewLocator fallback errors in navigation 2026-08-19 17:32:51 +01:00
4cda942fb4
doc: lift PostIt navigation rule to top-level section
The 'Navigation (PostIt)' rule was buried as a sub-item under
'Conventions de code', mixed with style rules. Lift it to a
top-level section between 'Tests' and 'Conventions de code' so
contributors looking for nav guidance find it without scrolling
through editorconfig preferences.

Add a pointer to doc/architecture/postit.md for the full
topology (NavRoot, SessionStatusViewModel, lifecycle signals
vs user-driven nav). Content of the rule itself is unchanged
from 12a71ada — only the placement and the cross-link.
2026-08-19 17:19:43 +01:00
12a71ada6a
doc: align navigation docs with VM-first pattern
The two recent commits (3fbbafc4, 0065de70) replaced the
OpenSettingsRequested event + CurrentViewModel assignment with
App.PushPageAsync(vm): the VM resolves the target ViewModel
through DI, App resolves the Control through the ViewLocator,
guards against double-push, and pushes via NavRoot. The docs
were still describing the pre-refactor world.

Update three places:

- CONTRIBUTING.md — the "Navigation (PostIt)" rule now
  describes App.PushPageAsync as the single channel and shows
  the canonical OpenSettings command as the example.
- doc/architecture/postit.md — the Navigation section
  distinguishes VM-first navigation (App.PushPageAsync) from
  lifecycle signals (LoginSucceeded, LogoutCompleted) and
  drops the obsolete OpenSettingsRequested row.
- src/PostIt/PostIt/App.axaml.cs — refresh the SettingsPage
  singleton justification: point (c) now describes the
  anti-empilement guard inside PushPageAsync, not the
  OpenSettingsRequested handler that no longer exists.

No production behaviour change — doc only (and the inline
comment that referenced a removed event).
2026-08-19 17:17:22 +01:00
0065de7000
PostIt: homogenize VM-first navigation flows 2026-08-19 17:06:46 +01:00
3fbbafc454
PostIt: route VM navigation through ViewLocator 2026-08-19 17:01:58 +01:00
30a0e10bae
fixes the compilation 2026-08-19 15:45:49 +01:00
aa098daed5
doc: codify PostIt navigation rule via ViewLocator
Navigation in PostIt is owned by src/PostIt/PostIt/ViewLocator.cs.
To open a screen, the caller assigns the target ViewModel to the
host's CurrentViewModel (which binds the IContentControl.Content);
the ViewLocator decides which Control instance to push and resolves
it through DI. ViewModels never instantiate views nor resolve them
from DI directly. Add the rule and a canonical example to
CONTRIBUTING.md so contributors do not re-derive the pattern from
scratch each time.
2026-08-19 15:20:29 +01:00
e35bc273a3
refacto BlogPost 2026-08-19 14:19:45 +01:00
84f3ffa9c2
test(postit): pin inoperative toolbar buttons (ACL, Mes cercles, [DEV] Signature) with headless UI tests
Three buttons on MainPage's toolbar are reported as inoperative
in the running app: ACL, Mes cercles, and [DEV] Signature. They
click but no dialog / page opens.

This commit adds headless UI tests that drive each button via
the Avalonia headless harness (KeyPressQwerty(Enter) on a
focused, x:Name'd button, per the CalculatorTests pattern in
Avalonia.Samples) and asserts the post-click top of
NavRoot.NavigationStack is a non-null Page.

The tests fail today on every button (stack size before == after
== 1): the click does not push anything. The bug is the user's
real complaint — the test is now wired to catch it.

To make the buttons reachable by the harness without walking
the visual tree (which does not see buttons hosted inside a
NavigationPage), name the two unnamed buttons:

- ACL  -> ManageAclButton
- Mes cercles -> OpenCirclesButton

([DEV] Signature was already named OpenSignatureDevButton.)

The XAML change is cosmetic; bindings and commands are
untouched. The test pattern follows SessionStatusBannerTests:
new MainWindow().Show(), PushAsync(MainPage), drive controls via
their generated x:Name fields.
2026-08-18 23:24:08 +01:00
f36679aa65
fix(blog): replace IApplicationUser Author with concrete BlogPostAuthorDto
System.Text.Json cannot materialise an interface without a
polymorphic converter. Until this commit, BlogPostDto.Author
was typed as the abstract interface IApplicationUser, which
crashed the "load posts" call in PostIt whenever the server
returned a post with a populated Author object (the common
case — GET /api/BlogApi).

Fix:

* Introduce a minimum-viable wire DTO BlogPostAuthorDto in
  Yavsc.Abstract.Blogspot (record: Id, UserName, Avatar).
  These are the only fields the client UI actually needs;
  the server-side ApplicationUser navigation is preserved
  for permission checks and authorisation.
* Change IBlogPost.Author and BlogPostDto.Author from
  IApplicationUser to BlogPostAuthorDto? (interface change,
  breaking). The EF entity BlogPost keeps its full
  ApplicationUser navigation property and exposes
  IBlogPost.Author via an explicit interface implementation
  that projects to BlogPostAuthorDto on demand (so EF can
  still lazy-load the navigation without forcing an eager
  join on every read).
* Restore the using directive that was accidentally removed
  when the BlogPostDto property was rewritten (needed for
  ICircleAuthorization in GetACL()).

Regression coverage (the missing test Paul flagged):

* Add BlogPostAuthorDtoTests in PostIt.Tests with four
  scenarios that exercise the wire shape on the client side:
  - A BlogPostDto JSON with a populated Author round-trips
    through JsonSerializer without throwing and the three
    fields (Id, UserName, Avatar) survive intact.
  - A BlogPostDto JSON with explicit "author": null
    deserialises with Author == null.
  - A BlogPostDto JSON without any Author field at all
    deserialises with Author == null (forward compat).
  - The serialised shape of BlogPostAuthorDto uses camelCase
    property names (matching the server's Web defaults), so
    the field names on the wire don't drift without a test
    catching it.

Tests: 55/55 PostIt.Tests (+4 new), 24/24 Yavsc.Blogs.Tests,
44/44 Yavsc.Org.Tests. No regressions.

Side note: yavsc.sln picks up Yavsc.Api.Client (added by
'feat/postit-acl' in 1.0.7 but never registered in the
solution file until now — probably auto-added by a recent
'dotnet build' that discovered the .csproj).
2026-08-18 22:01:09 +01:00
40d992ee18 Merge pull request 'feat/postit-fs' (#37) from feat/postit-fs into release/1.0.8-rc1
Reviewed-on: #37
2026-08-18 21:33:37 +01:00
d245d3972d
ci: retrigger Forgejo Actions on PR #37
All checks were successful
Dotnet build and test / build (pull_request) Successful in 7m48s
PR #37 (feat/postit-fs -> release/1.0.8-rc1) was opened before
the buildAndTest.yml workflow trigger was widened to include
release/*. Forgejo Actions does not re-evaluate the workflow
file on its own — a push event on the PR is needed to
re-trigger the CI. This empty commit is the push.
2026-08-18 21:26:40 +01:00
6695c9ed4d
build and test release/*
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-08-18 21:25:06 +01:00
65f6ad8752
cleanup 2026-08-18 21:14:52 +01:00
72d6497455
Merge branch 'release/1.0.8-rc1' into feat/postit-fs2 2026-08-18 19:32:30 +01:00
1ab6e59fe2
chore(release): bump version via gitversion for 1.0.8-rc1 2026-08-18 19:18:36 +01:00
1167169aa8 Merge pull request 'release/1.0.7' (#34) from release/1.0.7 into main
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 31s
Dotnet build and test / build (push) Successful in 9m5s
Reviewed-on: #34
2026-08-18 19:05:46 +01:00
03fc898af5
chore(release): add 1.0.7 preview section to CHANGELOG
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 14s
Dotnet build and test / build (pull_request) Successful in 10m53s
Forgejo Release / release (push) Successful in 8m42s
The release workflow's validate-release job requires a
'## [TAG] - channel' section in CHANGELOG.md before allowing
the tag to ship. Without this entry, the 1.0.7 tag push
fails the workflow with:

  ::error::No section matching '## [1.0.7]' found in CHANGELOG.md.
  Add a '## [1.0.7] - preview' section before tagging.

The section collects the 35 commits shipped between 1.0.6 and
1.0.7: ACL feature (per-post grants + circle membership), the
Publish toggle that replaces the abandoned Visibility enum,
the make release target, the IYavscApiClient abstraction, the
IContactService/IUserDirectory split, and the Forgejo Actions
release workflow rewrite (bash + jq, runner-provided
GITHUB_TOKEN, .csproj projects built directly inside the
runner container).

The '## [Unreleased]' block is consumed by this section, and
the trailing link reference is updated to point at
1.0.6...1.0.7 for the standard Keep-a-Changelog compare URL.
2026-08-18 18:33:40 +01:00
b613927409
MEF 2026-08-18 18:09:30 +01:00
c44d3db5a4
chore(release): bump version via gitversion for 1.0.8-rc1 2026-08-18 18:08:38 +01:00
5fe0d9eb45 Merge pull request 'release/1.0.7-rc1' (#36) from release/1.0.7-rc1 into release/1.0.7
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 10s
Dotnet build and test / build (pull_request) Successful in 6m45s
Forgejo Release / release (push) Failing after 29s
Reviewed-on: #36
2026-08-18 17:27:48 +01:00
15f018117f
chore(release): bump version via gitversion for 1.0.7-rc1 2026-08-18 17:20:59 +01:00
6e50967702
build(makefile): add 'release' target to automate release branch creation
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 16s
Dotnet build and test / build (push) Successful in 7m14s
Paul wanted a Makefile target that:
- takes the target version as an argument (V=1.0.7-rc1),
- creates a release/<V> branch from main,
- runs 'dotnet-gitversion /updateprojectfiles' to bump
  <Version> across all .csproj from git history,
- commits the bump on the release branch (not on main, so
  main stays clean),
- pushes the new branch to origin.

The /src/**/*.csproj paths and CHANGELOG.md are excluded from
Forgejo's protected-branch rule so the bump commit goes
through on the release branch.

The target refuses to run unless the operator is already on
main with a clean working tree — no automatic checkout to
main, so the bump never lands on the wrong branch by
accident.

The version in the branch name (V=...) is an intent label.
The version GitVersion writes into the .csproj is whatever
GitVersion computes from git history (last tag + commit
count), so assembly versions stay truthful even when the
branch name is aspirational.
2026-08-18 17:20:23 +01:00
495ad6d6be
links
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 17s
Dotnet build and test / build (pull_request) Successful in 11m15s
2026-08-18 16:40:52 +01:00
b89b8bfccc Merge pull request 'feat(post): add Publish toggle for blog posts (no schema change)' (#35) from feat/postit-acl into release/1.0.7
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 19s
Dotnet build and test / build (pull_request) Successful in 10m19s
Reviewed-on: #35
2026-08-18 16:23:23 +01:00
e79f6423db Merge pull request 'feat/postit-acl' (#32) from feat/postit-acl into release/1.0.7
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 16s
Dotnet build and test / build (pull_request) Has been cancelled
Reviewed-on: #32
2026-08-18 16:14:31 +01:00
3fb5f40acb
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.

The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).

Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish  body { publish: bool }
  Returns 204 on success, 404 when the post doesn't exist,
  Challenge() (401) when the caller is not the author
  (EditPermission gate). Idempotent: PUT because the
  resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
  factored out of the existing
  Modify(BlogPostEditViewModel) inline toggle, so the new
  endpoint reuses the same BlogSpotPublication row logic
  (add row if missing on publish=true, remove row if
  present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
  service after each Index/Details fetch — a single bulk
  lookup, not N+1 — and surfaces through the wire JSON
  so PostIt can show the current state without a follow-up
  request.
- ApplicationUser nav properties (Posts, Book,
  DeviceDeclaration, Connections, Circles, BlackList,
  Rooms, RoomAccess, Membership, BlogComments) now carry
  BOTH [JsonIgnore] (Newtonsoft) and
  [System.Text.Json.Serialization.JsonIgnore] so the
  Yavsc.Blogs test fixture (System.Text.Json) stops
  exploding on object cycles when serialising
  BlogPost.Author.Posts.Author.Posts. Production
  (Yavsc.Org, NewtonsoftJson) was already safe via the
  Newtonsoft-only attribute; this commit just makes the
  Yavsc.Blogs side consistent.

Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
  new endpoint.

DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
  field; serialised as a plain bool in JSON.

UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
  mirrors the existing DraftTitle/DraftArticle pattern;
  hydrated from SelectedPost.IsPublished on selection
  change. TogglePublish command pushes the new state to
  SetPublishAsync and updates both the buffer and the
  selected post locally so the UI reflects the change
  without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
  bound to DraftIsPublished TwoWay and wired to
  TogglePublishCommand. The toggle is its own action
  (not part of Save), matching the wire contract.

Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
    * PUT publish=true returns 204 and IsPublished is true
      in the next GET
    * PUT publish=false clears IsPublished
    * PUT on an unknown post returns 404
    * PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
  app.UseDeveloperExceptionPage() so 500s in tests
  surface a real stack trace instead of an empty
  InternalServerError body — much easier to diagnose
  future regressions.

Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
  rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
  in the admin web Yavsc (the Org UI already edits Publish
  inline; no work needed there).
2026-08-18 16:10:45 +01:00
42625f5ddd
Revert "feat(blog): add Visibility { Private, Public } to gate post reads"
This reverts commit 33ecfa7ebd.
2026-08-18 15:40:52 +01:00
33ecfa7ebd
feat(blog): add Visibility { Private, Public } to gate post reads
Replace the implicit 'ACL empty = private' convention with an
explicit two-axis model: Visibility is the master switch, the
ACL is the exception list.

Semantics (matches what BlogSpotService.Index / Details enforce):

  Visibility.Public  + empty ACL  : every caller sees it
  Visibility.Public  + non-empty  : only author + ACL circles + admin
  Visibility.Private + any ACL    : only author + admin (ACL ignored)
                                    ACL is preserved across
                                    Private/Public flips so
                                    reopening is lossless

The Public+non-empty shape is the 'restrict by exception' case:
open by default, narrowed by the ACL. This is intentionally
different from the previous behaviour, where a Public post
with a non-empty ACL was effectively ACL-restricted anyway —
the new model makes that explicit and removes ambiguity.

Server (Yavsc.Blogs / Yavsc.Server)
- New enum Visibility { Private, Public } in
  Yavsc.Abstract.Blogspot (so the wire DTO and the EF entity
  share the same type). Stored as int via .HasConversion<int>()
  on BlogPost.Visibility. Default Private on construction;
  the column default in the migration is 0 so existing rows
  land Private without any data migration.
- BlogSpotService.Index: filter rewritten to honour the two-
  axis model. Authenticated and anonymous callers now share
  the same shape (Public+emptyACL visible to all, otherwise
  scoped). Admin reads still go through PermissionHandler.
- PermissionHandler.IsPublic: dropped the blogSpotPublications
  lookup, replaced with the Visibility + empty-ACL check that
  matches the new model. PermissionHandler.IsSponsor and
  IsOwner unchanged.
- UserHelpers.UserPosts (the per-author feed for
  /CircleMembers/Details and similar): mirror of the
  Index filter, so the two code paths can't silently diverge.
- BlogPostEditViewModel.Publish untouched on this commit. It
  still controls whether a row exists in BlogSpotPublication;
  the two systems coexist (Publish = 'is this draft published',
  Visibility = 'who can read it'). Follow-up to consolidate.

EF migration (Yavsc.Org/Migrations/20260818143013_AddBlogPostVisibility)
- Scaffolded by 'dotnet ef migrations add', not hand-edited,
  per the repo preference for generated migrations.
- Adds the new Visibility column (int, NOT NULL, default 0).
- Also drops three shadow-state ClientId1 foreign keys and
  their indexes/columns on ClientScopes, ClientRedirectUris,
  ClientGrantTypes. These shadow FKs were created by EF from
  HasOne<Client>().HasForeignKey(e => e.ClientId) mappings
  that have long since been removed from
  ApplicationDbContext.OnModelCreating, but the snapshot was
  never regenerated against the current model. The columns
  are nullable ints with no production data, so the drop is
  lossless. Without this, EF Core would keep emitting
  warnings on every migration add and the model would drift
  further from reality.

DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- Visibility property added to BlogPostDto. System.Text.Json
  serialises the enum as its underlying int, so the JSON
  shape is a plain number, no JsonConverter needed.

Client UI (PostIt)
- MainPageViewModel: DraftVisibility ObservableProperty
  mirroring the existing DraftTitle/DraftArticle pattern.
  Initialised to Private so a fresh draft is private by
  default. Save command writes the chosen value into the
  BlogPostDto payload for both CreatePostAsync and
  UpdatePostAsync. OnSelectedPostChanged hydrates the buffer
  from the server-supplied value.
- AllVisibilities property on the VM exposes [Private, Public]
  in that order, bound by the ComboBox in MainPage.axaml.
- VisibilityLabelConverter (PostIt.Views) maps the enum to
  French user-facing labels ('Privé' / 'Public'); registered
  in App.axaml as a static resource.
- MainPage.axaml: a new ComboBox row in the editor pane
  between Title and Article. Uses the existing 'no hardcoded
  Background without Foreground' lesson so dark mode works.

Tests (Yavsc.Blogs.Tests)
- BlogVisibilityTests (5 [Fact]): drive GET /api/v1/blog with
  Visibility fixtures seeded directly in the in-memory DB:
    * Private + ACL: only the author sees it
    * Public + empty ACL: any authenticated caller sees it
    * Public + non-empty ACL: caller without ACL membership
      does NOT see it
    * Private + ACL: ACL is ignored, only the author sees it
    * Visibility round-trips through the JSON wire (int 1)
- UserHelpersVisibilityTests (4 [Fact]): exercise the helper
  directly so the two code paths (Index filter vs per-author
  feed) can't diverge silently. Same fixture, no HTTP.

Test totals: 29/29 Yavsc.Blogs.Tests (was 20, +5 BlogVisibility
+4 UserHelpersVisibility), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Visibilité :' label is localised; the
  rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ Visibility consolidation
  (which system wins when both are set on the same post?).
- Org-side UI for editing Visibility (the admin web Yavsc
  still edits posts without a visibility field).
2026-08-18 15:35:22 +01:00
5e3d361f88
feat(acl): server endpoints + client + UI for circle membership
Round-trip out a long-standing hole in the ACL feature: until
this commit a circle on Yavsc was an empty named bucket. You
could create 'Famille' and grant it on a post, but the circle
carried no members, so 'Famille' authorised no one. The MVC
admin controller (Yavsc.Org.Controllers.CircleMembersController)
existed but had no REST counterpart, so PostIt — which only
talks to the Yavsc.Blogs API — had no way to manage membership
at all.

This commit closes that gap end-to-end:

Server (Yavsc.Blogs)
- GET    /api/circle/{id}/members           list members
- POST   /api/circle/{id}/members           add a user (body { userId })
- DELETE /api/circle/{id}/members/{userId}  remove a user
  All three are scoped to caller == circle.OwnerId; non-owned
  circles return 404 (not 403) to avoid leaking existence, in
  line with the rest of the controller.
- Two new DTOs (CircleMemberDto, AddCircleMemberDto) for the
  wire shapes. CircleMemberDto mirrors UserSearchResultDto
  minus Email — membership UI doesn't need contact details.

Tests (Yavsc.Blogs.Tests)
- CircleMembersApiTests: 5 [Fact] covering empty list,
  add+get, duplicate add → 409, remove, and cross-owner 404.
  Test users (alice, bob) are seeded directly through the
  in-memory DbContext — the Blogs fixture doesn't stand up
  UserManager<ApplicationUser>.

Client (Yavsc.Api.Client)
- CircleMemberDto + 3 methods on CircleApiClient:
  GetMembersAsync, AddMemberAsync, RemoveMemberAsync.
  All match the server's contract: 404 flattens to null,
  409 surfaces as an exception (callers can dedupe beforehand
  if they want idempotent behaviour).

UI (PostIt)
- CirclesPageViewModel gains a Members ObservableCollection
  that auto-loads on SelectedCircle change (via the partial
  setter generated by [ObservableProperty]). Commands:
  LoadMembersAsync, OpenAddMember (raises an event the view
  subscribes to), OnAddMemberConfirmedAsync (called by the
  view when the dialog confirms a selection), RemoveMemberAsync.
  409 (already a member) is detected from the exception
  message and surfaced as a friendly status rather than an
  error — a likely race when the same user gets added twice
  through two UI paths.

- CirclesPage layout is now two-pane (circles + editor on the
  left, members of the selected circle on the right). The
  member pane has an 'Ajouter un membre' button that opens
  AddCircleMemberDialog. Code-behind wires the dialog's
  Confirmed event back into the VM via an async lambda
  wrapper (EventHandler<T> wants void, the VM method is
  async Task).

- AddCircleMemberDialog is a ContentPage (light modal, same
  pattern as PostAclDialog). Its ViewModel consumes
  IUserDirectory — the abstraction introduced by 04a31709
  to fix the 'user search should not be IContactService'
  confusion. The dialog raises Confirmed with the picked
  UserSummary; the host (CirclesPage) is responsible for
  calling CircleApiClient.AddMemberAsync.

Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: all visible text still hard-coded French.
- XAML accessibility audit of pre-existing pages.
- Avalonia.Headless UI tests of the new navigation flow.

Tests: 51/51 PostIt.Tests green, 20/20 Yavsc.Blogs.Tests
green (was 15; +5 for CircleMembersApiTests), 44/44
Yavsc.Org.Tests green (no regression).
2026-08-18 14:10:12 +01:00
ef59cd1735
fix(circle-api): use User.GetUserId() for owner scoping
The scoping that landed in e376aed8 ("restrict Circle + BlogAcl
reads and writes to caller's own data") reads the caller's uid
with User.FindFirstValue(ClaimTypes.NameIdentifier). That works
when the JWT bearer middleware remaps the "sub" claim to the
long ClaimTypes.NameIdentifier URI — which is the default
behaviour. But the BlogsWebServerFixture test host and any host
that sets MapInboundClaims = false (preserved here to keep
"sub" as "sub" for the resource-based ownership checks in
BlogSpotService) end up with no ClaimTypes.NameIdentifier claim
at all, only "sub". On those hosts, every OwnerId == uid
filter silently returns nothing, so the controller responds 404
even for the caller's own circles.

Switch to the canonical User.GetUserId() extension helper
(Yavsc.Server.Helpers.UserHelpers), which tries "sub" first,
then ClaimTypes.NameIdentifier, then "nameid". This aligns
CircleApiController with BlogApiController (which already uses
GetUserId()) and restores correct behaviour on hosts that run
with MapInboundClaims = false.

Drop the now-unused System.Security.Claims using.

No behavioural change for production: there, MapInboundClaims
remains true, ClaimTypes.NameIdentifier is populated, and
GetUserId() returns the same value as FindFirstValue would have.
2026-08-18 14:02:07 +01:00
04a31709a2
refactor(postit): split IContactService from IUserDirectory
IContactService used to be the catch-all for "people you can reach
from PostIt": on mobile it read the device-local address book, on
desktop it queried the central /api/user-search endpoint and merged
both worlds into a single ContactDto (a flat Email field, an
ObservableCollection cache, a SearchAsync method). Two unrelated
flows under the same name, with a wire shape (Email) silently
flattening the mobile provider's multi-email list.

Split into two interfaces, each with a single responsibility:

- IContactService: device-local address book only. Mobile provider
  reads MAUI Essentials Contacts.Default and carries the full email
  list per contact. Desktop provider is an honest stub returning an
  empty list — the desktop has no local address book, and inviting
  external people from desktop is a separate flow (manual email
  entry + invitation endpoint) that doesn't belong here.

- IUserDirectory: central Yavsc user directory, the only consumer
  of /api/user-search. Both Desktop and Mobile providers delegate
  to UserSearchClient; the platform split exists so future
  platform-specific sources (offline cache, directory-scoped
  providers) can plug in without disturbing consumers.

ContactDto restores IReadOnlyList<string> Emails (the flat Email
from d0e0f4c1 was a regression that matched the wire shape of
/api/user-search at the cost of the mobile provider's per-contact
list). UserSummary is a separate platform-neutral record that
mirrors the server's UserSearchResultDto without leaking transport
concerns.

App.axaml.cs registers both interfaces as singletons.

Build + 51/51 PostIt.Tests green. No UI consumer yet — these
interfaces are still plomberie; the ViewModel that joins them for
the "add to a circle" / "invite someone" flows is a follow-up.
2026-08-18 13:22:54 +01:00
29a66a8c30 Merge branch 'feat/postit-acl' into feat/app-invite 2026-08-18 01:16:34 +01:00
da3534bd34 Merge branch 'main' into feat/postit-acl
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 15s
Dotnet build and test / build (pull_request) Successful in 5m44s
2026-08-18 01:03:03 +01:00
f4d4c786b4 Merge pull request 'release/1.0.6' (#28) from release/1.0.6 into main
All checks were successful
Dotnet build and test / log-the-inputs (push) Successful in 19s
Dotnet build and test / build (push) Successful in 7m33s
Reviewed-on: #28
2026-08-18 01:02:44 +01:00
dd8cb60fb4
Forgejo badges 2026-08-18 01:01:22 +01:00
d0e0f4c175
feat(postit): wire Desktop address book to /api/user-search
Replaces the empty ContactService.Desktop stub with a real
implementation backed by UserSearchClient. Closes the loop
between the server-side /api/user-search endpoint (b3056f1c),
the client wrapper (6e7e0414), and the platform abstraction.

IContactService gains:
- SearchAsync(string query, CancellationToken): on desktop,
  hits /api/user-search and appends results to an in-memory
  cache. On mobile, throws PlatformNotSupportedException —
  mobile providers use the device-local address book
  (GetDeviceContactsAsync) and don't talk to a network search.
- Contacts (ObservableCollection<ContactDto>): live view of
  the cache; UI binds directly to it. Mobile populates it
  inside GetDeviceContactsAsync (eager load); desktop populates
  it via SearchAsync (lazy, on-demand).

ContactDto shape changes:
- Emails (IReadOnlyList<string>) -> Email (string?). The
  /api/user-search endpoint returns one email per user. The
  use case ('invite / add to a circle') only needs one.
- Mobile provider flattens its per-contact email list down
  to the first non-empty entry (a small functional loss that
  matches the wire shape).

App.axaml.cs constructs a ContactService from the
UserSearchClient singleton and registers it as
IContactService so future ViewModels can take the interface
by constructor injection.

Build + 51/51 tests green. The mobile provider is still
gated by #if ANDROID || IOS and not exercised by the
Desktop test target — runtime behaviour on Android will
need a smoke test on device when PostIt.Android lands.
2026-08-18 00:36:36 +01:00
6e7e04141b
feat(api-client): add UserSearchClient for /api/user-search
Adds the client-side half of the user-search endpoint landed
on the server in b3056f1c (commit 6 on this branch). The
client mirrors the server's filter contract:

- query: substring match on FullName or UserName
- email: exact match on Email
- take: 1..100, default 25

Empty (query + email) short-circuits to an empty list
client-side rather than letting the server return the first
`take` users alphabetically — the address-book UX is
"type to search", not "show me a directory".

The DTO (Yavsc.Api.Client.Dtos.UserSearchResultDto) is a flat
shape (Id, UserName, FullName, Avatar, Email) with no
navigation properties; field names match the JSON the server
emits so deserialisation is a no-op.

PostIt wiring:
- App.axaml.cs constructs a UserSearchClient singleton and
  registers it alongside CircleApiClient and BlogAclApiClient.
- The PostIt.csproj ProjectReference to Yavsc.Api.Client was
  in place before this commit on feat/postit-acl; the rebase
  of feat/app-invite on top of feat/postit-acl dropped it.
  This commit re-adds it.
2026-08-18 00:34:29 +01:00
69a660cafb
feat(app-invite): isolate ContactService to mobile targets
Splits the single ContactService class (which threw
PlatformNotSupportedException on non-Android/iOS targets) into a
platform-conditional structure:

- IContactService + ContactDto: shared abstraction in
  src/PostIt/PostIt/Services/IContactService.cs. ViewModels depend
  on this; concrete providers map their native shapes to ContactDto.

- ContactService.Mobile.cs: MAUI Essentials implementation, compiled
  only when ANDROID or IOS is defined. Wraps
  Contacts.Default.GetAllAsync() with permission handling and a
  NotImplementedInReferenceAssemblyException safety net.

- ContactService.Desktop.cs: stub returning an empty list, compiled
  when neither ANDROID nor IOS is defined. Replaces the
  'throw PlatformNotSupportedException' path so desktop targets
  (PostIt.Desktop, PostIt.Browser) build and run cleanly.

The Microsoft.Maui.Essentials portable facade is referenced from
PostIt.csproj, but it only becomes functional when the host
application project (PostIt.Android, future PostIt.iOS) also
references the platform-specific implementation.

No tests added: per AGENTS.md, a 'stub returns empty list' test on
PostIt.Tests (net10.0 desktop target) would be cosmetic and not
detect the real failure mode. Android-side tests require a working
PostIt.Android project, which doesn't exist yet.

Future providers (Google Contacts API, Exchange, CardDAV) plug in
as additional IContactService implementations selected by DI
configuration.
2026-08-18 00:31:26 +01:00
a8c219e0fa
WIP app invite: scaffold MAUI Essentials dependency in shared PostIt
Adds Microsoft.Maui.Essentials package and <UseMaui>true</UseMaui> to
src/PostIt/PostIt/PostIt.csproj so the shared project can compile code
that calls MAUI Essentials APIs (Microsoft.Maui.ApplicationModel.*).

Also adds a draft ContactService that wraps Contacts.Default.GetAllAsync()
behind a runtime platform check and permission request.

WIP caveats:
- The portable MAUI Essentials facade compiles on net10.0 but throws
  NotImplementedInReferenceAssemblyException at runtime when no
  platform-specific MAUI Essentials binary is loaded. A PostIt.Android
  project (or equivalent) must reference the Android MAUI Essentials
  implementation for Contacts.Default.GetAllAsync() to actually work.
- On desktop (Linux/macOS/Windows) the API is unsupported by design;
  ContactService currently throws PlatformNotSupportedException. A
  desktop stub returning Array.Empty<Contact>() is the likely next step.
- No tests yet. The scaffold is unverified at runtime; build passes.
2026-08-18 00:31:26 +01:00
ab8e77279b
ci(forgejo): put asset name in URL query string, not as curl arg
Le run #102 (re-publication du tag 1.0.6 après le fix jq + bump image v2)
a passé le PATCH /releases/10706 (jq a bien extrait l'id racine, plus
de 404), mais l'upload d'asset a planté avec un 400 "Missing 'name'
parameter".

Cause : sur l'appel curl de l'upload d'asset, l'argument `?name=...`
était passé en argument positionnel entre `--data-binary @file` et
l'URL. curl l'interprète comme un second fichier d'input (un fichier
nommé '?name=...'), pas comme un query param, et l'API Forgejo ne
voit jamais le name.

Fix : concaténer `?name=PostIt.Android.apk` à l'URL directement.
L'API Forgejo accepte le name en query string sur POST /releases/{id}/assets.
2026-08-18 00:31:25 +01:00
bea2e35bb4
chore(release): update 1.0.6 CHANGELOG section (image v2, jq fix)
La section [1.0.6] - stable du CHANGELOG mentionnait encore
debian12-dotnet10-android36-v1 et ne décrivait pas le fix du PATCH
release qui tombait en 404 à cause du sed greedy + JSON minifié.
Mets à jour avant de relancer la publication de la release
1.0.6 (workflow_dispatch), pour que le body publié reflète l'état
réel de l'infra (image v2 avec jq) et du workflow.
2026-08-18 00:31:25 +01:00
c2d55317ab
ci(forgejo): build JSON bodies with jq instead of hand-rolled sed
L'image runner pazof/yavsc-build-env installe jq (>= 1.7) à partir
de debian12-dotnet10-android36-v2 (Dockerfile du repo
dotnet-android-build-image, commit e06f096 "adds jq"). On en
profite pour supprimer json_escape et json_field à base de sed,
qui étaient fragiles :

  * sed est greedy par défaut : sur du JSON minifié d'une seule
    ligne (ce que renvoie l'API Forgejo de cette instance pour
    /releases/tags/<tag>), la regex s/.*"id".../\1/p attrape la
    DERNIÈRE occurrence de "id":<digits> sur la ligne, qui est
    l'id de l'auteur de la release (1, premier user du repo),
    pas l'id de la release (10706).
  * Le head -3 ajouté en PR #30 ne tient pas sur du JSON minifié :
    il n'isole rien et le sed greedy continue à capturer
    l'id de l'auteur.
  * PATCH /releases/1 tombait alors en 404 "The target couldn't
    be found" (cf. run échoué du 2026-08-17 04:05 sur le tag
    1.0.6).

jq résout les deux problèmes en une fois :
  * jq -r '.id' retourne le champ id racine, pas l'id imbriqué
    dans author.
  * jq -n --arg body "$RELEASE_BODY" '{body: $body, prerelease:
    $prerelease}' construit un body JSON proprement échappé
    (backslashes, guillemets, newlines, caractères de contrôle
    Unicode) sans avoir à le reproduire à la main.

Effet de bord : les bodies PATCH et POST sont écrits dans
/tmp/patch.json et /tmp/post.json puis passés à curl via
--data-binary @<file> au lieu d'une variable shell. Plus de
problème de quoting en chaîne shell, plus de collision avec
les espaces ou les caractères spéciaux du body.

Pré-requis côté runner : image pazof/yavsc-build-env:debian12-
dotnet10-android36-v2 (avec jq) + maj du label correspondant
dans la config du runner Forgejo.
2026-08-18 00:31:24 +01:00
24fede0bd0
ci(forgejo): limit json_field extraction to top-level keys
L'API Forgejo renvoie pour /releases/tags/<tag> un objet JSON
pretty-printed où l'id racine (release.id, ex. 10706) est sur la
première ligne, mais l'objet author contient aussi un id (souvent 1
pour le premier user du repo). L'ancienne regex sed matchait la
première occurrence globale de "id" dans le fichier, donc elle
retombait sur author.id=1 et le PATCH /releases/1 tombait en 404
'The target couldn't be found'.

Fix : on pipe le fichier dans 'head -3' pour ne matcher que les
premières lignes (couvre largement le préambule de l'objet release).
Si Forgejo renvoie du JSON minifié (une seule ligne), head -3
renvoie toute la ligne et la regex matche le premier id (la racine,
parce que les champs auteur sont après les champs racine).
2026-08-18 00:31:24 +01:00
2df364aa1e
ci(forgejo): build JSON bodies in pure bash, no python3
L'image runner pazof/yavsc-build-env n'a pas python3 (ni jq, ni
node). Le step de publication Forgejo utilisait python3 pour générer
les bodies JSON (POST /releases, PATCH /releases/{id}) et pour
extraire le 'id' de la réponse.

Fix : deux fonctions bash :
- json_escape : escaping JSON des chaînes (\\, \", \n, \r, \t)
- json_field : extraction d'un champ scalaire d'un fichier JSON via sed

Suffisant pour les bodies qu'on envoie (tag_name, name, body,
prerelease) et les champs qu'on lit (id).
2026-08-18 00:31:23 +01:00
copilot-swe-agent[bot]
8960ce7d93
fix(ci): fix validate-release CHANGELOG channel check to inspect heading line
Co-authored-by: pazof <3072814+pazof@users.noreply.github.com>
2026-08-18 00:31:23 +01:00
copilot-swe-agent[bot]
5c20c0bc04
Initial plan 2026-08-18 00:31:23 +01:00
64d25bb2f1
ci(forgejo): build .NET projects directly, skip docker
L'image runner pazof/yavsc-build-env a le SDK .NET 10 et le workload
Android, mais PAS le binaire 'docker' ni de daemon Docker. Le
'Build de l'image Docker' du workflow plantait avec 'docker: command
not found'.

Fix : on exécute directement les commandes dotnet du Dockerfile
(restore + build Yavsc.Org/Api/Blogs + build PostIt.Android -r
android-arm64), puis on copie l'APK depuis le chemin de sortie
standard bin/Release/net10.0-android/android-arm64/.

Note : le Dockerfile reste la voie canonique pour les builds en
local et via GitHub Actions (qui a docker). Ce fix concerne
uniquement le workflow Forgejo Actions où le runner n'a pas Docker.
2026-08-18 00:31:22 +01:00
e07c536e1f
ci(forgejo): check CHANGELOG channel suffix on the section title
The previous awk extracted the section body but excluded the title
line (## [TAG] - channel), so the '* - $CHANNEL*' pattern never
matched. Fix: include the title line in the extracted body, verify
the channel suffix on the title, then strip the title before passing
the body to the release API.
2026-08-18 00:31:22 +01:00
fd99260bc7
ci(forgejo): replace all Node-based actions with bash + curl
The runner's docker label points at pazof/yavsc-build-env, a Debian
image without Node.js. Any action like actions/checkout@v7,
actions/upload-artifact@v7, rasterstate/forgejo-release-action, etc.
fails at container start with 'executable file not found in /usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/home/paul/.dotnet/tools:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/home/paul/.nvm/versions/node/v22.23.0/bin:/home/paul/.local/bin:/home/paul/.npm-global/bin:/home/paul/bin:/home/paul/.nix-profile/bin'.

This workflow is rewritten in pure bash:
- replace actions/checkout with explicit git clone + checkout (full
  history + tags so GitVersion.MsBuild is happy);
- merge the two jobs into one (no inter-job artifacts needed since
  everything shares the runner's filesystem);
- replace rasterstate/forgejo-release-action with direct calls to the
  Forgejo REST API (/api/v1/repos/.../releases, .../assets), with
  python3 used to build and parse JSON bodies (jq not guaranteed in
  the runner image).

Auth: ${{ secrets.GITHUB_TOKEN }} (runner-provided). The
rasterstate action or any other Node-based action can be reinstated
later if the runner image is swapped for one with Node installed.
2026-08-18 00:31:21 +01:00
c4695dc254
ci(forgejo): use runner-provided GITHUB_TOKEN for release workflow
Repo-level secrets creation is broken on this Forgejo instance
(InsertEncryptedSecret fails with UTF-8 byte-sequence error, likely
a text-vs-bytea column type on the secret table). The fix is in
upstream Forgejo v16; until then, ${{ secrets.GITHUB_TOKEN }} (auto-
provided by the runner, scoped to contents: write for the current
repo) keeps the release workflow operational without any UI setup.

When the instance is upgraded and the secret table is migrated,
revert this commit to switch back to ${{ secrets.RELEASE_TOKEN }}
for least-privilege.
2026-08-18 00:31:21 +01:00
4a15edb9e5
ci(forgejo): publish release with PostIt APK on tag push
Adds .forgejo/workflows/release.yml: triggered by tag push or
workflow_dispatch, it validates the tag/CHANGELOG parity (stable /
preview / unstable), builds the PostIt Android APK via the existing
Dockerfile (--target build-env), and publishes a Forgejo release with
the APK as an asset via rasterstate/forgejo-release-action@v1.

Mirrors the validate-release logic of .github/workflows/docker-publish-android.yml
so the two channels (Forgejo source-of-truth + GitHub mirror) stay
consistent. Authentication uses ${{ secrets.RELEASE_TOKEN }}, a Forgejo
PAT scoped to write:repository configured in the repository's Actions
secrets.
2026-08-18 00:31:19 +01:00
b3056f1c2e
feat(user-search): add UserSearchApiController in Yavsc.Blogs
All checks were successful
Dotnet build and test / log-the-inputs (pull_request) Successful in 26s
Dotnet build and test / build (pull_request) Successful in 14m59s
Lives in Yavsc.Blogs (not Yavsc.Api) because Yavsc.Api is not
yet enabled in production; future migration to Yavsc.Api is a
single namespace + route prefix change.

Endpoint: GET /api/user-search?q=<name>&e=<email>&take=<n>
- Authorisation: [Authorize] (any authenticated caller).
- q: case-insensitive substring match on FullName OR UserName.
- e: case-insensitive exact match on Email.
- take: 1..100, default 25.

Returns a flat UserSearchResultDto (Id, UserName, FullName,
Avatar, Email) — no navigation properties, so the payload
stays small even if the user table grows.

The Email field is included because the address-book use case
(composing circle membership, sending invites) needs it.
On Yavsc's single-tenant deployments the user table is a
closed community; multi-tenant deployments should gate this
controller behind a tenant-scoped policy before exposing it.
The trade-off is documented in the controller's class-level
XML doc.
2026-08-18 00:20:10 +01:00
1b289c1387
refactor(model): rename Yavsc.Blogspot.BlogPost to BlogPostDto
When commit 0e95e283 moved BlogPost from PostIt.Models to
Yavsc.Blogspot, it created an unfortunate collision with the
server-side EF entity Yavsc.Models.Blog.BlogPost. The two
classes have nothing in common beyond the name; the DTO is
the wire shape PostIt exchanges with the Blogs API, the EF
entity is the persistence model. Server code that imports both
namespaces (BlogSpotService.cs, etc.) ended up with 'BlogPost
is an ambiguous reference between X and Y' errors.

Renaming the client DTO to BlogPostDto (matching the
naming convention of the other DTOs in Yavsc.Api.Client.Dtos
— CircleDto, CircleAuthorizationDto, UserSearchResultDto)
disambiguates without renaming the EF entity on the server.

The namespace stays Yavsc.Blogspot; only the class name
changes. All call sites (client code, tests, XAML DataTemplates,
XML doc comments) are updated mechanically.
2026-08-18 00:20:01 +01:00
0e7576857d
feat(postit): UI for managing Circles + per-post ACL
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 13s
Dotnet build and test / build (pull_request) Failing after 3m51s
Landing the user-facing surface for the BlogAcl work. The user
can now:

1. Open the 'Mes cercles' page (a new 'Mes cercles' button on
   the main page) and create / edit / delete their own
   circles. The page lists circles in an ObservableCollection
   bound to a ListBox; per-row buttons drive StartEdit and
   Delete; the bottom editor pushes new / edited circles via
   the Save command.

2. With a post selected, click the new 'ACL' button to open a
   modal 'PostAclDialog' for that post. The modal shows the
   current ACL entries (filtered server-side by Allowed.OwnerId
   == caller) and a dropdown of the caller's circles to add.
   Each entry has a 'Revoke' button.

Both pages follow the same pattern:
- ViewModel uses [ObservableProperty] for state and
  [RelayCommand] for verbs; IsBusy drives a ProgressBar
  overlay; StatusMessage surfaces server feedback.
- View follows the XAML-Background/Foreground lesson (no
  hard-coded colours), so dark mode works without
  contrast surprises.
- Code-behind is minimal — just AvaloniaXamlLoader.Load —
  because navigation is driven by RelayCommand + event
  (ManageAclRequested, OpenCirclesRequested) that the
  MainPage code-behind handles via its DataContextChanged
  handler.

The 'complete' scope (c) of this commit was confirmed by
Paul. Three follow-up tracks are deliberately out of scope
and tracked in MEMORY.md (2026-08-18):
- i18n: no .resx / IStringLocalizer today; all visible text
  is hard-coded French.
- Avalonia.Headless UI tests: only ViewModel-level coverage
  is feasible today; full navigation tests are a separate
  effort.
- XAML accessibility audit of pre-existing pages (Settings,
  MainPage) that predate the Background/Foreground lesson.

Build + 51/51 tests green.
2026-08-18 00:06:57 +01:00
a5887a2387
feat(postit): wire Circle + BlogAcl clients in the DI container
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Successful in 21s
Dotnet build and test / build (pull_request) Failing after 7m24s
App.axaml.cs is the composition root for PostIt. It now also
builds and registers:
- CircleApiClient (singleton) — backed by the same YavscApiClient
  and the same blogs base URL as BlogApiClient
- BlogAclApiClient (singleton) — same shape
- IYavscApiClient -> YavscApiClient mapping (singleton). The
  concrete class is still resolvable as YavscApiClient; the new
  registration makes the same instance available as
  IYavscApiClient so future consumers (and unit tests) can take
  the interface without coupling to the concrete type.

The 3 high-level clients are singletons: they hold no mutable
state of their own, just a reference to YavscApiClient and a
base URL. Reusing the same instance across requests is what the
HttpClient inside YavscApiClient was already designed for.
2026-08-17 23:51:51 +01:00
f835ad42a1
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.

Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
  now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
  the blogs base URL explicitly in its constructor so it doesn't
  need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
  Same conventions as CircleApiClient.

DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
  navigation properties on the server-side Circle (Owner,
  Members), which depend on ApplicationUser and other server
  types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
  reason: the server entity has Target and Allowed navigation
  properties the client never needs.

The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.

Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
ab40af8ef1
refactor(api-client): introduce IYavscApiClient abstraction in Yavsc.Api.Client
Yavsc.Api.Client is the new home for high-level HTTP clients
(BlogApiClient, CircleApiClient, BlogAclApiClient, etc.). It
depends on the host application's transport layer, but the host
(PostIt) is a UI app with OIDC, settings, and an ApplicationData
directory — none of which the abstract client library should
know about.

The IYavscApiClient interface captures just the transport
surface those clients need:
- HttpClient (so the client can configure BaseAddress)
- CallAsync<T> and CallAsync (the JSON over HTTP verb)

It deliberately leaves out LoginAsync / TrySilentLoginAsync /
CurrentAccessToken / HasValidSession / Settings — those are
authentication and configuration concerns, not transport. They
stay on the concrete YavscApiClient in PostIt.Services.

The concrete YavscApiClient now implements IYavscApiClient; the
existing public surface is unchanged (no breaking changes for
existing call sites in PostIt or the tests).

This commit only lays the foundation. The actual high-level
clients (Blog/Circle/BlogAcl) land in a follow-up commit that
re-uses this interface, so this one stays a small, reviewable
refactor.
2026-08-17 23:50:24 +01:00
0e95e28327
refactor(model): move BlogPost DTO from PostIt.Models to Yavsc.Blogspot
BlogPost is shared between the server (Yavsc.Server/Models/Blog/
BlogPost.cs is the EF entity) and any client that talks to the
blogs API. Keeping the client-side DTO in PostIt.Models made
sense when there was only one consumer; now that the
Yavsc.Api.Client project is about to host BlogApiClient alongside
CircleApiClient and BlogAclApiClient, the DTO has to live in a
layer both the client project and PostIt can reference without
inverting the dependency.

Yavsc.Abstract is the existing home for cross-tier interfaces
and DTOs (IBlogPost, IBlogPostPayLoad, IApplicationUser).
Yavsc.Blogspot is the sub-namespace already used by the
matching interface, so the new concrete class follows.

Why not move Circle and CircleAuthorizationToBlogPost at the
same time? Both depend on the concrete ApplicationUser class
(via the Owner and Target/Allowed navigation properties) which
lives in Yavsc.Server. Moving them would mean either dragging
ApplicationUser into the abstract layer (huge blast radius —
auth, billing, chat, etc.) or weakening the navigation
properties (breaks EF Core shaping). They're staying where
they are; the new Yavsc.Api.Client will get DTO counterparts
instead.

Updated call sites:
- 4 .cs files: replace 'using PostIt.Models;' with
  'using Yavsc.Blogspot;' where the file was actually using
  BlogPost. Files that only used SignaturePadData keep their
  'using PostIt.Models;' — that type stays put.
- 1 .axaml file: xmlns:models="using:PostIt.Models" ->
  xmlns:models="using:Yavsc.Blogspot" (one DataTemplate for
  the post list in MainPage).

Build + tests green (51/51).
2026-08-17 23:45:45 +01:00
e376aed887
fix(blogacl): restrict Circle + BlogAcl reads and writes to caller's own data
Closes the data-leak holes that survived the move of these controllers
from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its
membership should never be visible, modifiable, or deletable by anyone
other than its owner.

BlogAclApiController:
- GetBlogACL() was returning the full table; now filters by
  Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed)
  so EF Core can push the filter into SQL instead of materialising
  the whole table.
- Other endpoints (GetById, Put, Post, Delete) already enforced
  ownership; left as is.

CircleApiController:
- GetCircle() (no id) now filters by OwnerId.
- GetCircle(id) now requires c.Id == id && c.OwnerId == uid;
  returns 404 (not 403) on miss to avoid leaking the existence of
  someone else's circle.
- PutCircle verifies the existing record is owned by the caller,
  then forces circle.OwnerId = uid on the body (the client's value
  is ignored). Returns ChallengeResult when the caller doesn't own
  the record.
- PostCircle forces circle.OwnerId = uid (was trusting the body).
- DeleteCircle now filters by OwnerId; 404 on miss.

All checks use the same source of truth (User.FindFirstValue(
ClaimTypes.NameIdentifier)) that the existing BlogAclApiController
authz code already relies on.
2026-08-17 23:36:20 +01:00
40e5630cfc
refactor(blogacl): move BlogAcl + Circle controllers from Yavsc.Api to Yavsc.Blogs
These two controllers belong to the Blogs subsystem (their routes
/api/blogacl and /api/circle are blog-domain concerns, not the
generic Api surface). Moving them next to BlogApiController keeps
related code together and prepares the PostIt client to consume
them through the same BlogsApiUrl base address as the existing
BlogApiClient.

Mechanical changes only:
- Namespace Yavsc.Controllers -> Yavsc.Blogs.Controllers
- Drop unused 'using Yavsc.Helpers;' (no symbol in the new
  compilation unit depends on it; the build confirms it was
  dead since the controllers were first written)
- Fix typo in CircleApiController route: 'api/cirle' -> 'api/circle'
  (any client trying to call the documented route was hitting 404)

No functional changes to authorization or query shape. The known
security gaps in these controllers (GetBlogACL and GetCircle
return unfiltered collections, DeleteCircle has no ownership
check) are deliberately left untouched in this commit and will
be addressed in a follow-up.
2026-08-17 23:34:48 +01:00
157 changed files with 10089 additions and 874 deletions

View file

@ -21,20 +21,9 @@ on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
branches: [ "main", "release/*" ]
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:
runs-on: docker

View file

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

11
.vscode/mcp.json vendored
View file

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

View file

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

View file

@ -49,6 +49,57 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ».
- `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
Le repo applique `.editorconfig` (UTF-8, LF, `indent_size = 4` en
@ -64,6 +115,13 @@ Quelques règles non capturées par `.editorconfig` :
- Préférer les types BCL (`int`, `string`) aux types framework
(`Int32`, `String`).
- 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

View file

@ -18,6 +18,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" 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.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />

View file

@ -48,5 +48,77 @@ docker-build:
docker-run:
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
.PHONY: test release

View file

@ -1,9 +1,19 @@
# 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é.
# 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
* [![Build and Push Yavsc Apk](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml/badge.svg)](https://github.com/pazof/yavsc/actions/workflows/docker-publish-android.yml)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,10 +6,10 @@
<IsPackable>false</IsPackable>
<RootNamespace>PostIt.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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="Microsoft.NET.Test.Sdk" />

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -12,10 +12,10 @@
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<RuntimeIdentifiers Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">android-arm;android-arm64;android-x86;android-x64</RuntimeIdentifiers>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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>
<AndroidResource Include="Icon.png">
@ -31,6 +31,6 @@
<ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
<PackageReference Include="Microsoft.Maui.Essentials" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,84 @@
#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>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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="Avalonia.Browser" />

View file

@ -5,10 +5,10 @@
See https://docs.avaloniaui.net/docs/guides/platforms/platform-specific-code/dotnet for more details.-->
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>

View file

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

View file

@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Avalonia;
@ -7,6 +8,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using PostIt.Services;
using Yavsc.Api.Client;
using PostIt.ViewModels;
using PostIt.Views;
@ -47,58 +49,11 @@ public partial class App : Application
// build is ever reconfigured to skip the early check.
if (TryHandOffCustomSchemeUrl()) return;
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);
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);
this.ServiceProvider = BuildServices(new ServiceCollection());
AttachServiceProvider(ServiceProvider);
var settings = ServiceProvider.GetRequiredService<Settings>();
var sessionStatus = ServiceProvider.GetRequiredService<SessionStatusViewModel>();
var api = ServiceProvider.GetRequiredService<YavscApiClient>();
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(ServiceProvider));
@ -132,8 +87,7 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var homePage = ServiceProvider.GetRequiredService<HomePage>();
homePage.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
var homeVm = ServiceProvider.GetRequiredService<HomePageViewModel>();
window = new MainWindow();
window.SessionBanner.DataContext = sessionStatus;
@ -141,9 +95,8 @@ public partial class App : Application
// Build the navigation stack from scratch: HomePage is the
// root in both cases. App.BootAsync will push MainPage on
// top if the silent refresh succeeds.
window.DataContext = homePage.DataContext;
desktop.MainWindow = window;
_ = window.NavRoot.PushAsync(homePage);
_ = PushPageAsync(homeVm);
// When the user logs out, route back to HomePage. We
// ReplaceAsync the current top so we don't grow the stack
@ -153,8 +106,6 @@ public partial class App : Application
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var nav = w.NavRoot;
var hp = ServiceProvider.GetRequiredService<HomePage>();
hp.DataContext = ServiceProvider.GetRequiredService<HomePageViewModel>();
_ = nav.PopToRootAsync();
};
@ -166,35 +117,7 @@ public partial class App : Application
_ = PushMainPageAsync();
};
// 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);
window.Opened += async (_, _) => await BootAsync(this.ServiceProvider, api);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
{
@ -205,6 +128,110 @@ public partial class App : Application
}
}
/// <summary>
/// Build the DI container the app uses. Pulled out of
/// <see cref="OnFrameworkInitializationCompleted"/> so headless
/// tests can construct the same container at <c>TestApp</c> boot
/// without going through the full Avalonia desktop lifetime
/// (which never runs in a unit test). The container returned is
/// the exact one production uses — no test-only fakes, no
/// trimmed service list — so a test that exercises a VM, page,
/// or service resolves through the same wiring the real app
/// does, and a green test is a green contract for prod.
/// </summary>
internal static IServiceProvider BuildServices(ServiceCollection services)
{
var settings = new Settings();
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var contactService = new ContactService();
var userDirectory = new UserDirectory(userSearchClient);
// Vues
services.AddTransient<MainPage>();
// SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) PushPageAsync's anti-empilement guard sees
// the same instance across pushes, so a second Settings tap
// is a no-op rather than re-pushing the page. Transient would
// let the user accumulate stale SettingsPage instances on
// the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>();
// Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or
// AddCircleMemberDialogViewModel via App.PushPageAsync.
// App.PushPageAsync overwrites the page's DataContext with
// the caller-built VM, so the parameterless ctor is enough
// here — the parametrised ctors stay for direct test wiring.
services.AddTransient<PostAclDialog>();
services.AddTransient<AddCircleMemberDialog>();
// ViewModels
services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api);
services.AddSingleton<IYavscApiClient>(api);
services.AddSingleton(client);
services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient);
services.AddSingleton<IContactService>(contactService);
services.AddSingleton<IUserDirectory>(userDirectory);
services.AddTransient<MainPageViewModel>();
services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>();
services.AddTransient<CirclesPageViewModel>();
// Persistent session banner: one instance for the lifetime of
// the app so the same VM survives page navigation.
var sessionStatus = new SessionStatusViewModel { Api = api };
sessionStatus.Refresh();
services.AddSingleton(sessionStatus);
services.AddTransient<SessionStatusBanner>();
return services.BuildServiceProvider();
}
/// <summary>
/// Attach a pre-built DI container to this <see cref="App"/>
/// instance. Used by headless tests after
/// <see cref="BuildServices"/>; in production this happens
/// implicitly via <see cref="OnFrameworkInitializationCompleted"/>.
/// Idempotent w.r.t. <see cref="Settings.BindToServiceProvider"/>:
/// re-binding from a second App boot is a no-op.
/// </summary>
internal void AttachServiceProvider(IServiceProvider sp)
{
ServiceProvider = sp;
Settings.BindToServiceProvider(sp);
}
/// <summary>
/// Test-only hook: bind a concrete <see cref="MainWindow"/> so
/// command-driven navigation paths (<see cref="PushPage"/>) can
/// push onto a real <see cref="NavigationPage"/> in headless
/// fixtures that do not run the full desktop lifetime bootstrap.
/// </summary>
internal void AttachMainWindow(MainWindow mainWindow)
{
window = mainWindow ?? throw new ArgumentNullException(nameof(mainWindow));
}
private static void ApplyDarkMode(Settings settings)
{
Application.Current!.RequestedThemeVariant =
@ -232,19 +259,18 @@ public partial class App : Application
}
/// <summary>
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
/// Resolve a fresh <c>MainPageViewModel</c> from DI and push its
/// mapped page (via <see cref="ViewLocator"/>) on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
public static async Task PushMainPageAsync()
public static Task PushMainPageAsync()
{
var app = (App)Current;
var mainVm = app.ServiceProvider.GetRequiredService<MainPageViewModel>();
var mainPage = app.ServiceProvider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm;
await app.window.FindControl<NavigationPage>("NavRoot").PushAsync(mainPage).ConfigureAwait(true);
return app.PushPageAsync(mainVm);
}
private bool TryHandOffCustomSchemeUrl()
@ -279,4 +305,53 @@ public partial class App : Application
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

@ -1,37 +0,0 @@
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>
<LangVersion>latest</LangVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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>
<AvaloniaResource Include="Assets\**" />
@ -25,6 +25,7 @@
<PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="postit-settings.json">

View file

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

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

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

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

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

View file

@ -21,6 +21,18 @@ public class ViewLocator : IDataTemplate
}
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
{
@ -28,6 +40,9 @@ public class ViewLocator : IDataTemplate
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => _services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => _services.GetRequiredService<CirclesPage>(),
PostAclDialogViewModel => _services.GetRequiredService<PostAclDialog>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};

View file

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

@ -0,0 +1,310 @@
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,10 +2,14 @@ using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Models;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.Services;
using PostIt.Views;
namespace PostIt.ViewModels;
@ -24,7 +28,7 @@ public partial class MainPageViewModel : ViewModelBase
/// previous "{Binding SelectedPost.Title}" binding, the user's
/// keystrokes were silently dropped whenever
/// <c>SelectedPost was null</c>, which made the editor a trap
/// and caused Save to POST a <c>BlogPost</c> with an empty
/// and caused Save to POST a <c>BlogPostDto</c> with an empty
/// title — hence the 400 "The Title field is required".</summary>
[ObservableProperty]
public partial string DraftTitle { get; set; }
@ -34,8 +38,17 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
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]
public partial ViewModelBase? CurrentViewModel { get; set; }
public partial bool DraftIsPublished { get; set; }
public Settings SettingsModel { get; }
@ -46,13 +59,13 @@ public partial class MainPageViewModel : ViewModelBase
public partial string SearchText { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPost> Posts { get; set; }
public partial ObservableCollection<BlogPostDto> Posts { get; set; }
[ObservableProperty]
public partial ObservableCollection<BlogPost> FilteredPosts { get; set; }
public partial ObservableCollection<BlogPostDto> FilteredPosts { get; set; }
[ObservableProperty]
public partial BlogPost? SelectedPost { get; set; }
public partial BlogPostDto? SelectedPost { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
@ -68,9 +81,46 @@ public partial class MainPageViewModel : ViewModelBase
/// </summary>
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 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()
{
@ -82,8 +132,8 @@ public partial class MainPageViewModel : ViewModelBase
private void Init(Settings? settings)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPost>();
FilteredPosts = new ObservableCollection<BlogPost>();
Posts = new ObservableCollection<BlogPostDto>();
FilteredPosts = new ObservableCollection<BlogPostDto>();
SelectedPost = null;
IsBusy = false;
StatusMessage = "Ready";
@ -101,25 +151,38 @@ public partial class MainPageViewModel : ViewModelBase
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
CurrentViewModel = this;
DraftIsPublished = false;
}
/// <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>
/// Test-friendly constructor: caller supplies a pre-built
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
Init(settings);
}
partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPost? value)
partial void OnSelectedPostChanged(BlogPostDto? value)
{
// Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content
@ -130,6 +193,9 @@ public partial class MainPageViewModel : ViewModelBase
// doesn't show stale content.
DraftTitle = value?.Title ?? 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();
}
@ -176,7 +242,7 @@ public partial class MainPageViewModel : ViewModelBase
await ExecuteAsync(async () =>
{
// Build a fresh BlogPost from the editor buffer on
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
@ -188,7 +254,7 @@ public partial class MainPageViewModel : ViewModelBase
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{
var draft = new BlogPost
var draft = new BlogPostDto
{
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
@ -204,7 +270,7 @@ public partial class MainPageViewModel : ViewModelBase
}
else
{
var update = new BlogPost
var update = new BlogPostDto
{
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
@ -240,10 +306,70 @@ 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]
internal void OpenSettings()
internal async Task TogglePublish()
{
CurrentViewModel = SettingsModel;
if (SelectedPost is null || SelectedPost.Id == 0)
{
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()
@ -306,14 +432,23 @@ public partial class MainPageViewModel : ViewModelBase
DeleteCommand.NotifyCanExecuteChanged();
}
/// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
[RelayCommand(CanExecute = nameof(CanManageAcl))]
public async Task ManageAcl()
{
if (SelectedPost is null)
{
StatusMessage = "Select an existing post before managing ACL.";
return;
}
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

@ -0,0 +1,174 @@
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,6 +2,7 @@ using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
namespace PostIt.ViewModels;
@ -32,15 +33,6 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
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]
public partial bool IsLoggedIn { get; private set; }
@ -144,9 +136,10 @@ public partial class SessionStatusViewModel : ViewModelBase
}
[RelayCommand]
public async System.Threading.Tasks.Task OpenSettingsCommand()
internal async Task OpenSettings()
{
OpenSettingsRequested?.Invoke();
await System.Threading.Tasks.Task.CompletedTask;
var app = (App)App.Current!;
await app.PushPageAsync(app.ServiceProvider.GetRequiredService<Settings>()).ConfigureAwait(true);
}
}

View file

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

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

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

@ -0,0 +1,23 @@
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:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
xmlns:models="using:PostIt.Models"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
mc:Ignorable="d"
@ -33,6 +33,24 @@
<Button Command="{Binding Search}" Content="Filter" />
<Button Command="{Binding Save}" Content="Save" />
<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
capture page. Production entry point is a SignalR
@ -41,17 +59,17 @@
MainPage.axaml.cs once the SignalR handler lands.
-->
<Button x:Name="OpenSignatureDevButton"
Command="{Binding OpenSignatureDev}"
Content="[DEV] Signature"
Click="OpenSignatureDev"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</StackPanel>
</StackPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="40">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:BlogPost">
<DataTemplate x:DataType="models:BlogPostDto">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" />
<TextBlock Text="{Binding DateModified, StringFormat='Updated: {0:yyyy-MM-dd HH:mm}'}" FontSize="10" Foreground="Gray" />

View file

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

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

@ -0,0 +1,87 @@
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
{
[StringLength(YavscConstants.MaxUserNameLength)]
[RegularExpression(YavscConstants.UserNameRegExp)]
[StringLength(Constants.MaxUserNameLength)]
[RegularExpression(Constants.UserNameRegExp)]
[DataType(DataType.Text)]
[Display(Name = "UserName", Description = "User name")]
public string UserName { get; set; }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,10 +9,10 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Library>true</Library>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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" />

View file

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

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

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

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

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

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

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

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

@ -0,0 +1,29 @@
<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
{
[Produces("application/json")]
[Route("api/activity")]
[Route(Constants.APIPrefix + "/activity")]
public class ActivityApiController : Controller
{
private ApplicationDbContext _context;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,146 +0,0 @@
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
{
[Produces("application/json")]
[Route("api/ContactsApi")]
[Route(Constants.APIPrefix + "/ContactsApi")]
public class ContactsApiController : Controller
{
private readonly ApplicationDbContext _context;

View file

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

View file

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

View file

@ -5,10 +5,10 @@
<UserSecretsId>1c73094f-959f-4211-b1a1-6a69b236c283</UserSecretsId>
<RootNamespace>Yavsc.Api</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
<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="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

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

View file

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

View file

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

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