diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml
index f3c928ee0..b9e6f17b2 100644
--- a/.forgejo/workflows/buildAndTest.yml
+++ b/.forgejo/workflows/buildAndTest.yml
@@ -19,21 +19,21 @@ on:
required: false
type: boolean
push:
- branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
+ branches: [ "main" ]
pull_request:
- branches: [ "main", "release/*", "hotfix/*", "fix/*" ]
+ branches: [ "main", "release/*" ]
jobs:
build:
runs-on: docker
container:
- image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
+ image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps:
- name: Clone yavsc
run: |
set -e
cd /src
- git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
+ git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
cd _src
if [ -n "${GITHUB_REF:-}" ]; then
git fetch origin "$GITHUB_REF"
@@ -49,6 +49,12 @@ jobs:
run: |
echo "🏗️ Building the solution..."
cd /src/_src && dotnet build --verbosity normal
+ - name: Install SkiaSharp native dependencies
+ run: |
+ echo "📦 Installing libfontconfig/libfreetype for SkiaSharp (Avalonia.Headless)..."
+ apt-get update && apt-get install -y --no-install-recommends \
+ libfontconfig1 libfreetype6 libexpat1 zlib1g libbz2-1.0 libpng16-16 libbrotli1 \
+ && rm -rf /var/lib/apt/lists/*
- name: Test
run: |
echo "🚀 Lancement des tests..."
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
index 7d0670a17..911fc831d 100644
--- a/.forgejo/workflows/release.yml
+++ b/.forgejo/workflows/release.yml
@@ -52,7 +52,7 @@ jobs:
release:
runs-on: docker
container:
- image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v2
+ image: pazof/yavsc-build-env:debian13-dotnet10-android36-jdk21-v1
steps:
- name: Clone du repo au tag demandé
env:
@@ -69,7 +69,7 @@ jobs:
cd /src
if [[ ! -d _src/.git ]]; then
- git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
+ git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src
fi
cd _src
@@ -175,6 +175,12 @@ jobs:
run: |
cd /src/_src
dotnet restore
+ - name: Test
+ run: |
+ cd /src/_src && dotnet test \
+ --verbosity normal \
+ --filter="Category!=Platform-Android" \
+ --logger "xunit;LogFileName=test-results.xml"
- name: Build de PostIt.Android ARM64
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f8253c70..111c1491a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,45 +1,5 @@
# Changelog
-## [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
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
index bf1bf8bcb..83d21579d 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -2,16 +2,5 @@
Yavsc
NU1701, NU1901, NU1902, NU1507
-
- false
diff --git a/Directory.Packages.props b/Directory.Packages.props
index feea66ec9..f1be93f91 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,19 +4,22 @@
8.1.0-pazofrc007
-
-
+
+
+
+
+
@@ -34,9 +37,13 @@
+
+
+
+
@@ -46,4 +53,4 @@
-
\ No newline at end of file
+
diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props
index e8804a8bf..2f27340e9 100644
--- a/src/PostIt/Directory.Packages.props
+++ b/src/PostIt/Directory.Packages.props
@@ -1,31 +1,35 @@
-
+
-
- true
- 12.1.1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+ true
+ 12.1.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj
index a6d04cb33..3d8be385c 100644
--- a/src/PostIt/PostIt.Android/PostIt.Android.csproj
+++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj
@@ -11,8 +11,8 @@
false
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
diff --git a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj
index ff2f3f2b8..4534a2940 100644
--- a/src/PostIt/PostIt.Browser/PostIt.Browser.csproj
+++ b/src/PostIt/PostIt.Browser/PostIt.Browser.csproj
@@ -6,8 +6,8 @@
enable
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
diff --git a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
index 36ed1251e..948e726c5 100644
--- a/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
+++ b/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj
@@ -7,8 +7,8 @@
enable
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
app.manifest
diff --git a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs
index 112c11752..4f102be20 100644
--- a/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs
+++ b/src/PostIt/PostIt.Tests/BlogApiTestFakes.cs
@@ -62,17 +62,4 @@ internal sealed class RecordingYavscApiClient : YavscApiClient
});
return Task.FromResult(default(T)!);
}
-
- /// Multipart overload: BlogApiClient routes
- /// Create/UpdatePostAsync with attachments through here.
- /// Without this override the call would fall through to the
- /// base 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.
- public override Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
- {
- _recorder.Calls.Add((method, path, contentFactory));
- return Task.FromResult(default(T)!);
- }
}
diff --git a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs b/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs
deleted file mode 100644
index 946d3d28e..000000000
--- a/src/PostIt/PostIt.Tests/BlogAttachmentLinkTests.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using Yavsc.Blogspot;
-using Yavsc.Api.Client;
-using PostIt.ViewModels;
-
-namespace PostIt.Tests;
-
-///
-/// Pins the markdown link that
-/// appends to the article after a successful attachment upload.
-///
-/// The link must be an absolute URL derived from the OIDC
-/// authority via FileServerUrlHelpers.GetUserFilesUri
-/// (Yavsc.Abstract): the /files file server is hosted by
-/// Yavsc.Org — the IdP — not by the blogs API host, so a link
-/// built from BlogsApiUrl (or left relative) 404s when the
-/// reader opens the post. The regression this guards: a generated
-/// link of the shape /files/{user}/blogs/{id}/{name}
-/// (relative, authority-less) instead of
-/// https://{authority}/files/{user}/blogs/{id}/{name}.
-///
-public class BlogAttachmentLinkTests
-{
- [Fact]
- public async Task Save_with_attachment_appends_absolute_link_derived_from_the_oidc_authority()
- {
- 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(
- "note.txt",
- System.Text.Encoding.UTF8.GetBytes("payload test"),
- "text/plain"));
-
- await vm.SaveAsync();
-
- // The update branch issues two PUTs: the multipart one
- // carrying the file, then — because TryAppendAttachmentLinks
- // appended a link — a JSON one carrying the final article.
- var linkPut = recorder.Calls.LastOrDefault(
- c => c.method == HttpMethod.Put);
- Assert.NotEqual(default, linkPut);
- var sent = Assert.IsType>(linkPut.body);
- var multipartContent = Assert.IsType(sent());
-
- var expectedUrl = $"{authority}/files/tester/blogs/42/note.txt";
- var sentStringContent = multipartContent.First() as StringContent ;
-
- var sentString = await sentStringContent.ReadAsStringAsync(TestContext.Current.CancellationToken);
- Assert.Contains($"- [note.txt]({expectedUrl})", sentString);
- // The regression shape: a relative, authority-less link.
- Assert.DoesNotContain("](/files/", sentString);
- }
-}
diff --git a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs b/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
deleted file mode 100644
index 6158b4132..000000000
--- a/src/PostIt/PostIt.Tests/EstimateListPageViewModelTests.cs
+++ /dev/null
@@ -1,134 +0,0 @@
-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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
-
- await vm.InitializeAsync();
-
- Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
- Assert.Equal("Mes devis Ă valider", 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
-
- await vm.InitializeAsync();
-
- Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
- Assert.Equal("Mes devis en attente", 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider);
-
- 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 vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client);
-
- 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 Paths { get; } = new();
- public bool Empty { get; init; }
-
- public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
- {
- Paths.Add(path);
-
- if (typeof(T) == typeof(List))
- {
- var items = Empty
- ? new List()
- : new List
- {
- new()
- {
- Id = 1,
- Title = "Devis plomberie",
- Description = "Réparation fuite",
- ClientId = "bob",
- OwnerId = "alice",
- CommandType = "Rdv",
- Bill = new List
- {
- 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(),
- },
- };
- 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 CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
- => CallAsync(method, path, (object?)null, ct);
-
- public Task CallAsync(HttpMethod method, string path, Func contentFactory, CancellationToken ct = default)
- => CallAsync(method, path, (object?)null, ct);
-
- public ValueTask DisposeAsync() => ValueTask.CompletedTask;
- }
-}
diff --git a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj
index bbe2096e7..2c4f954d9 100644
--- a/src/PostIt/PostIt.Tests/PostIt.Tests.csproj
+++ b/src/PostIt/PostIt.Tests/PostIt.Tests.csproj
@@ -8,8 +8,8 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
index bc5e7298f..6bd54ca20 100644
--- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
+++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs
@@ -62,7 +62,6 @@ public static class ServiceCollectionHelpers
services.AddTransient();
services.AddTransient();
services.AddTransient();
- services.AddTransient();
// ViewModels
services.AddSingleton(settings);
diff --git a/src/PostIt/PostIt/PostIt.csproj b/src/PostIt/PostIt/PostIt.csproj
index a6f01cc31..09632bdf8 100644
--- a/src/PostIt/PostIt/PostIt.csproj
+++ b/src/PostIt/PostIt/PostIt.csproj
@@ -5,8 +5,8 @@
latest
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
@@ -33,7 +33,6 @@
-
diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs
index 04dbce1fd..7e3187d8b 100644
--- a/src/PostIt/PostIt/Services/YavscApiClient.cs
+++ b/src/PostIt/PostIt/Services/YavscApiClient.cs
@@ -194,7 +194,10 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
using var response = await SendAsync(method, path, body is null ? null : () => JsonContent.Create(body), ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
- return await ReadJsonAsync(response, ct).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ var dto = await JsonSerializer.DeserializeAsync(stream,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
+ return dto!;
}
///
@@ -212,31 +215,10 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
using var response = await SendAsync(method, path, contentFactory, ct).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
- return await ReadJsonAsync(response, ct).ConfigureAwait(false);
- }
-
- ///
- /// Deserialise the response body as ,
- /// tolerating an empty body. PUT /blogspot/{id} answers
- /// 204 No Content: without this guard,
- /// JsonSerializer.DeserializeAsync throws
- /// JsonException: The input does not contain any JSON
- /// tokens and the VM surfaces a successful save as an
- /// error. Callers that genuinely need a payload
- /// (GetPostAsync, CreatePostAsync) are unaffected:
- /// their endpoints always return a body.
- ///
- private static async Task ReadJsonAsync(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(payload,
- new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
+ var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ var dto = await JsonSerializer.DeserializeAsync(stream,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
+ return dto!;
}
///
diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs
index 11797b714..6f8c5d902 100644
--- a/src/PostIt/PostIt/ViewLocator.cs
+++ b/src/PostIt/PostIt/ViewLocator.cs
@@ -54,7 +54,6 @@ public class ViewLocator : IDataTemplate
BillingQueryDetailsPageViewModel => services.GetRequiredService(),
ProviderOngoingRequestsPageViewModel => services.GetRequiredService(),
EstimateEditionPageViewModel => services.GetRequiredService(),
- EstimateListPageViewModel => services.GetRequiredService(),
null => new TextBlock { Text = "No view for " },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
diff --git a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs b/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
deleted file mode 100644
index e5c4d355f..000000000
--- a/src/PostIt/PostIt/ViewModels/Activity/EstimateListPageViewModel.cs
+++ /dev/null
@@ -1,156 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Linq;
-using System.Net;
-using System.Net.Http;
-using System.Threading.Tasks;
-using CommunityToolkit.Mvvm.ComponentModel;
-using CommunityToolkit.Mvvm.Input;
-using PostIt.Helpers;
-using Yavsc.Api.Client;
-
-namespace PostIt.ViewModels;
-
-///
-/// Point de vue d'une liste de devis en cours (non encore validés par
-/// le client).
-///
-public enum EstimateListPerspective
-{
- /// « Mes devis à valider » — l'utilisateur courant est le client.
- Client,
-
- /// « Mes devis en attente » — l'utilisateur courant est le fournisseur.
- Provider,
-}
-
-///
-/// Liste des devis en cours servie par GET api/v1/estimate/asclient
-/// ou GET api/v1/estimate/asprovider selon la perspective choisie.
-///
-public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
-{
- private readonly EstimateApiClient _estimateClient;
- private readonly EstimateListPerspective _perspective;
- private List _allEstimates = new();
-
- public EstimateListPerspective Perspective => _perspective;
-
- public string Title => _perspective == EstimateListPerspective.Client
- ? "Mes devis Ă valider"
- : "Mes devis en attente";
-
- public string Subtitle => _perspective == EstimateListPerspective.Client
- ? "Point de vue client"
- : "Point de vue fournisseur";
-
- ///
- /// Libellé de la colonne « autre partie » : côté client on montre
- /// le fournisseur (OwnerId), côté fournisseur on montre le client.
- ///
- public string CounterpartLabel => _perspective == EstimateListPerspective.Client
- ? "Fournisseur"
- : "Client";
-
- [ObservableProperty]
- public partial ObservableCollection Estimates { get; set; } = new();
-
- [ObservableProperty]
- public partial string FilterText { get; set; } = string.Empty;
-
- [ObservableProperty]
- public partial EstimateDto? SelectedEstimate { get; set; }
-
- [ObservableProperty]
- public partial bool IsBusy { get; set; }
-
- [ObservableProperty]
- public partial string StatusMessage { get; set; } = "Chargement des devis...";
-
- [ObservableProperty]
- public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Chargement des devis...");
-
- public override bool CanNavigateNext
- {
- get => false;
- protected set { _ = value; }
- }
-
- public override bool CanNavigatePrevious
- {
- get => true;
- protected set { _ = value; }
- }
-
- public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective)
- {
- _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
- _perspective = perspective;
- }
-
- public Task InitializeAsync() => RefreshAsync();
-
- [RelayCommand]
- public async Task RefreshAsync()
- {
- IsBusy = true;
- try
- {
- var items = (_perspective == EstimateListPerspective.Client
- ? await _estimateClient.GetOngoingEstimatesAsClientAsync().ConfigureAwait(true)
- : await _estimateClient.GetOngoingEstimatesAsProviderAsync().ConfigureAwait(true))
- ?? new List();
-
- _allEstimates = items
- .OrderByDescending(x => x.Id)
- .ToList();
-
- ApplyFilter();
- this.SetInfoStatus(_allEstimates.Count == 0
- ? "Aucun devis en cours."
- : $"{_allEstimates.Count} devis en cours chargé(s).");
- }
- catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
- {
- _allEstimates = new List();
- Estimates = new ObservableCollection();
- this.SetWarningStatus("Accès refusé (scope 'api'). Déconnectez puis reconnectez-vous.");
- }
- catch (Exception ex)
- {
- _allEstimates = new List();
- Estimates = new ObservableCollection();
- this.SetErrorStatus($"Erreur: {ex.Message}");
- }
- finally
- {
- IsBusy = false;
- }
- }
-
- partial void OnFilterTextChanged(string value)
- {
- ApplyFilter();
- }
-
- private void ApplyFilter()
- {
- var query = FilterText?.Trim();
- var filtered = string.IsNullOrWhiteSpace(query)
- ? _allEstimates
- : _allEstimates.Where(x =>
- ContainsInsensitive(x.Title, query)
- || ContainsInsensitive(x.Description, query)
- || ContainsInsensitive(x.CommandType, query)
- || ContainsInsensitive(x.ClientId, query)
- || ContainsInsensitive(x.OwnerId, query))
- .ToList();
-
- Estimates = new ObservableCollection(filtered);
- }
-
- private static bool ContainsInsensitive(string? value, string query)
- => !string.IsNullOrWhiteSpace(value)
- && value.Contains(query, StringComparison.OrdinalIgnoreCase);
-}
diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
index 726129f12..78be2e789 100644
--- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs
@@ -80,9 +80,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
[ObservableProperty]
public partial Settings Settings { get; private set; }
- [ObservableProperty]
- public partial bool IsMarkdownViewerVisible { get; set; }
-
[RelayCommand]
internal async Task RefreshAsync()
{
@@ -119,7 +116,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// Build a fresh BlogPostDto from the editor buffer on
// every Save — we no longer mutate SelectedPost in
- // place. The previous behavior copied the buffer
+ // place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
@@ -163,7 +160,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
}
else
{
- TryAppendAttachmentLinks(SelectedPost, attachments);
var update = new BlogPostDto
{
Id = SelectedPost.Id,
@@ -174,7 +170,24 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
+
await BlogClient!.UpdatePostAsync(SelectedPost.Id, update, attachments);
+
+ if (TryAppendAttachmentLinks(SelectedPost, attachments))
+ {
+ var linkUpdate = new BlogPostDto
+ {
+ Id = SelectedPost.Id,
+ AuthorId = SelectedPost.AuthorId,
+ Photo = SelectedPost.Photo,
+ Title = DraftTitle,
+ Article = DraftArticle ?? string.Empty,
+ DateCreated = SelectedPost.DateCreated,
+ DateModified = DateTime.UtcNow,
+ };
+ await BlogClient.UpdatePostAsync(SelectedPost.Id, linkUpdate);
+ }
+
this.SetInfoStatus($"Billet {SelectedPost.Id} enregistré.");
DraftAttachments.Clear();
}
@@ -361,7 +374,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
public BlogsViewModel()
{
- WindowTitle = "Blog";
SettingsModel = new Settings();
Init(SettingsModel);
BlogClient = null;
@@ -377,7 +389,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
this.SetInfoStatus("PrĂŞt.");
Settings = settings ?? new Settings();
SearchText = Settings.SearchText;
- IsMarkdownViewerVisible = false;
WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
@@ -424,7 +435,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
///
public BlogsViewModel(BlogApiClient blogClient, Settings? settings = null, IServiceProvider? services = null)
{
- WindowTitle = "Blog";
SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); ;
Services = services;
diff --git a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
index ea92f1d0a..fd2ff1091 100644
--- a/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/Layout/HomePageViewModel.cs
@@ -32,15 +32,11 @@ public class HomePageViewModel : ViewModelBase
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
OpenProviderRequests = new AsyncRelayCommand(OpenProviderRequestsAsync);
- OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
- OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
}
public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; }
- public IAsyncRelayCommand OpenClientEstimates { get; }
- public IAsyncRelayCommand OpenProviderEstimates { get; }
private async Task OpenActivitiesAsync()
{
@@ -76,25 +72,6 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm);
}
- private async Task OpenEstimateListAsync(EstimateListPerspective perspective)
- {
- var app = (App?)Application.Current;
- if (app is null)
- {
- throw new InvalidOperationException("Application PostIt indisponible.");
- }
-
- var estimateClient = app.ServiceProvider?.GetRequiredService();
- if (estimateClient is null)
- {
- throw new InvalidOperationException("Client devis indisponible.");
- }
-
- var vm = new EstimateListPageViewModel(estimateClient, perspective);
- await vm.InitializeAsync();
- await app.PushPageAsync(vm);
- }
-
///
/// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can
diff --git a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
index 1c2802e33..35d5a59fe 100644
--- a/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
+++ b/src/PostIt/PostIt/ViewModels/Settings/Settings.cs
@@ -33,9 +33,6 @@ public partial class Settings : ViewModelBase
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
- [ObservableProperty]
- public partial bool IsMarkdownViewerVisible { get; set; } = false;
-
[ObservableProperty]
public partial string ProviderOngoingRequestsSortOption { get; set; } = string.Empty;
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml
deleted file mode 100644
index d5055dd1f..000000000
--- a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs
deleted file mode 100644
index 0eac3b66e..000000000
--- a/src/PostIt/PostIt/Views/Activity/EstimateListPage.axaml.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using Avalonia.Controls;
-using Avalonia.Markup.Xaml;
-
-namespace PostIt.Views;
-
-public partial class EstimateListPage : ContentPage
-{
- public EstimateListPage()
- {
- InitializeComponent();
- }
-
- private void InitializeComponent()
- {
- AvaloniaXamlLoader.Load(this);
- }
-}
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
index cb63ed6d3..d4124a651 100644
--- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
@@ -7,7 +7,6 @@
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views.Blogs"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
- xmlns:md="using:MarkdownViewer.Core.Controls"
mc:Ignorable="d"
x:Class="PostIt.Views.Blogs.BlogsPage"
x:DataType="vm:BlogsViewModel"
@@ -22,8 +21,6 @@
Padding="5"
Margin="5"
>
-
-
@@ -73,12 +70,6 @@
-
-
-
-
-
@@ -94,12 +85,15 @@
+
+
+
-
+
@@ -109,16 +103,12 @@
-
-
-
+
+
diff --git a/src/PostIt/PostIt/Views/Layout/HomePage.axaml b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
index 3b62b891b..813acc430 100644
--- a/src/PostIt/PostIt/Views/Layout/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
@@ -26,13 +26,5 @@
Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
-
-
diff --git a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
index 7665df8ab..14bc86435 100644
--- a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
+++ b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
@@ -7,6 +7,8 @@ namespace Yavsc.Server.Helpers
{
public static class AbstractFileSystemHelpers
{
+ public static string UserBillsDirName { set; get; }
+ public static string UserFilesDirName { set; get; }
///
/// Is Valid this Path?
@@ -62,6 +64,17 @@ namespace Yavsc.Server.Helpers
return sb.ToString();
}
+ public static UserDirectoryInfo GetUserFiles(string userId, string subdir)
+ {
+ UserDirectoryInfo di = new UserDirectoryInfo(UserFilesDirName, userId, subdir);
+ return di;
+ }
+ public static bool IsRegularFile(string userName, string subdir)
+ {
+ FileInfo fi = new FileInfo( Path.Combine(Path.Combine(UserFilesDirName, userName), subdir));
+ return fi.Exists;
+ }
+
// Server side only supports POSIX file systems
public const char RemoteDirectorySeparator = '/';
diff --git a/src/Yavsc.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj
index 4df6c53c2..78ffe531e 100644
--- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj
+++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj
@@ -11,7 +11,7 @@
latest
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs
index 48704dcb9..3de5b37ec 100644
--- a/src/Yavsc.Api.Client/BlogApiClient.cs
+++ b/src/Yavsc.Api.Client/BlogApiClient.cs
@@ -99,18 +99,7 @@ public sealed class BlogApiClient
return await _api.CallAsync(method, path, () => CreateMultipartContent(post, files), ct: ct);
}
- /// Build the multipart/form-data body PostIt
- /// sends when a blog post is saved with one or more
- /// attachments: a blog field holding the
- /// serialised camelCase with
- /// WhenWritingNull, plus one file part per entry
- /// in (each carrying its
- /// , falling back to
- /// application/octet-stream when unset). Exposed as
- /// public so the blogs backend tests can assert on the
- /// wire shape directly and round-trip it through the live
- /// BlogsWebServerFixture host.
- public static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files)
+ private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files)
{
var content = new MultipartFormDataContent();
var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
diff --git a/src/Yavsc.Api.Client/EstimateApiClient.cs b/src/Yavsc.Api.Client/EstimateApiClient.cs
index 4a80bbe62..15b260786 100644
--- a/src/Yavsc.Api.Client/EstimateApiClient.cs
+++ b/src/Yavsc.Api.Client/EstimateApiClient.cs
@@ -55,22 +55,6 @@ public sealed class EstimateApiClient
return _api.CallAsync(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
}
- ///
- /// Lists the ongoing estimates (not yet validated by the client) where
- /// the current user is the client who initiated the request
- /// (GET estimate/asclient).
- ///
- public Task> GetOngoingEstimatesAsClientAsync(CancellationToken ct = default)
- => _api.CallAsync>(HttpMethod.Get, Absolute($"{PathPrefix}/asclient"), ct: ct);
-
- ///
- /// Lists the ongoing estimates (not yet validated by the client)
- /// established by the current user as the provider
- /// (GET estimate/asprovider).
- ///
- public Task> GetOngoingEstimatesAsProviderAsync(CancellationToken ct = default)
- => _api.CallAsync>(HttpMethod.Get, Absolute($"{PathPrefix}/asprovider"), ct: ct);
-
///
/// Creates an estimate. When is set,
/// the server also stamps the linked command as validated.
diff --git a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
index f0cdb55c9..6ceaab113 100644
--- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
+++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
@@ -17,8 +17,8 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
diff --git a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
index cb945f51a..f48f81ce5 100644
--- a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
+++ b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
@@ -206,110 +206,4 @@ public sealed class EstimateApiControllerTests : IClassFixture
- /// Seeds an estimate owned by alice (the provider) for the given
- /// client, optionally already validated by the client.
- ///
- private long SeedEstimate(string clientId, bool clientValidated)
- {
- using var scope = _fixture.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
-
- var estimate = new Estimate
- {
- ClientId = clientId,
- OwnerId = "alice",
- CommandType = BillingCodes.Rdv,
- Title = "Devis prestation",
- Description = "Devis de test",
- AttachedFiles = new List(),
- AttachedGraphics = new List(),
- Bill = new List
- {
- new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
- },
- ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
- };
- db.Estimates.Add(estimate);
- db.SaveChanges();
- return estimate.Id;
- }
-
- [Fact]
- public async Task GetOngoingEstimatesAsClient_returns_only_the_current_users_ongoing_estimates()
- {
- _fixture.ResetAndSeedActivityGraph();
- var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
- SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
- SeedEstimate(clientId: "alice", clientValidated: false); // another client's: excluded
-
- using var http = NewClient("bob");
-
- var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
- var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
-
- Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
-
- using var doc = JsonDocument.Parse(body);
- var items = doc.RootElement.EnumerateArray().ToArray();
- var item = Assert.Single(items);
- Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
- Assert.Equal("bob", item.GetProperty("clientId").GetString());
- Assert.Equal("alice", item.GetProperty("ownerId").GetString());
- }
-
- [Fact]
- public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider()
- {
- _fixture.ResetAndSeedActivityGraph();
- var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false);
- SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded
-
- using var http = NewClient("alice");
-
- var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
- var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
-
- Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
-
- using var doc = JsonDocument.Parse(body);
- var items = doc.RootElement.EnumerateArray().ToArray();
- var item = Assert.Single(items);
- Assert.Equal(ongoingId, item.GetProperty("id").GetInt64());
- Assert.Equal("alice", item.GetProperty("ownerId").GetString());
- Assert.Single(item.GetProperty("bill").EnumerateArray());
- }
-
- [Fact]
- public async Task GetOngoingEstimatesAsClient_without_token_is_unauthorized()
- {
- _fixture.ResetAndSeedActivityGraph();
-
- var handler = new HttpClientHandler
- {
- ServerCertificateCustomValidationCallback = (_, _, _, _) => true
- };
- using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
-
- var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
-
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- }
-
- [Fact]
- public async Task GetOngoingEstimatesAsProvider_without_token_is_unauthorized()
- {
- _fixture.ResetAndSeedActivityGraph();
-
- var handler = new HttpClientHandler
- {
- ServerCertificateCustomValidationCallback = (_, _, _, _) => true
- };
- using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
-
- var response = await http.GetAsync("/api/v1/estimate/asprovider", TestContext.Current.CancellationToken);
-
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
- }
}
diff --git a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
index ebe96dfa8..760f5ce84 100644
--- a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
+++ b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
@@ -6,11 +6,8 @@
false
Yavsc.Api.Test
true
- 1.1.0.0
- 1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+
@@ -21,6 +18,7 @@
+
@@ -28,7 +26,8 @@
+
-
\ No newline at end of file
+
diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index ba099e7c7..d10e166e3 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -35,7 +35,6 @@ namespace Yavsc.ApiControllers
private readonly ILogger logger;
private readonly IBillingService billingService;
- private readonly SiteSettings siteSettings;
public BillingController(
IAuthorizationService authorizationService,
@@ -44,8 +43,7 @@ namespace Yavsc.ApiControllers
ApplicationDbContext context,
IOptions googleSettings,
IYavscMessageSender GCMSender,
- IBillingService billingService,
- IOptions siteSettings
+ IBillingService billingService
)
{
_googleSettings=googleSettings.Value;
@@ -55,7 +53,6 @@ namespace Yavsc.ApiControllers
this._localizer = SR;
_GCMSender=GCMSender;
this.billingService=billingService;
- this.siteSettings = siteSettings.Value;
}
[HttpGet("facture-{billingCode}-{id}.pdf"), Authorize]
@@ -68,7 +65,7 @@ namespace Yavsc.ApiControllers
return new ChallengeResult();
}
- var fi = bill.GetBillInfo(billingService, siteSettings);
+ var fi = bill.GetBillInfo(billingService);
if (!fi.Exists) return Ok(new { Error = "Not generated" });
return File(fi.OpenRead(), "application/x-pdf", fi.Name);
@@ -232,7 +229,7 @@ namespace Yavsc.ApiControllers
}
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
- await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro", siteSettings);
+ await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"pro");
estimate.ProviderValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
// Notify the client
@@ -276,7 +273,7 @@ namespace Yavsc.ApiControllers
}
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
- FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
+ FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
@@ -313,7 +310,7 @@ namespace Yavsc.ApiControllers
}
if (Request.Form.Files.Count!=1)
return new BadRequestResult();
- await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli", siteSettings);
+ await User.ReceiveProSignatureAsync(billingCode,id,Request.Form.Files[0],"cli");
estimate.ClientValidationDate = DateTime.UtcNow;
dbContext.SaveChanges(User.GetUserId());
return Ok (new { ClientValidationDate = estimate.ClientValidationDate });
@@ -330,9 +327,9 @@ namespace Yavsc.ApiControllers
}
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id);
- FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
+ FileInfo fi = new FileInfo(Path.Combine(AbstractFileSystemHelpers.UserBillsDirName, filename));
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" });
- return File(fi.OpenRead(), "application/x-pdf", filename);
+ return File(fi.OpenRead(), "application/x-pdf", filename); ;
}
///
@@ -408,8 +405,7 @@ namespace Yavsc.ApiControllers
FileReceivedInfo fi;
try
{
- fi = await User.ReceiveEstimateSignatureAsync(id, type, payload,
- siteSettings, token);
+ fi = await User.ReceiveEstimateSignatureAsync(id, type, payload, token);
}
catch (Exception ex)
{
diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs
index b72c6d7a2..3e3e870c1 100644
--- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs
@@ -198,28 +198,6 @@ namespace Yavsc.Controllers
return Ok(estimate);
}
- // GET: api/estimate/asclient
- // Ongoing estimates (not yet validated by the client) where the
- // current user is the client who initiated the request.
- [HttpGet("asclient")]
- public IActionResult GetOngoingEstimatesAsClient()
- {
- var uid = User.GetUserId();
- return Ok(_context.Estimates.Include(e => e.Bill)
- .Where(e => e.ClientId == uid && e.ClientValidationDate == default));
- }
-
- // GET: api/estimate/asprovider
- // Ongoing estimates (not yet validated by the client) established
- // by the current user as the provider.
- [HttpGet("asprovider")]
- public IActionResult GetOngoingEstimatesAsProvider()
- {
- var uid = User.GetUserId();
- return Ok(_context.Estimates.Include(e => e.Bill)
- .Where(e => e.OwnerId == uid && e.ClientValidationDate == default));
- }
-
protected override void Dispose(bool disposing)
{
if (disposing)
diff --git a/src/Yavsc.Api/Controllers/accounting/AccountController.cs b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
index f0986e98f..155274cdf 100644
--- a/src/Yavsc.Api/Controllers/accounting/AccountController.cs
+++ b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
@@ -2,13 +2,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.AspNetCore.Http;
using ImageMagick;
using Yavsc.Models;
using Yavsc.Api.Helpers;
using Yavsc.Server.Helpers;
using System.Diagnostics;
-using Microsoft.Extensions.Options;
namespace Yavsc.WebApi.Controllers
{
@@ -28,16 +28,13 @@ namespace Yavsc.WebApi.Controllers
];
readonly ApplicationDbContext _dbContext;
- private readonly SiteSettings siteSettings;
private readonly ILogger _logger;
public ApiAccountController(
- ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
- IOptions siteSettings)
+ ILoggerFactory loggerFactory, ApplicationDbContext dbContext)
{
_logger = loggerFactory.CreateLogger(nameof(ApiAccountController));
_dbContext = dbContext;
- this.siteSettings = siteSettings.Value;
}
[HttpGet("me")]
@@ -149,7 +146,7 @@ namespace Yavsc.WebApi.Controllers
try
{
- var info = user.ReceiveAvatar(avatarFile, siteSettings);
+ var info = user.ReceiveAvatar(avatarFile);
await _dbContext.SaveChangesAsync();
return Ok(new
{
diff --git a/src/Yavsc.Api/Helpers/RequestHelpers.cs b/src/Yavsc.Api/Helpers/RequestHelpers.cs
index 47601f6f5..d92e765eb 100644
--- a/src/Yavsc.Api/Helpers/RequestHelpers.cs
+++ b/src/Yavsc.Api/Helpers/RequestHelpers.cs
@@ -3,10 +3,8 @@ namespace Yavsc.Api.Helpers
public static class RequestHelpers
{
// Check for some apache proxy header, if any
-#pragma warning disable CS8632 // L'annotation pour les types référence Nullable doit être utilisée uniquement dans le code au sein d'un contexte d'annotations '#nullable'.
public static string? ForwardedFor(this HttpRequest request) {
string? host = request.Headers["X-Forwarded-For"];
-#pragma warning restore CS8632 // L'annotation pour les types référence Nullable doit être utilisée uniquement dans le code au sein d'un contexte d'annotations '#nullable'.
if (string.IsNullOrEmpty(host)) {
host = request.Host.Value;
} else { // Using X-Forwarded-For last address
diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs
index be4ae6579..5f7ef25c0 100644
--- a/src/Yavsc.Api/Program.cs
+++ b/src/Yavsc.Api/Program.cs
@@ -21,6 +21,7 @@ internal class Program
var builder = WebApplication.CreateBuilder(args);
builder.AddConfiguration("api");
+ Config.SiteSetup = builder.Configuration.GetSection("Site").Get() ?? new SiteSettings();
var services = builder.Services;
diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj
index 1a2accfc8..c8c30904d 100644
--- a/src/Yavsc.Api/Yavsc.Api.csproj
+++ b/src/Yavsc.Api/Yavsc.Api.csproj
@@ -7,8 +7,8 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf
- 1.1.0-feat-sign-estimate.1
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
+ 1.1.0-beta.1
diff --git a/src/Yavsc.Api/appsettings-api.json b/src/Yavsc.Api/appsettings-api.json
index b16e7b8b4..6783f565e 100644
--- a/src/Yavsc.Api/appsettings-api.json
+++ b/src/Yavsc.Api/appsettings-api.json
@@ -1,13 +1,11 @@
{
"Site": {
- "Authority": "https://yavsc.pschneider.fr",
+ "Authority": "https://localhost:5001",
"Audience": ["api"],
"CorsAllowedOrigins": [
- "https://yavsc.pschneider.fr",
- "https://blogs.pschneider.fr"
- ],
- "Bills": "bills",
- "TempDir": "temp"
+ "https://localhost:5003",
+ "https://yavsc.pschneider.fr"
+ ]
},
"Logging": {
"LogLevel": {
diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
index be2793bf3..00e2ea61b 100644
--- a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs
@@ -74,7 +74,7 @@ IClassFixture
var draft = new BlogPost
{
Id = 0,
- Title = "Billet JWT re-mappé",
+ Title = "Billet JWT remappe",
AuthorId = "payload-attacker",
Article = "Contenu de test.",
DateCreated = DateTime.UtcNow,
diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
index 13b5edb1b..9d4f6ad81 100644
--- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs
@@ -4,14 +4,11 @@ using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
-using Yavsc.Api.Client;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
using Yavsc.Tests.Shared;
using Yavsc.Blogs.Tests.Fixtures;
-using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
-using NuGet.Protocol;
namespace Yavsc.Blogs.Tests;
@@ -339,9 +336,10 @@ public sealed class BlogApiTests : IClassFixture
ResetAndSeedDefaultUser();
using var http = NewClient(subject: "tester");
- // AttachFiles writes under the fixture-configured
- // SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
- var filesRoot = _fixture.BlogFilesRoot;
+ var previousRoot = AbstractFileSystemHelpers.UserFilesDirName;
+ var tempRoot = Path.Combine(Path.GetTempPath(), "yavsc-blogs-tests-files-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempRoot);
+ AbstractFileSystemHelpers.UserFilesDirName = tempRoot;
try
{
@@ -364,9 +362,6 @@ public sealed class BlogApiTests : IClassFixture
var created = (await postResponse.Content.ReadFromJsonAsync(
TestContext.Current.CancellationToken))!;
- var attachmentCount = CountAttachmentsForPost(created.Id);
- Assert.True(attachmentCount == 0);
-
var update = new BlogPost
{
Id = created.Id,
@@ -377,10 +372,8 @@ public sealed class BlogApiTests : IClassFixture
DateModified = DateTime.UtcNow
};
- var form = new MultipartFormDataContent
- {
- { new StringContent(JsonSerializer.Serialize(update)), "blog" }
- };
+ var form = new MultipartFormDataContent();
+ form.Add(new StringContent(JsonSerializer.Serialize(update)), "blog");
var fileBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
var fileContent = new ByteArrayContent(fileBytes);
@@ -405,14 +398,13 @@ public sealed class BlogApiTests : IClassFixture
using var detailsDoc = JsonDocument.Parse(
await detailsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal("Mis a jour via multipart", detailsDoc.RootElement.GetProperty("title").GetString());
- attachmentCount = CountAttachmentsForPost(created.Id);
- Assert.True(attachmentCount >= 1);
+
+ Assert.True(CountAttachmentsForPost(created.Id) >= 1);
}
finally
{
- // BlogFilesRoot is the fixture-shared temp root (one per
- // test run); leave it in place for the other multipart
- // tests. The OS reclaims it.
+ AbstractFileSystemHelpers.UserFilesDirName = previousRoot;
+ try { Directory.Delete(tempRoot, recursive: true); } catch { }
}
}
@@ -458,137 +450,6 @@ public sealed class BlogApiTests : IClassFixture
Assert.Equal(HttpStatusCode.BadRequest, putResponse.StatusCode);
}
- [Fact]
- public async Task PutBlog_from_PostIt_client_with_attachment_returns_204_and_persists_attachment()
- {
- // Full PostIt "Save" flow for an existing post with a new
- // attachment, driven through the real BlogApiClient — the
- // same code PostIt runs — so any drift between the
- // client's wire shape and the controller's contract fails
- // here before it reaches a user:
- //
- // 1. BlogsViewModel.SaveAsync (update branch) calls
- // BlogApiClient.UpdatePostAsync(id, update, attachments)
- // with a non-empty attachments list →
- // multipart/form-data PUT built by
- // CreateMultipartContent: a "blog" field holding the
- // BlogPostDto serialised camelCase + WhenWritingNull,
- // plus one "file" part per attachment.
- // 2. TryAppendAttachmentLinks then appends a markdown
- // link per attachment to the article, and the VM
- // issues a second UpdatePostAsync without files →
- // plain JSON PUT.
- //
- // The test asserts the final state: the article carries
- // the markdown link, the attachment row is persisted, and
- // the file physically landed under the user-files root
- // the link points to.
- ResetAndSeedDefaultUser();
- using var http = NewClient(subject: "tester");
-
- // AttachFiles writes under the fixture-configured
- // SiteSettings.Blog (BlogFilesRoot), not a local temp dir.
- var filesRoot = _fixture.BlogFilesRoot;
-
- try
- {
- // BlogApiClient takes the versioned base address
- // (…/api/v1/) and prepends its "blogspot" prefix —
- // exactly how PostIt wires it against production.
- var client = new BlogApiClient(
- new TestApiTransport(http),
- _fixture.ApiUrl(""));
-
- // Seed the post through the same path PostIt uses for
- // a first Save on a new draft: CreatePostAsync without
- // files → plain JSON POST.
- var draft = new BlogPostDto
- {
- Title = "Avant PostIt",
- Article = "Contenu initial.",
- DateCreated = DateTime.UtcNow,
- DateModified = DateTime.UtcNow
- };
- var created = await client.CreatePostAsync(draft,
- ct: TestContext.Current.CancellationToken);
- Assert.NotNull(created);
- Assert.NotEqual(0, created!.Id);
- Assert.Equal("tester", created.AuthorId);
-
- // Step 1 — the update DTO the VM builds from the
- // editor buffer (SelectedPost is the read-only hint
- // for Id, AuthorId, Photo and DateCreated), sent with
- // one attachment → multipart PUT.
- var update = new BlogPostDto
- {
- Id = created.Id,
- AuthorId = created.AuthorId,
- Photo = created.Photo,
- Title = "Modifié depuis PostIt",
- Article = "Contenu modifié.",
- DateCreated = created.DateCreated,
- DateModified = DateTime.UtcNow
- };
- var attachments = new[]
- {
- new BlogUploadFile(
- "note.txt",
- System.Text.Encoding.UTF8.GetBytes("payload test"),
- "text/plain")
- };
-
- await client.UpdatePostAsync(created.Id, update, attachments,
- TestContext.Current.CancellationToken);
-
- // Step 2 — TryAppendAttachmentLinks appended a
- // markdown link to the article (the payload does not
- // embed Author, so the owner segment falls back to
- // AuthorId), and the VM PUTs the article again,
- // without files → JSON path of UpdatePostAsync.
- var fileUrl = $"{Yavsc.Constants.UserFilesPath}/tester/blogs/{created.Id}/note.txt";
- var linkUpdate = new BlogPostDto
- {
- Id = created.Id,
- AuthorId = created.AuthorId,
- Photo = created.Photo,
- Title = update.Title,
- Article = update.Article + $"\n\n- [note.txt]({fileUrl})",
- DateCreated = created.DateCreated,
- DateModified = DateTime.UtcNow
- };
-
- await client.UpdatePostAsync(created.Id, linkUpdate,
- ct: TestContext.Current.CancellationToken);
-
- // Final state, read back through the client.
- var details = await client.GetPostAsync(created.Id,
- TestContext.Current.CancellationToken);
- Assert.NotNull(details);
- Assert.Equal("Modifié depuis PostIt", details!.Title);
- Assert.Contains($"- [note.txt]({fileUrl})", details.Article);
-
- // The attachment row is persisted…
- Assert.True(CountAttachmentsForPost(created.Id) >= 1);
-
- // …and the file physically landed where the appended
- // markdown link points:
- // {UserFilesDirName}/{user}/blogs/{postId}/{fileName}.
- var expectedFile = Path.Combine(
- filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
- var written = Directory.Exists(filesRoot)
- ? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
- : "";
- Assert.True(File.Exists(expectedFile),
- $"Expected uploaded file at {expectedFile}. Actually written: {written}");
- }
- finally
- {
- // BlogFilesRoot is the fixture-shared temp root (one per
- // test run); leave it in place for the other multipart
- // tests. The OS reclaims it.
- }
- }
-
[Fact]
public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list()
{
diff --git a/src/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs b/src/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs
deleted file mode 100644
index 698a2369c..000000000
--- a/src/Yavsc.Blogs.Tests/CreateMultipartContentTests.cs
+++ /dev/null
@@ -1,255 +0,0 @@
-using System.Net;
-using System.Net.Http.Headers;
-using System.Net.Http.Json;
-using System.Text.Json;
-using Microsoft.Extensions.DependencyInjection;
-using Yavsc.Api.Client;
-using Yavsc.Models;
-using Yavsc.Models.Blog;
-using Yavsc.Tests.Shared;
-using Yavsc.Blogs.Tests.Fixtures;
-using BlogPostDto = Yavsc.Blogspot.BlogPostDto;
-
-namespace Yavsc.Blogs.Tests;
-
-///
-/// Direct tests for
-/// — the body builder PostIt runs when a post is saved with
-/// attachments. Two layers:
-///
-///
-/// - Shape: call the method with a sample
-/// + a couple of
-/// s and assert the produced
-/// carries a blog
-/// part (camelCase JSON, WhenWritingNull so unset graph
-/// members are dropped) and one file part per attachment
-/// (right filename, right bytes, declared Content-Type, and the
-/// application/octet-stream fallback when the type is
-/// unset). This is the contract the controller's
-/// ReadBlogRequestAsync + AttachFiles rely on; if
-/// the wire shape drifts, the round-trip below fails first, but
-/// the shape assertions pin down which field broke.
-/// - Round-trip: POST the very content the
-/// method built to the live
-/// host (authenticated as tester) and assert 201 Created
-/// + the attachment row persisted + the file physically landed
-/// under {BlogFilesRoot}/tester/blogs/{id}/{fileName}.
-/// This proves the shape the method produces is one the server
-/// actually accepts — not just well-formed multipart, but the
-/// contract BlogApiController.PostBlog binds.
-///
-///
-/// Built on for the round-trip
-/// leg (real SQLite FK enforcement, real BlogSpotService,
-/// real AddJwtBearer validating
-/// tokens). The shape leg needs no host, but the class is
-/// [Collection("Yavsc Blogs")] + IClassFixture so the
-/// one shared host serves both legs without spinning a second one.
-///
-[Collection("Yavsc Blogs")]
-public sealed class CreateMultipartContentTests : IClassFixture
-{
- private readonly BlogsWebServerFixture _fixture;
-
- public CreateMultipartContentTests(BlogsWebServerFixture fixture)
- {
- _fixture = fixture;
- }
-
- /// Reset the database and seed the
- /// tester row — required
- /// for the round-trip POST, whose BlogPost.AuthorId is
- /// a FK to AspNetUsers.Id that SQLite enforces. See
- /// for the
- /// same rationale.
- private void ResetAndSeedDefaultUser()
- {
- _fixture.ResetDatabase();
- _fixture.SeedUser("tester");
- }
-
- private HttpClient NewClient(string subject = "tester")
- {
- var handler = new HttpClientHandler
- {
- ServerCertificateCustomValidationCallback = (_, _, _, _) => true
- };
- var http = new HttpClient(handler)
- {
- BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
- };
- http.DefaultRequestHeaders.Authorization =
- new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
- return http;
- }
-
- private int CountAttachmentsForPost(long postId)
- {
- using var scope = _fixture.Services.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService();
- return db.BlogAttachedFiles.Count(a => a.PostId == postId);
- }
-
- [Fact]
- public async Task Build_produces_blog_part_camelCase_with_WhenWritingNull_and_one_file_part_per_attachment()
- {
- // A DTO that exercises both branches of the serialiser:
- // filled scalar fields (title, article, id, dates, authorId)
- // must come through in camelCase; unset graph members
- // (Author, Tags, Photo) must be dropped by
- // WhenWritingNull — the controller's
- // ReadBlogRequestAsync would otherwise receive explicit
- // nulls that confuse the resource-based ownership check.
- var post = new BlogPostDto
- {
- Id = 42,
- AuthorId = "tester",
- Author = null,
- Title = "Billet multipart",
- Article = "Contenu du billet.",
- Photo = null,
- Tags = null,
- DateCreated = new DateTime(2026, 9, 18, 12, 0, 0, DateTimeKind.Utc),
- DateModified = new DateTime(2026, 9, 18, 12, 30, 0, DateTimeKind.Utc)
- };
-
- var noteBytes = System.Text.Encoding.UTF8.GetBytes("payload test");
- var binBytes = new byte[] { 0x00, 0xFF, 0x42 };
- var files = new BlogUploadFile[]
- {
- new("note.txt", noteBytes, "text/plain"),
- new("bin.dat", binBytes, null) // no ContentType → octet-stream fallback
- };
-
- var content = BlogApiClient.CreateMultipartContent(post, files);
-
- // --- Top-level shape: a multipart/form-data body. ---
- var form = Assert.IsAssignableFrom(content);
- Assert.Equal("multipart/form-data", form.Headers.ContentType?.MediaType);
-
- // Index parts by their form field name. The "blog" field
- // is unique; the "file" field repeats once per attachment
- // (distinguished by ContentDisposition.FileName), so a
- // flat dictionary would collide — group instead.
- var byName = form
- .GroupBy(p => p.Headers.ContentDisposition?.Name?.Trim('"'))
- .ToDictionary(g => g.Key, g => g.ToList());
- Assert.Contains("blog", byName.Keys);
- Assert.Single(byName["blog"]);
-
- // --- "blog" part: camelCase JSON + WhenWritingNull. ---
- var blogJson = await byName["blog"][0].ReadAsStringAsync(
- TestContext.Current.CancellationToken);
- using var doc = JsonDocument.Parse(blogJson);
- var root = doc.RootElement;
-
- // camelCase: the serialiser used JsonNamingPolicy.CamelCase,
- // so the properties land as title/article/id/dateCreated/….
- Assert.True(root.TryGetProperty("title", out var titleEl));
- Assert.Equal(post.Title, titleEl.GetString());
- Assert.True(root.TryGetProperty("article", out var articleEl));
- Assert.Equal(post.Article, articleEl.GetString());
- Assert.True(root.TryGetProperty("id", out var idEl));
- Assert.Equal(post.Id, idEl.GetInt64());
- Assert.True(root.TryGetProperty("authorId", out var authorIdEl));
- Assert.Equal(post.AuthorId, authorIdEl.GetString());
- Assert.True(root.TryGetProperty("dateCreated", out _));
- Assert.True(root.TryGetProperty("dateModified", out _));
-
- // WhenWritingNull: the null graph members are omitted, not
- // emitted as explicit nulls.
- Assert.False(root.TryGetProperty("author", out _),
- $"Expected 'author' to be omitted (WhenWritingNull), got: {blogJson}");
- Assert.False(root.TryGetProperty("tags", out _),
- $"Expected 'tags' to be omitted (WhenWritingNull), got: {blogJson}");
- Assert.False(root.TryGetProperty("photo", out _),
- $"Expected 'photo' to be omitted (WhenWritingNull), got: {blogJson}");
-
- // --- "file" parts: one per attachment, right name, type,
- // and bytes. The "file" form field carries several parts;
- // collect them by filename. ---
- var fileParts = form
- .Where(p => p.Headers.ContentDisposition?.Name?.Trim('"') == "file")
- .ToDictionary(p => p.Headers.ContentDisposition?.FileName?.Trim('"'));
- Assert.Equal(2, fileParts.Count);
-
- var notePart = fileParts["note.txt"];
- Assert.Equal("text/plain", notePart.Headers.ContentType?.MediaType);
- Assert.Equal(noteBytes, await notePart.ReadAsByteArrayAsync(
- TestContext.Current.CancellationToken));
-
- // The attachment with no ContentType falls back to
- // application/octet-stream — the branch in
- // CreateMultipartContent that guards
- // string.IsNullOrWhiteSpace(file.ContentType).
- var binPart = fileParts["bin.dat"];
- Assert.Equal("application/octet-stream", binPart.Headers.ContentType?.MediaType);
- Assert.Equal(binBytes, await binPart.ReadAsByteArrayAsync(
- TestContext.Current.CancellationToken));
- }
-
- [Fact]
- public async Task Build_then_POST_round_trips_through_live_host_and_persists_attachment()
- {
- ResetAndSeedDefaultUser();
- using var http = NewClient(subject: "tester");
- var filesRoot = _fixture.BlogFilesRoot;
-
- // Create-shape DTO (Id=0 → the controller treats it as a
- // create), mirroring what PostIt's MainPageViewModel.Save
- // builds for a first Save with an attachment.
- var draft = new BlogPostDto
- {
- Id = 0,
- AuthorId = "tester",
- Title = "Billet avec pièce jointe",
- Article = "Contenu du billet multipart.",
- DateCreated = DateTime.UtcNow,
- DateModified = DateTime.UtcNow
- };
- var attachments = new BlogUploadFile[]
- {
- new("note.txt", System.Text.Encoding.UTF8.GetBytes("payload test"), "text/plain")
- };
-
- // The exact body PostIt would send — built by the method
- // under test, not hand-rolled here.
- using var content = BlogApiClient.CreateMultipartContent(draft, attachments);
-
- using var request = new HttpRequestMessage(HttpMethod.Post, _fixture.BlogSpotUrl())
- {
- Content = content
- };
- var response = await http.SendAsync(request, TestContext.Current.CancellationToken);
-
- // Dump the body on failure so the assertion message is
- // enough to start a fix (the framework default is opaque).
- if (response.StatusCode != HttpStatusCode.Created)
- {
- var body = await response.Content.ReadAsStringAsync(
- TestContext.Current.CancellationToken);
- Assert.Fail(
- $"Expected 201 Created, got {(int)response.StatusCode} {response.StatusCode}. Body: {body}");
- }
-
- var created = await response.Content.ReadFromJsonAsync(
- TestContext.Current.CancellationToken);
- Assert.NotNull(created);
- Assert.NotEqual(0, created!.Id);
- Assert.Equal("tester", created.AuthorId);
-
- // The attachment row is persisted…
- Assert.True(CountAttachmentsForPost(created.Id) >= 1);
-
- // …and the file physically landed where AttachFiles writes
- // it: {BlogFilesRoot}/{user}/blogs/{postId}/{fileName}.
- var expectedFile = Path.Combine(
- filesRoot, "tester", "blogs", created.Id.ToString(), "note.txt");
- var written = Directory.Exists(filesRoot)
- ? string.Join(", ", Directory.EnumerateFiles(filesRoot, "*", SearchOption.AllDirectories))
- : "";
- Assert.True(File.Exists(expectedFile),
- $"Expected uploaded file at {expectedFile}. Actually written: {written}");
- }
-}
\ No newline at end of file
diff --git a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
index d677e47bb..e0606f5d7 100644
--- a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
+++ b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
@@ -63,16 +63,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
public long PostId { get; private set; }
public string DefaultUserLogin { get => "alice"; }
- /// Root directory under which
- /// BlogSpotService.AttachFiles writes uploaded files
- /// during tests (the configured SiteSettings.Blog).
- /// Attachments land at
- /// {BlogFilesRoot}/{userName}/blogs/{postId}/{fileName}.
- /// A fresh per-run temp directory, so tests can assert on the
- /// physical file without touching the developer's real
- /// user-files tree.
- public string BlogFilesRoot { get; private set; } = "files-root";
-
// A single SqliteConnection held open at the static level,
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
// shared configuration into static slots. Closing the
@@ -122,18 +112,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
builder.Services.AddSingleton(
new NoopFileSystemAuthManager());
- // BlogSpotService.AttachFiles writes uploaded files under
- // siteSettings.Blog. Without a configured SiteSettings the
- // IOptions value has Blog == null and
- // Path.Combine(null, …) throws inside AttachFiles' catch-all,
- // silently persisting no attachment. Point Blog at a
- // per-run temp directory so the multipart tests can assert
- // on real files.
- builder.Services.Configure(s =>
- {
- s.Blog = BlogFilesRoot;
- });
-
// Real BlogSpotService — same instance the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager). With PermissionHandler registered
diff --git a/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs
index ab4418c57..7311ce042 100644
--- a/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs
+++ b/src/Yavsc.Blogs.Tests/Fixtures/MappedClaimsBlogsWebServerFixture.cs
@@ -39,18 +39,6 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
builder.Services.AddSingleton(new NoopFileSystemAuthManager());
-
- // BlogSpotService's constructor throws InvalidOperationException
- // ("SiteSettings.Blog is not configured.") when Blog is null, so
- // every request hitting BlogApiController would 500 resolving the
- // service. These tests never upload files (JSON-only POST/PUT), so
- // AttachFiles short-circuits — but the service still has to be
- // constructible. Point Blog at a per-run temp directory, mirroring
- // BlogsWebServerFixture.
- builder.Services.Configure(s =>
- {
- s.Blog = Path.Combine(Path.GetTempPath(), "postit-blogs-mappedclaims-" + Guid.NewGuid().ToString("N"));
- });
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddControllers()
diff --git a/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs b/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
deleted file mode 100644
index 9cbc8f1ba..000000000
--- a/src/Yavsc.Blogs.Tests/Fixtures/TestApiTransport.cs
+++ /dev/null
@@ -1,93 +0,0 @@
-using System.Net.Http.Json;
-using System.Text.Json;
-using Yavsc.Api.Client;
-
-namespace Yavsc.Blogs.Tests.Fixtures;
-
-///
-/// Minimal transport for integration
-/// tests: it forwards calls over a
-/// test-owned (already authenticated by
-/// the caller) and applies the contract documented on the
-/// interface — JSON bodies serialised with web defaults
-/// (camelCase, case-insensitive reads), and an
-/// carrying the response body
-/// on non-2xx statuses so assertion failures stay actionable.
-///
-/// Unlike PostIt's YavscApiClient, there is no
-/// silent-refresh-on-401: the test token never expires mid-test,
-/// and a 401 is a failure we want surfaced, not retried.
-///
-/// The is owned by the test, not by this
-/// transport, so is a no-op.
-///
-public sealed class TestApiTransport : IYavscApiClient
-{
- private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
-
- public TestApiTransport(HttpClient http)
- {
- Http = http ?? throw new ArgumentNullException(nameof(http));
- }
-
- public HttpClient Http { get; }
-
- public Task CallAsync(
- HttpMethod method,
- string path,
- object? body = null,
- CancellationToken ct = default)
- => SendAsync(method, path,
- body is null ? null : () => JsonContent.Create(body, options: JsonOptions),
- ct);
-
- public Task CallAsync(
- HttpMethod method,
- string path,
- Func contentFactory,
- CancellationToken ct = default)
- => SendAsync(method, path, contentFactory, ct);
-
- public async Task CallAsync(
- HttpMethod method,
- string path,
- object? body = null,
- CancellationToken ct = default)
- => await SendAsync