feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.
The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).
Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish body { publish: bool }
Returns 204 on success, 404 when the post doesn't exist,
Challenge() (401) when the caller is not the author
(EditPermission gate). Idempotent: PUT because the
resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
factored out of the existing
Modify(BlogPostEditViewModel) inline toggle, so the new
endpoint reuses the same BlogSpotPublication row logic
(add row if missing on publish=true, remove row if
present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
service after each Index/Details fetch — a single bulk
lookup, not N+1 — and surfaces through the wire JSON
so PostIt can show the current state without a follow-up
request.
- ApplicationUser nav properties (Posts, Book,
DeviceDeclaration, Connections, Circles, BlackList,
Rooms, RoomAccess, Membership, BlogComments) now carry
BOTH [JsonIgnore] (Newtonsoft) and
[System.Text.Json.Serialization.JsonIgnore] so the
Yavsc.Blogs test fixture (System.Text.Json) stops
exploding on object cycles when serialising
BlogPost.Author.Posts.Author.Posts. Production
(Yavsc.Org, NewtonsoftJson) was already safe via the
Newtonsoft-only attribute; this commit just makes the
Yavsc.Blogs side consistent.
Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
new endpoint.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
field; serialised as a plain bool in JSON.
UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
mirrors the existing DraftTitle/DraftArticle pattern;
hydrated from SelectedPost.IsPublished on selection
change. TogglePublish command pushes the new state to
SetPublishAsync and updates both the buffer and the
selected post locally so the UI reflects the change
without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
bound to DraftIsPublished TwoWay and wired to
TogglePublishCommand. The toggle is its own action
(not part of Save), matching the wire contract.
Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
* PUT publish=true returns 204 and IsPublished is true
in the next GET
* PUT publish=false clears IsPublished
* PUT on an unknown post returns 404
* PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
app.UseDeveloperExceptionPage() so 500s in tests
surface a real stack trace instead of an empty
InternalServerError body — much easier to diagnose
future regressions.
Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
in the admin web Yavsc (the Org UI already edits Publish
inline; no work needed there).
2026-08-18 16:10:45 +01:00
|
|
|
using System.Net;
|
|
|
|
|
using System.Net.Http.Json;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
|
using Yavsc.Models;
|
|
|
|
|
using Yavsc.Models.Blog;
|
|
|
|
|
using Yavsc.Tests.Shared;
|
|
|
|
|
|
|
|
|
|
namespace Yavsc.Blogs.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Behavioural tests for the publication toggle endpoint:
|
|
|
|
|
/// <c>PUT /api/BlogApi/{id}/publish</c> with body
|
|
|
|
|
/// <c>{ "publish": bool }</c>.
|
|
|
|
|
///
|
|
|
|
|
/// <para>The endpoint is the PostIt-facing way to toggle
|
|
|
|
|
/// whether a post is publicly readable (via
|
|
|
|
|
/// <c>BlogSpotPublication</c>). It does NOT change the
|
|
|
|
|
/// ACL — a Public post with a non-empty ACL is still
|
|
|
|
|
/// restricted to the ACL's circles for authenticated
|
|
|
|
|
/// callers; only anonymous reads open up.</para>
|
|
|
|
|
///
|
|
|
|
|
/// <para>Same fixture as <see cref="BlogApiTests"/>:
|
|
|
|
|
/// in-memory <c>ApplicationDbContext</c>, JWT bearer auth
|
|
|
|
|
/// via <see cref="TestTokenIssuer"/>.</para>
|
|
|
|
|
/// </summary>
|
feat(postit): circles+ACL UI, blog fixture→SQLite, seed default user
Bundled end-of-branch commit on feat/postit-acl-members.
PostIt UI for circles + per-post ACL
- Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders
(Bearer/OIDC scope tests vs. blog API fakes live where they
belong) and introduces PostItHeadlessCollection so the
Avalonia.Headless tests share a single xUnit collection
instead of contending with the EF-Core test host.
- Adds BlogAclApiTests (a brand-new behavioural layer over
POST /api/v1/blogacl) and the fakes it relies on
(BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember
DialogTests); pulls UserId-through-OIDC-sub path into
BearerScopeTests / FakeAuthorizingBrowser /
OidcStubAuthority.
- App.axaml.cs gets a small PushPageAsync touch-up the new
tests rely on.
- Drops UnitTest1.cs (xUnit scaffold, never used).
Yavsc.Blogs.Tests — SQLite instead of InMemory
- Bumps Yavsc.Blogs.Tests.csproj on
Microsoft.EntityFrameworkCore.Sqlite and rewrites
BlogsWebServerFixture to hold a single shared
SqliteConnection (Cache=Shared) for the fixture lifetime,
with a sync Dispose close to dodge async teardown hangs.
Reason: the EF Core InMemory provider silently ignores FKs,
which masked the kind of bug we are about to pin in the
ACL tests. SQLite enforces them, so any future INSERT that
forgets to seed its parent rows fails loudly here instead
of passing the test and breaking prod.
- PublishEndpointTests and BlogApiSmokeTests get a one-line
tweak to follow the new connection lifecycle.
Foreign-key fallout: seed the default user in the fixture
- Adds BlogsWebServerFixture.SeedUser(userName). Now that
SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every
test that POST/PUT/DELETE a BlogPost and sends AuthorId=
'tester' in the payload needs an AspNetUsers row to satisfy
the FK or it returns 500 with SQLite Error 19.
- BlogApiTests wraps the existing ResetDatabase with a
ResetAndSeedDefaultUser helper for the six mutating tests;
the four GET-only and ModelState-only tests keep the bare
ResetDatabase.
- Side benefit: every test in Yavsc.Blogs.Tests now finishes
cleanly instead of hanging at teardown — previously a stuck
test held the shared SqliteConnection open and the next
tests waited indefinitely.
Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25
green from a clean run, no fixture teardown hang.
2026-08-20 23:59:21 +01:00
|
|
|
[Collection("Yavsc Blogs")]
|
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.
The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).
Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish body { publish: bool }
Returns 204 on success, 404 when the post doesn't exist,
Challenge() (401) when the caller is not the author
(EditPermission gate). Idempotent: PUT because the
resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
factored out of the existing
Modify(BlogPostEditViewModel) inline toggle, so the new
endpoint reuses the same BlogSpotPublication row logic
(add row if missing on publish=true, remove row if
present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
service after each Index/Details fetch — a single bulk
lookup, not N+1 — and surfaces through the wire JSON
so PostIt can show the current state without a follow-up
request.
- ApplicationUser nav properties (Posts, Book,
DeviceDeclaration, Connections, Circles, BlackList,
Rooms, RoomAccess, Membership, BlogComments) now carry
BOTH [JsonIgnore] (Newtonsoft) and
[System.Text.Json.Serialization.JsonIgnore] so the
Yavsc.Blogs test fixture (System.Text.Json) stops
exploding on object cycles when serialising
BlogPost.Author.Posts.Author.Posts. Production
(Yavsc.Org, NewtonsoftJson) was already safe via the
Newtonsoft-only attribute; this commit just makes the
Yavsc.Blogs side consistent.
Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
new endpoint.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
field; serialised as a plain bool in JSON.
UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
mirrors the existing DraftTitle/DraftArticle pattern;
hydrated from SelectedPost.IsPublished on selection
change. TogglePublish command pushes the new state to
SetPublishAsync and updates both the buffer and the
selected post locally so the UI reflects the change
without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
bound to DraftIsPublished TwoWay and wired to
TogglePublishCommand. The toggle is its own action
(not part of Save), matching the wire contract.
Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
* PUT publish=true returns 204 and IsPublished is true
in the next GET
* PUT publish=false clears IsPublished
* PUT on an unknown post returns 404
* PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
app.UseDeveloperExceptionPage() so 500s in tests
surface a real stack trace instead of an empty
InternalServerError body — much easier to diagnose
future regressions.
Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
in the admin web Yavsc (the Org UI already edits Publish
inline; no work needed there).
2026-08-18 16:10:45 +01:00
|
|
|
public sealed class PublishEndpointTests : IClassFixture<BlogsWebServerFixture>
|
|
|
|
|
{
|
|
|
|
|
private readonly BlogsWebServerFixture _fixture;
|
|
|
|
|
|
|
|
|
|
public PublishEndpointTests(BlogsWebServerFixture fixture)
|
|
|
|
|
{
|
|
|
|
|
_fixture = fixture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ResetDatabase()
|
|
|
|
|
{
|
|
|
|
|
using var scope = _fixture.Services.CreateScope();
|
|
|
|
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
|
|
|
db.Database.EnsureDeleted();
|
|
|
|
|
db.Database.EnsureCreated();
|
|
|
|
|
|
|
|
|
|
// ApplicationUser has an AlternateKey on Email; the
|
|
|
|
|
// InMemory provider refuses to track entities whose
|
|
|
|
|
// alternate key is null, so we set it explicitly.
|
|
|
|
|
db.Users.Add(new ApplicationUser
|
|
|
|
|
{
|
|
|
|
|
Id = "alice",
|
|
|
|
|
UserName = "alice",
|
|
|
|
|
Email = "alice@example.com",
|
|
|
|
|
EmailConfirmed = true,
|
|
|
|
|
});
|
|
|
|
|
db.SaveChanges();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private long SeedPost(string authorId)
|
|
|
|
|
{
|
|
|
|
|
using var scope = _fixture.Services.CreateScope();
|
|
|
|
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
|
|
|
var post = new BlogPost
|
|
|
|
|
{
|
|
|
|
|
AuthorId = authorId,
|
|
|
|
|
Title = $"post-by-{authorId}",
|
|
|
|
|
Article = "test",
|
|
|
|
|
DateCreated = DateTime.UtcNow,
|
|
|
|
|
DateModified = DateTime.UtcNow,
|
|
|
|
|
};
|
|
|
|
|
db.BlogSpot.Add(post);
|
|
|
|
|
db.SaveChanges();
|
|
|
|
|
return post.Id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string PublishUrl(long id)
|
|
|
|
|
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/api/v1/blog/{id}/publish";
|
|
|
|
|
|
|
|
|
|
private string BlogsUrl
|
|
|
|
|
=> _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
|
|
|
|
|
|
|
|
|
|
private HttpClient NewClient(string subject)
|
|
|
|
|
{
|
|
|
|
|
var handler = new HttpClientHandler
|
|
|
|
|
{
|
|
|
|
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
|
|
|
|
};
|
|
|
|
|
var http = new HttpClient(handler)
|
|
|
|
|
{
|
|
|
|
|
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
|
|
|
|
|
};
|
|
|
|
|
http.DefaultRequestHeaders.Authorization =
|
|
|
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue(
|
|
|
|
|
"Bearer", TestTokenIssuer.Issue(subject));
|
|
|
|
|
return http;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PutPublish_true_returns_204_and_sets_IsPublished_in_subsequent_GET()
|
|
|
|
|
{
|
|
|
|
|
ResetDatabase();
|
|
|
|
|
var postId = SeedPost("alice");
|
|
|
|
|
|
|
|
|
|
using var http = NewClient("alice");
|
|
|
|
|
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
|
|
|
|
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
|
|
|
|
|
|
|
|
|
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
|
|
|
|
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.True(doc.RootElement.GetProperty("isPublished").GetBoolean());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PutPublish_false_returns_204_and_clears_IsPublished()
|
|
|
|
|
{
|
|
|
|
|
ResetDatabase();
|
|
|
|
|
var postId = SeedPost("alice");
|
|
|
|
|
|
|
|
|
|
using var http = NewClient("alice");
|
|
|
|
|
await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
|
|
|
|
|
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = false });
|
|
|
|
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
|
|
|
|
|
|
|
|
|
var get = await http.GetAsync($"{BlogsUrl}/{postId}");
|
|
|
|
|
using var doc = JsonDocument.Parse(await get.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.False(doc.RootElement.GetProperty("isPublished").GetBoolean());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PutPublish_on_unknown_post_returns_404()
|
|
|
|
|
{
|
|
|
|
|
ResetDatabase();
|
|
|
|
|
using var http = NewClient("alice");
|
|
|
|
|
var put = await http.PutAsJsonAsync(PublishUrl(99999L), new { publish = true });
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PutPublish_by_non_author_returns_challenge()
|
|
|
|
|
{
|
|
|
|
|
ResetDatabase();
|
|
|
|
|
var postId = SeedPost("alice");
|
|
|
|
|
|
|
|
|
|
using var http = NewClient("bob");
|
|
|
|
|
var put = await http.PutAsJsonAsync(PublishUrl(postId), new { publish = true });
|
|
|
|
|
// 401 Challenge (the controller returns Challenge()
|
|
|
|
|
// for AuthorizationFailureException). The exact code
|
|
|
|
|
// is framework-dependent; what matters is "not 204".
|
|
|
|
|
Assert.NotEqual(HttpStatusCode.NoContent, put.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
}
|