Compare commits

...
Author SHA1 Message Date
d24244aae6
Honor the administration setup
All checks were successful
Dotnet build and test / build (push) Successful in 22m1s
2026-10-11 08:31:27 +01:00
2744619249
fix(postit): mount session banner in headless tests
All checks were successful
Dotnet build and test / build (push) Successful in 25m17s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 07:28:17 +01:00
da35ac0a83
do not decide to attach dev tools from the compilation flag
Some checks failed
Dotnet build and test / build (push) Failing after 32m38s
2026-10-11 06:34:53 +01:00
875a020e0d
simpler, better, faster, stronger 2026-10-11 06:27:13 +01:00
eb347e6900
upload test resultes, in case of failure 2026-10-11 06:12:41 +01:00
c34941a4b9
no need for .env to test 2026-10-11 05:59:59 +01:00
afbefae111
make test>/dev/null 2026-10-11 05:43:03 +01:00
29a5b8be8d
run-on ubuntu-26.04, using the default container
All checks were successful
Dotnet build and test / build (push) Successful in 33m13s
2026-10-11 05:11:53 +01:00
d7e274c93a
run-on ubuntu-26.04 2026-10-11 05:08:24 +01:00
bc8ae74fc3
using pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 2026-10-11 04:29:29 +01:00
b5fa7b9072
Github tests
All checks were successful
Dotnet build and test / build (pull_request) Successful in 26m40s
Dotnet build and test / build (push) Successful in 26m24s
2026-10-11 02:26:31 +01:00
4ec169ed71
Improve PostIt draft attachment and save workflow
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 26m22s
Insert attachment links on file addition, resolve provisional links on first save, preserve the editor selection, and highlight unsaved draft changes. Include regression tests and changelog updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 02:03:48 +01:00
63b44b5a92
Fix PostIt attachment links to match uploaded filenames
Apply the shared filename filter before URL encoding, preserve escaped URLs and original link labels, and add regression coverage for accented and spaced filenames. Update the changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:38:25 +01:00
96ff4af440
Document changelog updates in project instructions
Require changelog updates by default for each commit and record the uploaded filename encoding fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:21:02 +01:00
79ba934bce
UseStaticFiles was called twice 2026-10-11 01:18:10 +01:00
2765d3eef8
Fix URL-safe encoding of uploaded filenames
Replace the fragment marker used for unsupported filename characters with an underscore and cover upload metadata and static-file downloads.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 01:17:26 +01:00
446b595077
renaming of the 3Blog3 setting => UserFilesRoot
All checks were successful
Dotnet build and test / build (pull_request) Successful in 33m37s
2026-10-11 00:16:44 +01:00
f9d95fd98f
Notify performers of API bookings and client-signed estimates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-11 00:14:33 +01:00
fae754f102
Display notifications on PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:54:55 +01:00
8b4b6a7c53
Add notifications API for PostIt home page
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:23:55 +01:00
7ee0c30500
Fix authenticated resource usage tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-10 20:03:19 +01:00
ebdf51f468
fix(postit): restore Markdown editor and preview scrolling
All checks were successful
Forgejo Release / release (push) Successful in 7m38s
Dotnet build and test / build (pull_request) Successful in 8m25s
Constrain the blog page layout, separate list and article scrolling, and remove content height limits and nested editor scrolling. Add six headless regression cases and update the changelog and layout documentation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 18:28:21 +01:00
cd7351a783
feat(postit): rejoin chat room automatically after reconnect
Also drop unused usings in BlogsViewModel.
2026-10-09 17:55:03 +01:00
95e8f251d2
test(postit): verify published badge and publication state notifications
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:47:01 +01:00
e7721bc3ab
feat(postit): show a live published badge beside blog post titles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 17:46:32 +01:00
3f73d7a909
co-auteurs
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m0s
2026-10-09 17:14:41 +01:00
a3207352c3
Release 1.0.8-rc23
All checks were successful
Forgejo Release / release (push) Successful in 14m32s
Dotnet build and test / build (pull_request) Successful in 17m1s
2026-10-09 16:01:49 +01:00
12f8b52173
admin page presentation 2026-10-09 16:01:26 +01:00
1faa80c884
fix(postit): serialize refresh token rotation across authentication paths
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 15:53:05 +01:00
f050ee4456
feat(account): enable native account management and register API Identity services
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 13:30:01 +01:00
5e22b9a232
fix(postit): preserve performer identity during save 2026-10-07 12:35:09 +01:00
008295c8f6
fix(postit): normalize profile calendar and bank clear state 2026-10-07 12:32:17 +01:00
6b75ae7682
feat(postit): admin search includes profile metadata 2026-10-07 12:29:16 +01:00
05e35d0677
feat(postit): allow username updates in profile flow 2026-10-07 12:26:02 +01:00
137665bfe0
feat(postit): include google calendar and bank details in profile 2026-10-07 12:22:49 +01:00
af1e572f32
feat(postit): migrate editable profile data and admin profile flow 2026-10-07 12:20:26 +01:00
f8c2a01ef4
Add PostIt resource usage admin view
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m24s
2026-10-07 02:26:01 +01:00
a843e77ae9
mise en forme du web 2026-10-06 17:22:48 +01:00
0c72c49ec1
rollback on test artifacts
All checks were successful
Dotnet build and test / build (push) Successful in 12m0s
2026-10-05 15:22:25 +01:00
7d10060efd
Release 1.0.8-rc22
All checks were successful
Dotnet build and test / build (push) Successful in 14m59s
Forgejo Release / release (push) Successful in 9m31s
2026-10-05 14:48:45 +01:00
4ff072ba06
load the CodeBlock 2026-10-05 14:07:15 +01:00
eeae7379e6
release 1.0.8-rc21
All checks were successful
Dotnet build and test / build (push) Successful in 12m29s
Forgejo Release / release (push) Successful in 8m27s
2026-10-04 21:58:35 +01:00
8b61887865
release 1.0.8-rc21 2026-10-04 21:58:08 +01:00
1897a6807d
Fix long-form markdown editor scrolling and focus
Some checks failed
Dotnet build and test / build (push) Failing after 7m50s
2026-10-04 20:18:43 +01:00
bba5fe093c
+ scroll 2026-10-04 20:12:34 +01:00
7e9e7613ba
Fix markdown draft preview sync in PostIt blog editor 2026-10-04 20:03:01 +01:00
84c98cb938
Release 1.0.8-rc20
All checks were successful
Dotnet build and test / build (push) Successful in 17m3s
Forgejo Release / release (push) Successful in 8m21s
2026-10-04 00:43:14 +01:00
9369ff4fcd
feat(chat): modele multi-salles cote client + fix serialisation ChatRoomInfo
Client PostIt:
- JoinedRooms, SelectedRoom, messages et utilisateurs par salle
  (FocusedMessages/FocusedUsers), OnRoomJoined alimente depuis la
  valeur de retour du Join
- ChatPage : ComboBox de choix de la salle en focus, listes bindees
  sur les vues de focus

Serveur:
- AddSignalR().AddJsonProtocol(IncludeFields=true) : ChatRoomInfo
  n'expose que des champs publics, STJ ne les serialisait pas et le
  client recevait des objets vides (JoinedRooms jamais alimente)
2026-10-04 00:35:26 +01:00
51e445b5ba
feat(chat): envoi de messages dans un salon joint
- DTO ChatMessage (Name/Room/Message) pour l'evenement ReceiveMessage
- ChatViewModel : MessageToSend, HasJoinedRoom, handler ReceiveMessage,
  SendMessageCommand (CanExecute: connecte + salon joint + texte non vide)
- ChatPage : TextBox de saisie (Enter envoie) + bouton Envoyer,
  StatusBar repositionnee en Grid.Row=3
2026-10-03 23:55:03 +01:00
75bb2600b5
fix(chat): KeyNotFoundException dans HubConnectionManager.OnConnected
ChatUserNames n'etait jamais alimente (relique d'un SetUserName disparu),
ce qui faisait lever KeyNotFoundException a chaque connexion SignalR et
fermait immediatement la connexion cote client ("Déconnecté (The server
closed the connection...)" dans PostIt).

- IConnexionManager.OnConnected prend desormais le userName en parametre
- HubConnectionManager.OnConnected alimente ChatUserNames et ChatCxIds
- ChatHub.OnConnectedAsync passe le userName resolu depuis les claims

Inclut aussi les changements PostIt lies (bandeau de statut avec
commande d'action, handlers Reconnecting/Reconnected/Closed).
2026-10-03 23:45:22 +01:00
b22a26f61e
SignalR chat connection OK 2026-10-03 22:47:47 +01:00
bb773ba29a
Fixes the /chathub URL 2026-10-03 20:31:28 +01:00
251cc9cd48
for the refact, not the result ... Live still is KO
Some checks failed
Dotnet build and test / build (push) Failing after 13m0s
2026-10-01 19:27:49 +01:00
73528d0c7c
Fixes thanks
Some checks failed
Dotnet build and test / build (push) Failing after 3m7s
2026-09-29 02:06:26 +01:00
f680cf6827
Fixes the compilation
Some checks failed
Dotnet build and test / build (push) Failing after 8m26s
2026-09-29 01:56:58 +01:00
68b224323b
Live.Avalonia integration
Some checks failed
Dotnet build and test / build (push) Failing after 8m25s
2026-09-29 01:26:37 +01:00
eacc4697c9
build image version bump
All checks were successful
Dotnet build and test / build (push) Successful in 6m31s
2026-09-28 00:43:17 +01:00
1e1b751906
localization + AudiRecorder 2026-09-27 23:42:52 +01:00
edac506c49
Build image version bump
Some checks failed
Dotnet build and test / build (push) Failing after 18m42s
2026-09-27 22:22:50 +01:00
549bae3b5f
should fix the CI
Some checks failed
Forgejo Release / release (push) Successful in 11m13s
Dotnet build and test / build (push) Failing after 14m19s
2026-09-27 21:29:32 +01:00
9fc196f9eb
Fixe les tests
Some checks failed
Dotnet build and test / build (pull_request) Failing after 35m49s
2026-09-27 01:49:26 +01:00
4a0c59e105
add testing dependencies
Some checks failed
Dotnet build and test / build (pull_request) Failing after 35m38s
2026-09-27 00:50:36 +01:00
46fa53c513
Fixed #55 - AvaloniaEdit control accessibility
Some checks failed
Dotnet build and test / build (pull_request) Failing after 14m11s
Forgejo Release / release (push) Failing after 22s
2026-09-27 00:25:25 +01:00
c1f447636b
[PostIT] #55 nearly ok, it-s better ...
Some checks failed
Dotnet build and test / build (pull_request) Failing after 33m44s
2026-09-26 18:02:53 +01:00
94a9239cd5
[PostIt][Blogs] Margins and wrap mode #55
Some checks failed
Dotnet build and test / build (pull_request) Failing after 16m51s
2026-09-26 11:42:28 +01:00
a94a5a0497
fixes the default setup for PostIt.Android
Some checks failed
Forgejo Release / release (push) Successful in 6m39s
Dotnet build and test / build (pull_request) Failing after 9m34s
2026-09-26 01:51:12 +01:00
8c8ab70d38
REORG 2026-09-26 00:35:43 +01:00
f0792a844f
make test # also 2026-09-25 23:26:26 +01:00
26e4fde1e7
make test-postit 2026-09-25 23:25:03 +01:00
70f8b7986e
View the test results
All checks were successful
Dotnet build and test / build (pull_request) Successful in 17m5s
2026-09-25 23:05:16 +01:00
1d12166611
https://wiki.openstreetmap.org/wiki/Blocked_tiles
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
Forgejo Release / release (push) Successful in 10m3s
2026-09-25 22:47:20 +01:00
5104cbc3f1
Status bar text is selectionnable
Some checks failed
Forgejo Release / release (push) Successful in 13m17s
Dotnet build and test / build (pull_request) Failing after 17m15s
2026-09-25 21:40:33 +01:00
7844449c99
Blogs: fix delete returning 40x despite the file being removed
DeleteUserDirOrFile (and DeleteUserFile) read FileInfo.Length AFTER
fi.Delete(), and FileInfo.Length throws FileNotFoundException once
the file is gone. The controller's catch turned that into a 400, so
the user saw "le fichier n'existe pas sur le serveur" while the delete
had in fact succeeded. Capture the length before deleting.

Adds FileSystemApiTests.Delete_fs_removes_the_file_and_returns_200
(asserts 200 + file gone from disk). 8/8 FileSystemApiTests pass.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 21:33:13 +01:00
f1a17cd8fe
CHANGELOG: add 1.0.8-rc18 section
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 21:21:18 +01:00
4554886e33
PostIt: fix grayed/inert file-row action buttons (wrong $parent binding)
The action buttons inside ItemsControl item templates (Télécharger /
Supprimer / Joindre on MyFiles; download/detach on the attachment
lists of BillingQueryDetailsPage and EstimateEditionPage) were bound
with $parent[vm:XxxViewModel].Cmd, which searches for an ancestor
CONTROL of type XxxViewModel — a viewmodel, not a control — so the
binding resolved to null and the Button stayed disabled (grayed) and
inert on click. Switch to the working CirclesPage/EstimateListPage
syntax: $parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd.

Adds MyFilesPageHeadlessTests: mounts MyFilesPage via NavigationPage
+ Window.Show() and asserts each row button's Command is bound,
armed (CanExecute), and fires the API call on click. 136/136 pass.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 21:17:55 +01:00
a307885310
Blogs: list personal files under the user's login, not the sub claim
GET api/v1/fs passed User.GetUserId() (the "sub" claim — a GUID in
production) to GetUserFiles, so the listing looked under
{Blog}/{sub-GUID}. But the personal-storage tree is keyed by the
user's login everywhere else: EnsureDestinationDirectory writes under
{Blog}/{name-claim}, the download endpoint and avatar paths use
user.UserName. So once the upload path was fixed (previous commit),
files landed under {Blog}/{login} while the listing still looked under
{Blog}/{GUID} — an empty tree — hence the PostIt "Mes fichiers" page
listing nothing.

GetDir now resolves the user from the DB (as Post already does) and
lists under user.UserName. UploadedFile.OwnerId stays "sub", so
EnrichWithFileIds still queries by uid.

Tests:
- TestTokenIssuer.Issue gains an optional `name` param (defaults to
  subject, preserving existing behavior) so a test token can mirror
  production where sub (GUID) ≠ name (login).
- FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login
  decouples sub from the login, uploads under the login, and asserts
  GET /api/v1/fs returns the file (and the subject-named directory does
  NOT exist). Verified to fail when the controller uses uid for the
  listing.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:50:58 +01:00
d8c13c81d7
Server: wire ProfileService so access tokens carry the name claim
The custom ProfileService (Yavsc.Services.ProfileService) was never
registered with IdentityServer, so IS8 fell back to its default
profile service — which only emits claims listed in each API
resource's UserClaims (RequestedClaimTypes). "name" was never among
them, so every access token lacked "name" and GetUserName() resolved
to null on all bearer resource hosts (Blogs/Api): the MyFiles upload
500 and the unreachable User.Identity.Name. The class was dead code;
all prior tweaks to it (FullName→UserName) and to ServiceExtensions
(MapInboundClaims=false) were necessary but insufficient until it was
actually wired in.

- HostingExtensions: AddProfileService<ProfileService>() after
  AddAspNetIdentity<ApplicationUser>().
- ProfileService: emit name=user.UserName, email, roles for any user
  grant whose client has "profile" in AllowedScopes, independent of
  the caller (the old ParsedScopes gate only fired for the id-token
  caller, never the access-token caller).
- ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name,
  MapInboundClaims=false (keep short claim names as-emitted).
- FileSystemHelpers: EnsureDestinationDirectory uses GetUserName()
  (the "name" claim) instead of User.Identity.Name for the per-user
  storage root.

Tests:
- TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror
  production shape (fixes FileSystemApiTests upload 500).
- Remoting: PasswordToken_carries_name_claim_equal_to_login mints a
  real JWT via the IS token endpoint (password grant, openid profile)
  and asserts the access token carries name=login and GetUserName()
  resolves to it.
- WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the
  inverse of Duende); allow openid+profile on the test client; re-seed
  the openid/profile identity resources in ReseedAuthTestData (a
  ResetAndMigrateDatabase wipes the shared in-memory store and previously
  left them gone → invalid_scope).
- BillingServiceTests: [Collection("Yavsc Server")] to serialize with
  the host build that mutates the static BillingService/Config
  registries (pre-existing parallel race that the new test's timing
  surfaced).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
8d2d6754f6
PostIt: negotiate HTTP/3 (QUIC) for Yavsc API calls
Set DefaultRequestVersion = 3.0 and DefaultVersionPolicy =
RequestVersionOrLower on the shared YavscApiClient.Http instance, so all
Yavsc.Api.Client traffic prefers HTTP/3 against the nginx edge vhosts
(listen 443 quic ; http3 on ; Alt-Svc) and falls back to h2/h1.1 where
QUIC isn't available (WASM fetch, Android/desktop without libmsquic).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 19:12:47 +01:00
94d8eb538c
CHANGELOG: add 1.0.8-rc17 section
All checks were successful
Forgejo Release / release (push) Successful in 9m46s
Dotnet build and test / build (pull_request) Successful in 10m42s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:42:01 +01:00
daf5949392
contrib: gate QUIC reuseport on production + Org only
reuseport is legal only once per addr:port (443/udp). preprod and prod
share the same IP today, so enabling it on preprod-Org clashed with
prod-Org and made nginx refuse the config. QUIC_OPTS now resolves to
" reuseport" only when APP_INSTALL_ENV=production AND the host is Org;
preprod and the Api/Blogs vhosts get a plain `listen 443 quic;`.

Also drop the stray space before `;` when QUIC_OPTS is empty by moving
the separator into the QUIC_OPTS value (`listen 443 quic$QUIC_OPTS;`).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 18:32:44 +01:00
2c8be78175
presentation 2026-09-25 17:23:27 +01:00
e1ce67a76e
laucnh my Debug session 2026-09-25 17:23:15 +01:00
384f6bc300
IBlogPost porte IsPublished : le endpoint liste ne le lâche plus sur le wire
Le blog API (Yavsc.Blogs/Program.cs) sérialise en System.Text.Json
(.AddControllers() par défaut), et BlogApiController.GetBlogspot retourne
IEnumerable<IBlogPost>. STJ n'émet que les membres du type déclaré
(l'interface), donc IsPublished — qui n'était que sur les classes
concrètes BlogPost / BlogPostDto — était dropped du payload liste.
PostIt lisait alors IsPublished=false pour tout billet, y compris
publiés (cas prod /BlogSpot/Details/13).

Fix : déclarer IsPublished sur IBlogPost. BlogPost et BlogPostDto
l'implémentent déjà. Tests de wire-shape
(IBlogPostWireShapeTests) ajoutés pour verrouiller le contrat au
niveau du boundary.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 15:11:25 +01:00
a17a9cef9a
ApiWebServerFixture : connexion admin Npgsql depuis l'env, plus de mot de passe en source
Applique le même traitement que ServerServicesFixture : la chaîne de
connexion admin PostgreSQL se lit depuis YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION
au lieu d'être codée en dur (mot de passe dev 8*5idas retiré). Sans cette
variable, le chemin Npgsql échoue avec un message explicite plutôt que de
retomber silencieusement sur la base de dev locale.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 15:11:00 +01:00
ed3dc733d3
Projet de tests Yavsc.Server.Tests : BlogSpotService.Details
Ajoute un projet de tests service-level pour BlogSpotService.Details
(owner/public/sponsor/Administrator + ACL scrub + IsPublished + auteurs
de commentaires). Le fixture supporte SQLite (défaut) et PostgreSQL via
YAVSC_SERVER_TEST_DB_PROVIDER=npgsql, sur le modèle de
ApiWebServerFixture (migrations Yavsc.Org, base dédiée yavscServerTestDb,
reset par purge FK-ordonnée). La connexion admin Npgsql se lit depuis
YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION (aucun mot de passe en source).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 14:45:22 +01:00
5553f2f290
Génération contrib : vhosts nginx h3 edge + appsettings (jq) + QUIC logiciel Kestrel
Vhosts nginx (contrib/template.vhost + Makefile, envsubst allowlist) :
HTTP/3 edge per contrib/quic.md — listen 443 quic reuseport (Org only),
http2/http3 on, Alt-Svc, ssl_protocols TLSv1.3. Cibles generate-nginx /
generate-nginx-prod -> generated/nginx/ (gitignoré). Allowlist préserve
les variables nginx intrinsèques ($host, $scheme, ...).

Appsettings (contrib/template.appsettings-{org,api,blogs}.json + Makefile) :
génération via jq (et non envsubst) car le mot de passe SMTP contient un qui cassait le JSON. Substitue $DOMAIN (DOMAIN_ROOT) partout et injecte
$SMTP_PASSWORD depuis env (échappement JSON, secret non exposé en cmd).
Cible generate-appsettings -> generated/appsettings/.

QUIC logiciel : Kestrel:Endpoints:Https:Protocols = Http1AndHttp2AndHttp3
sur les 3 templates appsettings (capacité, non joignable derrière nginx).

Fix bonus : générateur systemd HTTP_PORT"$($*_PORT)" (était
"$*_$(HTTP_PORT)" -> port vide localhost:Org_).

Runbook contrib/README.nginx.md. Prérequis host : nginx http_v3_module,
UDP 443, libmsquic (Kestrel h3).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 13:31:56 +01:00
3fe7b8b46c
templates for appsettings 2026-09-25 13:27:00 +01:00
a793996b70
Fixes the swagger ui access
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m25s
2026-09-25 10:20:18 +01:00
8e3862ed26
Tests d'attachement (devis/demande) et de l'espace de stockage perso
Couverture des endpoints backend introduits pour la gestion des fichiers
utilisateur et des pièces jointes par référence.

Yavsc.Api.Test:
- EstimateAttachmentsApiTests : attachement de fichiers au devis
  (fournisseur uniquement, fichier devant appartenir au caller,
  idempotence du re-attach, détachement, listage ouvert aux deux
  parties + admin, tiers refusé, 404 sur devis inconnu).
- FrontOfficeQueryAttachmentsApiTests : attachement à la demande
  (client uniquement, fichier devant appartenir au caller, détachement,
  listage ouvert aux deux parties, tiers refusé).

Yavsc.Blogs.Tests:
- FileSystemApiTests : POST api/v1/fs crée une ligne UploadedFile
  propriétaire (avec FileId en retour) et écrit le fichier sur disque ;
  GET api/v1/fs/file/{fileId} autorise le propriétaire et une partie
  au devis rattaché (client/fournisseur), refuse un tiers, 404 sur id
  inconnu.

306/306 verts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 17:14:47 +01:00
70ab05522b
Gestion des fichiers utilisateur et pièces jointes (demande/devis)
Espace de stockage personnel géré depuis PostIt, avec attachement par
référence (sans duplication) des fichiers à la demande du client
(NominativeServiceCommand) et au devis du fournisseur (Estimate).

Backend:
- UploadedFile.OwnerId (FK ApplicationUser, nullable) ; les téléversements
  vers api/v1/fs créent/mettent à jour une ligne propriétaire.
- Tables de jointure EstimateAttachedFile et QueryAttachedFile (PK composite),
  migration UserFileAttachments.
- EstimateApiController: GET/POST/DELETE estimate/{id}/attachments
  (fournisseur uniquement, fichier devant appartenir au caller).
- FrontOfficeApiController: GET/POST/DELETE front/query/{id}/attachments
  (client uniquement).
- FileSystemApiController: GET fs/file/{fileId} sert les octets depuis le
  host Blogs (autorisation propriétaire ou partie au devis/demande via DB
  partagée) — le host API ne gère que les métadonnées, contrainte des
  conteneurs docker à disques distincts.

Client .NET:
- UserFilesApiClient (list/upload/delete/download) pointant vers BlogsApiUrl.
- DTOs UserDirectoryDto/FileEntryDto/AttachmentDto/FileReceivedDto/UploadFile.
- Méthodes d'attachement sur EstimateApiClient et FrontOfficeApiClient.

PostIt (Avalonia):
- Page « Mes fichiers » (naviguer/téléverser/télécharger/supprimer), servant
  aussi de sélecteur pour l'attachement.
- Section pièces jointes dans l'édition du devis (fournisseur) et le détail
  de la demande (client), avec ajout depuis My Files, détachement,
  téléchargement.
- Enregistrement DI + mapping ViewLocator + bouton d'accès.

Tests:
- EstimateEditionPageViewModelTests mis à jour (nouveau ctor fsClient,
  historique d'appels du stub pour les assertions post-envoi).

Nommage HTTP/méthodes en anglais, documentation en français.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 16:35:36 +01:00
eb58fc8862
Merge branch 'main' into feature/estimate
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m38s
2026-09-24 15:10:15 +01:00
040446c0b8
ignore scan reports
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-24 15:09:48 +01:00
bad09cc7af
test 2026-09-24 15:08:56 +01:00
2c36d880e1
Gitleaks integration
All checks were successful
Dotnet build and test / build (push) Successful in 11m52s
2026-09-24 14:50:56 +01:00
f0d262cb37
test 2026-09-24 14:50:55 +01:00
1cf3c689d8
Secret scan 2026-09-24 14:50:28 +01:00
fbf42cc1db
version update
All checks were successful
Forgejo Release / release (push) Successful in 8m17s
Dotnet build and test / build (pull_request) Successful in 6m2s
2026-09-22 18:39:19 +01:00
ba40698cc9
CHANGELOG: add 1.0.8-rc16 section
Describes the estimate signature + TeX/PDF generation release impact:
signature pad on validation, role-aware accept/reject with signatures,
ProAccepted/ClientAccepted split, estimate.tex/estimate.pdf endpoints
in Yavsc.Api, vector TikZ signatures, PostIt PDF/TeX download buttons,
list refresh + title renames, the lualatex stdin runtime, and the
Emergency stop / log-write / RenderViewToString / Layout fixes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:35:48 +01:00
35d784fdf1
Fix lualatex stdin PDF gen + unit test for signed estimate PDF
GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex
treats stdin as a regular input file instead of the terminal — without
it, lualatex emergency-stops after the first line of a multi-line
document (the real root cause of the production "Emergency stop", not
bera/luaotfload which were separate host-package gaps already fixed).

EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the
returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is
extracted into its own file under ViewModels/FrontOffice.

Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector
draws of both signatures) and GenerateEstimatePdf on a devis signed by
both parties — asserts the PDF exists at the expected DestDir path, is
non-empty, and that only the PDF persists (aux/log cleaned). Skips
cleanly when lualatex is absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:33:47 +01:00
9c1f552bca
Draw estimate signatures as vector TikZ instead of PNG
The estimate foot now renders the provider and client signatures as
TikZ \draw/\fill paths straight from the captured stroke data, in two
side-by-side minipages with a \useasboundingbox so the boxes align
regardless of stroke coverage. A side that hasn't signed shows a
signature rule.

- TeXHelpers.SignatureToTikz walks the PostIt wire format
  ([k, x0,y0, ...] per stroke, coords in [0, CoordinateMax], y-down)
  and emits one \draw per stroke (round caps/joins, 1.5pt), flipping y
  so the signature is upright on paper; a single-point tap becomes a
  filled dot, mirroring the Avalonia round-capped Polyline.
- FrontOfficeApiController.LoadEstimateForRenderAsync includes
  Estimate.Signatures so SignaturePro/SignatureClient resolve.
- Estimate_tex.cshtml drops the \includegraphics of a PNG (which
  failed the build when the file was absent) and adds \usepackage{tikz}.
- README: document texlive-pictures (tikz) and texlive-fonts-extra
  (bera) as runtime deps; bera.sty missing is the likely cause of the
  remaining host Emergency stop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:45:05 +01:00
bbc37d3095
corrige la génération du Pdf 2026-09-22 16:34:51 +01:00
7caae103fe
PDF gen: capture lualatex stdout/stderr into the error message
The detailed LaTeX error (missing .sty, missing \includegraphics
target, …) was being lost: we only redirected stdin, and the engine
writes its diagnostics to the .log file we delete in cleanup. So a
generation failure surfaced only as the terse "Emergency stop" in the
journal, with no clue what actually failed.

Redirect stdout and stderr, and on non-zero exit include them in
GenerationErrorMessage so the caller sees the real engine diagnostic.
Keep the .log file on failure (only clean it on success) so it can be
inspected alongside.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:31:10 +01:00
f44dab0334
README: document lualatex runtime deps for PDF generation
texlive-binaries (lualatex) + texlive-luatex (luaotfload) are required
on the API host for estimate/bill PDF generation. Note that luaotfload
moved from texlive-latex-extra into texlive-luatex on recent Debian.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:28:52 +01:00
97d0584292
Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on
  ProviderValidationDate so a provider's signed estimate leaves their
  list; PostIt refreshes the list on navigation pop-back.
- Title renames: "Devis en attente de signature fournisseur/client"
  on both the provider and client sides (HomePage, VM, tests).
- Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into
  Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent,
  Bill/Estimate templates, _ViewImports). Program.cs wires
  AddControllersWithViews + SiteSettings; FrontOfficeApiController
  injects IRazorViewEngine (cast to IViewEngine) with optional deps
  and exposes estimate.tex/estimate.pdf endpoints keyed by query id.
- PDF generation drives through the Estimate_pdf view (side-effect
  render) and compiles via the system lualatex, fed TeX on stdin (no
  intermediate .tex), PDF to the bills dir, aux artifacts cleaned so
  only the .pdf persists. Resolve the bills dir to an absolute path so
  lualatex's -output-directory and working directory agree regardless
  of the host cwd (fixes "I can't write on file estimate-1.log").
- PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync;
  Télécharger PDF/TeX buttons on the validation and command-detail
  pages via a StorageProvider save helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:22:01 +01:00
70a123b840
CLI options : -c configurqtion-file.json 2026-09-21 18:53:17 +01:00
adc27a006b
Reopen estimate: carry signature strokes on the estimate payload
Reopening an already-validated estimate showed an empty pad: the
strokes were stored server-side but never sent back. Embed them in the
estimate payload and load them straight into the pad, in the same wire
format PostIt captured — no separate fetch.

- Server: Signature inverse navs get STJ [JsonIgnore] (cycle-safe
  under System.Text.Json, matching ApplicationUser); the four estimate
  read endpoints Include(e => e.Signatures) so strokes ride the payload
- Client: EstimateDto.Signatures with EstimateSignatureDto (Strokes,
  Type=Pro/0|Client/1, CoordinateMax, CapturedAtUtc)
- PostIt: SignaturePadControl.Load(int[]) replaces the buffer and
  raises RedrawRequested; EstimateValidationPageViewModel.LoadExisting
  Signature() picks the perspective's side off the estimate and loads
  it on Attach
- Tests: server asserts GET estimate/{id} carries the strokes; PostIt
  VM tests cover load + side-by-perspective

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 18:03:08 +01:00
827fe6b41f
Split QueryStatus.Accepted into ProAccepted/ClientAccepted
Distinguish the two parties' acceptances: ProAccepted (provider) and
ClientAccepted (client) replace the single Accepted value for new
acceptances. Accepted=2 is retained as a legacy value so existing DB rows
keep their integer mapping.

- FrontOfficeApiController.AcceptQuery sets status by ActorRole
- BillingController provider-ongoing filters match the new values
- FrontOfficeController (MVC) dashboard counts use ProAccepted||Accepted
- PostIt: IsOngoingStatus, GetStatusRank (client-accepted ranks first for
  the pro), and status-badge switches (P/C glyphs) handle the new values
- Tests assert ProAccepted (provider) vs ClientAccepted (client)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:32 +01:00
36ffe4683c
PostIt: validate/refuse an estimate with a signature (pro or client)
Adds the client-side ability to open an estimate from the list, draw a
signature on a reusable SignaturePadView control, and accept or reject it
against /api/v1/front/query/accept|reject. The server resolves the actor
role (provider vs client) from the caller's identity, so the same page works
for either party.

- Yavsc.Api.Client: QueryAcceptanceDto + FrontOfficeApiClient (accept/reject)
- PostIt: extract SignaturePadView UserControl from SignaturePage; new
  EstimateValidationPage (VM + view) with Valider/Refuser/Effacer/Retour;
  wire OpenEstimate entry point from EstimateListPage; register client/page
  in DI + ViewLocator
- Tests: FrontOfficeApiClient + EstimateValidationPageViewModel unit tests;
  update EstimateListPageViewModelTests ctor

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:18 +01:00
2ad7a55e34
Add missing Account/Delete confirmation page
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m44s
The POST Delete(UnregisterViewModel) action and the GET Delete() action
both existed in AccountController, but Views/Account/Delete.cshtml was
missing — so GET /Account/Delete had no page to render. Add the
confirmation view (posts back to Delete with the user's UserId, with a
"Confirmer la suppression" button and an "Annuler" cancel link).

Add a smoke test: an authenticated GET /Account/Delete returns 200 and
the confirmation page body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:31 +01:00
709846c58d
Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00
9aef10f281
make reinstall, better
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m56s
2026-09-21 14:58:40 +01:00
e28959b068
asciidoc => markdown 2026-09-21 14:39:25 +01:00
22c33c5388
Hide inserted estimates from client list; redirect anon to /signin
Estimates with status Inserted (provider hasn't worked on them yet)
were showing up in the client's "Mes devis à valider" list. The
asclient endpoint now requires ProviderValidationDate to be set, so
only estimates the provider has actually established and submitted
are visible to the client. The asprovider endpoint is unchanged
(providers still need to see incoming unprocessed requests).

Also fix the anonymous-access redirect on Yavsc.Org: the application
cookie's LoginPath defaulted to /Account/Login, which has no
controller. Anonymous hits to a restricted page now redirect to the
real /signin endpoint (with /signout and /Account/AccessDenied set
to match the existing controller routes).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:38:49 +01:00
60c10781f0
deploy in pre and prod : bug fixes
All checks were successful
Dotnet build and test / build (pull_request) Successful in 16m30s
2026-09-21 12:49:38 +01:00
625e062b4b
Fix white-on-white comment blocks in quartz theme
Quartz sets white body text on a coloured page background, but
--bs-tertiary-bg is near-white in its light variant. The .blogcomment
background now uses a semi-transparent black overlay so white text
stays legible, matching the dropdown hover fix.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:29:33 +01:00
1455ba5187
Restore tree-structured comments in Blog Details
The $.psc.blogcomment widget (from the removed wwwroot/js/comment.js)
provided reply-to-comment nesting. Rewrite Details.cshtml to drive the
same /api/v1/blogcomments endpoint with ParentId, delegating reply
clicks so they work across the whole nested tree.

Render comment metadata server-side in the Comment view component
template (the widget used to add it client-side) and Include Author so
nested comments get their author instead of relying on the request-scoped
identity map.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:21:33 +01:00
605d885917
Rewrite Blogspot Index view with skip/take pagination
Renders Prev/Next pager from the Index(string id, int skip, int take)
call profile, drops dead .sametitle JS, and replaces the redundant
ReadPermission branch and Minutes>0 modifié check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:12:20 +01:00
efc2296858
The blogspot iis now in the PostIt app,
All checks were successful
Dotnet build and test / build (pull_request) Successful in 21m55s
no more creation/edition from here
2026-09-21 10:40:49 +01:00
6a69a20364
accessibility 2026-09-21 10:38:09 +01:00
f09c657183
reinstall-prod target 2026-09-21 10:37:39 +01:00
93fd4e61ad
code cleaup
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m25s
2026-09-20 21:06:02 +01:00
d5f11e83a4
reprod deployment 2026-09-20 18:35:27 +01:00
11ae66f85b
--stop-on-fail
All checks were successful
Dotnet build and test / build (pull_request) Successful in 11m44s
2026-09-20 14:12:08 +01:00
8d1348197d
test is ialready skipped when adb is not available
Some checks failed
Dotnet build and test / build (pull_request) Failing after 13m48s
2026-09-20 13:42:45 +01:00
483abb78ec
nuget referencezs updated
Some checks failed
Dotnet build and test / build (pull_request) Failing after 15m42s
2026-09-20 01:53:20 +01:00
b3c2674235
Use AvaloniaEdit 2026-09-20 00:21:07 +01:00
db5c202918
Publish, bug fix 2026-09-19 22:20:52 +01:00
1773c5aaea
refacto publication 2026-09-19 21:46:08 +01:00
67da921bce
Blog Post Id 2026-09-19 21:08:10 +01:00
fb0d9589cf Merge pull request 'Estimate managment from PostIt' (#53) from feat/sign-estimate into main
All checks were successful
Dotnet build and test / build (push) Successful in 14m55s
Reviewed-on: #53
2026-09-19 18:39:25 +01:00
c450bd7ff8
Updated
Some checks failed
Dotnet build and test / build (pull_request) Failing after 16m28s
2026-09-19 18:09:02 +01:00
827cf7d2a3
Org Markdown : Markdig
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m22s
2026-09-19 18:00:09 +01:00
44f0664a5d
optimisation of workflows : Skiasharp deps already installed
All checks were successful
Dotnet build and test / build (pull_request) Successful in 11m20s
Forgejo Release / release (push) Successful in 6m11s
2026-09-19 16:37:57 +01:00
a12830e49b
test(blogs): configure SiteSettings.Blog in MappedClaimsBlogsWebServerFixture
Some checks failed
Forgejo Release / release (push) Failing after 6m42s
Dotnet build and test / build (pull_request) Failing after 5m30s
BlogSpotService's constructor throws InvalidOperationException
("SiteSettings.Blog is not configured.") when IOptions<SiteSettings>.Blog
is null, so every request to BlogApiController 500'd resolving the
service — all 3 BlogApiMappedClaimsTests were failing with
InternalServerError.

The mapped-claims fixture never configured SiteSettings (the main
BlogsWebServerFixture does, pointing Blog at a per-run temp dir).
Mirror that: Configure<SiteSettings>(s => s.Blog = <temp dir>)
before AddScoped<BlogSpotService>(). These tests are JSON-only
(AttachFiles short-circuits), but the service must be constructible.

Verified: BlogApiMappedClaimsTests 3/3 green; full Yavsc.Blogs.Tests
37/37 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 14:14:28 +01:00
2d8f92dde9
build: disable StaticWebAssets cache to stop MSB4018 race
DefineStaticWebAssets.ReadOrCreateCache opens the *.dswa.cache.json
files under obj/.../ via File.OpenRead with no try/catch, so under
parallel solution builds a sharing-violation / TOCTOU race on the cache
file escapes as MSB4018 and fails the whole build (seen on
Yavsc.Blogs during the rc15 solution build).

Set StaticWebAssetsCacheDefineStaticWebAssetsEnabled=false so
CacheManifestPath is empty and ReadOrCreateCache short-circuits to a
no-file-I/O path. The cache is a pure build-speed optimization; no
effect on output correctness, only on incremental rebuild speed of the
wwwroot-light web projects.

Verified: Yavsc.Api (transitive web deps) and Yavsc.Blogs.Tests build
clean, and zero *.dswa.cache.json files are produced.

Refs:
  https://source.dot.net/Microsoft.NET.Sdk.StaticWebAssets.Tasks/DefineStaticWebAssets.Cache.cs.html
  https://github.com/dotnet/sdk/issues/49039

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 13:53:25 +01:00
156b9c46e1
changelog: add 1.0.8-rc15 entry
Cover the 60 commits since 1.0.8-rc14: blog attachments + Markdown
preview, the Estimate pipeline (list/edition/validation + API + EF
migrations), provider ongoing requests, file-ACL and genericEstimate
migrations, POSTIT_SETTINGS_JSON, billing filtered by user, shared
YavscMessageSender, Gitleaks CI, and the test stabilization wave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 12:56:02 +01:00
1a968346a4
preview the Markdown
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m16s
2026-09-18 19:45:35 +01:00
574616c786
+MarkdownViewer
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m45s
2026-09-18 13:58:31 +01:00
bc6bebea06
post the file
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-18 13:38:42 +01:00
9e8d00d78f
corrige la création du lien
All checks were successful
Dotnet build and test / build (pull_request) Successful in 15m51s
2026-09-18 12:13:00 +01:00
ac1ba2ee68
test: cover BlogApiClient.CreateMultipartContent shape + server round-trip
Expose CreateMultipartContent as public so the blogs backend tests can
assert on its wire shape directly and round-trip it through the live
BlogsWebServerFixture host. New CreateMultipartContentTests:
- shape: blog part is camelCase JSON with WhenWritingNull, one file
  part per attachment (filename, Content-Type, octet-stream fallback);
- round-trip: POST the built body to /api/v1/blogspot, assert 201
  Created + attachment row + file landed under BlogFilesRoot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-18 11:39:45 +01:00
eebd46176d
-Config.Temp
All checks were successful
Dotnet build and test / build (pull_request) Successful in 12m53s
2026-09-18 11:13:44 +01:00
3bc4142383
refacto site settings
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m29s
2026-09-17 23:54:36 +01:00
3e7e76edf2
BlogAttachmentLinkTests
All checks were successful
Dotnet build and test / build (pull_request) Successful in 16m9s
2026-09-17 00:57:52 +01:00
96254b52cd
Supprime un faux message d'erreur
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m27s
2026-09-16 19:48:13 +01:00
d47dd0f4ba
better indications
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m36s
2026-09-16 18:53:17 +01:00
51a2a40c88
new defaults
All checks were successful
Dotnet build and test / build (pull_request) Successful in 21m36s
2026-09-16 18:23:52 +01:00
2b1c3d2a9b
code cleanup 2026-09-16 18:23:42 +01:00
ef9ee2fb87
fixes the upload 2026-09-16 18:23:27 +01:00
110ed279f5
Estimate lists
All checks were successful
Dotnet build and test / build (pull_request) Successful in 18m1s
2026-09-15 00:19:23 +01:00
48cacb74cb Merge pull request 'feat/files-control' (#52) from feat/files-control into main
Some checks failed
Dotnet build and test / build (push) Failing after 8m49s
Reviewed-on: #52
2026-09-14 10:20:31 +01:00
48ffe835df
install native dependencies
All checks were successful
Dotnet build and test / build (pull_request) Successful in 11m31s
2026-09-14 10:07:10 +01:00
2cd24dd383
fixes the pipeline
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m44s
2026-09-14 00:47:28 +01:00
c3c5494507
removes the ItemGroup.Using Include=Xunit
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-14 00:40:30 +01:00
75e1499bb1
build before testing (sunit not found by MSBuild task)
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m49s
2026-09-14 00:02:54 +01:00
0c5bd61991
Merge remote-tracking branch 'origin/main' into feat/files-control
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m26s
2026-09-13 23:39:41 +01:00
2484876c5b
The Estimate is validated
Some checks failed
Dotnet build and test / build (pull_request) Failing after 7m28s
2026-09-13 23:29:06 +01:00
848f8be42f
Estimate UI
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m20s
2026-09-13 18:54:27 +01:00
f70f487777
better and simpler UI 2026-09-13 17:51:42 +01:00
1af839e14e
POSTIT_SETTINGS_JSON env var
Some checks failed
Dotnet build and test / build (pull_request) Failing after 10m54s
2026-09-13 13:34:39 +01:00
f7e1f4a736
list only my billing codes
Some checks failed
Dotnet build and test / build (pull_request) Failing after 11m23s
2026-09-13 03:49:07 +01:00
7c565d3af9
take the scanning in account 2026-09-13 03:25:09 +01:00
9832d28173
use Sqlite 2026-09-13 03:22:22 +01:00
cbb59f019d
got saved a RdvQuery 2026-09-13 03:09:12 +01:00
67715f9d67
tests en vert
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5m36s
2026-09-12 17:31:18 +01:00
d92456e141
fixe tests
Some checks failed
Dotnet build and test / build (pull_request) Failing after 6m27s
2026-09-12 16:49:36 +01:00
5b48e0bfdb
GetOpenIdConfiguration_returns_ok against testing host 2026-09-12 14:30:50 +01:00
3836faf872
Use a shared YavscMessageSender 2026-09-12 13:57:25 +01:00
2397f4d3a8
Add provider ongoing requests flow and sort persistence 2026-09-12 13:29:49 +01:00
86091990c5
generic estimate 2026-09-12 12:59:44 +01:00
ddff760c41
fixes the tests
Some checks failed
Dotnet build and test / build (pull_request) Failing after 8m6s
2026-09-11 18:37:28 +01:00
344c38704b
MainPage => BlogsPage 2026-09-11 18:37:26 +01:00
af1a0e0bf9
Bad test, fix the test 2026-09-11 18:37:25 +01:00
c665a3e95d
Test the release, don't ignore test return code 2026-09-11 18:37:24 +01:00
3824a928fe
Bad test, fix the test
Some checks failed
Dotnet build and test / build (push) Failing after 7m26s
2026-09-11 13:42:29 +01:00
77e521b96d
Test the release, don't ignore test return code
Some checks failed
Dotnet build and test / build (push) Failing after 3m7s
2026-09-11 13:25:32 +01:00
91fc7f168b
implements a dico 2026-09-11 12:45:13 +01:00
3626be3d8b
We don't need these "release" branches
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m23s
2026-09-11 11:01:46 +01:00
7b842a9fe9
user the CommandBar
All checks were successful
Dotnet build and test / build (pull_request) Successful in 9m17s
2026-09-07 20:53:23 +01:00
b75d0add7d
db testing 2026-09-07 20:02:56 +01:00
555087232c
Removes an abusive assertion, that went wrong
All checks were successful
Dotnet build and test / build (pull_request) Successful in 10m58s
2026-09-07 19:16:23 +01:00
41d8fad8c3
Post Files 2026-09-07 15:41:33 +01:00
d8c0678462
code reorg 2026-09-07 14:39:53 +01:00
93426986f6
code format 2026-09-07 14:12:34 +01:00
ec87e8db15
scan the commit
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m30s
2026-09-07 13:20:50 +01:00
bb4792d8ae
Gitleaks integration
All checks were successful
Dotnet build and test / build (pull_request) Successful in 3m31s
2026-09-07 12:56:48 +01:00
f7d5b21d66
cleanup 2026-09-07 12:28:24 +01:00
e79fba7b66
retest
All checks were successful
Dotnet build and test / build (pull_request) Successful in 2m17s
2026-09-07 12:21:38 +01:00
d65f78d985
test
Some checks failed
Dotnet build and test / build (pull_request) Has been cancelled
2026-09-07 12:20:55 +01:00
7a4ca76957
picket scan
All checks were successful
Dotnet build and test / build (pull_request) Successful in 2m43s
2026-09-07 12:08:57 +01:00
c902852a24
Secret scan
Some checks failed
Dotnet build and test / build (pull_request) Failing after 5s
2026-09-07 11:57:00 +01:00
38bc659a58
CircleAuthorizationToFile EF migration
All checks were successful
Dotnet build and test / build (pull_request) Successful in 6m15s
2026-09-07 11:02:41 +01:00
10fdcb288c
unique email handling 2026-09-07 10:58:20 +01:00
229971783a
code reorg 2026-09-07 10:30:45 +01:00
445 changed files with 64159 additions and 5153 deletions

View file

@ -15,7 +15,7 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=db POSTGRES_DB=db
POSTGRES_USER=pguser POSTGRES_USER=pguser
POSTGRES_PASSWORD=pgpassword POSTGRES_PASSWORD=<your pg password>
ANTHROPIC_API_KEY=none ANTHROPIC_API_KEY=none
ANTHROPIC_MAX_TOKENS=0 ANTHROPIC_MAX_TOKENS=0

View file

@ -19,20 +19,21 @@ on:
required: false required: false
type: boolean type: boolean
push: push:
branches: [ "main" ] branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
pull_request: pull_request:
branches: [ "main", "release/*" ] branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
jobs: jobs:
build: build:
runs-on: docker runs-on: docker
container: container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
steps: steps:
- name: Clone yavsc - name: Clone yavsc
run: | run: |
set -e
cd /src cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
cd _src cd _src
if [ -n "${GITHUB_REF:-}" ]; then if [ -n "${GITHUB_REF:-}" ]; then
git fetch origin "$GITHUB_REF" git fetch origin "$GITHUB_REF"
@ -40,12 +41,26 @@ jobs:
fi fi
git submodule update --init --recursive git submodule update --init --recursive
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)" echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
- name: Secret scan
run: |
echo "🔍 Scanning for secrets..."
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \
- name: Build
run: |
echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal
- name: Test - name: Test
run: | run: |
echo "🚀 Lancement des tests..." echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \ cd /src/_src && dotnet test \
--verbosity normal \ --report-xunit \
--filter="Category!=Platform-Android" \ --report-xunit-html \
--logger "xunit;LogFileName=test-results.xml" \ --report-xunit-html-filename "test-results.html"
&& echo "✅ Success !" || echo "❌ Fail ($?)!" TEST_EXIT_CODE=$?
echo "Test exit code: $TEST_EXIT_CODE"
if [ $TEST_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed with exit code $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE
else
echo "✅ All tests passed successfully."
fi

View file

@ -52,7 +52,7 @@ jobs:
release: release:
runs-on: docker runs-on: docker
container: container:
image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1 image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4
steps: steps:
- name: Clone du repo au tag demandé - name: Clone du repo au tag demandé
env: env:
@ -69,7 +69,7 @@ jobs:
cd /src cd /src
if [[ ! -d _src/.git ]]; then if [[ ! -d _src/.git ]]; then
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
fi fi
cd _src cd _src
@ -200,6 +200,7 @@ jobs:
RELEASE_BODY: ${{ env.RELEASE_BODY }} RELEASE_BODY: ${{ env.RELEASE_BODY }}
IS_PRERELEASE: ${{ env.IS_PRERELEASE }} IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
run: | run: |
set -e
if [[ -z "$TAG" ]]; then if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved for the API call." echo "::error::No tag resolved for the API call."
exit 1 exit 1

3
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,3 @@
# Conventions du projet
- Par defaut, mettre a jour [CHANGELOG.md](../CHANGELOG.md) a chaque commit, en respectant sa structure existante et en documentant les changements du commit. Si aucune entree n'est pertinente, expliquer explicitement cette exception avant de committer.

View file

@ -8,8 +8,7 @@ on:
jobs: jobs:
production-deploy: production-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-26.04
steps: steps:
# 1. Récupération du code # 1. Récupération du code
- name: Check out the repo - name: Check out the repo
@ -25,8 +24,24 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 10.0.x
- name: Workloads restore
run: dotnet workload restore
- name: Test - name: Test
run: dotnet test --no-build --verbosity normal --filter="Category!=Platform-Android" env:
TEST_FAILED: ""
run: make test || export TEST_FAILED=$?
- uses: actions/upload-artifact@v7
with:
name: my-artifact
path: test-reports/**/*
- name: Fail if tests failed
if: ${{ env.TEST_FAILED != '' && env.TEST_FAILED != '0' }}
run: exit $TEST_FAILED
# 4. Build et Push de l'image de production finale # 4. Build et Push de l'image de production finale
- name: Build and push production image - name: Build and push production image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7

6
.gitignore vendored
View file

@ -43,3 +43,9 @@ DataDir/
*.log *.log
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
# Exclude test reports (local test report, not for the repo)
/test-reports
/doc/**/.*.md

45
.gitleaksignore Normal file
View file

@ -0,0 +1,45 @@
# Exclure uniquement les dossiers de sortie de compilation
bin/
obj/
src/*/bin/
src/*/obj/
test/*/bin/
test/*/obj/
# Toolchain front (Node / esbuild)
node_modules/
build/
package-lock.json
# Exclure les caches lourds
.git/
.vs/
.env
.*.env
*.csproj.lscache
data/
appsettings.*.json
appsettings-*.*.json
# Exception: the Testing-environment override for Yavsc.Org is a tracked
# configuration source, not a secrets file. TestWebApplicationFactory
# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
# AddConfiguration("org") in Program.Main loads this file as the
# last in the chain (it is optional). It overrides the connection
# string and SMTP section for the in-memory test host and contains
# no production secrets.
!src/Yavsc.Org/appsettings-org.Testing.json
generated/
*.tmp
tmp/
DataDir/
*.tests.trx
*.tests.html
*.log

6
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,6 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks

45
.vscode/launch.json vendored
View file

@ -4,13 +4,27 @@
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "PostIt Desktop",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj"
},
{
"name": "PostIt Desktop local",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/PostIt/PostIt.Desktop/bin/Debug/net10.0/PostIt.Desktop.dll",
"env": {
"POSTIT_SETTINGS_JSON": "/home/paul/Workspace/yavsc/src/PostIt/PostIt/postit-settings.json"
},
"preLaunchTask": "dotnet: build-postit-desktop"
},
{ {
"name": "Android Debug", "name": "Android Debug",
"type": "mono", "type": "maui",
"preLaunchTask": "run-debug-android", "request": "launch",
"request": "attach", "project": "${workspaceFolder}/src/PostIt/PostIt.Android/PostIt.Android.csproj"
"address": "localhost",
"port": 55555
}, },
{ {
"name": "Android Attach - Debug", "name": "Android Attach - Debug",
@ -20,16 +34,22 @@
"port": 55555 "port": 55555
}, },
{ {
"name": "API", "name": "Yavsc API",
"type": "dotnet", "type": "dotnet",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Api/Api.csproj" "projectPath": "${workspaceFolder}/src/Yavsc.Api/Yavsc.Api.csproj"
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc Org",
"type": "dotnet", "type": "coreclr",
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj", "program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"cwd": "${workspaceFolder}/src/Yavsc.Org",
"preLaunchTask": "build-web-yavsc-org"
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc Blogs",
@ -37,12 +57,7 @@
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj" "projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
}, },
{
"name": "PostIt Desktop",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
},
{ {
"name": "Test PostIt.Android launch (Xamarin.UITest)", "name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr", "type": "coreclr",

View file

@ -45,5 +45,10 @@
] ]
} }
}, },
"dotnet.defaultSolution": "yavsc.sln" "dotnet.defaultSolution": "yavsc.sln",
"axaml.selectedSolution": "/home/paul/Workspace/yavsc/yavsc.sln",
"chat.tools.terminal.autoApprove": {
"dotnet build": true,
"dotnet test": true
}
} }

96
.vscode/tasks.json vendored
View file

@ -1,27 +1,17 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"isRoot": true, "isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
}
],
"tasks": [ "tasks": [
{ {
"label": "run-debug-android", "label": "build-debug-android",
"command": "dotnet", "command": "dotnet",
"detail": "Build the PostIt Android project in debug mode",
"type": "shell", "type": "shell",
"runOptions": {
},
"problemMatcher": ["$msCompile"],
"options": { "options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
"env": { "env": {
@ -43,11 +33,15 @@
"label": "build", "label": "build",
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"], "args": ["build", "/property:GenerateFullPaths=true"],
"group": "build", "group": "build",
"isBuildCommand": true, "isBuildCommand": true,
"isTestCommand": false, "isTestCommand": false,
"isBackground": true "isBackground": true,
"options": {
"cwd": "${workspaceFolder}"
}
}, },
{ {
"label": "test blogs backend", "label": "test blogs backend",
@ -63,6 +57,28 @@
"isDefault": false "isDefault": false
} }
}, },
{
"label": "test api backend (npgsql)",
"type": "process",
"problemMatcher": "$msCompile",
"command": "dotnet",
"args": [
"test",
"Yavsc.Api.Test.csproj",
"-v",
"minimal"
],
"options": {
"cwd": "src/Yavsc.Api.Test",
"env": {
"YAVSC_API_TEST_DB_PROVIDER": "npgsql"
}
},
"group": {
"kind": "test",
"isDefault": false
}
},
{ {
"label": "build-webapi", "label": "build-webapi",
"type": "process", "type": "process",
@ -76,6 +92,50 @@
"kind": "build" "kind": "build"
}, },
"isBackground": true "isBackground": true
},
{
"label": "build-web-yavsc-org",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Org"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "build-web-yavsc-blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Blogs"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "dotnet: build-postit-desktop",
"type": "process",
"isBuildCommand": true,
"isTestCommand": false,
"isBackground": true,
"command": "dotnet",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"],
"options": {
"cwd": "src/PostIt/PostIt.Desktop"
},
"group": {
"kind": "build"
}
} }
] ]
} }

View file

@ -1,5 +1,504 @@
# Changelog # Changelog
## [1.0.8-rc25] - unstable
### Added
* [Yavsc.Api] Notification du performer à la création d'un rendez-vous
depuis PostIt, selon ses préférences de contact, sans supprimer l'envoi Org.
* [Yavsc.Api][Yavsc.Server] Notification du performer à la signature
client d'un devis (JSON ou image), enregistrée pour l'accueil PostIt
et transmise par e-mail et SignalR.
* [Yavsc.Api] Nouveau point d'entrée `GET /api/v1/notifications/me`
pour exposer les notifications publiques et ciblées à PostIt sur la
Home page ; les notifications d'administration restent filtrées.
* [PostIt] Affichage des notifications sur la page d'accueil, avec
actualisation, indicateur de chargement et message d'erreur. Les
notifications publiques sont accessibles sans connexion et la liste
est rechargée lors d'un changement de session.
### Changed
* [PostIt] Insertion des liens de fichiers dès leur ajout au brouillon ;
les liens provisoires des nouveaux billets sont résolus au premier
enregistrement. Save conserve l'édition ouverte et est mis en valeur
lorsque le titre, le corps ou les pièces jointes sont modifiés.
* [Projet] Instructions Copilot persistantes : mise à jour du changelog
par défaut à chaque commit, avec justification explicite des exceptions.
### Fixed
* [PostIt.Tests] Les tests du bandeau de session utilisent le bandeau
réellement monté dans la fenêtre headless, au lieu d'un contrôle détaché.
Les fenêtres sont fermées après chaque test et les quatre boutons
de navigation sont vérifiés.
* [PostIt] Les liens Markdown des pièces jointes utilisent désormais
le même filtre de nom que l'upload serveur, avant l'encodage URL.
Le libellé conserve le nom original, notamment ses accents.
* [Yavsc.Abstract][Yavsc.Blogs] Encodage des caractères non supportés
dans les noms de fichiers téléversés avec `_` au lieu de `#`, pour
éviter les fragments d'URL et permettre le téléchargement via les
fichiers statiques, notamment pour les noms accentués.
* [Yavsc.Api][Yavsc.Blogs] Les appels API et la bande passante sont
désormais enregistrés côté serveur pour les requêtes authentifiées :
le middleware de télémétrie est maintenant branché après
l'authentification, ce qui permet de comptabiliser `ApiCalls` et
`BandwidthMb` au lieu d'enregistrer des zéros.
## [1.0.8-rc24] - unstable
### Added
* [PostIt] Reconnection automatique aux salons de discussion au reconnect
### Changed
* [PostIt] La liste des billets et l'éditeur Markdown se partagent
l'espace disponible et défilent séparément ; un séparateur permet
de redimensionner les deux zones.
### Fixed
* [PostIt] Défilement des articles longs en édition et en aperçu :
suppression de la limite de hauteur qui masquait le contenu Markdown
et du défilement imbriqué autour d'AvaloniaEdit. Six tests headless
couvrent les barres automatiques, le redimensionnement et le
défilement indépendant de la liste.
## [1.0.8-rc23] - unstable
### Added
* [PostIt] Badge « Publié » à côté du titre des billets publiés dans la
liste, mis à jour lors de la publication ou du retour en brouillon.
* [PostIt] Gestion native du compte inspirée de `/Manage` : modification
du nom d'utilisateur, du nom complet et de l'adresse, téléversement
d'avatar, confirmation e-mail, préférence de courriel mensuel,
création ou changement de mot de passe et activation/désactivation
de l'authentification à deux facteurs.
* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des
comptes bancaires, dissociation des connexions externes et suppression
du compte avec confirmation du nom d'utilisateur.
* [PostIt] Configuration du profil professionnel et des activités,
navigation vers les cercles et les publications depuis le compte.
* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche
dans les métadonnées du profil et consultation de la consommation
de ressources.
* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de
ressources, API de synthèse et migration `ResourceUsageRecords`.
* [Tests] Couverture des parcours profil et professionnel, de l'API de
consommation de ressources et du renouvellement des jetons :
rotation à usage unique, appels simultanés, réponses 401 concurrentes
et rejet d'un jeton de renouvellement révoqué.
### Changed
* [PostIt] Réorganisation de la page de compte en sections de profil,
sécurité et préférences ; actions de modification désactivées tant
que le profil n'est pas chargé ou qu'une opération est en cours.
* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark`
dans les pages de profil et de configuration professionnelle.
* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par
PostIt, avec l'état de sécurité, les connexions externes, les comptes
bancaires et les compteurs du compte.
### Fixed
* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores
EF nécessaires à `UserManager<ApplicationUser>` : leur absence
empêchait l'activation du contrôleur de compte et provoquait une 500.
* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton
ne contient pas d'identifiant utilisateur ou que le compte n'existe
plus, au lieu d'une exception serveur.
* [PostIt] Synchronisation de tous les chemins de renouvellement des
jetons, y compris après une 401 et au démarrage : les appels
concurrents ne réutilisent plus le même `refresh_token`. Un rejet
`invalid_grant` efface la session et invite à se reconnecter.
* [PostIt] Effacement des informations obsolètes après un échec de
rechargement du profil et normalisation des valeurs de calendrier
lors de la sauvegarde.
* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel
lors de son enregistrement.
* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir
d'un résumé agrégé : opérations dédiées avec contrôle de propriété
et validation de longueur des champs.
### Known limitations
* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des
crédits ne sont pas encore disponibles dans l'interface native.
## [1.0.8-rc22] - unstable
### Fixed
User experience login into PostIt
## [1.0.8-rc21] - unstable
### Fixed
User experience editing the blog article.
## [1.0.8-rc20] - unstable
Chat SignalR fonctionnel de bout en bout (connexion, salons multiples,
envoi/réception de messages), enregistreur audio et localisation dans
PostIt, intégration Live.Avalonia démarrée, et reverse-proxy nginx
apprêté pour WebSocket.
### Added
* [PostIt] Interface de chat : page `ChatPage` (connexion au hub,
saisie du nom de salon, liste des messages et des membres présents),
entrée depuis la page d'accueil.
* [PostIt] Envoi de messages dans un salon joint : DTO `ChatMessage`
(Name/Room/Message) pour l'événement `ReceiveMessage`, saisie avec
envoi sur `Enter`, commande inactive tant qu'aucune salle n'est en
focus (le hub refuse l'envoi hors salon, `LabNoJoinNoSend`).
* [PostIt] Modèle multi-salles côté client : `JoinedRooms`,
`SelectedRoom` (salle en focus), messages et utilisateurs indexés par
salle (`FocusedMessages`/`FocusedUsers`), ComboBox de choix de la
salle active ; un `Join` sur une salle déjà jointe ne fait que
changer le focus.
* [PostIt] Enregistreur audio (`AudioRecorder`, interface
`IPCMProcessor`) et ressources de localisation
(`Resources.{resx,en-EN,pt-PT}`).
* [PostIt][WIP] Début d'intégration Live.Avalonia (preview XAML à
chaud ; encore KO).
* [contrib] Bloc `location /chathub` dans `template.vhost` : upgrade
WebSocket (`Upgrade`/`Connection`), `proxy_read_timeout 3600s` et
`proxy_buffering off` — sans quoi la connexion SignalR tombait au
premier silence ou l'upgrade échouait derrière nginx.
### Changed
* [PostIt] `StatusNotice` déplacé de `Models` vers `ViewModels` et
enrichi d'une commande d'action optionnelle (`CheckCommand` +
`CheckCommandLabel`, interface `IWithLabel`) : le bandeau de statut
propose désormais l'action contextuelle (Connecter / Déconnecter /
Reconnecter / Vérifier la connexion).
* [PostIt] `ChatViewModel` suit le cycle de vie complet de la
connexion : handlers `Reconnecting` / `Reconnected` / `Closed` de
`WithAutomaticReconnect`, pour refléter l'état réel au lieu d'un
`IsConnected` optimiste.
* [Yavsc.Api] `AddSignalR().AddJsonProtocol(IncludeFields = true)` :
`ChatRoomInfo` n'expose que des champs publics, que System.Text.Json
ne sérialise pas par défaut — le client recevait des objets vides
(`Name` null, `Users` vide) et la liste des salons joints n'était
jamais alimentée.
* [Yavsc.Server] `IConnexionManager.OnConnected` prend le `userName`
en paramètre (résolu depuis les claims dans `ChatHub`) au lieu de le
relire depuis un dictionnaire jamais alimenté.
* [PostIt.Android] `network_security_config.xml` déplacé sous
`Resources/xml/` (chemin canonique Android).
### Fixed
* [Yavsc.Server] `KeyNotFoundException` dans
`HubConnectionManager.OnConnected` à chaque connexion SignalR :
`ChatUserNames` (et `ChatCxIds`) n'étaient jamais alimentés — relique
d'un `SetUserName` disparu lors d'une refactorisation. L'exception
dans `OnConnectedAsync` faisait fermer la connexion par le hub, et
PostIt affichait « Déconnecté (The server closed the connection…) »
immédiatement après la connexion.
* [PostIt] URL du hub SignalR : retrait du préfixe `/api/v1` de
`ApiUrl` avant d'apposer `Constants.ChatHubPath` (le hub est mappé à
la racine du service, pas sous le préfixe versionné).
* [PostIt] La `StatusBar` de la page chat référençait `Grid.Row="4"`
sur une grille à 3 rangées (rangée hors limites, bandeau invisible).
## [1.0.8-rc19] - unstable
[PostIt] Blogs UX
### Fixed
La saisie du billet est maintenant plutôt possible,
en petit écran.
## [1.0.8-rc18] - unstable
* [PostIt] Page "About" avec lines vers le dépôt et les issues Forgejo
* Accès aux fichiers personnels PostIt rendu fonctionnel de bout en bout
(arbre de stockage indexé par le login, pas par le `sub`), boutons
d'action des listes réparés (binding `$parent`), revendication `name`
enfin présente dans les access tokens (branchement du `ProfileService`),
et négociation HTTP/3 côté client PostIt.
### Added
* [PostIt] Négociation HTTP/3 (QUIC) pour les appels API Yavsc :
`DefaultRequestVersion = 3.0`, `DefaultVersionPolicy =
RequestVersionOrLower` sur le `HttpClient` partagé de `YavscApiClient`,
avec repli h2/h1.1 où QUIC n'est pas disponible (WASM fetch, Android/
desktop sans libmsquic).
* [Tests] `MyFilesPageHeadlessTests` : monte `MyFilesPage` via
`NavigationPage` + `Window.Show()` et vérifie que chaque bouton de
ligne (Télécharger / Supprimer) a sa `Command` liée, est armé
(`CanExecute`) et déclenche l'appel API au clic.
* [Tests] `Remoting.PasswordToken_carries_name_claim_equal_to_login` :
mint un vrai JWT via le token endpoint IdentityServer (password grant,
`openid profile`) et asserte que l'access token porte `name`=login et
que `GetUserName()` résout vers ce login.
* [Tests] `FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login` :
découple `sub` et login, téléverse sous le login, et asserte que
`GET /api/v1/fs` retourne le fichier (et que le répertoire nommé d'après
le `sub` n'existe pas).
### Changed
* [Yavsc.Org] Branchement du `ProfileService` custom
(`AddProfileService<ProfileService>()` après
`AddAspNetIdentity<ApplicationUser>()`). La classe était du code mort :
IdentityServer8 retombait sur son profile service par défaut, qui
n'émet que les claims listés dans les `UserClaims` des API resources.
`name` n'en faisait jamais partie, donc tout access token était sans
`name` et `GetUserName()` résolvait à `null` sur les hosts ressources
(Blogs/Api) — cause du 500 sur l'upload MyFiles et du
`User.Identity.Name` injoignable. Le service émet désormais
`name`=`user.UserName`, `email` et les rôles pour tout client dont
les `AllowedScopes` contiennent `profile`, indépendamment du caller.
* [Yavsc.Server] `ServiceExtensions` : `NameClaimType =
JwtClaimTypes.Name`, `MapInboundClaims = false` (conserver les noms
courts de claims tels qu'émis).
* [Yavsc.Blogs] `FileSystemHelpers.EnsureDestinationDirectory` utilise
`GetUserName()` (claim `name`) au lieu de `User.Identity.Name` pour
la racine de stockage par utilisateur.
* [Tests] `WebServerFixture` : `AccessTokenType = 0` (Jwt dans IS8 ;
1 = Reference, l'inverse de Duende) ; `openid`+`profile` sur le client
de test ; réensemencement des identity resources openid/profile dans
`ReseedAuthTestData` (un `ResetAndMigrateDatabase` vide le store
in-memory partagé et les laissait absentes → `invalid_scope`).
* [Tests] `TestTokenIssuer.Issue` gagne un paramètre `name` optionnel
(défaut = subject) pour refléter la forme production où
`sub` (GUID) ≠ `name` (login).
* [Tests] `BillingServiceTests` : `[Collection("Yavsc Server")]` pour
sérialiser avec le build d'hôte qui mute les registres statiques
`BillingService`/`Config` (race parallèle pré-existante).
### Fixed
* [Yavsc.Blogs] La liste `GET /api/v1/fs` cherchait sous
`{Blog}/{sub-GUID}` (`User.GetUserId()`), alors que l'arbre de
stockage perso est indexé par le login partout ailleurs
(`EnsureDestinationDirectory` écrit sous `{Blog}/{name-claim}`,
le endpoint de téléchargement et les avatars utilisent
`user.UserName`). Une fois l'upload corrigé, les fichiers
atterrissaient sous `{Blog}/{login}` tandis que la liste cherchait
sous `{Blog}/{GUID}` — un arbre vide — d'où la page PostIt
« Mes fichiers » qui ne listait rien. `GetDir` résout désormais
l'utilisateur en base et liste sous `user.UserName`.
`UploadedFile.OwnerId` reste `sub`, donc `EnrichWithFileIds`
interpole toujours par `uid`.
* [PostIt] Boutons d'action des lignes de liste grisés et inactifs au
clic (Télécharger / Supprimer / Joindre sur MyFiles ; download/detach
sur les listes de pièces jointes de `BillingQueryDetailsPage` et
`EstimateEditionPage`). Le binding `$parent[vm:XxxViewModel].Cmd`
cherchait un ancêtre CONTRÔLE de type `XxxViewModel` — un viewmodel,
pas un contrôle — donc résolvait à `null` et le `Button` restait
désactivé (grisé) et inerte au clic. Corrigé en
`$parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd` (syntaxe
déjà en vigueur dans `CirclesPage` / `EstimateListPage`).
* [PostIt] Les messages de la barre de statut (`StatusBar`) n'étaient
pas sélectionnables/copiables : le texte était un `TextBlock` (non
sélectionnable dans Avalonia). Remplacé par `SelectableTextBlock`.
* [Yavsc.Blogs] La suppression d'un fichier (`DELETE api/v1/fs/{name}`)
renvoyait un 40x alors que le fichier était bien supprimé du disque.
`DeleteUserDirOrFile` (et `DeleteUserFile`) lisaient
`FileInfo.Length` **après** `fi.Delete()` ; or `FileInfo.Length`
lève `FileNotFoundException` une fois le fichier disparu — le `catch`
du contrôleur transformait cette exception en 400, d'où le message
perçu « le fichier n'existe pas sur le serveur » tandis que la
suppression avait réussi. La taille est désormais capturée avant la
suppression.
* PostIt default settings
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
production où `IsPublished` disparaissait du payload de liste du blog API.
### Added
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
le détail de demande et la page d'édition de devis.
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
(`EstimateApiController`) et les demandes front-office
(`FrontOfficeApiController`).
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
`UserFileAttachments`.
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
`README.nginx.md`.
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
`FileSystemApiTests` (pièces jointes et stockage perso).
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
### Changed
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
production sur le vhost Org. La preprod et la prod partageant la même
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
la config par nginx.
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
provient de la variable d'environnement
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
source.
### Fixed
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
de la liste du blog API. Le endpoint `GetBlogspot` retourne
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
déclaré (l'interface), qui omet les propriétés du type concret —
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
`CommentsController` (Index/Details/…) atterrissaient sur la même route
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
`SwaggerGeneratorException` (conflit method/path) au chargement de
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
les actions scaffoldées.
## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être
signé par les deux parties (fournisseur et client) puis téléchargé en PDF
ou TeX depuis PostIt, avec les signatures dessinées en vecteur.
### Added
* [PostIt] Pad de signature sur la page de validation d'un devis : on valide
ou on refuse en joignant une signature (côté fournisseur ou client), capturée
au format filaire PostIt (strokes) et envoyée sur l'acceptation.
* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` :
autorisation selon le rôle (fournisseur/client/admin), signature optionnelle
persistée sur l'estimate avec horodatage `ProviderValidationDate` /
`ClientValidationDate` selon le côté signataire.
* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted`
pour distinguer l'acceptation de chaque partie.
* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et
`estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est
rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`,
`BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs`
câble `AddControllersWithViews` + `SiteSettings`).
* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel
(`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client
côte à côte dans deux minipages ; plus de dépendance à un PNG de signature.
* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` /
`GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX »
dans PostIt sur la page de validation du devis et le détail de commande,
avec un helper `StorageProvider` d'enregistrement de fichier.
* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back),
de sorte qu'un devis validé quitte immédiatement la liste du signataire.
* [PostIt] Renommage des titres de liste : « Devis en attente de signature
fournisseur » / « Devis en attente de signature client ».
* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`.
* [CLI] Option `-c configuration-file.json` au lancement.
* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline
signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF
existe au chemin de sortie attendu et que seul le PDF persiste (aux/log
nettoyés) ; skip propre si `lualatex` est absent.
* [README] Dépendances système au runtime pour la génération PDF :
`texlive-binaries` (lualatex), `texlive-luatex` (luaotfload),
`texlive-pictures` (tikz), `texlive-fonts-extra` (bera).
### Changed
* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur
`ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste
« en attente ».
* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni
sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le
répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés
afin que seul le `.pdf` persiste.
### Fixed
* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument
fichier lit stdin en mode terminal et s'arrête après la première ligne d'un
document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier
d'entrée — stdin est alors traité comme un fichier régulier et le document
multi-lignes compile. C'était la cause racine de l'échec en production, pas
`bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets).
* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif
`Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de
`-output-directory` et de répertoire de travail ; résolu en chemin absolu.
* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model`
nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine`
résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine`
unique).
* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait
`null.cshtml` ; corrigé en `Layout = null` (null C#).
* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par
`lualatex`.
* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log`
qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message
d'erreur, et le `.log` est conservé en cas d'échec.
## [1.0.8-rc15] - unstable
### Added
* [PostIt] Saisie et envoi de pieces jointes sur un billet de blog: le client monte les fichiers en `multipart/form-data` lors du `Save`, ajoute un lien Markdown par piece jointe a l'article, puis re-PUT le billet. Une collection `DraftAttachments` porte les fichiers en cours d'edition.
* [PostIt] Apercu Markdown dans l'editeur de blogs (integration `MarkdownViewer.Core`) avec bascule edition/preview sur `BlogsPage`.
* [PostIt] Gestion des devis: page de liste `EstimateListPage` (vues client et prestataire), page d'edition `EstimateEditionPage` avec lignes de devis (`EstimateLineItemViewModel`), et flux de validation d'un devis.
* [PostIt] Flux des requetes en cours cote prestataire (`ProviderOngoingRequestsPage`) avec tri persistant.
* [Yavsc.Api] Endpoints devis `GET /api/v1/estimate/asclient` et `GET /api/v1/estimate/asprovider` pour lister les devis en cours selon le role.
* [Yavsc.Api] Endpoint `GET /api/v1/billing/provider/ongoing` et `POST /api/v1/billing/prosign/{billingCode}/{id}` pour le flux prestataire.
* [Yavsc.Api.Client] `EstimateApiClient` + DTOs (`EstimateDto`, `EstimateLineDto`) pour le pipeline devis cote client lourd.
* [Yavsc.Api.Client] Reprise de l'upload de fichiers blog dans `BlogApiClient` (`CreateMultipartContent`, `BlogUploadFile`) — la meme forme filaire que PostIt utilise en production.
* [Yavsc.Abstract] `FileServerUrlHelpers` pour deriver les URLs publiques des fichiers utilisateur depuis l'autorite OIDC (alignement sur `UserFilesPath`).
* [Yavsc.Server] `BlogSpotService.AttachFiles` : ecrit les pieces jointes sous la racine des fichiers utilisateur (`{UserFilesDirName}/{user}/blogs/{postId}/{fileName}`).
* [Yavsc.Blogs] `BlogApiController` accepte `multipart/form-data` (champ `blog` + parts `file`) sur `POST` et `PUT /api/v1/blogspot`, en plus du payload JSON existant.
* [PostIt] Variable d'environnement `POSTIT_SETTINGS_JSON` pour surcharger les parametres au lancement (utile en CI/test).
* [Yavsc.Server] Migration EF `fileACL`: `CircleAuthorizationToFile` gagne `OwnerId` + `Access` et un graphe proprieetaire, base du controle d'acces fichier par cercle.
* [Yavsc.Server] Migration EF `genericEstimate`: `Estimate.Query` passe de `RdvQuery` a `NominativeServiceCommand?` pour supporter des devis non lies a un RDV.
* [Yavsc.Server] Migrations EF `NominativeServiceCommand` et `AddHairCutQueryLocationId` (localisation de la coupe).
* [CI] Integration du scan de secrets Gitleaks dans le pipeline Forgejo + `.gitleaksignore`.
* [Tests] Non-regressions: round-trip multipart blog (`BlogApiTests`, `CreateMultipartContentTests` forme + serveur), `BlogAttachmentLinkTests`, `FileServerUrlHelpersTests`, `EstimateApiControllerTests`, VMs `EstimateList`/`EstimateEdition`/`ProviderOngoingRequests`, `BillingControllerTests` (filtrage par utilisateur), `GetOpenIdConfiguration` contre l'hote de test.
### Changed
* [PostIt] Renommage `MainPage` -> `BlogsPage` (et `PushMainPageAsync` -> `PushBlogsPageAsync`) pour refletter le role de la page.
* [PostIt] Refacto des `SiteSettings` : reorganisation des parametres, nouvelles valeurs par defaut, et indications plus claires dans les `appsettings`.
* [Yavsc.Api] `BillingController` ne liste plus que les codes billing de l'utilisateur authentifie (filtre par `UserId`), au lieu de l'ensemble des requetes prestataire.
* [Yavsc.Api] Mutualisation d'un `YavscMessageSender` partage dans l'hote API (au lieu d'une instance par controller).
* [Yavsc.Org] La detection du doublon d'email a l'inscription utilise desormais le code erreur generic `DbException` 23505 (fournisseur-agnostique) au lieu de `PostgresException`, pour rester correct en test SQLite.
* [PostIt] `YavscApiClient` tolere les corps vides (`204 No Content` sur `PUT /blogspot/{id}`) au lieu de lever un `JsonException`.
* [CI] Le pipeline compile avant les tests, installe les dependances natives, et retire le `ItemGroup.Using Include=Xunit` superflu.
### Fixed
* [PostIt] Suppression d'un faux message d'erreur affiche apres un `PUT /blogspot/{id}` reussi (204 traite comme succes, plus comme une reponse vide invalide).
* [PostIt] Correction de la creation des liens de pieces jointes (`TryAppendAttachmentLinks`) : segment proprietaire et chemin `blogs/{id}/{fileName}` alignes sur ce que `AttachFiles` ecrit reellement.
* [Yavsc.Blogs] Correction de l'upload / de la persistance des pieces jointes (`FileSystemHelpers` + `BlogApiController` multipart).
* [Tests] Stabilisation des suites: seeds billing avec metadonnees d'audit, `GetOpenIdConfiguration_returns_ok` contre l'hote de test, retraits d'assertions abusives, tests SQLite verts.
* [PostIt] Nettoyage de fichiers temporaires accidentels et ignore des repertoires `tmp` dans le depot.
## [1.0.8-rc14] - unstable ## [1.0.8-rc14] - unstable
### Added ### Added
@ -329,6 +828,10 @@ the ACL now comes along with the article,
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc17]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc16...1.0.8-rc17
[1.0.8-rc18]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc17...1.0.8-rc18
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6

View file

@ -2,5 +2,16 @@
<PropertyGroup> <PropertyGroup>
<RootNamespace>Yavsc</RootNamespace> <RootNamespace>Yavsc</RootNamespace>
<NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn> <NoWarn>NU1701, NU1901, NU1902, NU1507</NoWarn>
<!-- Disable the StaticWebAssets build cache (the *.dswa.cache.json
files written under obj/.../ by DefineStaticWebAssets).
ReadOrCreateCache opens them with File.OpenRead and no
try/catch, so under parallel solution builds a sharing
violation / TOCTOU race on the cache file escapes as
MSB4018 and fails the whole build (seen on Yavsc.Blogs
during the rc15 solution build). The cache is a pure
build-speed optimization; turning it off has no effect on
output correctness, only on incremental rebuild speed of
the (wwwroot-light) web projects. -->
<StaticWebAssetsCacheDefineStaticWebAssetsEnabled>false</StaticWebAssetsCacheDefineStaticWebAssetsEnabled>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -4,53 +4,71 @@
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version> <IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" /> <PackageVersion Include="DotMarkdown" Version="0.3.0" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" /> <PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" /> <PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" /> <PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" /> <PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
<PackageVersion Include="MailKit" Version="4.18.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
<PackageVersion Include="MimeKit" Version="4.18.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PayPalMerchantSDK" Version="2.16.250" />
<PackageVersion Include="pazof.rules" Version="1.1.3" />
<PackageVersion Include="RazorEngine.NetCore" Version="3.1.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -24,41 +24,24 @@
# ===================================================================== # =====================================================================
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
ARG BUILD_ENV_TAG=debian12-dotnet10-android36-v1 ARG BUILD_ENV_TAG=debian13-dotnet10-android36-jdk21-v2
FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env FROM pazof/yavsc-build-env:${BUILD_ENV_TAG} AS build-env
WORKDIR /src WORKDIR /src
# (1) Manifest + fichiers de projet pour le cache de restore
COPY *.sln ./
COPY Directory.*.props ./
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# (2) Tout le code source
COPY . . COPY . .
# (4) Restore
RUN dotnet restore RUN dotnet restore
# (5) Build des trois services web principaux # Build des trois services web principaux
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Api/Yavsc.Api.csproj -c Release --no-restore -clp:ErrorsOnly
RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Blogs/Yavsc.Blogs.csproj -c Release --no-restore -clp:ErrorsOnly
# (6) Build APK Android (utilisé par le workflow docker-publish-android). # Build APK Android (utilisé par le workflow docker-publish-android).
ARG ANDROID_TARGET_RID=android-arm64 ARG ANDROID_TARGET_RID=android-arm64
RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID} RUN dotnet build src/PostIt/PostIt.Android/PostIt.Android.csproj -c Release --no-restore -clp:ErrorsOnly -r ${ANDROID_TARGET_RID}
# (7) Publish des trois services web (les artifacts sont consommés par # Publish des trois services web (les artifacts sont consommés par
# les stages publish-org / publish-api / publish-blogs ci-dessous). # les stages publish-org / publish-api / publish-blogs ci-dessous).
RUN mkdir -p /app/publish RUN mkdir -p /app/publish
RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org RUN dotnet publish src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-build -o /app/publish/Yavsc.Org

View file

@ -1,36 +1,12 @@
# Utilisation de l'image globale pour le build et l'exécution # Utilisation de l'image globale pour le build et l'exécution
FROM pazof/yavsc-build-env:debian12-dotnet10-android36-v1 AS build-env FROM pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v4 AS build-env
WORKDIR /src WORKDIR /src
# 1. Copie de la solution # 1. Copie de la solution
COPY *.sln ./
COPY Directory.*.props ./
# 2. Copie de l'intégralité des fichiers de projets (.csproj) pour la restauration
COPY src/Yavsc.Org/*.csproj ./src/Yavsc.Org/
COPY src/Yavsc.Abstract/*.csproj ./src/Yavsc.Abstract/
COPY src/Yavsc.Server/*.csproj ./src/Yavsc.Server/
COPY src/Yavsc.Api/*.csproj ./src/Yavsc.Api/
COPY src/Yavsc.Blogs/*.csproj ./src/Yavsc.Blogs/
COPY src/cli/*.csproj ./src/cli/
COPY src/Yavsc.Org.Tests/*.csproj ./src/Yavsc.Org.Tests/
# Projets associés à PostIt
COPY src/PostIt/PostIt/*.csproj ./src/PostIt/PostIt/
COPY src/PostIt/PostIt.Android/*.csproj ./src/PostIt/PostIt.Android/
COPY src/PostIt/PostIt.Browser/*.csproj ./src/PostIt/PostIt.Browser/
COPY src/PostIt/PostIt.Desktop/*.csproj ./src/PostIt/PostIt.Desktop/
# 4. Copie de l'intégralité du code source
COPY . . COPY . .
RUN dotnet workload restore
# 4. Restauration des dépendances pour tous les projets # 4. Restauration des dépendances pour tous les projets
RUN dotnet restore RUN dotnet restore
# 5. Copie de la totalité du code source (filtrée par votre .dockerignore)
COPY . .
# 6. Compilation du serveur Web principal # 6. Compilation du serveur Web principal
RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly RUN dotnet build src/Yavsc.Org/Yavsc.Org.csproj -c Release --no-restore -clp:ErrorsOnly

View file

@ -5,19 +5,43 @@ DESTDIR=/tmp/yavsc
APP_PATH=srv/www/yavsc APP_PATH=srv/www/yavsc
APP_FULL_PATH=$(DESTDIR)/$(APP_PATH) APP_FULL_PATH=$(DESTDIR)/$(APP_PATH)
include .env
all: all:
dotnet build --nologo dotnet build --nologo
clean: clean:
dotnet clean -c $(CONFIG) dotnet clean -c $(CONFIG)
brutal_clean:
@find -name "obj" -exec rm -rf {} \;
@find -name "bin" -exec rm -rf {} \;
src/Yavsc/bin/output/wwwroot: src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish
test: test:
ASPNETCORE_ENVIRONMENT=Development dotnet test ASPNETCORE_ENVIRONMENT=Development dotnet test --report-xunit \
--report-xunit-html \
--results-directory "test-reports"
test-postit:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "postit-test-results.html" \
--project src/PostIt/PostIt.Tests/PostIt.Tests.csproj
backend-tests: Blogs-backend-test Org-backend-test Api-backend-test
Api-backend-test:
Blogs-backend-test:
Org-backend-test:
%-backend-test:
ASPNETCORE_ENVIRONMENT=Development dotnet test \
--report-xunit \
--report-xunit-html \
--results-directory "test-reports" \
--report-xunit-html-filename "$*-backend-test-results.html" \
--project src/test/Yavsc.$*.Tests/Yavsc.$*.Tests.csproj
watch: watch:
dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj dotnet watch -p:Configuration=$(CONFIG) --project src/Yavsc/Yavsc.csproj
@ -48,70 +72,4 @@ docker-build:
docker-run: docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc docker run -d -p 5000:5000 --name yavsc yavsc
# Crée une branche release/<V> depuis main, met à jour les .PHONY: test install docker-image docker-build docker-run
# `<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
@if [ -n "$$(git status --porcelain)" ]; then \
echo "Working tree sale, refus de créer une branche release."; \
git status --short; \
exit 1; \
fi
@BRANCH="release/$(V)"; \
if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
echo "La branche $$BRANCH existe déjà en local."; \
echo " Pour la supprimer : git branch -D $$BRANCH"; \
exit 1; \
fi; \
if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
echo "La branche $$BRANCH existe déjà sur origin."; \
exit 1; \
fi; \
echo "==> Fetch + vérification synchro main"; \
git fetch origin main; \
if ! git merge-base --is-ancestor origin/main HEAD; then \
echo "main a avancé plus loin que HEAD. Fais :"; \
echo " git pull --ff-only origin main"; \
exit 1; \
fi; \
echo "==> Création de $$BRANCH depuis main"; \
git checkout -b "$$BRANCH"; \
echo "==> dotnet-gitversion /updateprojectfiles"; \
dotnet-gitversion /updateprojectfiles; \
echo "==> Commit du bump"; \
git add .; \
if git diff --cached --quiet; then \
echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
else \
git commit -m "chore(release): bump version via gitversion for $(V)"; \
fi; \
echo "==> Push de $$BRANCH sur origin"; \
git push -u origin "$$BRANCH"; \
echo "==> Terminé. Branche $$BRANCH live sur origin."
.PHONY: test release

View file

@ -61,9 +61,40 @@ make test
### les services et l'API ### les services et l'API
#### Dépendances système au runtime
La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`,
`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande
système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit
directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc
être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service
systemd `yavscApi`.
Sous Debian/Ubuntu :
```bash
sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra
```
- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic).
- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex,
notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur
`module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF.
(Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.)
- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures
du client et du fournisseur en bas du devis (vecteur, pas de PNG).
- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis.
Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found`
(`Emergency stop`, aucun PDF).
Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF
generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet
de compiler le document hors-ligne.
### La Prod ### La Prod
`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`. `cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.
puis, pour une première installation puis, pour une première installation
`make install_service`. `make install_service`.
@ -201,6 +232,12 @@ L'impact de l'usage d'un nouveau nom d’environnement d'execution, à l'heure d
* ~/Views/Home/Index.cshtml * ~/Views/Home/Index.cshtml
* ~/Views/Home/About.cshtml * ~/Views/Home/About.cshtml
# 👥 Crédits & Co-auteurs
Ce projet a été développé de manière hybride :
- **Conception, architecture et revue de code :** Paul Schneider
- **Assistance au code et optimisation :** IA générative (Gemini, Google, Claude (Anthropic), https://qoder.com/ (ex-Windsurf))
# Remerciements # Remerciements
Photo de <a href="https://unsplash.com/fr/@dewang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Dewang Gupta</a>sur <a href="https://unsplash.com/fr/photos/homme-debout-pres-dun-arbre-Mu3T3DmvQQw?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a> Photo de <a href="https://unsplash.com/fr/@dewang?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Dewang Gupta</a>sur <a href="https://unsplash.com/fr/photos/homme-debout-pres-dun-arbre-Mu3T3DmvQQw?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>

View file

@ -1,23 +1,32 @@
DESTDIR=
DOTNET_FRAMEWORK=net10.0
APP_PROJECT_NAMES=Org Blogs Api APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=.. SLNDIR=..
APP_INSTALL_ENV=preprod
APP_INSTALL_PREFIX=preprod-
include $(SLNDIR)/.env include .$(APP_INSTALL_ENV).env
include .env
generated/: reinstall: copy-binaries
@mkdir -p $@ @for project in $(APP_PROJECT_NAMES); do \
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
@echo "💯 backend started"
generated/yavscOrg.service: reinstall-prod:
generated/yavscBlogs.service: make reinstall APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/yavscApi.service:
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
generated/$(APP_INSTALL_PREFIX)yavscApi.service:
generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \ HTTP_PORT="$($*_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \ APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="yavsc$*" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
POSTGRES_PORT="$(POSTGRES_PORT)" \ POSTGRES_PORT="$(POSTGRES_PORT)" \
POSTGRES_DB="$(POSTGRES_DB)" \ POSTGRES_DB="$(POSTGRES_DB)" \
@ -34,11 +43,83 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
envsubst > $@ envsubst > $@
@echo Created service file: $@ @echo Created service file: $@
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
copy-services: copy-service-Org copy-service-Blogs copy-service-Api # --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
copy-service-Org: /etc/systemd/system/yavscOrg.service # make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service # make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
copy-service-Api: /etc/systemd/system/yavscApi.service #
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
NGINX_TEMPLATE := template.vhost
NGINX_OUT_DIR := generated/nginx
NGINX_HOSTS := Org Blogs Api
DOMAIN_ROOT ?= pschneider.fr
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
# prod Org -> yavsc.pschneider.fr
# preprod Org -> preprod-yavsc.pschneider.fr
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
@cat $(NGINX_TEMPLATE) \
| DOMAIN="$(call nginx_domain,$*)" \
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
HTTP_HOST="$(HTTP_HOST)" \
BACKEND_PORT="$($*_PORT)" \
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
@echo Created nginx vhost: $@
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
generate-nginx-prod:
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
$(NGINX_OUT_DIR):
@mkdir -p $@
# --- appsettings (générés depuis template.appsettings-*.json) ---
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
#
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
# contient un \). jq lit le secret depuis l'environnement (export) ->
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
export ASPNETCORE_Smtp__Password
APPSETTINGS_OUT_DIR := generated/appsettings
APPSETTINGS_HOSTS := org api blogs
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
@jq --arg domain "$(DOMAIN_ROOT)" \
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
template.appsettings-$*.json > $@
@echo Created appsettings: $@
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
$(APPSETTINGS_OUT_DIR):
@mkdir -p $@
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
install-services: install-service-Org install-service-Blogs install-service-Api
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
@ -52,39 +133,36 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \ rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
sync ; \ sync ; \
ls $${BINDIR}/appsettings* 2>&1 ; \ ls $${BINDIR}/appsettings* 2>&1 ; \
sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \ sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \
done done
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) @sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
@sync
/etc/systemd/system/yavsc%.service: generated/yavsc%.service $(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service
sudo cp $^ $@ sudo cp $^ $@
sudo chown root:root $@ sudo chown root:root $@
build_publish_%: clean_publish_dir_% build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
clean_publish_dir_%: clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
install: build_publish copy-binaries copy-services install: copy-binaries copy-services
@sudo systemctl daemon-reload @sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
sudo systemctl enable yavsc$${project} ; \ echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start yavsc$${project} ; \ sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \
echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done done
reinstall: copy-binaries
@sync
@for project in $(APP_PROJECT_NAMES); do \
sudo systemctl start yavsc$${project} ; done
stop-services: stop-services:
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
sudo systemctl stop yavsc$${project} ; \ echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done done
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
@ -93,8 +171,19 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR) @echo APPDIR: $(APPDIR)
showApiLogs:
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
showOrgLogs:
@sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log
showBlogsLogs:
@sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log
clean: clean:
@rm -rf generated @rm -rf generated
generated/:
@mkdir -p $@

View file

@ -1,5 +0,0 @@
# Read me
## Note aux icones
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩

134
contrib/README.nginx.md Normal file
View file

@ -0,0 +1,134 @@
# Vhosts nginx — génération par `make generate-nginx`
Les vhosts nginx sont **générés** à l'image des unités de service systemd
(`template.service` + `envsubst` → `generated/*.service`) : un template
unique `contrib/template.vhost` est substitué par `envsubst` vers
`contrib/generated/nginx/`, qui est **gitignoré**.
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
`template.appsettings-*.json`) est une capacité complémentaire — non
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
uniquement), mais disponible si Kestrel est un jour exposé en direct.
Directives générées (par vhost) :
```nginx
listen 443 ssl; listen [::]:443 ssl; http2 on;
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_protocols TLSv1.3;
```
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
confondus).
## Prérequis côté host
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
`listen ... quic` suffit à activer h3) :
```bash
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
```
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
backports ou `nginx-full`.
2. **UDP 443 ouvert** :
```bash
sudo ufw allow 443/udp
sudo ufw status | grep '443/udp' # → ALLOW
```
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
échoue sur le bind IPv6).
4. Certbot / certs Let's Encrypt déjà en place (chemins
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
## Génération
```bash
cd contrib
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
make generate-nginx
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
make generate-nginx-prod
# ou manuellement :
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
```
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
`https` en prod, `http` en preprod.
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|-------|--------------|-------------|-------------------------|------------------------|
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
Domaines : `<prefix><subdomain>.pschneider.fr`
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
## Déploiement sur le host
```bash
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
# (idem yavscApi, yavscBlogs)
sudo nginx -t && sudo systemctl reload nginx
```
## Vérification (sur le host)
```bash
# 1. Module h3 présent
nginx -V 2>&1 | grep -o 'http_v3_module'
# 2. UDP 443 ouvert
sudo ufw status | grep '443/udp'
# 3. En-tête Alt-Svc annoncé
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
# 5. Fallback h2 et h1 toujours fonctionnels
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
```
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
Network → colonne « Protocole » → `h3` après mise à niveau.
## Comment ça marche (template + envsubst)
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
via `envsubst` avec une **allowlist** :
```makefile
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
```
Cette allowlist limite la substitution aux 5 variables citées, de sorte
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
allowlist, `envsubst` détruirait ces variables nginx.
## Hors périmètre
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
systemd + nginx).
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).

View file

@ -0,0 +1,33 @@
{
"Site": {
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["api"],
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN"
],
"Bills": "bills",
"TempDir": "temp"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:92"
},
"Https": {
"Url": "https://localhost:93",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -0,0 +1,40 @@
{
"Site": {
"Title": "Yavsc Blogs",
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"UserFilesRoot": "files"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:90"
},
"Https": {
"Url": "https://localhost:91",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -0,0 +1,74 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": [
"blogs",
"api"
],
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
"ApiUrl": "https://preprod-api.$DOMAIN",
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN",
"https://preprod-api.$DOMAIN"
],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Admin": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"UserFilesRoot": "files",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"
},
"Smtp": {
"Host": "mercure.pschneider.fr",
"Port": 465,
"SenderName": "Paul Schneider",
"SenderEmail": "paul@pschneider.fr",
"UserName": "paul",
"Password": "$SMTP_PASSWORD"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:88"
},
"Https": {
"Url": "https://localhost:89",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -10,10 +10,10 @@ RestartSec=5s
Type=simple Type=simple
User=yavsc User=yavsc
Group=yavsc Group=yavsc
WorkingDirectory=$DESTDIR WorkingDirectory=$APPDIR
ExecStart=$DESTDIR/Yavsc.$APP_NAME ExecStart=$APPDIR/Yavsc.$APP_NAME
Restart=always Restart=always
Environment="HOME=$DESTDIR" Environment="HOME=$APPDIR"
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS" Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
Environment="ASPNETCORE_Environment=$ENV" Environment="ASPNETCORE_Environment=$ENV"
@ -29,8 +29,6 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\""
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=$APP_SYSLOG_ID SyslogIdentifier=$APP_SYSLOG_ID
[Install] [Install]

81
contrib/template.vhost Normal file
View file

@ -0,0 +1,81 @@
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
#
# Variables substituées par envsubst (allowlist) :
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
# même IP, donc seule la prod (Org) l'active.
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
# préservées (hors allowlist).
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
# ACME ; ne pas servir l'app en HTTP clair.)
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
return 301 https://$host$request_uri;
}
server {
# 1. TCP : HTTPS classique + HTTP/2
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
listen 443 quic$QUIC_OPTS;
listen [::]:443 quic$QUIC_OPTS;
http3 on;
server_name $DOMAIN;
# 3. Certificats Let's Encrypt (Certbot).
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
ssl_protocols TLSv1.3;
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 100s;
proxy_buffering off;
}
# SignalR (ChatHub) : WebSocket exige Upgrade/Connection et un timeout
# long, sinon la connexion tombe au premier silence ou l'upgrade échoue.
# Connection est fixé à "upgrade" (pas de map $connection_upgrade, qui
# exigerait une directive map {} globale hors de ce vhost) : SignalR
# négocie toujours un upgrade sur ce chemin, et la négociation initiale
# (POST /negotiate, sans en-tête Upgrade) n'est pas affectée par cet
# en-tête côté Kestrel.
location /chathub {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_buffering off;
}
include server-status.conf;
}

View file

@ -1,54 +0,0 @@
server {
if ($host = yavsc.pschneider.fr) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name yavsc.pschneider.fr;
location / {
root /srv/www/yavsc/wwwroot;
dav_methods PUT;
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name yavsc.pschneider.fr;
ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot
# Configure the SignalR Endpoint
location / {
# App server url
proxy_pass http://localhost:3003;
proxy_cache off;
# WebSockets were implemented after http/1.0
proxy_http_version 1.1;
# Configuration for ServerSentEvents
proxy_buffering off;
# Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds
proxy_read_timeout 100s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View file

@ -126,6 +126,20 @@ le DI est construit. Ordre, dans cet ordre :
> à cette table, *ne pas* ajouter de registration pour > à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`. > `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Défilement de la page des billets
La page `BlogsPage` utilise une grille à hauteur contrainte : l'en-tête
et les commandes restent visibles, tandis que la liste des billets et
l'éditeur se partagent l'espace disponible. Un `GridSplitter` permet
de modifier cette répartition. La liste et l'article défilent séparément.
Dans `MarkdownEditorControl`, AvaloniaEdit gère son propre défilement ;
l'aperçu Markdown est placé dans un `ScrollViewer`. Ne pas limiter
la hauteur du `MarkdownViewer` lui-même : son contenu doit pouvoir
dépasser le viewport pour que les barres automatiques s'activent.
Ne pas placer cette grille dans un `StackPanel` vertical ou un
`ScrollViewer` de page, qui lui donnerait une hauteur de mesure illimitée.
## Navigation ## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"` Le host de navigation est un `NavigationPage x:Name="NavRoot"`

View file

@ -26,7 +26,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: web-runtime target: web-runtime
args: args:
BUILD_ENV_TAG: debian12-dotnet10-android36-v1 BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -53,9 +53,7 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: network_mode: host
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# # Indispensable pour que Yavsc.Org (IdentityServer8) démarre. # # Indispensable pour que Yavsc.Org (IdentityServer8) démarre.
# # Sans ce montage, 'docker compose up' échoue côté web avec # # Sans ce montage, 'docker compose up' échoue côté web avec
@ -73,7 +71,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: api-runtime target: api-runtime
args: args:
BUILD_ENV_TAG: debian12-dotnet10-android36-v1 BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -90,9 +88,7 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: network_mode: host
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro
@ -105,7 +101,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
target: blogs-runtime target: blogs-runtime
args: args:
BUILD_ENV_TAG: debian12-dotnet10-android36-v1 BUILD_ENV_TAG: debian13-dotnet10-android36-jdk21-v2
secrets: secrets:
- source: yavsc_appsettings - source: yavsc_appsettings
target: yavsc_appsettings target: yavsc_appsettings
@ -122,9 +118,7 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
networks: network_mode: host
- yavsc-internal
- yavsc-public
# volumes: # volumes:
# - /etc/letsencrypt:/etc/letsencrypt:ro # - /etc/letsencrypt:/etc/letsencrypt:ro

View file

@ -1,5 +1,13 @@
{ {
"version": 1, "version": 1,
"isRoot": true, "isRoot": true,
"tools": {} "tools": {
"picket": {
"version": "0.2.12",
"commands": [
"picket"
],
"rollForward": false
}
}
} }

5
global.json Normal file
View file

@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

View file

@ -1,35 +1,39 @@
<Project> <Project>
<Import <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- PostIt-product-specific versions --> <!-- PostIt-product-specific versions -->
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase> <AvaloniaVersionBase>12.1.3</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless" Version="12.1.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui" Version="5.1.0" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="LibVLCSharp.Avalonia" Version="3.10.1" />
<PackageVersion Include="Live.Avalonia" Version="1.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" /> <PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="MarkdownViewer.Core" Version="1.0.6-47" />
<PackageVersion Include="Material.Avalonia" Version="3.21.0" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" /> <PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.12" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" /> <PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" /> <PackageVersion Include="PvRecorder" Version="1.2.13" />
<PackageVersion Include="Silk.NET.OpenAL" Version="2.23.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0.3" />
</ItemGroup> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup>
</Project> </Project>

View file

@ -2,6 +2,7 @@
using Android.App; using Android.App;
using Android.Content; using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using Android.Views;
using AndroidX.Core.Provider; using AndroidX.Core.Provider;
using AndroidX.Emoji2.Text; using AndroidX.Emoji2.Text;
using Avalonia.Android; using Avalonia.Android;
@ -15,7 +16,9 @@ namespace PostIt.Android;
Theme = "@style/MyTheme.NoActionBar", Theme = "@style/MyTheme.NoActionBar",
Icon = "@drawable/icon", Icon = "@drawable/icon",
MainLauncher = true, MainLauncher = true,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] WindowSoftInputMode = SoftInput.AdjustResize,
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)
]
public class MainActivity : AvaloniaMainActivity public class MainActivity : AvaloniaMainActivity
{ {
/// <summary> /// <summary>

View file

@ -11,8 +11,8 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">
@ -27,4 +27,14 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />
</ItemGroup> </ItemGroup>
</Project> <!-- This is an Avalonia app, not a MAUI app: exclude the .NET MAUI VS Code
extension's injected HotReload agent (references Microsoft.Maui.*).
The agent file is added via CustomAfterMicrosoftCommonTargets, i.e. after
this file's item groups, so the removal must run in a target. -->
<Target Name="ExcludeMauiDeployHotReloadAgent" BeforeTargets="CoreCompile"
Condition="'$(MauiDeployHotReloadAgentSource)' != ''">
<ItemGroup>
<Compile Remove="$(MauiDeployHotReloadAgentSource)" />
</ItemGroup>
</Target>
</Project>

View file

@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application android:label="PostIt" android:icon="@drawable/Icon"> <application
android:label="PostIt"
android:icon="@drawable/Icon"
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true">
</application> </application>
</manifest> </manifest>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain> <!-- Debug address -->
</domain-config>
</network-security-config>

View file

@ -6,8 +6,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser" /> <PackageReference Include="Avalonia.Browser" />

View file

@ -7,8 +7,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -1,6 +1,7 @@
using System; using System;
using Avalonia; using Avalonia;
using PostIt.Services; using PostIt.Services;
using PostIt.CLIOptions;
namespace PostIt.Desktop; namespace PostIt.Desktop;
@ -12,6 +13,8 @@ sealed class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
CLIOptionHandler.HandleArgs(args);
// Short-circuit 2nd-instance launches (OS handing us the // Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window. // postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes // If we let Avalonia initialise, the new MainWindow flashes

View file

@ -162,6 +162,12 @@ public class ActivitiesPageViewModelTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -352,6 +352,12 @@ public class BillingCommandPageViewModelTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -45,10 +45,8 @@ public class BillingQueriesPageViewModelTests
Assert.Equal(2, vm.Queries.Count); Assert.Equal(2, vm.Queries.Count);
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase)); Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.CanOpenDetails);
vm.SelectedQuery = vm.Queries[0]; Assert.True(vm.Queries.Count > 0);
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
} }
private sealed class StubBillingApi : IYavscApiClient private sealed class StubBillingApi : IYavscApiClient
@ -129,6 +127,12 @@ public class BillingQueriesPageViewModelTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -62,4 +62,25 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
}); });
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }
/// <summary>Multipart overload: BlogApiClient routes
/// Create/UpdatePostAsync with attachments through here.
/// Without this override the call would fall through to the
/// base <see cref="YavscApiClient"/> and attempt a real HTTP
/// round-trip against the stub authority. The body is a
/// factory, so we record the delegate itself; tests that
/// care about the multipart shape invoke it.</summary>
public override Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
{
_recorder.Calls.Add((method, path, contentFactory));
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.",
});
return Task.FromResult(default(T)!);
}
} }

View file

@ -0,0 +1,116 @@
using Yavsc.Blogspot;
using Yavsc.Api.Client;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// Pins the markdown link that <see cref="BlogsViewModel.SaveAsync"/>
/// appends to the article after a successful attachment upload.
///
/// <para>The link must be an absolute URL derived from the OIDC
/// authority via <c>FileServerUrlHelpers.GetUserFilesUri</c>
/// (Yavsc.Abstract): the <c>/files</c> file server is hosted by
/// Yavsc.Org — the IdP — not by the blogs API host, so a link
/// built from <c>BlogsApiUrl</c> (or left relative) 404s when the
/// reader opens the post. The regression this guards: a generated
/// link of the shape <c>/files/{user}/blogs/{id}/{name}</c>
/// (relative, authority-less) instead of
/// <c>https://{authority}/files/{user}/blogs/{id}/{name}</c>.</para>
/// </summary>
public class BlogAttachmentLinkTests
{
[Theory]
[InlineData("note.txt", "note.txt")]
[InlineData("INSC_E03686381_SCHNEIDER_000113378AA__Troisi\u00e8me__.PDF",
"INSC_E03686381_SCHNEIDER_000113378AA__Troisi_232me__.PDF")]
[InlineData("rapport final.pdf", "rapport%20final.pdf")]
public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority(
string originalName, string urlName)
{
const string authority = "https://idp.example.test";
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "https://blogs.example.test/api/v1/");
var settings = new Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority,
ClientId = "stub",
Scopes = new[] { "openid" },
},
};
var vm = new BlogsViewModel(blog, settings);
// Update branch: an existing post is selected, the user
// edits the buffers and attaches a file.
vm.SelectedPost = new BlogPostDto
{
Id = 42,
AuthorId = "tester",
Title = "Avant",
Article = "Contenu initial.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
vm.DraftTitle = "Après";
vm.DraftArticle = "Contenu modifié.";
vm.DraftAttachments.Add(new BlogUploadFile(
originalName,
System.Text.Encoding.UTF8.GetBytes("payload test"),
"text/plain"));
var expectedUrl = $"{authority}/files/tester/blogs/42/{urlName}";
Assert.Contains($"- [{originalName}]({expectedUrl})", vm.DraftArticle);
Assert.Empty(recorder.Calls);
var draftBeforeSave = vm.DraftArticle;
await vm.SaveAsync();
var linkPut = Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
Assert.NotEqual(default, linkPut);
var sent = Assert.IsType<System.Func<HttpContent>>(linkPut.body);
var multipartContent = Assert.IsType<MultipartFormDataContent>(sent());
var sentStringContent = Assert.IsType<StringContent>(multipartContent.First());
var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
using var document = System.Text.Json.JsonDocument.Parse(sentString);
var article = document.RootElement.GetProperty("article").GetString();
Assert.Contains($"- [{originalName}]({expectedUrl})", article);
// The regression shape: a relative, authority-less link.
Assert.DoesNotContain("](/files/", article);
Assert.Equal(draftBeforeSave, vm.DraftArticle);
Assert.NotNull(vm.SelectedPost);
Assert.False(vm.IsDraftModified);
}
[Fact]
public async Task New_post_resolves_only_the_provisional_link_at_first_save()
{
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var vm = new BlogsViewModel(new BlogApiClient(api, "https://blogs.example.test/api/v1/"),
new Settings { Authentication = new AuthenticationSettings { Authority = "https://idp.example.test" } });
vm.DraftTitle = "Nouveau billet";
vm.DraftArticle = "Texte conservé.";
vm.DraftAttachments.Add(new BlogUploadFile("Troisi\u00e8me.PDF", new byte[] { 1 }));
Assert.Contains("postit-attachment:", vm.DraftArticle);
Assert.Empty(recorder.Calls);
await vm.SaveAsync();
Assert.Equal("Nouveau billet", vm.DraftTitle);
Assert.Contains("Texte conservé.", vm.DraftArticle);
Assert.Contains("- [Troisi\u00e8me.PDF](https://idp.example.test/files/tester/blogs/42/Troisi_232me.PDF)",
vm.DraftArticle);
Assert.DoesNotContain("postit-attachment:", vm.DraftArticle);
Assert.Equal(42, vm.SelectedPost?.Id);
Assert.Empty(vm.DraftAttachments);
Assert.False(vm.IsDraftModified);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Post);
Assert.Single(recorder.Calls, c => c.method == HttpMethod.Put);
}
}

View file

@ -0,0 +1,284 @@
using System.Net.Http;
using PostIt.Models;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateEditionPageViewModelTests
{
private static BillingQuerySummaryDto SampleQuery() => new()
{
Id = 42,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
};
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
}
[Fact]
public void Constructor_prefills_description_and_adds_a_first_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
Assert.Equal("Coupe simple", vm.EstimateDescription);
Assert.Single(vm.Lines);
Assert.Same(vm.Lines[0], vm.SelectedLine);
Assert.Contains("#42", vm.ContextLabel);
Assert.Contains("cli-1", vm.ContextLabel);
}
[Fact]
public void AddLine_appends_and_selects_the_new_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.AddLineCommand.Execute(null);
Assert.Equal(2, vm.Lines.Count);
Assert.Same(vm.Lines[1], vm.SelectedLine);
}
[Fact]
public void RemoveLine_removes_the_selected_line()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
var first = vm.Lines[0];
vm.RemoveLineCommand.Execute(null);
Assert.Empty(vm.Lines);
Assert.Null(vm.SelectedLine);
Assert.False(vm.RemoveLineCommand.CanExecute(null));
Assert.DoesNotContain(first, vm.Lines);
}
[Fact]
public void Total_sums_line_totals_and_tracks_edits()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Count = 2;
vm.Lines[0].UnitaryCost = 15.5m;
Assert.Equal(31m, vm.Total);
Assert.Equal($"{31m:0.00} EUR", vm.TotalLabel);
vm.AddLineCommand.Execute(null);
vm.Lines[1].Count = 1;
vm.Lines[1].UnitaryCost = 9m;
Assert.Equal(40m, vm.Total);
}
[Fact]
public async Task Send_without_title_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("titre", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_without_any_line_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines.Clear();
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("ligne", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_with_a_blank_line_name_warns_and_does_not_post()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Description = "Oubli du nom";
await vm.SendCommand.ExecuteAsync(null);
Assert.Null(api.LastBody);
Assert.Equal(StatusSeverity.Warning, vm.ActionStatus.Severity);
Assert.Contains("nom", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_posts_the_estimate_payload_to_the_estimate_route()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = " Devis coupe ";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].Count = 2.4m;
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType);
Assert.Equal("Devis coupe", payload.Title);
Assert.Equal("Coupe simple", payload.Description);
Assert.Empty(payload.AttachedFiles);
Assert.Empty(payload.AttachedGraphics);
var line = Assert.Single(payload.Bill);
Assert.Equal("Coupe", line.Name);
Assert.Equal(2, line.Count);
Assert.Equal(25m, line.UnitaryCost);
Assert.Equal("EUR", line.Currency);
}
[Fact]
public async Task Send_marks_the_page_as_sent_and_disables_resend()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
await vm.SendCommand.ExecuteAsync(null);
Assert.True(vm.HasSent);
Assert.False(vm.SendCommand.CanExecute(null));
Assert.Equal("Devis envoyé", vm.SendLabel);
Assert.Equal(StatusSeverity.Info, vm.ActionStatus.Severity);
Assert.Contains("#7", vm.ActionStatus.Message);
}
[Fact]
public async Task Send_surfaces_server_errors_as_error_status()
{
var api = new StubEstimateApi { Failure = new HttpRequestException("boom", null, System.Net.HttpStatusCode.InternalServerError) };
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis coupe";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
await vm.SendCommand.ExecuteAsync(null);
Assert.False(vm.HasSent);
Assert.Equal(StatusSeverity.Error, vm.ActionStatus.Severity);
Assert.True(vm.SendCommand.CanExecute(null));
}
[Fact]
public async Task Send_accepts_negative_amounts_for_discount_lines()
{
var api = new StubEstimateApi();
var vm = CreateViewModel(api);
vm.EstimateTitle = "Devis avec remise";
vm.Lines[0].Name = "Coupe";
vm.Lines[0].Description = "Coupe simple";
vm.Lines[0].UnitaryCost = 25m;
vm.AddLineCommand.Execute(null);
vm.Lines[1].Name = "Remise fidélité";
vm.Lines[1].Description = "Remise client régulier";
vm.Lines[1].UnitaryCost = -5m;
Assert.Equal(20m, vm.Total);
await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate");
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent);
}
private sealed class StubEstimateApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public HttpMethod? LastMethod { get; private set; }
public object? LastBody { get; private set; }
public Exception? Failure { get; init; }
/// <summary>Historique complet des appels (le plus récent en dernier).</summary>
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
{
LastMethod = method;
LastPath = path;
LastBody = body;
Calls.Add((method, path, body));
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (Failure is not null)
{
throw Failure;
}
if (typeof(T) == typeof(EstimateCreatedDto))
{
var payload = (EstimateDto)body!;
var created = new EstimateCreatedDto { Id = 7, Bill = payload.Bill };
return Task.FromResult((T)(object)created);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,138 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class EstimateListPageViewModelTests
{
[Fact]
public async Task Client_perspective_calls_asclient_endpoint()
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
Assert.Equal("Devis en attente de signature client", vm.Title);
Assert.Equal(2, vm.Estimates.Count);
// Most recent first
Assert.Equal(2, vm.Estimates[0].Id);
Assert.Equal(1, vm.Estimates[1].Id);
}
[Fact]
public async Task Provider_perspective_calls_asprovider_endpoint()
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
Assert.Equal(2, vm.Estimates.Count);
}
[Fact]
public async Task FilterText_filters_by_title_and_counterpart()
{
var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync();
vm.FilterText = "plomberie";
Assert.Single(vm.Estimates);
Assert.Equal(1, vm.Estimates[0].Id);
vm.FilterText = "BOB";
Assert.Single(vm.Estimates);
Assert.Equal("bob", vm.Estimates[0].ClientId);
vm.FilterText = string.Empty;
Assert.Equal(2, vm.Estimates.Count);
}
[Fact]
public async Task Empty_result_sets_an_info_status()
{
var api = new StubEstimateListApi { Empty = true };
var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync();
Assert.Empty(vm.Estimates);
Assert.Contains("Aucun devis", vm.StatusMessage);
}
private sealed class StubEstimateListApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public bool Empty { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<EstimateDto>))
{
var items = Empty
? new List<EstimateDto>()
: new List<EstimateDto>
{
new()
{
Id = 1,
Title = "Devis plomberie",
Description = "Réparation fuite",
ClientId = "bob",
OwnerId = "alice",
CommandType = "Rdv",
Bill = new List<EstimateLineDto>
{
new() { Name = "Main d'œuvre", Description = "Intervention", Count = 1, UnitaryCost = 80m },
},
},
new()
{
Id = 2,
Title = "Devis électricité",
Description = "Mise aux normes",
ClientId = "carol",
OwnerId = "alice",
CommandType = "Rdv",
Bill = new List<EstimateLineDto>(),
},
};
return Task.FromResult((T)(object)items);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using PostIt.Controls;
using PostIt.ViewModels;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
/// signature pads (provider + client) into the right
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
/// is not tested here; the VM is driven through the same
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
/// and the API client through a recording fake
/// <see cref="IYavscApiClient"/>. The test host's
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
/// run to completion without navigation side effects.
/// </summary>
public class EstimateValidationPageViewModelTests
{
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
{
Id = 7,
CommandId = commandId,
CommandType = "Rdv",
Title = "Devis plomberie",
Description = "Réparation",
ClientId = "bob",
OwnerId = "alice",
};
private static void DrawOneStroke(SignaturePadControl pad)
{
pad.AppendPointForTest(100, 200);
pad.AppendPointForTest(300, 400);
pad.SealStrokeForTest();
}
[Fact]
public async Task Validate_calls_accept_with_strokes_and_command_id()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var clientPad = new SignaturePadControl();
vm.Attach(proPad, clientPad);
DrawOneStroke(proPad); // provider signs on their writable pad
await vm.ValidateAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/accept", api.LastPath);
Assert.Contains("billingCode=Rdv", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.NotNull(body.Strokes);
Assert.NotEmpty(body.Strokes);
Assert.Equal(10_000, body.CoordinateMax);
// The provider perspective declares the Pro side, so the
// server stores the signature under the provider's author
// regardless of role inference.
Assert.Equal(SignatureType.Pro, body.SignatureType);
}
[Fact]
public async Task Validate_from_client_perspective_declares_client_side()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var cliPad = new SignaturePadControl();
vm.Attach(new SignaturePadControl(), cliPad);
DrawOneStroke(cliPad); // client signs on their writable pad
await vm.ValidateAsync();
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
}
[Fact]
public async Task Reject_calls_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
await vm.RejectAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/reject", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Validate_without_signature_warns_and_does_not_call()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
await vm.ValidateAsync(); // no throw: returns before GoBack
Assert.Null(api.LastMethod);
Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_command_gates_on_signature_and_command()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
vm.Attach(proPad, new SignaturePadControl());
// No CommandId ⇒ cannot validate even after signing.
DrawOneStroke(proPad);
Assert.False(vm.ValidateCommand.CanExecute(null));
var vm2 = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad2 = new SignaturePadControl();
vm2.Attach(proPad2, new SignaturePadControl());
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
DrawOneStroke(proPad2);
Assert.True(vm2.ValidateCommand.CanExecute(null));
}
[Fact]
public void RoleLabel_reflects_perspective()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var pro = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var cli = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase);
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Only_the_author_pad_is_writable()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var proVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
proVm.Attach(proPad, cliPad);
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
Assert.True(cliPad.IsReadOnly);
var cliVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var proPad2 = new SignaturePadControl();
var cliPad2 = new SignaturePadControl();
cliVm.Attach(proPad2, cliPad2);
Assert.True(proPad2.IsReadOnly);
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
}
[Fact]
public void Load_existing_signatures_renders_both_sides_in_their_pads()
{
var proStrokes = new[] { 1, 1000, 1000 };
var cliStrokes = new[] { 1, 9000, 9000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
// Client perspective: both pads show the stored signatures, but
// only the client pad is writable.
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
Assert.True(vm.HasProSignature);
Assert.True(vm.HasClientSignature);
}
[Fact]
public void Load_existing_pro_signature_renders_in_pro_pad_only()
{
var strokes = new[] { 1, 5000, 5000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(strokes, proPad.Snapshot().Strokes);
Assert.True(cliPad.Snapshot().IsEmpty);
Assert.True(vm.HasProSignature);
Assert.False(vm.HasClientSignature);
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,157 @@
using System.Net.Http;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
/// the PostIt signature payload, using a recording fake
/// <see cref="IYavscApiClient"/> (no network).
/// </summary>
public class FrontOfficeApiClientTests
{
private static QueryAcceptanceRequestDto SampleBody() => new()
{
Strokes = new[] { 2, 100, 200, 300, 400 },
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task Accept_posts_to_accept_endpoint_with_signature_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var body = SampleBody();
await client.AcceptQueryAsync("Rdv", 42, body);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Same(body, api.LastBody);
}
[Fact]
public async Task Reject_posts_to_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.RejectQueryAsync("Rdv", 42);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Accept_returns_deserialized_response()
{
var api = new RecordingApi
{
Response = new QueryAcceptanceResponseDto
{
QueryId = 42,
Status = "Accepted",
Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" },
},
};
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody());
Assert.Equal(42, response.QueryId);
Assert.Equal("Accepted", response.Status);
Assert.NotNull(response.Signature);
Assert.Equal("Pro", response.Signature!.Type);
}
[Fact]
public async Task Accept_rejects_invalid_arguments()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await Assert.ThrowsAsync<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
}
[Fact]
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.GetEstimatePdfAsync(42);
Assert.Equal(HttpMethod.Get, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/42/estimate.pdf",
api.LastPath);
}
[Fact]
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.GetEstimateTexAsync(42);
Assert.Equal(HttpMethod.Get, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/42/estimate.tex",
api.LastPath);
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public QueryAcceptanceResponseDto? Response { get; set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
var response = Response ?? new QueryAcceptanceResponseDto();
return Task.FromResult((T)(object)response);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = null;
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,199 @@
using System.Net.Http;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Abstract.Models.Messaging;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class HomePageNotificationsTests
{
[Fact]
public async Task Refresh_loads_public_notifications_from_the_business_host()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
transport.Response = Task.FromResult(new List<Notification> { Note(1, "Information") });
await vm.RefreshNotificationsAsync();
Assert.Equal("https://business.example/api/v1/notifications", transport.LastPath);
Assert.True(transport.WasAnonymous);
Assert.Equal("Information", Assert.Single(vm.Notifications).title);
Assert.False(vm.IsLoadingNotifications);
Assert.False(vm.HasNoNotifications);
Assert.Null(vm.NotificationsError);
}
[Fact]
public async Task Refresh_replaces_notifications_and_distinguishes_empty_from_failed()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
vm.Notifications.Add(Note(1, "Old"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.True(vm.HasNoNotifications);
transport.Response = Task.FromException<List<Notification>>(new HttpRequestException("Server unavailable"));
await vm.RefreshNotificationsAsync();
Assert.Empty(vm.Notifications);
Assert.False(vm.HasNoNotifications);
Assert.Contains("Server unavailable", vm.NotificationsError);
Assert.False(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Recovered") });
await vm.RefreshNotificationsAsync();
Assert.Null(vm.NotificationsError);
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
[Fact]
public async Task Refresh_ignores_an_older_response_after_a_newer_refresh()
{
var transport = new NotificationTransport();
var vm = CreateViewModel(transport);
var oldResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = oldResponse.Task;
var oldLoad = vm.RefreshNotificationsAsync();
Assert.True(vm.IsLoadingNotifications);
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Current") });
await vm.RefreshNotificationsAsync();
oldResponse.SetResult(new List<Notification> { Note(1, "Stale") });
await oldLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.False(vm.IsLoadingNotifications);
}
[AvaloniaFact]
public async Task Login_and_logout_reload_notifications_and_discard_in_flight_private_data()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-notifications-{Guid.NewGuid():N}.json");
var settings = new Settings();
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var api = new YavscApiClient(settings, store);
try
{
var session = new SessionStatusViewModel { Api = api };
var transport = new NotificationTransport();
var vm = CreateViewModel(transport, session);
session.Refresh();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
var privateResponse = new TaskCompletionSource<List<Notification>>();
transport.Response = privateResponse.Task;
var privateLoad = vm.RefreshNotificationsAsync();
Assert.False(transport.WasAnonymous);
Assert.EndsWith("/notifications/me", transport.LastPath);
vm.Notifications.Add(Note(9, "Private"));
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Public") });
session.SetError("Disconnected");
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
Assert.True(transport.WasAnonymous);
privateResponse.SetResult(new List<Notification> { Note(1, "Late private data") });
await privateLoad;
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
File.Delete(tokenPath);
}
}
[AvaloniaFact]
public async Task Home_page_loads_on_navigation_and_renders_wrapped_titles_and_bodies()
{
var transport = new NotificationTransport
{
Response = Task.FromResult(new List<Notification> { Note(1, "Notification title") })
};
var vm = CreateViewModel(transport);
var page = new HomePage { DataContext = vm };
var nav = new NavigationPage();
var window = new Window { Content = nav, Width = 320, Height = 450 };
try
{
window.Show();
await nav.PushAsync(page);
window.UpdateLayout();
Assert.Single(vm.Notifications);
var texts = page.GetVisualDescendants().OfType<TextBlock>().ToArray();
Assert.Contains(texts, t => t.Text == "Notification title"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
Assert.Contains(texts, t => t.Text == "Notification body"
&& t.TextWrapping == Avalonia.Media.TextWrapping.Wrap);
var refresh = Assert.Single(page.GetVisualDescendants().OfType<Button>(),
b => b.Command == vm.RefreshNotificationsCommand);
Assert.True(refresh.IsEnabled);
Assert.Contains(page.GetVisualDescendants().OfType<ScrollViewer>(),
s => s.Extent.Height > s.Viewport.Height);
await nav.PushAsync(new ContentPage());
transport.Response = Task.FromResult(new List<Notification> { Note(2, "Updated title") });
await nav.PopAsync();
Assert.Equal(2, Assert.Single(vm.Notifications).Id);
}
finally
{
window.Close();
}
}
private static HomePageViewModel CreateViewModel(NotificationTransport transport,
SessionStatusViewModel? session = null)
=> new(null!, new Settings(), session ?? new SessionStatusViewModel(),
new NotificationsApiClient(transport, "https://business.example/api/v1/"));
private static Notification Note(long id, string title)
=> new() { Id = id, title = title, body = "Notification body", click_action = "Close" };
private sealed class NotificationTransport : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public bool WasAnonymous { get; private set; }
public Task<List<Notification>> Response { get; set; } = Task.FromResult(new List<Notification>());
public Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
=> ReadAsync<T>(path, true);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
Assert.Equal(HttpMethod.Get, method);
return ReadAsync<T>(path, false);
}
private async Task<T> ReadAsync<T>(string path, bool anonymous)
{
LastPath = path;
WasAnonymous = anonymous;
return (T)(object)await Response;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null,
CancellationToken ct = default) => throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory,
CancellationToken ct = default) => throw new NotSupportedException();
public ValueTask DisposeAsync()
{
Http.Dispose();
return ValueTask.CompletedTask;
}
}
}

View file

@ -0,0 +1,182 @@
using Yavsc.Abstract.Identity;
using PostIt.ViewModels;
namespace PostIt.Tests;
public class HomePageProviderFlowTests
{
[Fact]
public void HomePage_exposes_provider_requests_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProviderRequests);
Assert.True(vm.OpenProviderRequests.CanExecute(null));
}
[Fact]
public void HomePage_exposes_profile_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenProfile);
Assert.True(vm.OpenProfile.CanExecute(null));
}
[Fact]
public void HomePage_exposes_performer_configuration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenPerformerConfiguration);
Assert.True(vm.OpenPerformerConfiguration.CanExecute(null));
}
[Fact]
public void HomePage_exposes_administration_command()
{
var vm = new HomePageViewModel();
Assert.NotNull(vm.OpenAdministration);
Assert.True(vm.OpenAdministration.CanExecute(null));
}
[Fact]
public void Administration_page_filters_users_by_search_text()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "3", UserName = "carol", FullName = "Carol Smith" });
vm.SearchText = "ali";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_filters_users_by_full_name()
{
var vm = new AdministrationPageViewModel();
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example" });
vm.Users.Add(new Yavsc.Abstract.Identity.UserInfo { UserId = "2", UserName = "bob", FullName = "Bob Jones" });
vm.SearchText = "example";
Assert.Single(vm.FilteredUsers);
Assert.Equal("alice", vm.FilteredUsers[0].UserName);
}
[Fact]
public void Administration_page_tracks_selected_user()
{
var vm = new AdministrationPageViewModel();
var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" };
vm.Users.Add(user);
vm.SelectedUser = user;
Assert.Same(user, vm.SelectedUser);
Assert.Equal("alice", vm.SelectedUser?.UserName);
}
[Fact]
public void UserProfilePage_exposes_full_name_and_address_fields()
{
var vm = new UserProfilePageViewModel();
Assert.Equal("—", vm.FullName);
Assert.Equal("—", vm.Address);
Assert.Equal("Non configuré", vm.DedicatedGoogleCalendar);
Assert.Equal("Aucune information bancaire", vm.BankInfoSummary);
Assert.NotNull(vm.RefreshCommand);
}
[Fact]
public void ProfileUpdateRequest_allows_username_updates()
{
var request = new ProfileUpdateRequest
{
UserName = "alice.new"
};
Assert.Equal("alice.new", request.UserName);
}
[Fact]
public void UserProfilePage_can_save_profile_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
Assert.NotNull(vm.SaveProfileCommand);
Assert.False(vm.SaveProfileCommand.CanExecute(null));
}
[Fact]
public void UserProfilePage_builds_profile_payload_for_calendar_and_bank_values()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "calendar-123",
BankInfoSummary = "FR14 2004 1010 0505 0001 3M02 606"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal("alice.new", payload.UserName);
Assert.Equal("calendar-123", payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void UserProfilePage_turns_placeholder_values_into_empty_payload_fields()
{
var vm = new UserProfilePageViewModel
{
UserName = "alice.new",
FullName = "Alice Example",
Address = "14 rue de l’Érable",
DedicatedGoogleCalendar = "Non configuré",
BankInfoSummary = "Aucune information bancaire"
};
var payload = vm.BuildProfileUpdateRequest();
Assert.Equal(string.Empty, payload.GoogleCalendarId);
Assert.Null(payload.BankInfoSummary);
}
[Fact]
public void PerformerConfigurationPage_preserves_current_user_id_in_save_payload()
{
var vm = new PerformerConfigurationPageViewModel
{
PerformerId = "user-42",
UserName = "alice",
ExerciseCountryCode = "fr",
SIREN = "123456789",
Website = "https://example.com",
Active = true,
AcceptNotifications = true,
AcceptPublicContact = true,
UseGeoLocalizationToReduceDistanceWithClients = true
};
var payload = vm.BuildPerformerProfileSettings();
Assert.Equal("user-42", payload.PerformerId);
Assert.Equal("alice", payload.UserName);
Assert.Equal("fr", payload.ExerciseCountryCode);
}
}

View file

@ -0,0 +1,34 @@
Loin vers l'infini s'étendent
Les grands prés marécageux .
Pas un seul oiseau ne chante
Sur les arbres secs et creux !
*(Refrain)*
Ô terre de détresse,
Où nous devons sans cesse,
Piocher, piocher .
Dans ce camp morne et sauvage
Entouré de murs de fer,
Il nous semble vivre en cage
Au milieu d'un grand désert .
Bruit des pas et bruit des armes,
Sentinelles jours et nuits,
Et du sang, et des cris, des larmes,
La mort pour celui qui fuit .
Mais un jour dans notre vie,
Le printemps refleurira .
Liberté, liberté chérie
Je dirai : « Tu es à moi ! »
*(Dernier refrain)*
Ô terre d'allégresse,
Où nous pourrons sans cesse,
Aimer, aimer !
[audio : Mario Hacquard - Youtube](https://www.youtube.com/watch?v=ogw_mgk1pFs)

View file

@ -8,6 +8,8 @@ using Yavsc.Blogspot;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Helpers;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -72,13 +74,13 @@ public class MainPageButtonsTests
{ } { }
} }
private static MainViewModel MakeViewModel(BlogPostDto? selectedPost = null) private static BlogsViewModel MakeViewModel(BlogPostDto? selectedPost = null)
{ {
var api = new ThrowingApi(); var api = new ThrowingApi();
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var circle = new CircleApiClient(api, "http://localhost/"); var circle = new CircleApiClient(api, "http://localhost/");
var acl = new BlogAclApiClient(api, "http://localhost/"); var acl = new BlogAclApiClient(api, "http://localhost/");
// Minimal DI graph: only what MainPageViewModel resolves // Minimal DI graph: only what BlogsViewModel resolves
// when the user clicks a navigation button. Today that's // when the user clicks a navigation button. Today that's
// SignaturePageViewModel / CirclesPageViewModel / ACL // SignaturePageViewModel / CirclesPageViewModel / ACL
// dependencies. The graph intentionally stays local to this // dependencies. The graph intentionally stays local to this
@ -93,7 +95,7 @@ public class MainPageButtonsTests
services.AddTransient<SignaturePage>(); services.AddTransient<SignaturePage>();
services.AddTransient<CirclesPage>(); services.AddTransient<CirclesPage>();
services.AddTransient<PostAclDialog>(); services.AddTransient<PostAclDialog>();
var vm = new MainViewModel(blog, services: services.BuildServiceProvider()); var vm = new BlogsViewModel(blog, services: services.BuildServiceProvider());
if (selectedPost is not null) vm.SelectedPost = selectedPost; if (selectedPost is not null) vm.SelectedPost = selectedPost;
return vm; return vm;
} }
@ -101,7 +103,7 @@ public class MainPageButtonsTests
/// <summary> /// <summary>
/// Mount a real <see cref="MainView"/> (as /// Mount a real <see cref="MainView"/> (as
/// <c>SessionStatusBannerTests</c> does), push a /// <c>SessionStatusBannerTests</c> does), push a
/// <see cref="MainPage"/> with the given VM onto /// <see cref="BlogsPage"/> with the given VM onto
/// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via /// <c>NavRoot</c>. <c>PushAsync</c> is awaited (via
/// <c>GetAwaiter().GetResult()</c>) so the page is on the /// <c>GetAwaiter().GetResult()</c>) so the page is on the
/// nav stack before the test tries to interact with its /// nav stack before the test tries to interact with its
@ -109,13 +111,17 @@ public class MainPageButtonsTests
/// realised and <c>KeyPressQwerty</c> has a real /// realised and <c>KeyPressQwerty</c> has a real
/// <see cref="TopLevel"/> to dispatch against. /// <see cref="TopLevel"/> to dispatch against.
/// </summary> /// </summary>
private static (MainView window, MainPage page) MountMainPage(MainViewModel vm) private static (MainView window, BlogsPage page) MountMainPage(BlogsViewModel vm)
{ {
var window = new MainView(); var window = new MainView();
var page = new MainPage { DataContext = vm }; var page = new BlogsPage { DataContext = vm };
var app = (PostIt.App)Application.Current!; var app = (PostIt.App)Application.Current!;
app.AttachMainWindow(window); app.AttachMainWindow(window);
window.NavRoot.PushAsync(page).GetAwaiter().GetResult(); window.NavRoot.PushAsync(page).GetAwaiter().GetResult();
var mainWindow = new Window { Content = window };
mainWindow.Show();
return (window, page); return (window, page);
} }
@ -145,7 +151,7 @@ public class MainPageButtonsTests
} }
[AvaloniaFact] [AvaloniaFact]
public void Acl_button_click_pushes_a_page_onto_nav_stack() public async Task Acl_button_click_pushes_a_page_onto_nav_stack()
{ {
// Arrange: a VM whose SelectedPost is non-null so // Arrange: a VM whose SelectedPost is non-null so
// CanManageAcl evaluates to true and the button is // CanManageAcl evaluates to true and the button is
@ -178,7 +184,7 @@ public class MainPageButtonsTests
} }
[AvaloniaFact] [AvaloniaFact]
public void Circles_button_click_pushes_a_page_onto_nav_stack() public async Task Circles_button_click_pushes_a_page_onto_nav_stack()
{ {
// Arrange: OpenCircles has no CanExecute guard today — // Arrange: OpenCircles has no CanExecute guard today —
// any click should fire it and push the page. // any click should fire it and push the page.
@ -203,7 +209,7 @@ public class MainPageButtonsTests
public void Signature_dev_button_click_pushes_a_page_onto_nav_stack() public void Signature_dev_button_click_pushes_a_page_onto_nav_stack()
{ {
// Arrange: the "[DEV] Signature" button is bound to the // Arrange: the "[DEV] Signature" button is bound to the
// MainPageViewModel.OpenSignatureDevCommand [RelayCommand]. // BlogsViewModel.OpenSignatureDevCommand [RelayCommand].
// The click must push SignaturePage on top of NavRoot. // The click must push SignaturePage on top of NavRoot.
// The ServiceCollection registered in MakeViewModel provides // The ServiceCollection registered in MakeViewModel provides
// SignaturePageViewModel so the command can resolve it via // SignaturePageViewModel so the command can resolve it via

View file

@ -5,10 +5,11 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs;
namespace PostIt.Tests; namespace PostIt.Tests;
/// <summary> /// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>. /// Headless UI tests for the "Save" flow in <see cref="BlogsPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c> /// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/> /// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because /// hosting the page (via a <see cref="Frame"/> because
@ -40,9 +41,9 @@ public class MainPageSaveTests
var recorder = new CallRecorder(); var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder); var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog); var viewModel = new BlogsViewModel(blog);
var page = new MainPage { DataContext = viewModel }; var page = new BlogsPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it // MainPage is a ContentPage (a Page, not a Control), so it
// must be hosted in a navigation surface. The production // must be hosted in a navigation surface. The production
// MainWindow.axaml uses NavigationPage, and the API is the // MainWindow.axaml uses NavigationPage, and the API is the
@ -63,9 +64,7 @@ public class MainPageSaveTests
const string typed = "Mon premier billet"; const string typed = "Mon premier billet";
titleBox.Text = typed; titleBox.Text = typed;
var saveButton = window.GetVisualDescendants() var saveButton = page.SaveButton;
.OfType<Button>()
.Single(b => b.Content as string == "Save");
saveButton.Command!.Execute(null); saveButton.Command!.Execute(null);
// The Save command is async (RelayCommand over Task) but // The Save command is async (RelayCommand over Task) but

View file

@ -0,0 +1,143 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.LogicalTree;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using PostIt.Controls;
using PostIt.Views.Blogs;
namespace PostIt.Tests;
public class MarkdownEditorScrollTests
{
private static readonly string LongArticle =
string.Join("\n\n", Enumerable.Range(1, 100).Select(i => $"Paragraph {i}: article content."));
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Long_article_scrolls_to_the_end_and_adapts_to_resizing(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = LongArticle,
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
AssertScrollable(scroller);
var initialViewport = scroller.Viewport.Height;
window.Height = 300;
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
Assert.True(scroller.Viewport.Height < initialViewport);
AssertScrollable(scroller);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Blog_page_bounds_the_article_viewport(bool preview)
{
var page = new BlogsPage();
var editor = page.GetLogicalDescendants().OfType<MarkdownEditorControl>().Single();
editor.MarkdownText = LongArticle;
editor.IsPreviewVisible = preview;
var posts = page.FindControl<ListBox>("PostsListBox")!;
posts.ItemsSource = Enumerable.Range(1, 50)
.Select(i => new Yavsc.Blogspot.BlogPostDto { Title = $"Post {i}" }).ToArray();
var navigation = new NavigationPage();
navigation.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Width = 800, Height = 700, Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.InRange(scroller.Viewport.Height, 1, window.ClientSize.Height);
AssertScrollable(scroller);
AssertScrollable(posts.GetVisualDescendants().OfType<ScrollViewer>().Single());
var editorBottom = editor.TranslatePoint(new Point(0, editor.Bounds.Height), window);
Assert.NotNull(editorBottom);
Assert.InRange(editorBottom.Value.Y, 1, window.ClientSize.Height);
}
finally
{
CloseWindow(window, editor);
}
}
[AvaloniaTheory]
[InlineData(false)]
[InlineData(true)]
public void Short_article_needs_no_vertical_scrollbar(bool preview)
{
var editor = new MarkdownEditorControl
{
MarkdownText = "Short article",
IsPreviewVisible = preview
};
var window = new Window { Width = 600, Height = 400, Content = editor };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var scroller = FindArticleScroller(editor, preview);
Assert.True(scroller.Extent.Height <= scroller.Viewport.Height);
Assert.DoesNotContain(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
}
finally
{
CloseWindow(window, editor);
}
}
private static void CloseWindow(Window window, MarkdownEditorControl editor)
{
// The package's shared renderer caches the visual; detach it between headless applications.
editor.FindControl<MarkdownViewer.Core.Controls.MarkdownViewer>("PreviewViewer")!.Content = null;
window.UpdateLayout();
window.Close();
}
private static ScrollViewer FindArticleScroller(MarkdownEditorControl editor, bool preview)
{
if (!preview)
return editor.GetVisualDescendants().OfType<TextEditor>().Single()
.GetVisualDescendants().OfType<ScrollViewer>().Single();
return editor.FindControl<ScrollViewer>("PreviewScrollViewer")!;
}
private static void AssertScrollable(ScrollViewer scroller)
{
Assert.True(scroller.Extent.Height > scroller.Viewport.Height,
$"Expected overflow, but extent={scroller.Extent.Height}, viewport={scroller.Viewport.Height}.");
Assert.Contains(scroller.GetVisualDescendants().OfType<ScrollBar>(),
bar => bar.Orientation == Avalonia.Layout.Orientation.Vertical && bar.IsVisible);
scroller.ScrollToEnd();
Assert.True(scroller.Offset.Y > 0);
Assert.Equal(scroller.Extent.Height - scroller.Viewport.Height, scroller.Offset.Y, 1);
}
}

View file

@ -0,0 +1,208 @@
using System.Net.Http;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using PostIt.ViewModels;
using PostIt.Views;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Headless UI coverage for the "Mes fichiers" page
/// (<see cref="MyFilesPage"/>). The user reported that once a file
/// is listed, "l'item est inerte au click, et ses boutons restent
/// grisés, désactivés" — neither the row nor its action buttons
/// (Télécharger / Supprimer) respond to a click.
///
/// <para>These tests mount a real <see cref="MyFilesPage"/> under
/// the Avalonia headless platform with a stub
/// <see cref="IYavscApiClient"/> that lists one file, then walk the
/// realised visual tree to find the row's action buttons and
/// assert the two things that would silently break and reproduce
/// the "grayed / inert" symptom:</para>
/// <list type="number">
/// <item><description>The <c>Command</c> binding
/// (<c>$parent[vm:MyFilesViewModel].DownloadFileCommand</c>)
/// resolved to a non-null command — a null command leaves the
/// button disabled (grayed) and click-silent.</description></item>
/// <item><description>The command is <b>armed</b>
/// (<c>CanExecute</c> true) for the listed file — an
/// <c>AsyncRelayCommand</c> with
/// <c>AllowConcurrentExecutions=false</c> (the
/// <see cref="CommunityToolkit.Mvvm.Input.RelayCommandAttribute"/>
/// default) disables itself while running; a button stuck
/// "running" is exactly the grayed-and-inert report.</description></item>
/// <item><description>Executing the command actually invokes the
/// file download / delete on the API client — the row
/// "activates" on click instead of being inert.</description></item>
/// </list>
/// <para>The OS save picker is absent under headless, so
/// <c>FileSaveHelpers.SaveAsync</c> throws or cancels and the
/// download command's <c>catch</c> sets an error status — but the
/// API download is awaited <em>before</em> the picker, so the call
/// is recorded regardless. We do not <c>await</c> the command's
/// <c>ExecutionTask</c> to completion, to avoid blocking on the
/// (platform-dependent) picker.</para>
/// </summary>
public class MyFilesPageHeadlessTests
{
private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage()
{
var api = new StubFsApi();
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/");
var vm = new MyFilesViewModel(fsClient);
var page = new MyFilesPage { DataContext = vm };
return (page, vm, api);
}
/// <summary>
/// Mount the page the way the app does — pushed onto a
/// <see cref="NavigationPage"/> shown in a <see cref="Window"/>
/// (a <see cref="ContentPage"/> realizes its content template via
/// the navigation host, cf. <c>RdvPageHeadlessTests</c>) — after
/// listing a single file by awaiting
/// <see cref="MyFilesViewModel.InitializeAsync"/>. Populating
/// <see cref="MyFilesViewModel.Files"/> before the first layout
/// pass lets the <c>ItemsControl</c> realise its item templates
/// under headless.
/// </summary>
private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync()
{
var (page, vm, api) = CreatePage();
await vm.InitializeAsync();
var nav = new NavigationPage();
nav.PushAsync(page).GetAwaiter().GetResult();
var window = new Window { Content = nav };
window.Show();
// Let bindings / the ItemsControl item templates apply.
await Task.Delay(50);
return (page, vm, api);
}
/// <summary>
/// Find the first action button in a file row whose content
/// matches <paramref name="content"/>. The file rows live in an
/// <c>ItemsControl</c> DataTemplate (no <c>x:Name</c>), so we
/// walk the visual tree.
/// </summary>
private static Button? FindRowButton(MyFilesPage page, string content)
=> page.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => b.Content is string s && s == content);
[AvaloniaFact]
public async Task Download_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
// Sanity: the listing populated a file row.
Assert.NotEmpty(vm.Files);
Assert.NotNull(vm.Files[0].Id);
var downloadBtn = FindRowButton(page, "Télécharger");
Assert.NotNull(downloadBtn);
Assert.NotNull(downloadBtn!.Command); // binding resolved (not grayed-via-null)
var param = downloadBtn.CommandParameter;
Assert.True(downloadBtn.Command!.CanExecute(param), // armed (not stuck running)
"Télécharger must be armed (CanExecute true) for a listed file.");
// Act: click → fire the command.
downloadBtn.Command.Execute(param);
// Assert: the row activated — the API download was invoked
// (it is awaited before the headless-absent save picker).
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Get && c.Path == "fs/file/42"))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Télécharger must invoke GET fs/file/{id} on the API client.");
}
[AvaloniaFact]
public async Task Delete_button_on_a_file_row_is_bound_armed_and_fires_on_click()
{
var (page, vm, api) = await MountAsync();
Assert.NotEmpty(vm.Files);
var deleteBtn = FindRowButton(page, "Supprimer");
Assert.NotNull(deleteBtn);
Assert.NotNull(deleteBtn!.Command);
var param = deleteBtn.CommandParameter;
Assert.True(deleteBtn.Command!.CanExecute(param),
"Supprimer must be armed (CanExecute true) for a listed file.");
deleteBtn.Command.Execute(param);
var recorded = false;
for (int i = 0; i < 40; i++)
{
if (api.Calls.Any(c => c.Method == HttpMethod.Delete))
{
recorded = true;
break;
}
await Task.Delay(25);
}
Assert.True(recorded,
"Clicking Supprimer must invoke DELETE fs/{path} on the API client.");
}
private sealed class StubFsApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
=> Calls.Add((method, path, body));
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (typeof(T) == typeof(UserDirectoryDto))
{
var dir = new UserDirectoryDto
{
UserName = "alice",
Files = new[]
{
new FileEntryDto { Id = 42, Name = "note.txt", Size = 4 }
}
};
return Task.FromResult((T)(object)dir);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -21,9 +21,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
private readonly RSA _rsa; private readonly RSA _rsa;
private readonly string _kid; private readonly string _kid;
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private readonly HashSet<string> _refreshTokens = new(StringComparer.Ordinal);
public string Issuer { get; } public string Issuer { get; }
public string LoopbackRedirectUri { get; } public string LoopbackRedirectUri { get; }
public int RefreshRequestCount { get; private set; }
private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback) private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
{ {
@ -100,7 +102,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["response_types_supported"] = new[] { "code" }, ["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" }, ["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" }, ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["grant_types_supported"] = new[] { "authorization_code" }, ["grant_types_supported"] = new[] { "authorization_code", "refresh_token" },
["code_challenge_methods_supported"] = new[] { "S256" }, ["code_challenge_methods_supported"] = new[] { "S256" },
}; };
@ -132,6 +134,16 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
body = await reader.ReadToEndAsync(); body = await reader.ReadToEndAsync();
var form = ParseForm(body); var form = ParseForm(body);
if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token")
{
RefreshRequestCount++;
if (!form.TryGetValue("refresh_token", out var suppliedToken)
|| !_refreshTokens.Remove(suppliedToken))
{
await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400);
return;
}
}
// We accept any code and don't validate PKCE on the stub side; // We accept any code and don't validate PKCE on the stub side;
// the OidcClient itself validates the redirect_uri match. // the OidcClient itself validates the redirect_uri match.
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@ -142,11 +154,13 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests", ["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
["exp"] = now + 600, ["exp"] = now + 600,
["iat"] = now, ["iat"] = now,
["jti"] = Guid.NewGuid().ToString("N"),
}; };
var accessToken = SignJwt(claims); var accessToken = SignJwt(claims);
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_'); .TrimEnd('=').Replace('+', '-').Replace('/', '_');
_refreshTokens.Add(refreshToken);
var response = new var response = new
{ {
access_token = accessToken, access_token = accessToken,
@ -193,10 +207,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable
return dict; return dict;
} }
private static async Task WriteJsonAsync(HttpListenerResponse response, object payload) private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200)
{ {
response.ContentType = "application/json"; response.ContentType = "application/json";
response.StatusCode = 200; response.StatusCode = statusCode;
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
await response.OutputStream.WriteAsync(bytes); await response.OutputStream.WriteAsync(bytes);
response.Close(); response.Close();

View file

@ -270,6 +270,12 @@ public class PostAclDialogTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -8,14 +8,13 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Xamarin.UITest" /> <PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" /> <PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" /> <PackageReference Include="Avalonia.Headless.XUnit" />

View file

@ -1,35 +1,153 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views.Blogs;
namespace PostIt.Tests; namespace PostIt.Tests;
public class PostItViewModelTests public class PostItViewModelTests
{ {
[Fact]
public void Draft_modified_tracks_title_body_and_attachments_not_publication()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
vm.SelectedPost = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
Assert.False(vm.IsDraftModified);
vm.DraftIsPublished = true;
Assert.False(vm.IsDraftModified);
vm.DraftTitle = "Autre";
Assert.True(vm.IsDraftModified);
vm.DraftTitle = "Titre";
Assert.False(vm.IsDraftModified);
vm.DraftArticleDocument!.Insert(0, "Edit ");
Assert.True(vm.IsDraftModified);
vm.DraftArticle = "Corps";
Assert.False(vm.IsDraftModified);
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
Assert.True(vm.IsDraftModified);
}
[AvaloniaFact]
public async Task Save_keeps_selection_and_editor_open_and_resets_highlight()
{
var recorder = new CallRecorder();
var vm = new BlogsViewModel(new BlogApiClient(new RecordingYavscApiClient(recorder),
"https://blogs.example.test/api/v1/"));
var selected = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.Posts.Add(selected);
vm.SearchText = "";
vm.SelectedPost = selected;
var page = new BlogsPage { DataContext = vm };
var navigation = new NavigationPage();
await navigation.PushAsync(page);
var window = new Window { Content = navigation };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
selected = Assert.Single(vm.FilteredPosts);
selected.AuthorId = "tester";
selected.Title = "Titre";
selected.Article = "Corps";
page.PostsListBox.SelectedItem = selected;
Dispatcher.UIThread.RunJobs();
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Same(selected, vm.SelectedPost);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
vm.DraftTitle = "Titre modifié";
Dispatcher.UIThread.RunJobs();
Assert.Contains("modified", page.SaveButton.Classes);
Assert.Equal(Avalonia.Media.FontWeight.Bold, page.SaveButton.FontWeight);
Assert.Same(vm.SaveCommand, page.SaveButton.Command);
await vm.SaveAsync();
Dispatcher.UIThread.RunJobs();
Assert.Same(page, navigation.NavigationStack.Last());
Assert.Same(selected, vm.SelectedPost);
Assert.Same(selected, page.PostsListBox.SelectedItem);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal("Corps", vm.DraftArticle);
Assert.DoesNotContain("modified", page.SaveButton.Classes);
}
finally
{
window.Close();
}
}
[Fact]
public async Task Failed_save_preserves_draft_attachments_and_modified_state()
{
var vm = new BlogsViewModel(new BlogApiClient(new ThrowingYavscApiClient(), "http://localhost/"));
var post = new BlogPostDto { Id = 42, AuthorId = "tester", Title = "Titre", Article = "Corps" };
vm.SelectedPost = post;
vm.DraftTitle = "Titre modifié";
vm.DraftAttachments.Add(new BlogUploadFile("note.txt", new byte[] { 1 }));
var article = vm.DraftArticle;
await vm.SaveAsync();
Assert.Same(post, vm.SelectedPost);
Assert.Equal("Titre modifié", vm.DraftTitle);
Assert.Equal(article, vm.DraftArticle);
Assert.Single(vm.DraftAttachments);
Assert.True(vm.IsDraftModified);
Assert.False(vm.IsBusy);
}
[AvaloniaFact]
public void Published_badge_tracks_post_publication_state()
{
var post = new BlogPostDto { Id = 42, Title = "Test post", IsPublished = true };
var page = new BlogsPage();
var row = page.PostsListBox.ItemTemplate!.Build(post)!;
row.DataContext = post;
var window = new Window { Content = row };
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var badge = Assert.Single(row.GetVisualDescendants().OfType<Border>(),
border => border.Name == "PublishedBadge");
Assert.True(badge.IsVisible);
post.IsPublished = false;
Dispatcher.UIThread.RunJobs();
Assert.False(badge.IsVisible);
post.IsPublished = true;
Dispatcher.UIThread.RunJobs();
Assert.True(badge.IsVisible);
}
finally
{
window.Close();
}
}
[Fact] [Fact]
public void SearchCommand_filters_posts_by_title_article_or_author() public void SearchCommand_filters_posts_by_title_article_or_author()
{ {
// MainPageViewModel no longer owns a BlogApiClient instance by // BlogsViewModel no longer owns a BlogApiClient instance by
// default; tests construct one with a fake YavscApiClient that // default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test). // throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient(); var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi, "http://localhost/"); var blog = new BlogApiClient(fakeApi, "http://localhost/");
var viewModel = new MainViewModel(blog); var viewModel = new BlogsViewModel(blog);
viewModel.Posts.Add(new BlogPostDto { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); 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 = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" }); viewModel.Posts.Add(new BlogPostDto { Id = 3, Title = "Third post", Article = "Search me", AuthorId = "carol" });
viewModel.SearchText = "search"; viewModel.SearchText = "search";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts); Assert.Single(viewModel.FilteredPosts);
Assert.Equal(3, viewModel.FilteredPosts[0].Id); Assert.Equal(3, viewModel.FilteredPosts[0].Id);
viewModel.SearchText = "bob"; viewModel.SearchText = "bob";
viewModel.SearchCommand.Execute(null);
Assert.Single(viewModel.FilteredPosts); Assert.Single(viewModel.FilteredPosts);
Assert.Equal(2, viewModel.FilteredPosts[0].Id); Assert.Equal(2, viewModel.FilteredPosts[0].Id);
@ -60,14 +178,25 @@ public class PostItViewModelTests
{ {
var api = new RecordingPublishApi(); var api = new RecordingPublishApi();
var blog = new BlogApiClient(api, "http://localhost/"); var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog); var viewModel = new BlogsViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false }; viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
var changes = new List<string?>();
viewModel.SelectedPost.PropertyChanged += (_, args) => changes.Add(args.PropertyName);
await viewModel.SetPublishStateAsync(true); await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue); Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished); Assert.True(viewModel.DraftIsPublished);
Assert.True(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished) }, changes);
await viewModel.SetPublishStateAsync(false);
Assert.False(api.LastPublishValue);
Assert.False(viewModel.DraftIsPublished);
Assert.False(viewModel.SelectedPost.IsPublished);
Assert.Equal(new[] { nameof(BlogPostDto.IsPublished), nameof(BlogPostDto.IsPublished) }, changes);
} }
/// <summary>Test fake that always throws if the API is invoked.</summary> /// <summary>Test fake that always throws if the API is invoked.</summary>
@ -146,6 +275,12 @@ public class PostItViewModelTests
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -0,0 +1,238 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ProviderOngoingRequestsPageViewModelTests
{
[Fact]
public async Task RefreshAsync_calls_provider_endpoint_and_filters_out_unknown_billing_codes()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/bill/provider/ongoing", api.Paths);
Assert.Equal(3, vm.Queries.Count);
Assert.Equal(12, vm.Queries[0].Id);
Assert.Equal(11, vm.Queries[1].Id);
Assert.Equal(10, vm.Queries[2].Id);
}
[Fact]
public async Task FilterText_filters_by_activity_code_and_status()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.FilterText = "mbrush";
Assert.Single(vm.Queries);
Assert.Equal("MBrush", vm.Queries[0].BillingCode);
vm.FilterText = "accepted";
Assert.Single(vm.Queries);
Assert.Equal(QueryStatus.Accepted, vm.Queries[0].Status);
}
[Fact]
public async Task OpenSelectedEditorCommand_can_execute_only_when_selection_exists()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
Assert.False(vm.OpenSelectedEditorCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.OpenSelectedEditorCommand.CanExecute(null));
}
[Fact]
public async Task SelectedSortOption_date_keeps_most_recent_first()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDate;
Assert.Equal(new long[] { 12, 11, 10 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public async Task SelectedSortOption_date_ascending_keeps_oldest_first()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByDateAsc;
Assert.Equal(new long[] { 10, 11, 12 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public async Task SelectedSortOption_status_prioritizes_inprogress_then_accepted_then_inserted()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus;
Assert.Equal(new long[] { 11, 12, 10 }, vm.Queries.Select(q => q.Id).ToArray());
}
[Fact]
public void Constructor_reads_saved_sort_option_from_settings()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var settings = new Settings
{
ProviderOngoingRequestsSortOption = ProviderOngoingRequestsPageViewModel.SortByStatus,
};
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByStatus, vm.SelectedSortOption);
}
[Fact]
public void Constructor_falls_back_to_default_when_saved_sort_is_invalid()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var settings = new Settings
{
ProviderOngoingRequestsSortOption = "invalide",
};
var vm = new ProviderOngoingRequestsPageViewModel(client, settings);
Assert.Equal(ProviderOngoingRequestsPageViewModel.SortByDate, vm.SelectedSortOption);
}
[Fact]
public async Task CreateEstimateForSelectedCommand_requires_a_selection()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var estimateClient = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client, estimateClient: estimateClient);
await vm.InitializeAsync();
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
vm.SelectedQuery = vm.Queries[0];
Assert.True(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
[Fact]
public async Task CreateEstimateForSelectedCommand_is_disabled_without_estimate_client()
{
var api = new StubProviderApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ProviderOngoingRequestsPageViewModel(client);
await vm.InitializeAsync();
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.CreateEstimateForSelectedCommand.CanExecute(null));
}
private sealed class StubProviderApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
{
var data = new List<BillingQuerySummaryDto>
{
new()
{
Id = 10,
BillingCode = "Rdv",
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Inserted,
Description = "Rendez-vous",
EventDate = new DateTime(2026, 9, 10, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 11,
BillingCode = "MBrush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-2",
Status = QueryStatus.InProgress,
Description = "Coupe multiple",
EventDate = new DateTime(2026, 9, 11, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 12,
BillingCode = "Brush",
ActivityCode = "hair",
PerformerId = "perf-1",
ClientId = "cli-3",
Status = QueryStatus.Accepted,
Description = "Coupe simple",
EventDate = new DateTime(2026, 9, 12, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 13,
BillingCode = "",
ActivityCode = "unknown",
PerformerId = "perf-1",
ClientId = "cli-4",
Status = QueryStatus.Accepted,
Description = "Doit être filtrée",
EventDate = new DateTime(2026, 9, 13, 10, 0, 0, DateTimeKind.Utc),
}
};
return Task.FromResult((T)(object)data);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -74,6 +74,12 @@ public class RdvPageHeadlessTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask; => Task.CompletedTask;
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -0,0 +1,117 @@
using System.Net.Http;
using System.Text.Json;
using Yavsc.Abstract.Resources;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ResourceUsageApiClientTests
{
[Fact]
public async Task GetCurrentAsync_calls_user_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetCurrentAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/me", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(12.5m, usage.ApiCalls);
Assert.Equal(3.2m, usage.CpuSeconds);
}
[Fact]
public async Task GetAdminOverviewAsync_calls_admin_usage_endpoint()
{
var fake = new FakeYavscApiClient();
var client = new ResourceUsageApiClient(fake, "https://example.invalid/api/v1/");
var usage = await client.GetAdminOverviewAsync();
Assert.Equal("https://example.invalid/api/v1/resource-usage/admin", fake.LastPath);
Assert.Equal("GET", fake.LastMethod);
Assert.Equal(2, usage.Count);
Assert.Equal("administrator", usage[0].UserName);
}
private sealed class FakeYavscApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public string? LastPath { get; private set; }
public string? LastMethod { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method.Method;
LastPath = path;
if (typeof(T) == typeof(ResourceUsageSummary))
{
var summary = new ResourceUsageSummary
{
UserId = "user-42",
UserName = "alice",
ApiCalls = 12.5m,
CpuSeconds = 3.2m,
BandwidthMb = 18.4m,
StorageMb = 51.2m,
EstimatedCompensation = 4.9m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
};
return Task.FromResult((T)(object)summary);
}
if (typeof(T) == typeof(List<ResourceUsageSummary>))
{
var list = new List<ResourceUsageSummary>
{
new()
{
UserId = "admin-user",
UserName = "administrator",
ApiCalls = 90m,
CpuSeconds = 14m,
BandwidthMb = 500m,
StorageMb = 2048m,
EstimatedCompensation = 32m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
},
new()
{
UserId = "user-99",
UserName = "bob",
ApiCalls = 40m,
CpuSeconds = 7m,
BandwidthMb = 120m,
StorageMb = 600m,
EstimatedCompensation = 15m,
Currency = "EUR",
RecordedAtUtc = DateTimeOffset.UtcNow
}
};
return Task.FromResult((T)(object)list);
}
throw new NotSupportedException($"Unsupported type: {typeof(T).FullName}");
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> Task.CompletedTask;
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,6 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -23,41 +22,36 @@ namespace PostIt.Tests;
/// <c>new Window().Show()</c> throws because the harness has not /// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para> /// been initialised for that thread.</para>
/// ///
/// <para>The session banner's <c>DataContext</c> is not wired in /// <para>These tests set the DataContext directly on the banner
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is /// mounted in the window. The production code path is exercised
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para> /// end-to-end by the manual launch, not here.</para>
/// </summary> /// </summary>
public class SessionStatusBannerTests public class SessionStatusBannerTests : IDisposable
{ {
[AvaloniaFact] private MainWindow? _window;
public void Banner_renders_three_buttons_in_the_visual_tree()
{
MainWindow window = new MainWindow();
window.Show();
var buttons = window.GetVisualDescendants() [AvaloniaFact]
public void Banner_renders_four_buttons_in_the_visual_tree()
{
SessionStatusBanner banner = CreateBanner();
var buttons = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.ToList(); .ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.Equal(4, buttons.Count); Assert.Equal(4, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter"); Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter"); Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres"); Assert.Contains(buttons, b => b.Content as string == "Paramètres");
Assert.Contains(buttons, b => b.Content as string == "À propos");
} }
[AvaloniaFact] [AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out() public void Banner_login_button_is_visible_when_logged_out()
{ {
MainWindow window = new MainWindow(); SessionStatusBanner banner = CreateBanner();
window.Show();
var login = window.GetVisualDescendants() var login = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se connecter"); .Single(b => b.Content as string == "Se connecter");
@ -69,12 +63,9 @@ public class SessionStatusBannerTests
[AvaloniaFact] [AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out() public void Banner_logout_button_is_hidden_when_logged_out()
{ {
SessionStatusBanner banner = CreateBanner(); SessionStatusBanner banner = CreateBanner();
Assert.False((banner.DataContext as SessionStatusViewModel)!
.IsLoggedIn); // VM default
Assert.False(Assert.IsType<SessionStatusViewModel>(banner.DataContext).IsLoggedIn);
var logout = banner.GetVisualDescendants() var logout = banner.GetVisualDescendants()
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter"); .Single(b => b.Content as string == "Se déconnecter");
@ -91,22 +82,23 @@ public class SessionStatusBannerTests
.OfType<Button>() .OfType<Button>()
.Single(b => b.Content as string == "Paramètres"); .Single(b => b.Content as string == "Paramètres");
// Paramètres is the only button with no IsVisible // Paramètres has no IsVisible binding and is always shown.
// binding — always shown. The user's only path to the
// settings page goes through this button.
Assert.True(settings.IsVisible); Assert.True(settings.IsVisible);
} }
private static SessionStatusBanner CreateBanner() private SessionStatusBanner CreateBanner()
{ {
MainWindow window = new MainWindow(); _window = new MainWindow();
window.Show(); _window.Show();
var banner = window.MainView.SessionBanner; var banner = _window.GetVisualDescendants()
var status = new SessionStatusViewModel(); .OfType<SessionStatusBanner>()
banner.DataContext = status; .Single();
banner.DataContext = new SessionStatusViewModel();
return banner; return banner;
} }
public void Dispose() => _window?.Close();
[AvaloniaFact] [AvaloniaFact]
public void Banner_session_label_reflects_DataContext() public void Banner_session_label_reflects_DataContext()
{ {

View file

@ -0,0 +1,38 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Controls;
using PostIt.Models;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>
/// The <see cref="StatusBar"/> surfaces the page's action status
/// (success / warning / error message). The user reported being
/// unable to copy these messages — the status text was a
/// <c>TextBlock</c>, which Avalonia does not make selectable. It is
/// now a <see cref="SelectableTextBlock"/> so the message can be
/// selected and copied (Ctrl+C / context menu). This test pins that:
/// if the message control regresses to a plain <c>TextBlock</c>,
/// selectability is lost and the test fails.
/// </summary>
public class StatusBarTests
{
[AvaloniaFact]
public void Status_message_is_rendered_by_a_SelectableTextBlock()
{
var bar = new StatusBar {
DataContext =
StatusNotice.Error("Échec de la suppression : boom") };
var window = new Window { Content = bar };
window.Show();
var message = bar.GetVisualDescendants()
.OfType<SelectableTextBlock>()
.FirstOrDefault();
Assert.NotNull(message);
Assert.Equal("Échec de la suppression : boom", message!.Text);
}
}

View file

@ -17,6 +17,56 @@ namespace PostIt.Tests;
/// </summary> /// </summary>
public class YavscApiClientTests public class YavscApiClientTests
{ {
[Fact]
public async Task GetAnonymousAsync_surfaces_http_errors_without_attempting_login_or_retry()
{
using var apiServer = new StubApiServer(forceFirstRequest: true);
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
await using var client = new YavscApiClient(new Settings(), new TokenStore(tokensPath));
await Assert.ThrowsAsync<HttpRequestException>(() =>
client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken));
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetAnonymousAsync_does_not_require_or_send_session_credentials(bool hasSession)
{
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var tokensPath = TokensPath();
try
{
var store = new TokenStore(tokensPath);
if (hasSession)
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token",
DateTimeOffset.UtcNow.AddHours(1), null));
await using var client = new YavscApiClient(new Settings(), store);
var posts = await client.GetAnonymousAsync<List<StubApiServer.Post>>(
apiServer.BaseUrl + "/posts", TestContext.Current.CancellationToken);
Assert.Equal(2, posts.Count);
Assert.Equal(1, apiServer.RequestCount);
Assert.Empty(apiServer.SeenBearers);
}
finally
{
File.Delete(tokensPath);
}
}
private static int GetFreePort() private static int GetFreePort()
{ {
var l = new TcpListener(IPAddress.Loopback, 0); var l = new TcpListener(IPAddress.Loopback, 0);
@ -370,20 +420,105 @@ public class YavscApiClientTests
} }
} }
// SKIPPED — see comment. [Fact]
// public async Task Concurrent_silent_login_and_api_calls_share_one_refresh()
// We can't cover "TrySilentLoginAsync purges the store when the {
// refresh token is rejected" with OidcStubAuthority: the stub's using var authority = await OIDCStubAuthority.StartAsync();
// /connect/token endpoint is unconditional and hands out a fresh using var apiServer = new StubApiServer();
// refresh token regardless of what the caller sends. To exercise await apiServer.StartAsync();
// the RefreshFailedException path we'd need an authority option var settings = BuildSettings(authority, apiServer.BaseUrl);
// to fail on a specific refresh-token string; until then the var tokensPath = TokensPath();
// production refresh-failure path is covered manually (and by try
// the structural guarantee that _store.Clear() runs in the catch {
// block of ForceRefreshAsync when result.IsError). await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
// ExpireCachedAccessToken(tokensPath);
// [Fact] var store = new TokenStore(tokensPath);
// public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... } var previousRefreshToken = store.Load()!.RefreshToken;
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var ct = TestContext.Current.CancellationToken;
var silentLogins = Enumerable.Range(0, 8)
.Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray();
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(silentLogins.Cast<Task>().Concat(calls));
Assert.All(silentLogins, login => Assert.True(login.Result));
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task Concurrent_401_responses_share_one_refresh()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var client = await LoginAndPersistAsync(settings, authority, tokensPath);
apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}";
var ct = TestContext.Current.CancellationToken;
var calls = Enumerable.Range(0, 8)
.Select(_ => client.CallAsync<List<StubApiServer.Post>>(HttpMethod.Get, "posts", ct)).ToArray();
await Task.WhenAll(calls);
Assert.All(calls, call => Assert.NotEmpty(call.Result));
Assert.Equal(1, authority.RefreshRequestCount);
Assert.True(client.HasValidSession);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
[Fact]
public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token()
{
using var authority = await OIDCStubAuthority.StartAsync();
using var apiServer = new StubApiServer();
await apiServer.StartAsync();
var settings = BuildSettings(authority, apiServer.BaseUrl);
var tokensPath = TokensPath();
try
{
await using var original = await LoginAndPersistAsync(settings, authority, tokensPath);
var store = new TokenStore(tokensPath);
var tokens = store.Load()!;
store.Save(tokens with
{
RefreshToken = "revoked-refresh-token",
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5)
});
await using var client = new YavscApiClient(settings, store);
client.Http.BaseAddress = new Uri(settings.ApiUrl);
var error = await Assert.ThrowsAsync<RefreshFailedException>(() =>
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
Assert.True(error.IsPermanent);
Assert.Contains("reconnecter", error.Message);
Assert.False(client.HasValidSession);
Assert.Null(store.Load());
Assert.Equal(0, apiServer.RequestCount);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
private static T Last<T>(System.Collections.Generic.List<T> list) private static T Last<T>(System.Collections.Generic.List<T> list)
{ {
@ -467,6 +602,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
public string BaseUrl { get; private set; } = string.Empty; public string BaseUrl { get; private set; } = string.Empty;
public List<string> SeenBearers { get; } = new(); public List<string> SeenBearers { get; } = new();
public int RequestCount => _requestCount; public int RequestCount => _requestCount;
public string? RejectedBearer { get; set; }
public StubApiServer(bool forceFirstRequest = false) public StubApiServer(bool forceFirstRequest = false)
{ {
@ -499,7 +635,8 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable
if (!string.IsNullOrEmpty(auth)) if (!string.IsNullOrEmpty(auth))
SeenBearers.Add(auth!); SeenBearers.Add(auth!);
if (_forceFirstRequest && _requestCount == 1) if ((_forceFirstRequest && _requestCount == 1)
|| (RejectedBearer is not null && auth == RejectedBearer))
{ {
ctx.Response.StatusCode = 401; ctx.Response.StatusCode = 401;
ctx.Response.Close(); ctx.Response.Close();

View file

@ -11,5 +11,6 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View file

@ -6,17 +6,26 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Helpers; using PostIt.Helpers;
using System.Globalization;
using Live.Avalonia;
using System.Diagnostics;
using System.Linq;
using ReactiveUI;
using System.Reactive;
namespace PostIt; namespace PostIt;
public partial class App : Application public partial class App : Application, ILiveView, IVMPusherApp
{ {
public static bool LiveMode { get; private set; }
private int _bootStarted; private int _bootStarted;
public static string? CliConfigFileSpecification { get; private set; }
/// <summary> /// <summary>
/// DI container the platform entry points hand to ViewModels so /// DI container the platform entry points hand to ViewModels so
@ -41,63 +50,167 @@ public partial class App : Application
{ {
if (TryHandOffCustomSchemeUrl()) return; if (TryHandOffCustomSchemeUrl()) return;
this.ServiceProvider = new ServiceCollection().BuildServices(); this.ServiceProvider = new ServiceCollection().BuildPostItServices();
var settings = ServiceProvider.GetRequiredService<Settings>(); var settings = ServiceProvider.GetRequiredService<Settings>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (!LiveMode)
{ {
var window = ServiceProvider.GetRequiredService<MainWindow>(); // Debugging requires pdb loading etc, so we disable live reloading
desktop.MainWindow = window; // during a test run with an attached debugger.
View = window.MainView;
this.ConfigureRootView(window.MainView);
ApplyDarkMode(settings); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
InitializeClassicDesktopLifetime(settings, desktop);
}
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
} }
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) else
{ {
singleViewFactoryApplicationLifetime.MainViewFactory = // Here, we create a new LiveViewHost, located in the 'Live.Avalonia'
() => // namespace, and pass an ILiveView implementation to it. The ILiveView
{ // implementation should have a parameterless constructor! Next, we
View = ServiceProvider.GetRequiredService<MainView>(); // start listening for any changes in the source files. And then, we
this.ConfigureRootView(View); // show the LiveViewHost window. Simple enough, huh?
ApplyDarkMode(settings); var window = new LiveViewHost(this, Console.WriteLine);
return View; var view = ServiceProvider!.GetRequiredService<MainView>();
};
} if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) {
{ InitializeClassicDesktopLifetime(window, view, settings, desktop);
singleViewPlatform.MainView = View = ServiceProvider.GetRequiredService<MainView>(); }
ConfigureRootView(View); else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
ApplyDarkMode(settings); {
InitializeActivityLifetime(settings, singleViewFactoryApplicationLifetime);
}
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{
InitializeSingleViewLifetime(settings, singleViewPlatform);
}
window.StartWatchingSourceFilesForHotReloading();
Console.WriteLine("LiveViewHost started for hot reloading.");
window.Show();
} }
#if DEBUG
// Here we subscribe to ReactiveUI default exception handler to avoid app
// termination in case if we do something wrong in our view models. See:
// https://www.reactiveui.net/docs/handbook/default-exception-handler/
//
// In case if you are using another MV* framework, please refer to its
// documentation explaining global exception handling.
Console.WriteLine("Debug mode: ReactiveUI default exception handler is set.");
RxApp.DefaultExceptionHandler = Observer.Create<Exception>(Console.WriteLine);
#endif
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private void ConfigureRootView(MainView rootView) private bool IsProduction()
{
// Déclencher le Boot une seule fois lors du chargement du contrôle à l'écran.
rootView.AttachedToVisualTree += async (_, _) => await BootOnceAsync();
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{ {
// Remplacer Window.NavRoot par rootView.NavRoot #if DEBUG
rootView.NavRoot.PopToRootAsync(); return false;
}; #else
return true;
#endif
}
rootView.SessionBanner.DataContext = sessionStatus; public async Task<Page> PushPageAsync(ViewModelBase viewModel)
}
private async Task BootOnceAsync()
{ {
if (Interlocked.Exchange(ref _bootStarted, 1) == 1) var template = DataTemplates.FirstOrDefault(t => t.Match(viewModel));
if (template is null)
{ {
return; throw new InvalidOperationException($"No IDataTemplate found for {viewModel.GetType().Name}.");
} }
var api = ServiceProvider!.GetRequiredService<YavscApiClient>(); Control? view = template.Build(viewModel) ;
await BootAsync(this.ServiceProvider!, api); if (view is null)
{
throw new InvalidOperationException(
$"Template for {viewModel.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 = viewModel;
MainView? mainView = (Current as App).View;
// Avoid stacking the same singleton page twice (e.g. SettingsPage).
var stack = mainView.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], page))
{
return page;
}
await mainView.NavRoot.PushAsync(page);
return page;
}
public void InitializeSingleViewLifetime(Settings settings, ISingleViewApplicationLifetime singleViewPlatform)
{
singleViewPlatform.MainView = View = ServiceProvider!.GetRequiredService<MainView>();
ConfigureRootView(View);
ApplyStettings(settings);
}
public void InitializeActivityLifetime(Settings settings, IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{
singleViewFactoryApplicationLifetime.MainViewFactory =
() =>
{
this.ConfigureRootView(ServiceProvider!.GetRequiredService<MainView>());
ApplyStettings(settings);
return View!;
};
}
public void InitializeClassicDesktopLifetime(Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
var window = ServiceProvider!.GetRequiredService<MainWindow>();
var mainView = (MainView)CreateView(window);
InitializeClassicDesktopLifetime(window, mainView, settings, desktop);
}
public void InitializeClassicDesktopLifetime(Window window, MainView mainView, Settings settings, IClassicDesktopStyleApplicationLifetime desktop)
{
this.ConfigureRootView(mainView);
ApplyStettings(settings);
desktop.MainWindow = window;
window.Content = mainView;
window.Show();
}
public void ConfigureRootView(MainView rootView)
{
rootView.AttachedToVisualTree += (_, _) =>
Dispatcher.UIThread.Post(() => _ = BootOnceAsync());
var sessionStatus = ServiceProvider!.GetRequiredService<SessionStatusViewModel>();
sessionStatus.LogoutCompleted += () =>
{
// Remplacer Window.NavRoot par rootView.NavRoot
rootView.NavRoot.PopToRootAsync();
};
if (rootView.DataContext==null)
rootView.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
rootView.SessionBanner.DataContext = sessionStatus;
this.View = rootView;
} }
/// <summary> /// <summary>
@ -111,10 +224,24 @@ private void ConfigureRootView(MainView rootView)
View = mainView ?? throw new ArgumentNullException(nameof(mainView)); View = mainView ?? throw new ArgumentNullException(nameof(mainView));
} }
private static void ApplyDarkMode(Settings settings) private static void ApplyStettings(Settings settings)
{ {
Application.Current!.RequestedThemeVariant = if (!string.IsNullOrWhiteSpace(settings.SelectedLanguage))
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light; Yavsc.Lang.Resources.Culture = new CultureInfo(settings.SelectedLanguage);
Current!.RequestedThemeVariant =
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
}
private async Task BootOnceAsync()
{
if (Interlocked.Exchange(ref _bootStarted, 1) == 1)
{
return;
}
var api = ServiceProvider!.GetRequiredService<YavscApiClient>();
await BootAsync(this.ServiceProvider!, api);
} }
/// <summary> /// <summary>
@ -132,9 +259,9 @@ private void ConfigureRootView(MainView rootView)
var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true);
var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>(); var sessionStatus = provider.GetRequiredService<SessionStatusViewModel>();
sessionStatus.Refresh(); sessionStatus.Refresh();
var homePage = provider.GetRequiredService<HomePageViewModel>(); var homePageVm = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!; var app = (App)Current!;
await app.PushPageAsync(homePage); await app.PushPageAsync(homePageVm);
} }
/// <summary> /// <summary>
@ -148,7 +275,7 @@ private void ConfigureRootView(MainView rootView)
public static async Task PushBlogsPageAsync() public static async Task PushBlogsPageAsync()
{ {
var app = (App)Current!; var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>(); var mainVm = app.ServiceProvider!.GetRequiredService<BlogsViewModel>();
await mainVm.InitializeAsync(); await mainVm.InitializeAsync();
await app.PushPageAsync(mainVm); await app.PushPageAsync(mainVm);
} }
@ -189,4 +316,32 @@ private void ConfigureRootView(MainView rootView)
{ {
await View!.NavRoot.PopAsync(); await View!.NavRoot.PopAsync();
} }
internal static void UseConfigFileWhenLoading(string configFile)
{
CliConfigFileSpecification = configFile;
}
internal static void EnableLiveMode()
{
LiveMode = true;
}
public object CreateView(Window window)
{
if (this.ServiceProvider == null)
{
var serviceCollection = new ServiceCollection();
this.ServiceProvider = serviceCollection.BuildPostItServices();
}
if (window.DataContext == null)
window.DataContext = ServiceProvider!.GetRequiredService<HomePageViewModel>();
// The AppView class will inherit the DataContext
// of the window. The AppView class can be a
// UserControl, a Grid, a TextBlock, whatever.
this.View = ServiceProvider!.GetRequiredService<MainView>();
this.ConfigureRootView(this.View);
return this.View;
}
} }

View file

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using Mono.Options;
using PostIt;
using PostIt.ViewModels;
namespace PostIt.CLIOptions;
public static class CLIOptionHandler
{
public static void HandleArgs(string[] args)
{
bool showHelp = false;
string configFile = null;
bool liveMode = false;
var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
{ "l|live", "Enable live mode", v => liveMode = true },
{ "v|version", "Show the application version", v => Console.WriteLine("PostIt version "
+ typeof(App).Assembly.GetName().Version?.ToString()) },
{ "h|help", "Show this message and exit", v => showHelp = true }
};
List<string> extra = options.Parse(args);
if (showHelp)
{
Console.WriteLine("Usage: app [OPTIONS]+");
options.WriteOptionDescriptions(Console.Out);
return;
}
if (configFile != null)
{
App.UseConfigFileWhenLoading(configFile);
}
if (liveMode)
{
App.EnableLiveMode();
}
}
}

View file

@ -0,0 +1,56 @@
<UserControl
x:Class="PostIt.Controls.MarkdownEditorControl"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls"
x:Name="Root"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding !IsPreviewVisible, ElementName=Root}"
Padding="0">
<AvaloniaEdit:TextEditor
x:Name="SourceEditor"
Document="{Binding Document, ElementName=Root, Mode=TwoWay}"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"
ShowLineNumbers="True"
WordWrap="True"
BorderThickness="0"
Padding="5,5,5,15"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
TextInputOptions.Multiline="True"
TextInputOptions.ReturnKeyType="Default" />
</Border>
<Border
BorderBrush="LightGray"
BorderThickness="1"
CornerRadius="5"
IsVisible="{Binding IsPreviewVisible, ElementName=Root}"
Padding="8">
<ScrollViewer
x:Name="PreviewScrollViewer"
BringIntoViewOnFocusChange="False"
VerticalScrollBarVisibility="Auto"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Auto">
<md:MarkdownViewer
x:Name="PreviewViewer"
HorizontalContentAlignment="Stretch"
VerticalAlignment="Top"
ClipToBounds="False"
UseLayoutRounding="True"
MarkdownText="{Binding MarkdownText, ElementName=Root}" />
</ScrollViewer>
</Border>
</Grid>
</UserControl>

View file

@ -0,0 +1,63 @@
using System;
using Avalonia;
using Avalonia.Controls;
using AvaloniaEdit.Document;
namespace PostIt.Controls;
public partial class MarkdownEditorControl : UserControl
{
public static readonly StyledProperty<string?> MarkdownTextProperty =
AvaloniaProperty.Register<MarkdownEditorControl, string?>(nameof(MarkdownText));
public static readonly StyledProperty<TextDocument?> DocumentProperty =
AvaloniaProperty.Register<MarkdownEditorControl, TextDocument?>(nameof(Document));
public static readonly StyledProperty<bool> IsPreviewVisibleProperty =
AvaloniaProperty.Register<MarkdownEditorControl, bool>(nameof(IsPreviewVisible), false);
public string? MarkdownText
{
get => GetValue(MarkdownTextProperty);
set => SetValue(MarkdownTextProperty, value);
}
public TextDocument? Document
{
get => GetValue(DocumentProperty);
set => SetValue(DocumentProperty, value);
}
public bool IsPreviewVisible
{
get => GetValue(IsPreviewVisibleProperty);
set => SetValue(IsPreviewVisibleProperty, value);
}
public MarkdownEditorControl()
{
InitializeComponent();
PropertyChanged += OnPropertyChanged;
}
private void OnPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == MarkdownTextProperty)
{
var text = e.NewValue as string ?? string.Empty;
if (Document is null || !string.Equals(Document.Text, text, StringComparison.Ordinal))
{
Document = new TextDocument(text);
}
}
else if (e.Property == DocumentProperty)
{
var newDocument = e.NewValue as TextDocument;
var text = newDocument?.Text ?? string.Empty;
if (!string.Equals(MarkdownText, text, StringComparison.Ordinal))
{
MarkdownText = text;
}
}
}
}

View file

@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
set => SetValue(CaptureAreaProperty, value); set => SetValue(CaptureAreaProperty, value);
} }
/// <summary>
/// When true the control ignores pointer input — it only displays
/// strokes loaded into its buffer (e.g. the other party's
/// signature, which must be shown but not editable). <see cref="Load"/>
/// and <see cref="Clear"/> still work.
/// </summary>
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
public bool IsReadOnly
{
get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
/// <summary> /// <summary>
/// Captured strokes in wire form. Exposed as a read-only view /// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI /// over the internal buffer. The buffer only mutates on the UI
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
private void OnCapturePressed(object? sender, PointerPressedEventArgs e) private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{ {
if (IsReadOnly) return;
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea); e.Pointer.Capture(CaptureArea);
_capturing = true; _capturing = true;
@ -181,6 +197,38 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty); RedrawRequested?.Invoke(this, EventArgs.Empty);
} }
/// <summary>
/// Replace the buffer with the given wire-format strokes — e.g. a
/// signature loaded back from the server — and raise
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
/// Loaded strokes are all sealed (there is no pending stroke).
/// Malformed input (a non-positive length prefix, or a stroke that
/// runs past the end of the array) is truncated at the offending
/// point so the buffer never holds an inconsistent state.
/// </summary>
public void Load(int[] strokes)
{
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
_strokes.Clear();
_pendingPoints = 0;
_capturing = false;
int i = 0;
while (i < strokes.Length)
{
int k = strokes[i];
if (k <= 0) break;
if (i + 1 + 2 * k > strokes.Length) break;
_strokes.Add(k);
for (int p = 0; p < 2 * k; p++)
_strokes.Add(strokes[i + 1 + p]);
i += 1 + 2 * k;
}
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary> /// <summary>
/// Defensive copy of the current buffer wrapped in a /// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the /// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

@ -0,0 +1,28 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PostIt.Controls"
x:Class="PostIt.Controls.SignaturePadView"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<!--
Capture surface. The SignaturePadControl is render-agnostic,
so we host it inside a Border that is the capture area, and
overlay a Canvas for the visual feedback. The code-behind
repaints the canvas on every RedrawRequested. The Border size
is driven by the CaptureWidth / CaptureHeight styled properties.
-->
<Border x:Name="PadFrame"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="InnerPad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Models;
namespace PostIt.Controls;
/// <summary>
/// Reusable signature-capture surface: wraps the render-agnostic
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
/// (the hit-test region) with an overlay <see cref="Canvas"/> that
/// the code-behind repaints from the control's stroke buffer. Owns
/// the ink-rendering logic so every page that captures a signature
/// (the dev-only <c>SignaturePage</c>, the estimate validation page,
/// ...) shares one implementation.
///
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
/// (DIPs, defaults 600×200) to size the surface. View models attach
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
/// read snapshots, drive <c>Clear</c>, and subscribe to
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
/// contract the headless tests use, since a <see cref="UserControl"/>
/// needs its XAML loaded to be drivable.
/// </summary>
public partial class SignaturePadView : UserControl
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
// Last size the ink layer was painted at. LayoutUpdated repaints
// only when this changes, so adding/removing polyline children
// (which invalidates layout) does not loop.
private Size _lastPaintedSize;
public static readonly StyledProperty<double> CaptureWidthProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
public static readonly StyledProperty<double> CaptureHeightProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
public double CaptureWidth
{
get => GetValue(CaptureWidthProperty);
set => SetValue(CaptureWidthProperty, value);
}
public double CaptureHeight
{
get => GetValue(CaptureHeightProperty);
set => SetValue(CaptureHeightProperty, value);
}
/// <summary>
/// The underlying capture control. View models attach to this to
/// read <see cref="SignaturePadControl.Snapshot"/> / call
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
/// events; this view repaints the ink layer from the same buffer.
/// </summary>
public SignaturePadControl SignaturePad => InnerPad;
public SignaturePadView()
{
InitializeComponent();
// Wire the capture area: the Pad is the control, the
// surrounding Border (PadFrame) is the hit-test region. For an
// inline control with no template, capture wiring happens on
// first measure, which is guaranteed before the user can
// interact, so attaching here is safe.
InnerPad.CaptureArea = PadFrame;
ApplyFrameSize();
InnerPad.RedrawRequested += OnPadRedrawRequested;
// Repaint once layout completes so an ink buffer loaded before
// the first arrange (e.g. an existing signature on reopen)
// still renders without waiting for a pointer event. The
// handler repaints only on a size change to avoid a layout
// loop (child changes invalidate layout → LayoutUpdated).
LayoutUpdated += OnLayoutUpdated;
}
private void OnLayoutUpdated(object? sender, EventArgs e)
{
var size = EffectivePadSize();
if (size != _lastPaintedSize)
Repaint();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty)
{
ApplyFrameSize();
Repaint();
}
}
private void ApplyFrameSize()
{
if (PadFrame is null) return;
PadFrame.Width = Math.Max(1, CaptureWidth);
PadFrame.Height = Math.Max(1, CaptureHeight);
}
private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint();
/// <summary>Defensive copy of the current buffer.</summary>
public SignaturePadData Snapshot() => InnerPad.Snapshot();
/// <summary>Forget every captured stroke.</summary>
public void Clear() => InnerPad.Clear();
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
/// <summary>
/// The effective ink surface size: the arranged bounds when
/// available, otherwise the explicitly sized
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
/// latter covers a repaint triggered before the first layout pass
/// (e.g. loading an existing signature on reopen), when Bounds is
/// still empty.
/// </summary>
private Size EffectivePadSize()
{
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
return default;
return new Size(w, h);
}
private void Repaint()
{
if (InkLayer is null) return;
var size = EffectivePadSize();
if (size == default) return;
_lastPaintedSize = size;
InkLayer.Children.Clear();
var w = size.Width;
var h = size.Height;
var pending = InnerPad.PendingStroke;
var strokes = InnerPad.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(pending.Count / 2);
for (int p = 0; p < pending.Count; p += 2)
{
int nx = pending[p];
int ny = pending[p + 1];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
}
}

View file

@ -10,13 +10,19 @@
Padding="8"> Padding="8">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Glyph}" <TextBlock Text="{Binding Glyph}"
FontWeight="Bold" FontWeight="Bold"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
VerticalAlignment="Top"/> VerticalAlignment="Top"/>
<TextBlock Text="{Binding Message}" <SelectableTextBlock Text="{Binding Message}"
Foreground="{Binding Foreground}" Foreground="{Binding Foreground}"
TextWrapping="Wrap" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Content="{Binding CheckCommandLabel}"
Foreground="{Binding Foreground}"
VerticalAlignment="Center"
IsVisible="{Binding HasCheckCommand}"
Command="{Binding CheckCommand}"
/>
</StackPanel> </StackPanel>
</Border> </Border>
</UserControl> </UserControl>

View file

@ -0,0 +1,61 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace PostIt.Helpers;
/// <summary>
/// Saves a downloaded byte payload to a file chosen by the user through
/// the platform save picker. This is the first file-save (as opposed to
/// file-open) helper in PostIt; the open picker is already used for blog
/// attachment and avatar uploads
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
///
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
/// both the desktop and single-view lifetimes.</para>
/// </summary>
public static class FileSaveHelpers
{
/// <summary>
/// Prompt the user to choose a destination and write
/// <paramref name="content"/> to it. Returns false when the user
/// cancels the picker; throws on IO/platform errors so the caller
/// can surface them.
/// </summary>
public static async Task<bool> SaveAsync(
string suggestedName,
string defaultExtension,
byte[] content,
CancellationToken ct = default)
{
var view = (Application.Current as App)?.View;
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
if (topLevel is null)
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Enregistrer sous",
DefaultExtension = defaultExtension,
SuggestedFileName = suggestedName,
FileTypeChoices = new[]
{
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
{
Patterns = new[] { $"*.{defaultExtension}" }
}
}
}).ConfigureAwait(true);
if (file is null)
return false; // user cancelled
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
await stream.WriteAsync(content, ct).ConfigureAwait(true);
return true;
}
}

View file

@ -2,7 +2,10 @@ using System;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -10,7 +13,7 @@ namespace PostIt.Helpers;
public static class ServiceCollectionHelpers public static class ServiceCollectionHelpers
{ {
public static IServiceProvider BuildServices(this ServiceCollection services) public static IServiceProvider BuildPostItServices(this ServiceCollection services)
{ {
var settings = new Settings(); var settings = new Settings();
settings.Load(); settings.Load();
@ -21,6 +24,7 @@ public static class ServiceCollectionHelpers
var api = new YavscApiClient(settings, tokenStore); var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl); var client = new BlogApiClient(api, settings.BlogsApiUrl);
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
@ -29,13 +33,21 @@ public static class ServiceCollectionHelpers
() => settings.ApiUrl, () => settings.ApiUrl,
() => settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var resourceUsageClient = new ResourceUsageApiClient(api, () => settings.ApiUrl);
var notificationsClient = new NotificationsApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues // Vues
services.AddSingleton<MainView>(); services.AddTransient<MainWindow>();
services.AddSingleton<MainPage>(); services.AddTransient<MainView>();
services.AddSingleton<MainWindow>(); services.AddTransient<BlogsPage>();
// Models
services.AddSingleton<ChatViewModel>();
// SettingsPage is a singleton: there must be one and only one // SettingsPage is a singleton: there must be one and only one
// instance of the settings UI for the lifetime of the app. // instance of the settings UI for the lifetime of the app.
@ -49,15 +61,27 @@ public static class ServiceCollectionHelpers
// the navigation stack, each bound to a fresh // the navigation stack, each bound to a fresh
// SettingsViewModel and missing any in-flight edits. // SettingsViewModel and missing any in-flight edits.
services.AddSingleton<SettingsPage>(); services.AddSingleton<SettingsPage>();
services.AddSingleton<AboutPage>();
services.AddSingleton<HomePage>(); services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>(); services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>(); services.AddTransient<CommandFormsPage>();
services.AddTransient<ChatPage>();
services.AddTransient<RdvPage>(); services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>(); services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>(); services.AddTransient<BillingQueriesPage>();
services.AddTransient<BillingQueryDetailsPage>(); services.AddTransient<BillingQueryDetailsPage>();
services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
services.AddTransient<UserProfilePage>();
services.AddTransient<PerformerConfigurationPage>();
services.AddTransient<AdministrationPage>();
services.AddTransient<ResourceUsagePage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api); services.AddSingleton<YavscApiClient>(api);
@ -67,9 +91,18 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient); services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient); services.AddSingleton(activityClient);
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton(resourceUsageClient);
services.AddSingleton(notificationsClient);
services.AddSingleton(userFilesClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<AboutPageViewModel>();
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddTransient<UserProfilePageViewModel>();
services.AddTransient<PerformerConfigurationPageViewModel>();
services.AddTransient<AdministrationPageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>(); services.AddSingleton<ActivitiesPageViewModel>();
@ -89,7 +122,7 @@ public static class ServiceCollectionHelpers
sessionStatus.Refresh(); sessionStatus.Refresh();
services.AddSingleton(sessionStatus); services.AddSingleton(sessionStatus);
services.AddSingleton<SessionStatusBanner>(); services.AddSingleton<SessionStatusBanner>();
services.AddSingleton<MainViewModel>(); services.AddSingleton<BlogsViewModel>();
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }
} }

View file

@ -1,51 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using PostIt.ViewModels;
namespace PostIt.Helpers;
public static class ViewModelBaseHelpers
{
public static async Task PushPageAsync(this App app, ViewModelBase vm)
{
var window = app.View;
if (window is null)
{
throw new InvalidOperationException("MainWindow is not initialized yet.");
}
var template = app.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;
}
await window.NavRoot.PushAsync(page);
}
}

View file

@ -0,0 +1,10 @@
using System.Threading.Tasks;
using PostIt.ViewModels;
using Avalonia.Controls;
namespace PostIt;
internal interface IVMPusherApp
{
Task<Page> PushPageAsync(ViewModelBase viewModel);
}

View file

@ -0,0 +1,6 @@
using System;
public interface IPCMProcessor : IDisposable
{
void Process(short[] audioFrame);
}

View file

@ -0,0 +1,72 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Yavsc.Lang {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
public Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Yavsc.Lang.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Bienvenue dans PostIt !!!.
/// </summary>
public static string GreetingText {
get {
return ResourceManager.GetString("GreetingText", resourceCulture);
}
}
}
}

View file

@ -0,0 +1,5 @@
<root>
<data name="GreetingText">
<value>Welcome to PostIt!</value>
</data>
</root>

View file

@ -0,0 +1,5 @@
<root>
<data name="GreetingText">
<value>Bemvindo em PostIt!</value>
</data>
</root>

View file

@ -0,0 +1,5 @@
<root>
<data name="GreetingText">
<value>Bienvenue dans PostIt !!!</value>
</data>
</root>

View file

@ -0,0 +1,15 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO de message de salon, calqué sur la charge envoyée par
/// <c>ChatHub.Send</c> côté serveur ({ Name, Room, Message }).
/// Le protocole JSON de SignalR sérialise en camelCase ; la
/// désérialisation est insensible à la casse par défaut.
/// </summary>
public class ChatMessage
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -0,0 +1,14 @@
namespace PostIt.ViewModels.Chat
{
/// <summary>
/// DTO minimal calqué sur l'objet anonyme émis par le hub
/// (<c>{ Name, Room, Message }</c>) via l'événement
/// <c>ReceiveMessage</c>.
/// </summary>
public class ChatMessageDto
{
public string? Name { get; set; }
public string? Room { get; set; }
public string? Message { get; set; }
}
}

View file

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace PostIt.ViewModels.Chat
{
// DTO minimal calqué sur votre logique serveur ChatRoomInfo
public class ChatRoomInfo
{
public string? Name { get; set; }
public List<string> Users { get; set; } = new();
}
}

View file

@ -0,0 +1,6 @@
namespace PostIt.ViewModels;
internal interface IWithLabel
{
string Label { get; }
}

View file

@ -0,0 +1,8 @@
namespace PostIt.Models;
public enum StatusSeverity
{
Info,
Warning,
Error
}

View file

@ -5,8 +5,10 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-estimate.1</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -23,20 +25,43 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="Avalonia.Themes.Fluent" /> <PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport"> <PackageReference Include="AvaloniaUI.DiagnosticsSupport">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="LibVLCSharp.Avalonia" />
<PackageReference Include="Live.Avalonia" />
<PackageReference Include="Mapsui" /> <PackageReference Include="Mapsui" />
<PackageReference Include="Mapsui.Avalonia12" /> <PackageReference Include="Mapsui.Avalonia12" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />
<PackageReference Include="PvRecorder" />
<PackageReference Include="Silk.NET.OpenAL" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />
<ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" /> <ProjectReference Include="../../Yavsc.Api.Client/Yavsc.Api.Client.csproj" />
</ItemGroup> </ItemGroup>
</Project> <ItemGroup>
<EmbeddedResource Update="Lang/Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Compile Update="Lang/Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Lang/Resources.resx</DependentUpon>
</Compile>
<Watch Include="**\*.xaml" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,67 @@
using System;
using System.Threading;
using Pv;
public class AudioRecorder : IDisposable
{
private int frameLength;
PvRecorder recorder;
Thread thread;
short[] audioFrame;
IPCMProcessor processor;
public bool processing { get; private set; }
public AudioRecorder(IPCMProcessor processor, int frameLength=512, int deviceIndex = -1)
{
this.frameLength = frameLength;
this.recorder = PvRecorder.Create(frameLength: frameLength, deviceIndex: deviceIndex);
this.audioFrame = new short[frameLength];
this.processor=processor;
}
public static string [] GetAvailableDevices()
{
return PvRecorder.GetAvailableDevices();
}
public void Start()
{
recorder.Start();
thread = new Thread(ThreadLoop);
thread.Start();
}
public void ThreadLoop()
{
while (recorder.IsRecording)
{
if (thread.ThreadState != ThreadState.Running)
{
break;
}
if (processing)
{
Thread.Sleep(10);
continue;
}
processing = true;
audioFrame = recorder.Read();
processor.Process(audioFrame);
processing = false;
}
}
public void Stop()
{
thread.Interrupt();
thread.Join();
recorder.Stop();
thread = null;
}
public void Dispose()
{
recorder.Dispose();
}
}

View file

@ -39,6 +39,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
private readonly SemaphoreSlim _refreshGate = new(1, 1); private readonly SemaphoreSlim _refreshGate = new(1, 1);
private RefreshTokenRecord? _tokens; private RefreshTokenRecord? _tokens;
private static readonly HttpRequestOptionsKey<bool> AnonymousRequest = new("PostIt.AnonymousRequest");
public virtual async Task<T> GetAnonymousAsync<T>(string path, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Options.Set(AnonymousRequest, true);
using var response = await Http.SendAsync(request, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null) public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{ {
@ -49,6 +59,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_bearer = new BearerTokenHandler(this); _bearer = new BearerTokenHandler(this);
Http = new HttpClient(_bearer, disposeHandler: true); Http = new HttpClient(_bearer, disposeHandler: true);
// HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur
// tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443").
// RequestVersionOrLower négocie h3 quand le serveur ET la plateforme
// le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1
// sinon — notamment WASM, où le navigateur n'expose pas QUIC au
// fetch, et Android si libmsquic n'est pas disponible. Tous les
// clients Yavsc.Api.Client partagent cette même instance `Http`.
Http.DefaultRequestVersion = new Version(3, 0);
Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
_tokens = store.Load(); _tokens = store.Load();
} }
@ -191,13 +211,52 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
object? body = null, object? body = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
var dto = await JsonSerializer.DeserializeAsync<T>(stream, }
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
return dto!; /// <summary>
/// Call a multipart endpoint, transparently refreshing the token if needed.
/// </summary>
public virtual async Task<T> CallAsync<T>(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
{
if (contentFactory is null)
throw new ArgumentNullException(nameof(contentFactory));
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await ReadJsonAsync<T>(response, ct).ConfigureAwait(false);
}
/// <summary>
/// Deserialise the response body as <typeparamref name="T"/>,
/// tolerating an empty body. <c>PUT /blogspot/{id}</c> answers
/// <c>204 No Content</c>: without this guard,
/// <c>JsonSerializer.DeserializeAsync</c> throws
/// <c>JsonException: The input does not contain any JSON
/// tokens</c> and the VM surfaces a successful save as an
/// error. Callers that genuinely need a payload
/// (<c>GetPostAsync</c>, <c>CreatePostAsync</c>) are unaffected:
/// their endpoints always return a body.
/// </summary>
private static async Task<T> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken ct)
{
if (response.StatusCode == HttpStatusCode.NoContent)
return default!;
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(payload))
return default!;
return JsonSerializer.Deserialize<T>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
} }
/// <summary> /// <summary>
@ -217,7 +276,23 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
object? body = null, object? body = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
}
/// <summary>
/// Call a multipart endpoint that returns no useful body (DELETE, etc.).
/// </summary>
public async Task CallAsync(
HttpMethod method,
string path,
Func<HttpContent> contentFactory,
CancellationToken ct = default)
{
if (contentFactory is null)
throw new ArgumentNullException(nameof(contentFactory));
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
} }
@ -232,7 +307,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
=> CallAsync(method, path, body: null, ct); => CallAsync(method, path, body: null, ct);
private async Task<HttpResponseMessage> SendAsync( private async Task<HttpResponseMessage> SendAsync(
HttpMethod method, string path, object? body, CancellationToken ct) HttpMethod method, string path, Func<HttpContent>? contentFactory, CancellationToken ct)
{ {
if (_tokens is null) if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first."); throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
@ -240,8 +315,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
await EnsureFreshTokenAsync(ct).ConfigureAwait(false); await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(method, path); using var req = new HttpRequestMessage(method, path);
if (body is not null) if (contentFactory is not null)
req.Content = JsonContent.Create(body); req.Content = contentFactory();
var response = await Http.SendAsync(req, ct).ConfigureAwait(false); var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
@ -249,11 +324,11 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
// Server rejected: could be revocation, clock skew, audience mismatch. // Server rejected: could be revocation, clock skew, audience mismatch.
// Force a refresh and retry exactly once. // Force a refresh and retry exactly once.
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false); await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false);
using var retry = new HttpRequestMessage(method, path); using var retry = new HttpRequestMessage(method, path);
if (body is not null) if (contentFactory is not null)
retry.Content = JsonContent.Create(body); retry.Content = contentFactory();
response = await Http.SendAsync(retry, ct).ConfigureAwait(false); response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
} }
@ -314,13 +389,46 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
return; return;
await ForceRefreshAsync(ct).ConfigureAwait(false);
}
private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null)
{
await _refreshGate.WaitAsync(ct).ConfigureAwait(false); await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
try try
{ {
if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) var tokens = _tokens;
if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken))
throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter.");
if (rejectedAccessToken is null
? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew
: !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal))
return; return;
await ForceRefreshAsync(ct).ConfigureAwait(false); var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
var isPermanent = result.Error is "invalid_grant" or "invalid_client";
if (isPermanent)
{
_store.Clear();
_tokens = null;
}
throw new RefreshFailedException(
isPermanent
? $"Session expirée. Veuillez vous reconnecter ({result.Error})."
: $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.",
isPermanent);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? tokens.IdToken);
_store.Save(_tokens);
} }
finally finally
{ {
@ -328,30 +436,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
} }
} }
private async Task ForceRefreshAsync(CancellationToken ct)
{
if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken))
throw new InvalidOperationException("No refresh token available.");
var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false);
if (result.IsError)
{
// Refresh token dead: revoked, expired, or rotation-theft
// detected. Purge and force an interactive re-login.
_store.Clear();
_tokens = null;
throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true);
}
_tokens = new RefreshTokenRecord(
AccessToken: result.AccessToken!,
RefreshToken: result.RefreshToken ?? _tokens.RefreshToken,
AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
IdToken: result.IdentityToken ?? _tokens.IdToken);
_store.Save(_tokens);
}
/// <summary> /// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a /// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image /// single multipart file named <c>file</c> and validates the image
@ -394,8 +478,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
var response = await attemptUpload().ConfigureAwait(false); var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose(); response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false); await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false); response = await attemptUpload().ConfigureAwait(false);
} }
@ -423,6 +508,42 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
return "Avatar mis à jour."; return "Avatar mis à jour.";
} }
/// <summary>
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
/// contract as <see cref="SetAvatarAsync"/>.
/// </summary>
public virtual async Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter;
response.Dispose();
await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_store.Clear(); _store.Clear();
@ -503,7 +624,8 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) HttpRequestMessage request, CancellationToken cancellationToken)
{ {
if (_owner._tokens is not null) if (_owner._tokens is not null
&& !(request.Options.TryGetValue(AnonymousRequest, out var anonymous) && anonymous))
request.Headers.Authorization = new AuthenticationHeaderValue( request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", _owner._tokens.AccessToken); "Bearer", _owner._tokens.AccessToken);
return base.SendAsync(request, cancellationToken); return base.SendAsync(request, cancellationToken);

View file

@ -5,7 +5,10 @@ using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
using PostIt.ViewModels.Chat;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Blogs;
using PostIt.Views.Chat;
using PostIt.Views.Commands; using PostIt.Views.Commands;
namespace PostIt; namespace PostIt;
@ -38,8 +41,9 @@ public class ViewLocator : IDataTemplate
var services = app!.ServiceProvider!; var services = app!.ServiceProvider!;
return data switch return data switch
{ {
MainViewModel => services.GetRequiredService<MainPage>(), BlogsViewModel => services.GetRequiredService<BlogsPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => services.GetRequiredService<SettingsPage>(),
AboutPageViewModel => services.GetRequiredService<AboutPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(), ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(), CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
@ -51,6 +55,16 @@ public class ViewLocator : IDataTemplate
PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(), PostAclDialogViewModel => services.GetRequiredService<PostAclDialog>(),
BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(), BillingQueriesPageViewModel => services.GetRequiredService<BillingQueriesPage>(),
BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(), BillingQueryDetailsPageViewModel => services.GetRequiredService<BillingQueryDetailsPage>(),
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
UserProfilePageViewModel => services.GetRequiredService<UserProfilePage>(),
PerformerConfigurationPageViewModel => services.GetRequiredService<PerformerConfigurationPage>(),
AdministrationPageViewModel => services.GetRequiredService<AdministrationPage>(),
ResourceUsagePageViewModel => services.GetRequiredService<ResourceUsagePage>(),
ChatViewModel => services.GetRequiredService<ChatPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -5,7 +5,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Models;
using PostIt.Services; using PostIt.Services;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;

View file

@ -12,6 +12,7 @@ using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.Net.Http; using System.Net.Http;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;

View file

@ -8,6 +8,7 @@ using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client; using Yavsc.Api.Client;

View file

@ -7,10 +7,12 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
using PostIt.Models;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -43,7 +45,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
? $"Demandes en cours ({Form.Title})" ? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}"; : $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}"; public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => true; public bool CanOpenDetails => !IsReadOnly;
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
@ -75,7 +77,7 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
public Task InitializeAsync() => RefreshAsync(); public Task InitializeAsync() => RefreshAsync();
private bool CanOpenSelectedQuery() => SelectedQuery is not null; private bool CanOpenSelectedQuery() => CanOpenDetails && SelectedQuery is not null;
[RelayCommand] [RelayCommand]
public async Task RefreshAsync() public async Task RefreshAsync()
@ -130,13 +132,19 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
try try
{ {
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
Activity, Activity,
Performer, Performer,
Form, Form,
_billingClient, _billingClient,
frontClient,
fsClient,
details, details,
IsReadOnly); IsReadOnly,
canAttach: !IsReadOnly);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
@ -171,5 +179,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
} }
private static bool IsOngoingStatus(QueryStatus status) private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress; => status is QueryStatus.Inserted
or QueryStatus.Accepted
or QueryStatus.ProAccepted
or QueryStatus.ClientAccepted
or QueryStatus.InProgress;
} }

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -14,8 +15,18 @@ namespace PostIt.ViewModels;
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly BillingApiClient _billingClient; private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQueryDetailsDto _details; private readonly BillingQueryDetailsDto _details;
/// <summary>
/// Vrai lorsque l'utilisateur courant est le client de la demande
/// et peut y attacher/détacher des fichiers de son espace perso.
/// Le fournisseur consulte et télécharge les pièces mais n'attache
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
/// </summary>
public bool CanAttach { get; }
public ActivityInfo Activity { get; } public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; } public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; } public CommandFormSummary Form { get; }
@ -51,6 +62,10 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
get => false; get => false;
@ -68,19 +83,28 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
ActivityUserDisplayItem performer, ActivityUserDisplayItem performer,
CommandFormSummary form, CommandFormSummary form,
BillingApiClient billingClient, BillingApiClient billingClient,
FrontOfficeApiClient frontClient,
UserFilesApiClient fsClient,
BillingQueryDetailsDto details, BillingQueryDetailsDto details,
bool isReadOnly) bool isReadOnly,
bool canAttach)
{ {
Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form)); Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
_details = details ?? throw new ArgumentNullException(nameof(details)); _details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
CanAttach = canAttach;
this.SetInfoStatus("Details de commande charges."); this.SetInfoStatus("Details de commande charges.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
[RelayCommand] [RelayCommand]
private async Task OpenEditorAsync() private async Task OpenEditorAsync()
{ {
@ -131,6 +155,157 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>Charge les pièces jointes de la demande.</summary>
public async Task InitializeAsync()
{
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task LoadQueryAttachmentsAsync()
{
try
{
var list = await _frontClient
.GetQueryAttachmentsAsync(BillingCode, Id)
.ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
// Non bloquant : les pièces jointes sont un complément.
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
}
}
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché à la demande par référence via
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
/// (<see cref="CanAttach"/>).
/// </summary>
[RelayCommand]
private async Task AddQueryAttachmentAsync()
{
if (!CanAttach) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingCode = BillingCode;
var queryId = Id;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
{
if (attachment is null || !CanAttach) return;
IsBusy = true;
try
{
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée de la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_details.Id),
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate linked to this command as a compiled PDF
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
/// <c>texi2pdf</c> on the server host.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_details.Id),
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private static string EmptyAsPlaceholder(string? value, string placeholder) private static string EmptyAsPlaceholder(string? value, string placeholder)
=> string.IsNullOrWhiteSpace(value) ? placeholder : value; => string.IsNullOrWhiteSpace(value) ? placeholder : value;
@ -166,6 +341,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#E6F7EC", QueryStatus.Accepted => "#E6F7EC",
QueryStatus.ProAccepted => "#E6F7EC",
QueryStatus.ClientAccepted => "#E1F5FE",
QueryStatus.InProgress => "#FFF4D6", QueryStatus.InProgress => "#FFF4D6",
QueryStatus.Rejected => "#FDECEA", QueryStatus.Rejected => "#FDECEA",
QueryStatus.Failed => "#ECEFF1", QueryStatus.Failed => "#ECEFF1",
@ -177,6 +354,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#2E7D32", QueryStatus.Accepted => "#2E7D32",
QueryStatus.ProAccepted => "#2E7D32",
QueryStatus.ClientAccepted => "#1565C0",
QueryStatus.InProgress => "#B26A00", QueryStatus.InProgress => "#B26A00",
QueryStatus.Rejected => "#C62828", QueryStatus.Rejected => "#C62828",
QueryStatus.Failed => "#607D8B", QueryStatus.Failed => "#607D8B",
@ -188,6 +367,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#1B5E20", QueryStatus.Accepted => "#1B5E20",
QueryStatus.ProAccepted => "#1B5E20",
QueryStatus.ClientAccepted => "#0D47A1",
QueryStatus.InProgress => "#7A4A00", QueryStatus.InProgress => "#7A4A00",
QueryStatus.Rejected => "#8E0000", QueryStatus.Rejected => "#8E0000",
QueryStatus.Failed => "#37474F", QueryStatus.Failed => "#37474F",
@ -199,10 +380,12 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "OK", QueryStatus.Accepted => "OK",
QueryStatus.ProAccepted => "P",
QueryStatus.ClientAccepted => "C",
QueryStatus.InProgress => "~", QueryStatus.InProgress => "~",
QueryStatus.Rejected => "!", QueryStatus.Rejected => "!",
QueryStatus.Failed => "X", QueryStatus.Failed => "X",
QueryStatus.Success => "V", QueryStatus.Success => "V",
_ => "i", _ => "i",
}; };
} }

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