Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ff43044
feat: add Kindle-style reading time tracker
BenjaminMichaelis Aug 9, 2026
98f2203
fix: use Keys.FirstOrDefault() instead of PrimaryKey (null after JSON…
BenjaminMichaelis Aug 9, 2026
21b5b5d
fix: address reading tracker review feedback
BenjaminMichaelis Oct 7, 2026
f4fd7ca
fix: address follow-up reading tracker review
BenjaminMichaelis Oct 8, 2026
c6526f6
fix: address reading tracker accuracy review
BenjaminMichaelis Oct 14, 2026
3d270ab
test: remove unnecessary null-forgiving operators after IsNotNull ass…
BenjaminMichaelis Oct 14, 2026
00c2448
fix(reading-tracker): exclude generated code listings from word counts
BenjaminMichaelis Oct 14, 2026
2749b8a
refactor(reading-tracker): server-authoritative WPM, app-level tracke…
BenjaminMichaelis Nov 6, 2026
213f4d6
refactor(reading): extract profile service and speed policy, drop Rea…
BenjaminMichaelis Nov 6, 2026
14a0bed
refactor(reading): build word-count snapshot at host start with per-p…
BenjaminMichaelis Nov 6, 2026
f07f437
refactor(reading): emit window.READING_CONTEXT via view component
BenjaminMichaelis Nov 6, 2026
b99f488
test(reading): test requirements, add policy unit tests and broader c…
BenjaminMichaelis Nov 6, 2026
fad4fb4
Merge pr1267-content
BenjaminMichaelis Nov 6, 2026
3277c58
Merge branch 'pr1267-client' into pr1267-integration
BenjaminMichaelis Nov 6, 2026
39bf2a1
fix: reconcile reading tracker client/server contract and scope the c…
BenjaminMichaelis Nov 6, 2026
bea88a7
test: align reading test fake with refactored IWordCountService
BenjaminMichaelis Nov 6, 2026
25f7da3
fix: validated single-source reading options, provider-agnostic upser…
BenjaminMichaelis Nov 6, 2026
988871a
test: record antiforgery vs validation failure contract for POST /api…
BenjaminMichaelis Nov 6, 2026
81e647c
fix(client): single-source reading constants, retry queue, credit car…
BenjaminMichaelis Nov 6, 2026
39b3058
test(client): Vitest suites for reading tracker, API, context, format…
BenjaminMichaelis Nov 6, 2026
7a282f5
fix(client): classify 400s by errors member and split intervals at th…
BenjaminMichaelis Nov 6, 2026
37fb18a
test: rewrite reading tests with literal worked examples, boundaries,…
BenjaminMichaelis Nov 6, 2026
67979f0
fix(client): ignore in-flight results after a cross-tab reset; docume…
BenjaminMichaelis Nov 6, 2026
4ea315a
fix: validate reading options against the client's interval sizing an…
BenjaminMichaelis Nov 6, 2026
587ba63
test: cover per-endpoint rate limits, WPM rounding, profile outage, r…
BenjaminMichaelis Nov 6, 2026
18322ce
test: opt-in SQL Server tests for migrations and the reading profile …
BenjaminMichaelis Nov 6, 2026
8545d14
test: assert reading-tracker requirements instead of timing internals…
BenjaminMichaelis Nov 6, 2026
75b03a3
test: assert reading-tracker requirements rather than implementation …
BenjaminMichaelis Nov 6, 2026
8257a60
ci: run SQL Server tests against a service container
BenjaminMichaelis Nov 6, 2026
3927478
fix: signal antiforgery rejection explicitly instead of inferring it …
BenjaminMichaelis Nov 6, 2026
42497c1
feat: persist unsent reading intervals across page loads, scoped to t…
BenjaminMichaelis Nov 6, 2026
d648eb2
refactor: remove unused book-stats and profile reading endpoints
BenjaminMichaelis Nov 6, 2026
db2f6e7
refactor: validate reading session requests declaratively with indexe…
BenjaminMichaelis Nov 6, 2026
6b4ea76
fix: address review feedback on reading tracker (delivery durability,…
BenjaminMichaelis Nov 7, 2026
7f067f3
test: harden reading tracker tests and make lifecycle delivery avoid …
BenjaminMichaelis Nov 7, 2026
3e7aaf7
fix: close cross-tab and quota double-send paths in outbox delivery; …
BenjaminMichaelis Nov 7, 2026
eaf7ac2
fix: close transient-branch double send, scope auth-failure discard t…
BenjaminMichaelis Nov 7, 2026
669407b
test: move SQL Server integration tests out of this PR
BenjaminMichaelis Nov 7, 2026
a3ce7cb
fix(reading): skip unknown pages, keep stationary time, scope reset, …
BenjaminMichaelis Nov 7, 2026
ca5e847
fix: keepalive sends no longer block periodic flushes
BenjaminMichaelis Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/Build-Test-And-Deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ jobs:
working-directory: EssentialCSharp.Web
run: npm run build

- name: Test frontend
working-directory: EssentialCSharp.Web
run: npm test

build-and-test:
name: Build and test (Ubuntu)
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
Expand Down
7 changes: 6 additions & 1 deletion EssentialCSharp.Web.Tests/McpTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,18 @@ public static async Task<string> CreateUserAsync(IServiceProvider services, stri

public static async Task<(string CookieName, string CookieValue)> CreateIdentityApplicationCookieAsync(
IServiceProvider services,
string userId)
string userId,
Func<System.Security.Claims.ClaimsPrincipal, System.Security.Claims.ClaimsPrincipal>? transformPrincipal = null)
{
using var scope = services.CreateScope();
var signInManager = scope.ServiceProvider.GetRequiredService<SignInManager<EssentialCSharpWebUser>>();
EssentialCSharpWebUser user = await signInManager.UserManager.FindByIdAsync(userId)
?? throw new InvalidOperationException($"Could not find test user '{userId}' to create an identity cookie.");
var principal = await signInManager.CreateUserPrincipalAsync(user);
if (transformPrincipal is not null)
{
principal = transformPrincipal(principal);
}

CookieAuthenticationOptions cookieOptions = scope.ServiceProvider
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
Expand Down
115 changes: 115 additions & 0 deletions EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Net;
using System.Text;
using EssentialCSharp.Web.Controllers;
using EssentialCSharp.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace EssentialCSharp.Web.Tests;

/// <summary>
/// The reading client stops tracking when an antiforgery token is rejected but only drops the batch when the
/// body fails validation, so the two must be distinguishable: a rejected token is flagged with the explicit
/// <see cref="ReadingAntiforgeryFailureHeaderAttribute.HeaderName"/> header, a validation failure is not.
/// </summary>
public class ReadingAntiforgeryContractTests : IntegrationTestBase
{
private const long SeededWords = ReadingTestSupport.BaselineWords;
private const long SeededSeconds = ReadingTestSupport.BaselineSeconds;
private const string SessionUrl = "/api/reading/session";
private const string ResetUrl = "/api/reading/reset";
private const string ValidBody = $$"""{"intervals":[{"pageKey":"{{ReadingTestSupport.LargePage}}","wordsRead":10,"activeSeconds":10}]}""";
private const string InvalidBody = """{"intervals":[]}""";

protected override void ConfigureTestServices(IServiceCollection services) =>
ReadingTestSupport.UseFakes(services, new ReadingTestSupport.FakeWordCountService());

[Test]
[Arguments(SessionUrl, null)]
[Arguments(SessionUrl, "not-a-valid-token")]
[Arguments(ResetUrl, null)]
[Arguments(ResetUrl, "not-a-valid-token")]
public async Task WriteWithoutAValidAntiforgeryToken_IsRejectedWithTheExplicitSignalAndLeavesProfileUnchanged(string url, string? token)
{
(string userId, ReadingTestSupport.AntiforgeryCredentials credentials) = await CreateUserWithCredentialsAsync();

using HttpResponseMessage response = await PostAsync(url, credentials, token, ValidBody);

using (Assert.Multiple())
{
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
await Assert.That(AntiforgeryFailureSignal(response)).IsNotNull();
await Assert.That(await ReadTotalsAsync(userId)).IsEqualTo((SeededWords, SeededSeconds));
}
}

[Test]
public async Task ValidationFailureWithAValidToken_HasNoAntiforgerySignalAndCarriesErrors()
{
(string userId, ReadingTestSupport.AntiforgeryCredentials credentials) = await CreateUserWithCredentialsAsync();

using HttpResponseMessage response = await PostAsync(SessionUrl, credentials, credentials.RequestToken, InvalidBody);
ReadingTestSupport.ProblemResponse problem = await ReadingTestSupport.ReadProblemAsync(response);

using (Assert.Multiple())
{
await Assert.That(problem.Status).IsEqualTo(HttpStatusCode.BadRequest).Because(problem.ToString());
await Assert.That(AntiforgeryFailureSignal(response)).IsNull();
await Assert.That(problem.HasErrorsMember).IsTrue().Because(problem.ToString());
await Assert.That(await ReadTotalsAsync(userId)).IsEqualTo((SeededWords, SeededSeconds));
}
}

[Test]
public async Task WriteWithAValidAntiforgeryToken_IsAccepted()
{
(string userId, ReadingTestSupport.AntiforgeryCredentials credentials) = await CreateUserWithCredentialsAsync();

using HttpResponseMessage response = await PostAsync(SessionUrl, credentials, credentials.RequestToken, ValidBody);

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(await ReadTotalsAsync(userId)).IsEqualTo((SeededWords + 10, SeededSeconds + 10));
}

private static string? AntiforgeryFailureSignal(HttpResponseMessage response) =>
response.Headers.TryGetValues(ReadingAntiforgeryFailureHeaderAttribute.HeaderName, out IEnumerable<string>? values)
? values.Single()
: null;

private async Task<(string UserId, ReadingTestSupport.AntiforgeryCredentials Credentials)> CreateUserWithCredentialsAsync()
{
string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-csrf");
await ReadingTestSupport.SeedProfileAsync(Factory.Services, userId, SeededWords, SeededSeconds);
return (userId, await ReadingTestSupport.CreateSignedInAntiforgeryCredentialsAsync(Factory, userId));
}

private async Task<HttpResponseMessage> PostAsync(
string url, ReadingTestSupport.AntiforgeryCredentials credentials, string? token, string body)
{
using HttpClient client = McpTestHelper.CreateClient(Factory);
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
request.Headers.Add("Cookie", credentials.CookieHeader);
if (token is not null)
{
request.Headers.Add(credentials.HeaderName, token);
}

return await client.SendAsync(request);
}

private async Task<(long Words, long Seconds)> ReadTotalsAsync(string userId) =>
await InServiceScopeAsync(async services =>
{
UserReadingProfileTotals totals = await services.GetRequiredService<EssentialCSharpWebContext>()
.UserReadingProfiles.AsNoTracking()
.Where(p => p.UserId == userId)
.Select(p => new UserReadingProfileTotals(p.TotalWordsRead, p.TotalActiveSeconds))
.SingleAsync();
return (totals.Words, totals.Seconds);
});

private sealed record UserReadingProfileTotals(long Words, long Seconds);
}
Loading
Loading