diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index cb441a0b..a6585225 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -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' diff --git a/EssentialCSharp.Web.Tests/McpTestHelper.cs b/EssentialCSharp.Web.Tests/McpTestHelper.cs index cae923ad..7116f83a 100644 --- a/EssentialCSharp.Web.Tests/McpTestHelper.cs +++ b/EssentialCSharp.Web.Tests/McpTestHelper.cs @@ -101,13 +101,18 @@ public static async Task CreateUserAsync(IServiceProvider services, stri public static async Task<(string CookieName, string CookieValue)> CreateIdentityApplicationCookieAsync( IServiceProvider services, - string userId) + string userId, + Func? transformPrincipal = null) { using var scope = services.CreateScope(); var signInManager = scope.ServiceProvider.GetRequiredService>(); 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>() diff --git a/EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs b/EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs new file mode 100644 index 00000000..bf7c0787 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingAntiforgeryContractTests.cs @@ -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; + +/// +/// 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 +/// header, a validation failure is not. +/// +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? 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 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() + .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); +} diff --git a/EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs b/EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs new file mode 100644 index 00000000..9a23152c --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingContextRenderingTests.cs @@ -0,0 +1,262 @@ +using System.Net; +using System.Text.Json; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace EssentialCSharp.Web.Tests; + +/// +/// The layout hands the reading-time client its data through window.READING_CONTEXT (content pages only) +/// and a csrf-token meta tag (signed-in users only). Values are checked against a fake word-count service +/// with distinct numbers per field, so swapped or zeroed fields fail. +/// +public class ReadingContextRenderingTests : IntegrationTestBase +{ + private const string ContentPage = "/" + ReadingTestSupport.ContentPageKey; + + private readonly ReadingOptions _options = ReadingTestSupport.CreateOptions(); + + protected override void ConfigureTestServices(IServiceCollection services) => + ReadingTestSupport.UseFakes(services, new ReadingTestSupport.FakeWordCountService(), _options); + + [Test] + public async Task AnonymousContentPage_RendersThePageStatisticsAndNoPersonalWpm() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + PageWordStats expected = ReadingTestSupport.ContentPageStats; + + using JsonDocument context = await GetReadingContextAsync(client, ContentPage); + JsonElement root = context.RootElement; + + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.Field(root, "isAuthenticated").GetBoolean()).IsFalse(); + await Assert.That(ReadingTestSupport.Field(root, "pageKey").GetString()).IsEqualTo(ReadingTestSupport.ContentPageKey); + await Assert.That(ReadingTestSupport.Field(root, "pageWords").GetInt32()).IsEqualTo(expected.PageWords); + await Assert.That(ReadingTestSupport.Field(root, "chapterWords").GetInt32()).IsEqualTo(expected.ChapterWords); + await Assert.That(ReadingTestSupport.Field(root, "wordsBeforePage").GetInt32()).IsEqualTo(expected.WordsBeforePage); + await Assert.That(ReadingTestSupport.Field(root, "chapterStartWords").GetInt32()).IsEqualTo(expected.ChapterStartWords); + await Assert.That(ReadingTestSupport.Field(root, "bookWords").GetInt32()).IsEqualTo(expected.BookWords); + await Assert.That(ReadingTestSupport.Field(root, "wpm").ValueKind).IsEqualTo(JsonValueKind.Null); + } + } + + [Test] + public async Task ContentPage_RendersTheConfiguredReadingOptionsAsClientConstants() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + var defaults = new ReadingOptions(); + + using JsonDocument context = await GetReadingContextAsync(client, ContentPage); + JsonElement root = context.RootElement; + + using (Assert.Multiple()) + { + // Guard: the test options must differ from the defaults, otherwise a hard-coded constant would pass. + await Assert.That(_options.DefaultWpm).IsNotEqualTo(defaults.DefaultWpm); + await Assert.That(_options.MaxCreditWpm).IsNotEqualTo(defaults.MaxCreditWpm); + await Assert.That(_options.IdleThresholdSeconds).IsNotEqualTo(defaults.IdleThresholdSeconds); + await Assert.That(_options.PeriodicFlushActiveSeconds).IsNotEqualTo(defaults.PeriodicFlushActiveSeconds); + + await Assert.That(ReadingTestSupport.Field(root, "defaultWpm").GetInt32()).IsEqualTo(_options.DefaultWpm); + await Assert.That(ReadingTestSupport.Field(root, "maxCreditWpm").GetInt32()).IsEqualTo(_options.MaxCreditWpm); + await Assert.That(ReadingTestSupport.Field(root, "idleThresholdSeconds").GetInt32()).IsEqualTo(_options.IdleThresholdSeconds); + await Assert.That(ReadingTestSupport.Field(root, "periodicFlushActiveSeconds").GetInt32()).IsEqualTo(_options.PeriodicFlushActiveSeconds); + } + } + + [Test] + [Arguments("/about")] + [Arguments("/Identity/Account/Login")] + public async Task NonContentPage_RendersNormallyWithoutReadingContext(string path) + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, path); + string html = await ReadingTestSupport.ReadOkHtmlAsync(response); + + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.PageTitle(html)).IsNotNull().And.IsNotEmpty() + .Because("the page itself must render"); + await Assert.That(ReadingTestSupport.TryParseReadingContext(html)).IsNull(); + } + } + + [Test] + public async Task AnonymousVisitor_GetsNoCsrfMetaAndNoAntiforgeryCookie() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + string antiforgeryCookieName = ReadingTestSupport.AntiforgeryCookieName(Factory.Services); + + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, ContentPage); + string html = await ReadingTestSupport.ReadOkHtmlAsync(response); + response.Headers.TryGetValues("Set-Cookie", out IEnumerable? cookies); + + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.CsrfToken(html)).IsNull(); + await Assert.That(cookies ?? []).DoesNotContain(c => c.StartsWith(antiforgeryCookieName + "=", StringComparison.Ordinal)); + } + } + + [Test] + public async Task SignedInUser_GetsCsrfTokenAndTheirDerivedWpmInReadingContext() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context"); + // 1200 words in 360 seconds is 200 WPM. + await ReadingTestSupport.SeedProfileAsync(Factory.Services, userId, 1200, 360); + + string html = await GetSignedInHtmlAsync(userId); + using JsonDocument context = ReadingTestSupport.ParseReadingContext(html); + + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.Field(context.RootElement, "isAuthenticated").GetBoolean()).IsTrue(); + await Assert.That(ReadingTestSupport.Field(context.RootElement, "wpm").GetInt32()).IsEqualTo(200); + await Assert.That(context.RootElement.TryGetProperty("userId", out _)).IsFalse() + .Because("the user id is read from a meta tag so it is not exposed on a window global"); + await Assert.That(ReadingTestSupport.CsrfToken(html)).IsNotNull().And.IsNotEmpty(); + } + } + + [Test] + public async Task SignedInUser_OnPageWithoutReadingTracker_GetsNoCsrfMeta() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context-about"); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpRequestMessage request = new(HttpMethod.Get, "/about"); + request.Headers.Add("Cookie", await ReadingTestSupport.CreateSignedInCookieHeaderAsync(Factory, userId)); + + using HttpResponseMessage response = await client.SendAsync(request); + string html = await ReadingTestSupport.ReadOkHtmlAsync(response); + + await Assert.That(ReadingTestSupport.CsrfToken(html)).IsNull(); + } + + [Test] + public async Task SignedInUser_OnManageProfilePage_GetsCsrfMetaForTheResetButton() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context-manage"); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpRequestMessage request = new(HttpMethod.Get, "/Identity/Account/Manage"); + request.Headers.Add("Cookie", await ReadingTestSupport.CreateSignedInCookieHeaderAsync(Factory, userId)); + + using HttpResponseMessage response = await client.SendAsync(request); + string html = await ReadingTestSupport.ReadOkHtmlAsync(response); + + await Assert.That(ReadingTestSupport.CsrfToken(html)).IsNotNull().And.IsNotEmpty(); + } + + [Test] + public async Task SignedInUser_WpmIsRoundedToTheNearestWholeNumber() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context-round"); + // 1300 words in 420 seconds is 185.71 WPM, which rounds up (truncating would give 185). + await ReadingTestSupport.SeedProfileAsync(Factory.Services, userId, 1300, 420); + + using JsonDocument context = ReadingTestSupport.ParseReadingContext(await GetSignedInHtmlAsync(userId)); + + await Assert.That(ReadingTestSupport.Field(context.RootElement, "wpm").GetInt32()).IsEqualTo(186); + } + + [Test] + public async Task SignedInUserWithoutReadingHistory_GetsNullWpm() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context-new"); + + using JsonDocument context = ReadingTestSupport.ParseReadingContext(await GetSignedInHtmlAsync(userId)); + + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.Field(context.RootElement, "isAuthenticated").GetBoolean()).IsTrue(); + await Assert.That(ReadingTestSupport.Field(context.RootElement, "wpm").ValueKind).IsEqualTo(JsonValueKind.Null); + } + } + + private static async Task GetReadingContextAsync(HttpClient client, string path) + { + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, path); + return ReadingTestSupport.ParseReadingContext(await ReadingTestSupport.ReadOkHtmlAsync(response)); + } + + private async Task GetSignedInHtmlAsync(string userId) + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpRequestMessage request = new(HttpMethod.Get, ContentPage); + request.Headers.Add("Cookie", await ReadingTestSupport.CreateSignedInCookieHeaderAsync(Factory, userId)); + + using HttpResponseMessage response = await client.SendAsync(request); + return await ReadingTestSupport.ReadOkHtmlAsync(response); + } +} + +/// A content page the word-count service has no statistics for still renders a usable context. +public class ReadingContextUnknownPageTests : IntegrationTestBase +{ + protected override void ConfigureTestServices(IServiceCollection services) => + ReadingTestSupport.UseFakes(services, new ReadingTestSupport.FakeWordCountService(includeContentPage: false)); + + [Test] + public async Task PageWithoutStats_FallsBackToZeroPageValuesAndTheBookTotal() + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + + using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, "/" + ReadingTestSupport.ContentPageKey); + using JsonDocument context = ReadingTestSupport.ParseReadingContext(await ReadingTestSupport.ReadOkHtmlAsync(response)); + JsonElement root = context.RootElement; + + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.Field(root, "pageKey").GetString()).IsEqualTo(ReadingTestSupport.ContentPageKey); + await Assert.That(ReadingTestSupport.Field(root, "pageWords").GetInt32()).IsEqualTo(0); + await Assert.That(ReadingTestSupport.Field(root, "chapterWords").GetInt32()).IsEqualTo(0); + await Assert.That(ReadingTestSupport.Field(root, "wordsBeforePage").GetInt32()).IsEqualTo(0); + await Assert.That(ReadingTestSupport.Field(root, "chapterStartWords").GetInt32()).IsEqualTo(0); + await Assert.That(ReadingTestSupport.Field(root, "bookWords").GetInt32()).IsEqualTo(ReadingTestSupport.BookWords); + } + } +} + +/// A reading-profile outage must not break book pages. +public class ReadingContextProfileFailureTests : IntegrationTestBase +{ + protected override void ConfigureTestServices(IServiceCollection services) + { + ReadingTestSupport.UseFakes(services, new ReadingTestSupport.FakeWordCountService()); + services.RemoveAll(); + services.AddScoped(); + } + + [Test] + public async Task ProfileServiceFailure_StillRendersThePageWithNullWpm() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-context-outage"); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using HttpRequestMessage request = new(HttpMethod.Get, "/" + ReadingTestSupport.ContentPageKey); + request.Headers.Add("Cookie", await ReadingTestSupport.CreateSignedInCookieHeaderAsync(Factory, userId)); + + using HttpResponseMessage response = await client.SendAsync(request); + string html = await ReadingTestSupport.ReadOkHtmlAsync(response); + + using JsonDocument context = ReadingTestSupport.ParseReadingContext(html); + using (Assert.Multiple()) + { + await Assert.That(ReadingTestSupport.Field(context.RootElement, "isAuthenticated").GetBoolean()).IsTrue(); + await Assert.That(ReadingTestSupport.Field(context.RootElement, "wpm").ValueKind).IsEqualTo(JsonValueKind.Null); + } + } + + private sealed class ThrowingReadingProfileService : IReadingProfileService + { + public Task RecordSessionAsync(string userId, IReadOnlyList intervals, CancellationToken cancellationToken) => + throw new InvalidOperationException("profile store unavailable"); + + public Task GetProfileAsync(string userId, CancellationToken cancellationToken) => + throw new InvalidOperationException("profile store unavailable"); + + public Task ResetAsync(string userId, CancellationToken cancellationToken) => + throw new InvalidOperationException("profile store unavailable"); + } +} diff --git a/EssentialCSharp.Web.Tests/ReadingControllerTests.cs b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs new file mode 100644 index 00000000..00307e6d --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingControllerTests.cs @@ -0,0 +1,710 @@ +using System.Net; +using System.Security.Claims; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using EssentialCSharp.Web.Controllers; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace EssentialCSharp.Web.Tests; + +/// +/// End-to-end behavior of the reading API. The speed-scoring arithmetic is covered exhaustively by +/// ; here each scoring outcome has one smoke case that proves the policy is wired in. +/// +public class ReadingControllerTests : IntegrationTestBase +{ + private const long BaselineWords = ReadingTestSupport.BaselineWords; + private const long BaselineSeconds = ReadingTestSupport.BaselineSeconds; + + private const string LargePage = ReadingTestSupport.LargePage; + + // The content package is a placeholder in many environments, so word counts and reading options are fixed + // (see ReadingTestSupport). + private readonly ReadingOptions Options = ReadingTestSupport.CreateOptions(); + + protected override void ConfigureTestServices(IServiceCollection services) => + ReadingTestSupport.UseFakes(services, new ReadingTestSupport.FakeWordCountService(), Options); + + // ---- authentication ---- + + [Test] + [Arguments("/api/reading/session")] + [Arguments("/api/reading/reset")] + public async Task ProtectedEndpoints_Anonymous_Return401WithoutRedirect(string url) + { + using HttpClient client = CreateClientWithoutAutoRedirect(); + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonBody($$"""{"intervals":[{"pageKey":"{{LargePage}}","wordsRead":10,"activeSeconds":10}]}""") + }; + + using HttpResponseMessage response = await client.SendAsync(request); + + using (Assert.Multiple()) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + await Assert.That(response.Headers.Location).IsNull(); + } + } + + // A missing or invalid antiforgery token is covered by ReadingAntiforgeryContractTests. + + // ---- POST session: one smoke case per scoring outcome (the math lives in ReadingSpeedPolicyTests) ---- + + [Test] + public async Task PostSession_NormalInterval_AddsWordsAndTimeAndReturnsUpdatedProfile() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync(client, Interval(LargePage, 100, 60)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + JsonElement body = await response.Content.ReadFromJsonAsync(); + using (Assert.Multiple()) + { + await Assert.That(body.GetProperty("totalWordsRead").GetInt64()).IsEqualTo(1300L); + await Assert.That(body.GetProperty("totalActiveSeconds").GetInt64()).IsEqualTo(420L); + // 1300 words in 420 s. + await Assert.That(body.GetProperty("wpm").GetDouble()).IsEqualTo(185.714).Within(0.001); + } + await AssertProfileAsync(userId, 1300, 420); + } + + [Test] + public async Task PostSession_VerySlowInterval_IsCreditedAtTheSlowThresholdSpeed() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + // 50 words in 1800 s is far below the reader's 50 WPM threshold; at that speed 50 words take 60 s. + using HttpResponseMessage response = await PostSessionAsync(client, Interval(LargePage, 50, 1800)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 1250, 420); + } + + [Test] + public async Task PostSession_VerySlowIntervalForColdStartUser_IsClampedAgainstTheDefaultSpeed() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-cold"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + // With no reading history the default speed (210 WPM) is the reference, so the slow threshold is 52.5 WPM: + // 50 words are credited ceil(50 / 52.5 * 60) = 58 s rather than 1800 s. + using HttpResponseMessage response = await PostSessionAsync(client, Interval(LargePage, 50, 1800)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 50, 58); + } + + [Test] + public async Task PostSession_ImplausiblyFastInterval_IsIgnoredAndStillSucceeds() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + // 301 words in 20 s is 903 WPM, above the 900 WPM cutoff. + using HttpResponseMessage response = await PostSessionAsync(client, Interval(LargePage, 301, 20)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_AllIntervalsIgnoredForUserWithoutProfile_ReturnsZerosAndCreatesNoRow() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-ignored"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, 301, 20), Interval(LargePage, 600, 20)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + JsonElement body = await response.Content.ReadFromJsonAsync(); + using (Assert.Multiple()) + { + await Assert.That(body.GetProperty("totalWordsRead").GetInt64()).IsEqualTo(0L); + await Assert.That(body.GetProperty("totalActiveSeconds").GetInt64()).IsEqualTo(0L); + await Assert.That(body.GetProperty("wpm").ValueKind).IsEqualTo(JsonValueKind.Null); + await Assert.That(await HasProfileRowAsync(userId)).IsFalse(); + } + } + + [Test] + public async Task PostSession_WordsAbovePageWordCount_AreCappedAtPageWordCount() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-cap"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + // The small page has 150 words; claiming 151 is credited as 150 (172 s at the 52.5 WPM slow threshold). + using HttpResponseMessage response = await PostSessionAsync(client, Interval(ReadingTestSupport.SmallPage, 151, 600)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 150, 172); + } + + // ---- POST session: boundaries ---- + + [Test] + public async Task PostSession_MaxWordsRead_IsAccepted() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-maxwords"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + // 100 000 words is the DTO maximum; the page has 5000, so 5000 are credited (750 WPM over 400 s). + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, ReadingIntervalRequest.MaxWordsRead, 400)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 5000, 400); + } + + [Test] + public async Task PostSession_OneOverMaxWordsRead_IsRejectedOnWordsRead() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, ReadingIntervalRequest.MaxWordsRead + 1, 400)); + + await ReadingTestSupport.AssertValidationProblemAsync(response, "Intervals[0]", nameof(ReadingIntervalRequest.WordsRead)); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_MaxActiveSecondsPerInterval_IsAccepted() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-maxseconds"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, 100, Options.MaxActiveSecondsPerInterval)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 100, 115); // clamped to the 52.5 WPM slow threshold + } + + [Test] + public async Task PostSession_OneOverMaxActiveSecondsPerInterval_IsRejectedOnActiveSeconds() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, 10, 10), Interval(LargePage, 100, Options.MaxActiveSecondsPerInterval + 1)); + + await ReadingTestSupport.AssertValidationProblemAsync(response, "Intervals[1]", nameof(ReadingIntervalRequest.ActiveSeconds)); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_AtMaximumIntervalsPerRequest_IsAccepted() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-batch-max"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + int count = Options.MaxIntervalsPerRequest; + + using HttpResponseMessage response = await PostSessionAsync( + client, Enumerable.Repeat(Interval(LargePage, 10, 10), count).ToArray()); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 10L * count, 10L * count); + } + + [Test] + public async Task PostSession_OneOverMaximumIntervalsPerRequest_IsRejectedAndDoesNotChangeProfile() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Enumerable.Repeat(Interval(LargePage, 10, 10), Options.MaxIntervalsPerRequest + 1).ToArray()); + + await ReadingTestSupport.AssertValidationProblemAsync(response, nameof(ReadingSessionRequest.Intervals)); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_NullIntervalEntry_IsRejectedOnThatEntry() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/reading/session") + { + Content = JsonBody($$"""{"intervals":[{"pageKey":"{{LargePage}}","wordsRead":10,"activeSeconds":10},null]}""") + }; + + using HttpResponseMessage response = await client.SendAsync(request); + + await ReadingTestSupport.AssertValidationProblemAsync(response, "Intervals[1]"); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_NoIntervals_IsRejected() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync(client); + + await ReadingTestSupport.AssertValidationProblemAsync(response, nameof(ReadingSessionRequest.Intervals)); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_DeclaresARequestSizeLimitInItsMetadataThatFitsAMaximalLegitimateRequestButIsFarBelowTheServerDefault() + { + // The in-memory TestServer does not enforce Kestrel's body-size feature, so an oversized request cannot be + // rejected here; read the limit the endpoint declares (which Kestrel enforces) from the endpoint metadata. + EndpointDataSource endpoints = Factory.Services.GetRequiredService(); + long?[] declaredLimits = endpoints.Endpoints + .Where(e => e.Metadata.GetMetadata() is { ActionName: nameof(ReadingController.PostSession) }) + .Select(e => e.Metadata.GetMetadata()?.MaxRequestBodySize) + .Distinct() + .ToArray(); + long? limit = declaredLimits.Length == 1 ? declaredLimits[0] : null; + + // The largest request a well-behaved client may send: the maximum number of intervals, each with a + // maximum-length page key and the largest numeric values. + object maximalInterval = Interval( + ReadingTestSupport.MaxLengthPageKey, ReadingIntervalRequest.MaxWordsRead, Options.MaxActiveSecondsPerInterval); + int maximalRequestBytes = JsonSerializer.SerializeToUtf8Bytes( + new { intervals = Enumerable.Repeat(maximalInterval, Options.MaxIntervalsPerRequest).ToArray() }).Length; + const long KestrelDefaultMaxRequestBodySize = 30_000_000; + + using (Assert.Multiple()) + { + await Assert.That(limit).IsNotNull().Because("the endpoint must declare its own request size limit"); + await Assert.That(limit ?? 0).IsGreaterThanOrEqualTo(maximalRequestBytes); + await Assert.That(limit ?? long.MaxValue).IsLessThan(KestrelDefaultMaxRequestBodySize); + } + } + + [Test] + public async Task PostSession_PageKeyOfMaximumLength_IsAccepted() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-key-max"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(ReadingTestSupport.MaxLengthPageKey, 10, 10)); + + await Assert.That(ReadingTestSupport.MaxLengthPageKey.Length).IsEqualTo(ReadingIntervalRequest.PageKeyMaxLength); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, 10, 10); + } + + [Test] + public async Task PostSession_PageKeyOneOverMaximumLength_IsRejectedOnPageKey() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(new string('k', ReadingIntervalRequest.PageKeyMaxLength + 1), 10, 10)); + + await ReadingTestSupport.AssertValidationProblemAsync(response, "Intervals[0]", nameof(ReadingIntervalRequest.PageKey)); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + // ---- POST session: invalid input ---- + + [Test] + [Arguments("empty body", "")] + [Arguments("JSON null", "null")] + [Arguments("malformed JSON", "{not json")] + [Arguments("bare array (old wire format)", """[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":10}]""")] + [Arguments("missing intervals", "{}")] + [Arguments("null intervals", """{"intervals":null}""")] + [Arguments("null interval entry", """{"intervals":[null]}""")] + [Arguments("zero words", """{"intervals":[{"pageKey":"{KEY}","wordsRead":0,"activeSeconds":10}]}""")] + [Arguments("negative words", """{"intervals":[{"pageKey":"{KEY}","wordsRead":-1,"activeSeconds":10}]}""")] + [Arguments("zero seconds", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":0}]}""")] + [Arguments("negative seconds", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":-1}]}""")] + [Arguments("int.MaxValue words", """{"intervals":[{"pageKey":"{KEY}","wordsRead":2147483647,"activeSeconds":10}]}""")] + [Arguments("int.MaxValue seconds", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":2147483647}]}""")] + [Arguments("missing page key", """{"intervals":[{"wordsRead":10,"activeSeconds":10}]}""")] + [Arguments("empty page key", """{"intervals":[{"pageKey":"","wordsRead":10,"activeSeconds":10}]}""")] + [Arguments("one bad interval among good ones", """{"intervals":[{"pageKey":"{KEY}","wordsRead":10,"activeSeconds":10},{"pageKey":"{KEY}","wordsRead":0,"activeSeconds":10}]}""")] + public async Task PostSession_InvalidInput_IsRejectedAndLeavesProfileUnchanged(string scenario, string bodyTemplate) + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/reading/session") + { + Content = JsonBody(bodyTemplate.Replace("{KEY}", LargePage, StringComparison.Ordinal)) + }; + + using HttpResponseMessage response = await client.SendAsync(request); + + ReadingTestSupport.ProblemResponse problem = await ReadingTestSupport.ReadProblemAsync(response); + + using (Assert.Multiple()) + { + await Assert.That(problem.Status).IsEqualTo(HttpStatusCode.BadRequest).Because($"{scenario}: {problem}"); + await Assert.That(problem.HasErrorsMember).IsTrue().Because($"{scenario}: expected a validation problem with an errors member, got {problem}"); + } + + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_UnknownPageKey_IsSkippedWithoutFailingTheRequest() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync(client, Interval("no-such-page", 10, 10)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_KnownPageWithZeroWords_IsSkippedLikeAnUnknownPage() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync(client, Interval(ReadingTestSupport.EmptyPage, 10, 10)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task PostSession_ValidIntervalAlongsideUnknownPageKey_CreditsTheValidInterval() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, 100, 60), Interval("no-such-page", 10, 10)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(userId, BaselineWords + 100, BaselineSeconds + 60); + } + + // ---- user isolation ---- + + [Test] + public async Task PostSession_UpdatesOnlyTheAuthenticatedUsersProfile() + { + string callerId = await CreateUserWithBaselineProfileAsync(); + string otherId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(callerId); + + using HttpResponseMessage response = await PostSessionAsync(client, Interval(LargePage, 50, 30)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await AssertProfileAsync(callerId, BaselineWords + 50, BaselineSeconds + 30); + await AssertProfileAsync(otherId, BaselineWords, BaselineSeconds); + } + + // ---- reset ---- + + [Test] + public async Task Reset_RemovesOnlyTheCallersProfile() + { + string callerId = await CreateUserWithBaselineProfileAsync(); + string otherId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(callerId); + + using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(await HasProfileRowAsync(callerId)).IsFalse(); + await AssertProfileAsync(otherId, BaselineWords, BaselineSeconds); + } + + [Test] + public async Task Reset_UserWithoutData_Returns204() + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-reset-empty"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage response = await client.PostAsync("/api/reading/reset", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + } + + [Test] + public async Task Reset_CalledTwice_IsIdempotent() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage first = await client.PostAsync("/api/reading/reset", null); + using HttpResponseMessage second = await client.PostAsync("/api/reading/reset", null); + + using (Assert.Multiple()) + { + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(await HasProfileRowAsync(userId)).IsFalse(); + } + } + + [Test] + public async Task Reset_ThenRecording_StartsAFreshProfileAgainstTheDefaultSpeed() + { + string userId = await CreateUserWithBaselineProfileAsync(); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + using HttpResponseMessage reset = await client.PostAsync("/api/reading/reset", null); + // After a reset the reader is held to the default speed again, so 50 words in 1800 s are credited 58 s. + using HttpResponseMessage record = await PostSessionAsync(client, Interval(LargePage, 50, 1800)); + + using (Assert.Multiple()) + { + await Assert.That(reset.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(record.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + await AssertProfileAsync(userId, 50, 58); + } + + // ---- rate limiting ---- + + [Test] + [Arguments("/api/reading/session")] + [Arguments("/api/reading/reset")] + public async Task AuthenticatedEndpoint_ThrottlesAUserWhoKeepsCallingWith429_WithoutAffectingOtherUsers(string url) + { + // The permit count is a production tuning value and is deliberately not asserted: keep calling until the + // first 429. The ceiling is only a sanity bound that turns "never throttled" into a failure. + const int Ceiling = 1000; + string limitedUser = await McpTestHelper.CreateUserAsync(Factory, "reading-limited"); + string otherUser = await McpTestHelper.CreateUserAsync(Factory, "reading-unlimited"); + using HttpClient limitedClient = await CreateAuthenticatedClientAsync(limitedUser); + using HttpClient otherClient = await CreateAuthenticatedClientAsync(otherUser); + + var statuses = new List(); + HttpResponseMessage? throttled = null; + try + { + while (throttled is null && statuses.Count < Ceiling) + { + HttpResponseMessage response = await SendAsync(limitedClient, url); + statuses.Add(response.StatusCode); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + throttled = response; + } + else + { + response.Dispose(); + } + } + + using HttpResponseMessage otherUserResponse = await SendAsync(otherClient, url); + + using (Assert.Multiple()) + { + await Assert.That(throttled).IsNotNull().Because($"no 429 within {Ceiling} requests"); + await Assert.That(throttled?.Headers.RetryAfter).IsNotNull().Because("a throttled response tells the client when to retry"); + await Assert.That(statuses.Take(statuses.Count - 1)) + .DoesNotContain(s => s != HttpStatusCode.OK && s != HttpStatusCode.NoContent) + .Because("every request before the throttle must succeed"); + await Assert.That(otherUserResponse.StatusCode).IsNotEqualTo(HttpStatusCode.TooManyRequests); + } + } + finally + { + throttled?.Dispose(); + } + } + + private static Task SendAsync(HttpClient client, string url) => + url.EndsWith("/session", StringComparison.Ordinal) + ? PostSessionAsync(client, Interval(LargePage, 10, 10)) + : client.PostAsync(url, null); + + // ---- profile timestamps ---- + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task PostSession_StampsUpdatedAtUtcWithTheTimeOfTheWrite(bool userAlreadyHasProfile) + { + string userId = userAlreadyHasProfile + ? await CreateUserWithBaselineProfileAsync(updatedAtUtc: DateTime.UtcNow.AddDays(-30)) + : await McpTestHelper.CreateUserAsync(Factory, "reading-stamp"); + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + + DateTime before = DateTime.UtcNow; + using HttpResponseMessage response = await PostSessionAsync(client, Interval(LargePage, 10, 10)); + DateTime after = DateTime.UtcNow; + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + DateTime updatedAt = DateTime.SpecifyKind((await GetProfileAsync(userId)).UpdatedAtUtc, DateTimeKind.Utc); + using (Assert.Multiple()) + { + await Assert.That(updatedAt).IsGreaterThanOrEqualTo(before.AddSeconds(-1)); + await Assert.That(updatedAt).IsLessThanOrEqualTo(after.AddSeconds(1)); + } + } + + // ---- identity without a user id ---- + + [Test] + [Arguments("/api/reading/session")] + [Arguments("/api/reading/reset")] + public async Task AuthenticatedPrincipalWithoutUserIdClaim_IsUnauthorizedAndWritesNothing(string url) + { + // A signed-in principal that carries no NameIdentifier claim has no user to act for. The antiforgery token is + // minted for the same stripped principal so the request is rejected for the missing id, not for the token. + string victimId = await CreateUserWithBaselineProfileAsync(); + ReadingTestSupport.AntiforgeryCredentials credentials = await ReadingTestSupport.CreateSignedInAntiforgeryCredentialsAsync( + Factory, + victimId, + principal => new ClaimsPrincipal(new ClaimsIdentity( + principal.Claims.Where(c => c.Type != ClaimTypes.NameIdentifier), + principal.Identity?.AuthenticationType))); + using HttpClient client = CreateClientWithoutAutoRedirect(); + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonBody($$"""{"intervals":[{"pageKey":"{{LargePage}}","wordsRead":10,"activeSeconds":10}]}""") + }; + request.Headers.Add("Cookie", credentials.CookieHeader); + request.Headers.Add(credentials.HeaderName, credentials.RequestToken); + + using HttpResponseMessage response = await client.SendAsync(request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + await AssertProfileAsync(victimId, BaselineWords, BaselineSeconds); + } + + // ---- concurrency ---- + + [Test] + [Arguments(false)] + [Arguments(true)] + public async Task PostSession_ManyParallelRequests_AreAllAcceptedAndTheTotalsAddUp(bool userAlreadyHasProfile) + { + // On SQLite this proves the endpoint is wired for a burst and the final sum is right; it does not prove + // lost-update safety, because SQLite serializes writers. That needs a real SQL Server (tracked separately). + // The burst is kept small enough that no request is rate limited. + const int parallelRequests = 10; + const int wordsPerRequest = 40; + const int secondsPerRequest = 20; + string userId = userAlreadyHasProfile + ? await CreateUserWithBaselineProfileAsync() + : await McpTestHelper.CreateUserAsync(Factory, "reading-concurrent"); + long baselineWords = userAlreadyHasProfile ? BaselineWords : 0; + long baselineSeconds = userAlreadyHasProfile ? BaselineSeconds : 0; + + // Authenticate every client up front so the requests themselves start together. + var clients = new List(); + try + { + for (int i = 0; i < parallelRequests; i++) + { + clients.Add(await CreateAuthenticatedClientAsync(userId)); + } + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task[] requests = clients.Select(client => Task.Run(async () => + { + await gate.Task; + using HttpResponseMessage response = await PostSessionAsync( + client, Interval(LargePage, wordsPerRequest, secondsPerRequest)); + return response.StatusCode; + })).ToArray(); + + gate.SetResult(); + HttpStatusCode[] statuses = await Task.WhenAll(requests); + + await Assert.That(statuses).IsEquivalentTo(Enumerable.Repeat(HttpStatusCode.OK, parallelRequests)); + await AssertProfileAsync( + userId, + baselineWords + (long)parallelRequests * wordsPerRequest, + baselineSeconds + (long)parallelRequests * secondsPerRequest); + } + finally + { + foreach (HttpClient client in clients) + { + client.Dispose(); + } + } + } + + // ---- personal data ---- + + [Test] + public async Task DownloadPersonalData_IncludesReadingTotalsForUserWithAProfile() + { + string userId = await CreateUserWithBaselineProfileAsync(); + string otherUserId = await McpTestHelper.CreateUserAsync(Factory, "reading-export-none"); + Dictionary withProfile = await DownloadPersonalDataAsync(userId); + Dictionary withoutProfile = await DownloadPersonalDataAsync(otherUserId); + + using (Assert.Multiple()) + { + await Assert.That(withProfile["Reading total words read"]).IsEqualTo("1200"); + await Assert.That(withProfile["Reading total active seconds"]).IsEqualTo("360"); + await Assert.That(withoutProfile.Keys.Where(k => k.StartsWith("Reading", StringComparison.Ordinal))).IsEmpty(); + } + } + + private async Task> DownloadPersonalDataAsync(string userId) + { + using HttpClient client = await CreateAuthenticatedClientAsync(userId); + using HttpResponseMessage response = await client.PostAsync("/Identity/Account/Manage/DownloadPersonalData", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + return await response.Content.ReadFromJsonAsync>() + ?? throw new InvalidOperationException("The personal data download was empty."); + } + + // ---- helpers ---- + + private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json"); + + private static object Interval(string pageKey, int wordsRead, int activeSeconds) => + new { pageKey, wordsRead, activeSeconds }; + + private static Task PostSessionAsync(HttpClient client, params object[] intervals) => + client.PostAsJsonAsync("/api/reading/session", new { intervals }); + + private async Task CreateUserWithBaselineProfileAsync(DateTime? updatedAtUtc = null) + { + string userId = await McpTestHelper.CreateUserAsync(Factory, "reading-test"); + await ReadingTestSupport.SeedProfileAsync(Factory.Services, userId, BaselineWords, BaselineSeconds, updatedAtUtc); + return userId; + } + + private async Task HasProfileRowAsync(string userId) => + await InServiceScopeAsync(services => + services.GetRequiredService() + .UserReadingProfiles.AnyAsync(p => p.UserId == userId)); + + private async Task GetProfileAsync(string userId) => + await InServiceScopeAsync(services => + services.GetRequiredService() + .UserReadingProfiles.AsNoTracking().SingleAsync(p => p.UserId == userId)); + + private async Task AssertProfileAsync(string userId, long expectedWords, long expectedSeconds) + { + UserReadingProfile profile = await GetProfileAsync(userId); + using (Assert.Multiple()) + { + await Assert.That(profile.TotalWordsRead).IsEqualTo(expectedWords); + await Assert.That(profile.TotalActiveSeconds).IsEqualTo(expectedSeconds); + } + } + + private Task CreateAuthenticatedClientAsync(string userId, bool includeAntiforgery = true) => + ReadingTestSupport.CreateSignedInClientAsync(Factory, userId, includeAntiforgery); +} diff --git a/EssentialCSharp.Web.Tests/ReadingOptionsStartupTests.cs b/EssentialCSharp.Web.Tests/ReadingOptionsStartupTests.cs new file mode 100644 index 00000000..93c7b33e --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingOptionsStartupTests.cs @@ -0,0 +1,43 @@ +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Tests; + +/// +/// A misconfigured Reading: section must stop the host at startup instead of surfacing as odd behavior later. +/// +public class ReadingOptionsStartupTests : IntegrationTestBase +{ + [Test] + [Arguments("Reading:HardCutoffWpm", "0")] // violates a data-annotation range + [Arguments("Reading:MaxIntervalsPerRequest", "5")] // in range, violates only a cross-field rule (below the client outbox size) + public async Task InvalidReadingConfiguration_PreventsTheHostFromStarting(string key, string value) + { + using var factory = Factory.Inner.WithWebHostBuilder(builder => + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary { [key] = value }))); + + Exception? failure = null; + try + { + using HttpClient client = McpTestHelper.CreateClient(factory); + } + catch (Exception ex) + { + failure = ex; + } + + await Assert.That(failure).IsNotNull().Because("an invalid Reading configuration must fail host startup"); + await Assert.That(ExceptionChain(failure!)).Contains(e => e is OptionsValidationException); + } + + private static IEnumerable ExceptionChain(Exception exception) + { + for (Exception? current = exception; current is not null; current = current.InnerException) + { + yield return current; + } + } +} diff --git a/EssentialCSharp.Web.Tests/ReadingOptionsValidatorTests.cs b/EssentialCSharp.Web.Tests/ReadingOptionsValidatorTests.cs new file mode 100644 index 00000000..551be743 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingOptionsValidatorTests.cs @@ -0,0 +1,141 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Tests; + +public class ReadingOptionsValidatorTests +{ + private static ValidateOptionsResult CrossField(ReadingOptions options) => + new ReadingOptionsValidator().Validate(null, options); + + private static List AnnotatedMembersWithErrors(ReadingOptions options) + { + var results = new List(); + Validator.TryValidateObject(options, new ValidationContext(options), results, validateAllProperties: true); + return results.SelectMany(r => r.MemberNames).ToList(); + } + + /// + /// Each integer option with its inclusive valid range and a way to set just that option. These bounds are the + /// documented contract of the options (a configured value outside them must be rejected), deliberately restated + /// here rather than read back from the [Range] attributes, which would only test the attributes against themselves. + /// + private static readonly (string Property, int Min, int Max, Func With)[] IntegerRanges = + [ + (nameof(ReadingOptions.HardCutoffWpm), 1, 10_000, v => new ReadingOptions { HardCutoffWpm = v }), + (nameof(ReadingOptions.MaxIntervalsPerRequest), 1, 1_000, v => new ReadingOptions { MaxIntervalsPerRequest = v }), + (nameof(ReadingOptions.DefaultWpm), 1, 10_000, v => new ReadingOptions { DefaultWpm = v }), + (nameof(ReadingOptions.MaxCreditWpm), 1, 10_000, v => new ReadingOptions { MaxCreditWpm = v }), + (nameof(ReadingOptions.IdleThresholdSeconds), 1, 86_400, v => new ReadingOptions { IdleThresholdSeconds = v }), + (nameof(ReadingOptions.PeriodicFlushActiveSeconds), 1, 3_600, v => new ReadingOptions { PeriodicFlushActiveSeconds = v }), + (nameof(ReadingOptions.MaxActiveSecondsPerInterval), 1, 86_400, v => new ReadingOptions { MaxActiveSecondsPerInterval = v }), + ]; + + /// Every integer option just outside (valid: false) and exactly on (valid: true) both bounds. + public static IEnumerable<(string Property, int Value, Func With, bool Valid)> IntegerBoundaryCases() + { + foreach ((string property, int min, int max, Func with) in IntegerRanges) + { + yield return (property, min - 1, with, false); + yield return (property, min, with, true); + yield return (property, max, with, true); + yield return (property, max + 1, with, false); + } + } + + [Test] + public async Task Defaults_Pass() + { + var options = new ReadingOptions(); + + using (Assert.Multiple()) + { + await Assert.That(CrossField(options).Succeeded).IsTrue(); + await Assert.That(AnnotatedMembersWithErrors(options)).IsEmpty(); + } + } + + [Test] + [MethodDataSource(nameof(IntegerBoundaryCases))] + public async Task IntegerOption_IsAcceptedWithinItsRangeAndRejectedOutsideIt( + string property, int value, Func with, bool valid) + { + List membersWithErrors = AnnotatedMembersWithErrors(with(value)); + + if (valid) + { + await Assert.That(membersWithErrors).DoesNotContain(property); + } + else + { + await Assert.That(membersWithErrors).Contains(property); + } + } + + [Test] + [Arguments(-0.01, false)] + [Arguments(0.0, true)] + [Arguments(0.25, true)] + [Arguments(1.0, true)] + [Arguments(1.01, false)] + public async Task SlowOutlierFactor_IsAcceptedWithinZeroToOneInclusive(double value, bool valid) + { + List membersWithErrors = AnnotatedMembersWithErrors(new ReadingOptions { SlowOutlierFactor = value }); + + if (valid) + { + await Assert.That(membersWithErrors).DoesNotContain(nameof(ReadingOptions.SlowOutlierFactor)); + } + else + { + await Assert.That(membersWithErrors).Contains(nameof(ReadingOptions.SlowOutlierFactor)); + } + } + + /// Cross-field rules: each case names the option the failure message must mention, or null when it is valid. + public static IEnumerable<(Func Options, string? FailingProperty)> CrossFieldCases() + { + yield return (() => new ReadingOptions { HardCutoffWpm = 700, MaxCreditWpm = 701 }, nameof(ReadingOptions.MaxCreditWpm)); + yield return (() => new ReadingOptions { HardCutoffWpm = 700, MaxCreditWpm = 700 }, null); + + yield return ( + () => new ReadingOptions { MaxActiveSecondsPerInterval = 100, IdleThresholdSeconds = 100, PeriodicFlushActiveSeconds = 101 }, + nameof(ReadingOptions.PeriodicFlushActiveSeconds)); + yield return ( + () => new ReadingOptions { MaxActiveSecondsPerInterval = 100, IdleThresholdSeconds = 100, PeriodicFlushActiveSeconds = 100 }, + null); + + yield return ( + () => new ReadingOptions { MaxActiveSecondsPerInterval = 100, PeriodicFlushActiveSeconds = 60, IdleThresholdSeconds = 101 }, + nameof(ReadingOptions.IdleThresholdSeconds)); + + yield return ( + () => new ReadingOptions { MaxIntervalsPerRequest = ReadingOptionsValidator.MinIntervalsPerRequest - 1 }, + nameof(ReadingOptions.MaxIntervalsPerRequest)); + yield return ( + () => new ReadingOptions { MaxIntervalsPerRequest = ReadingOptionsValidator.MinIntervalsPerRequest }, + null); + } + + [Test] + [MethodDataSource(nameof(CrossFieldCases))] + public async Task CrossFieldRules_FailNamingTheOffendingOptionOnlyWhenViolated( + Func options, string? failingProperty) + { + ValidateOptionsResult result = CrossField(options()); + + if (failingProperty is null) + { + await Assert.That(result.Succeeded).IsTrue(); + } + else + { + using (Assert.Multiple()) + { + await Assert.That(result.Failed).IsTrue(); + await Assert.That(result.FailureMessage).Contains(failingProperty); + } + } + } +} diff --git a/EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs b/EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs new file mode 100644 index 00000000..57067c40 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingSpeedPolicyTests.cs @@ -0,0 +1,101 @@ +using EssentialCSharp.Web.Services; + +namespace EssentialCSharp.Web.Tests; + +/// +/// The outlier rules for a reported reading interval. Intervals faster than the hard cutoff are ignored; intervals +/// slower than SlowOutlierFactor * referenceWpm are credited as if read at that speed (so idle time does not +/// drag the reader's WPM down); everything else is credited as reported. The credited word count never changes. +/// The grid test states only invariants; the exact clamp arithmetic is pinned by the worked examples. +/// +public class ReadingSpeedPolicyTests +{ + private const double ReferenceWpm = 200; + + private static ReadingOptions Options => ReadingTestSupport.CreateOptions(); + + private static double IntervalWpm(int words, int seconds) => words * 60.0 / seconds; + + public static IEnumerable<(int Words, int Seconds, double ReferenceWpm)> IntervalGrid() + { + foreach (int words in new[] { 1, 100, 400, 1000, 5000 }) + { + foreach (int seconds in new[] { 1, 5, 30, 60, 300, 1800, 3600 }) + { + foreach (double reference in new double[] { 0, 50, ReferenceWpm, 1000 }) + { + yield return (words, seconds, reference); + } + } + } + } + + [Test] + [MethodDataSource(nameof(IntervalGrid))] + public async Task Evaluate_UpholdsTheCreditInvariants(int words, int seconds, double referenceWpm) + { + ReadingOptions options = Options; + double intervalWpm = IntervalWpm(words, seconds); + + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, referenceWpm, options); + + if (intervalWpm > options.HardCutoffWpm) + { + await Assert.That(credit).IsNull().Because($"{intervalWpm:F0} WPM is above the {options.HardCutoffWpm} WPM cutoff"); + return; + } + + await Assert.That(credit).IsNotNull(); + ReadingCredit value = credit!.Value; + double creditedWpm = IntervalWpm(value.Words, value.Seconds); + + // Independent properties of any accepted interval, stated without re-deriving the clamp formula. + using (Assert.Multiple()) + { + await Assert.That(value.Words).IsEqualTo(words).Because("words are never rescaled"); + await Assert.That(value.Seconds).IsBetween(1, seconds).Because("credit only ever shortens the time, never below one second"); + await Assert.That(creditedWpm).IsGreaterThanOrEqualTo(intervalWpm - 1e-9).Because("crediting never makes the reader look slower"); + if (referenceWpm <= 0) + { + await Assert.That(value.Seconds).IsEqualTo(seconds).Because("without a reference speed nothing is a slow outlier"); + } + } + } + + /// Worked examples; null means the interval is ignored, otherwise the credited seconds (words are unchanged). + [Test] + // Hard cutoff 900 WPM: 300 words in 20 s is exactly 900 WPM (kept), 301 words is 903 WPM (ignored). + [Arguments(300, 20, 200.0, 900, 0.25, 20)] + [Arguments(301, 20, 200.0, 900, 0.25, null)] + [Arguments(301, 20, 0.0, 900, 0.25, null)] + // Slow threshold 0.25 * 200 = 50 WPM: exactly at it is kept; clearly or slightly below it is clamped to the threshold speed. + [Arguments(50, 60, 200.0, 900, 0.25, 60)] + [Arguments(100, 60, 200.0, 900, 0.25, 60)] + [Arguments(100, 130, 200.0, 900, 0.25, 120)] + [Arguments(100, 1200, 200.0, 900, 0.25, 120)] + // The slow factor comes from the options: a 0.5 factor makes the threshold 100 WPM. + [Arguments(100, 600, 200.0, 900, 0.5, 60)] + // A factor of 0 disables slow-outlier handling; a factor of 1 treats anything below the reference as slow. + [Arguments(10, 600, 200.0, 900, 0.0, 600)] + [Arguments(100, 60, 200.0, 900, 1.0, 30)] + [Arguments(100, 30, 200.0, 900, 1.0, 30)] + // No reference speed: nothing is a slow outlier. + [Arguments(10, 600, 0.0, 900, 0.25, 600)] + // One word against a very fast reference rounds up to one whole second. + [Arguments(1, 3600, 100_000.0, 900, 0.25, 1)] + // Non-positive input is ignored. + [Arguments(0, 10, 200.0, 900, 0.25, null)] + [Arguments(10, 0, 200.0, 900, 0.25, null)] + [Arguments(-5, 10, 200.0, 900, 0.25, null)] + [Arguments(10, -5, 200.0, 900, 0.25, null)] + public async Task Evaluate_WorkedExamples( + int words, int seconds, double referenceWpm, int hardCutoffWpm, double slowFactor, int? expectedSeconds) + { + var options = new ReadingOptions { HardCutoffWpm = hardCutoffWpm, SlowOutlierFactor = slowFactor }; + + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, seconds, referenceWpm, options); + + ReadingCredit? expected = expectedSeconds is { } s ? new ReadingCredit(words, s) : null; + await Assert.That(credit).IsEqualTo(expected); + } +} diff --git a/EssentialCSharp.Web.Tests/ReadingTestSupport.cs b/EssentialCSharp.Web.Tests/ReadingTestSupport.cs new file mode 100644 index 00000000..fcbe0116 --- /dev/null +++ b/EssentialCSharp.Web.Tests/ReadingTestSupport.cs @@ -0,0 +1,301 @@ +using System.Net; +using System.Security.Claims; +using System.Text.Json; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using EssentialCSharp.Web.Services; +using HtmlAgilityPack; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using TUnit.AspNetCore; + +namespace EssentialCSharp.Web.Tests; + +/// +/// Known content statistics and reading options shared by the reading-tracker tests, so that every +/// asserted number comes from a literal here rather than from the content package or the app's defaults. +/// +internal static class ReadingTestSupport +{ + public const string SmallPage = "reading-test-small"; + public const int SmallPageWords = 150; + public const string LargePage = "reading-test-large"; + public const int LargePageWords = 5000; + public const string EmptyPage = "reading-test-empty"; + + /// A page whose key is exactly characters. + public static readonly string MaxLengthPageKey = new('k', Controllers.ReadingIntervalRequest.PageKeyMaxLength); + + /// The page rendered at /hello-world; deliberately distinct values so swapped fields are detected. + public const string ContentPageKey = "hello-world"; + public static readonly PageWordStats ContentPageStats = new(1234, 3000, 400, 100, 5150); + + public const int BookWords = 5150; + + /// A typical seeded reading profile: 1200 words in 360 seconds is 200 WPM, i.e. a slow-outlier threshold of 50 WPM. + public const long BaselineWords = 1200; + public const long BaselineSeconds = 360; + + /// + /// Explicit options, a fresh instance per call so no test can leak mutations into another. Nothing here relies on + /// defaults, and every value differs from its default. + /// + public static ReadingOptions CreateOptions() => new() + { + HardCutoffWpm = 900, + SlowOutlierFactor = 0.25, + MaxIntervalsPerRequest = 50, + DefaultWpm = 210, + MaxCreditWpm = 810, + IdleThresholdSeconds = 310, + PeriodicFlushActiveSeconds = 65, + MaxActiveSecondsPerInterval = 1800 + }; + + public static void UseFakes(IServiceCollection services, IWordCountService wordCounts, ReadingOptions? options = null) + { + services.RemoveAll(); + services.AddSingleton(wordCounts); + services.RemoveAll>(); + services.AddSingleton>(Microsoft.Extensions.Options.Options.Create(options ?? CreateOptions())); + } + + /// A word-count service backed by a fixed dictionary; unknown pages have no stats and zero words. + public sealed class FakeWordCountService(IReadOnlyDictionary pages, int bookWords = BookWords) : IWordCountService + { + /// The standard fixture pages (, , ...). + public FakeWordCountService(bool includeContentPage = true) + : this(CreateDefaultPages(includeContentPage)) + { + } + + /// A service in which only is readable, with words. + public static FakeWordCountService WithSinglePage(string pageKey, int words) => + new(new Dictionary { [pageKey] = new(words, words, 0, 0, words) }, bookWords: words); + + private static Dictionary CreateDefaultPages(bool includeContentPage) + { + Dictionary result = new() + { + [SmallPage] = new(SmallPageWords, SmallPageWords, 0, 0, BookWords), + [LargePage] = new(LargePageWords, LargePageWords, 0, 0, BookWords), + [EmptyPage] = new(0, 0, 0, 0, BookWords), + [MaxLengthPageKey] = new(SmallPageWords, SmallPageWords, 0, 0, BookWords), + }; + if (includeContentPage) + { + result[ContentPageKey] = ContentPageStats; + } + + return result; + } + + public int GetPageWordCount(string pageKey) => pages.TryGetValue(pageKey, out PageWordStats? s) ? s.PageWords : 0; + public int GetBookWordCount() => bookWords; + public PageWordStats? GetPageStats(string pageKey) => pages.GetValueOrDefault(pageKey); + } + + public static async Task SeedProfileAsync( + IServiceProvider rootServices, string userId, long words, long seconds, DateTime? updatedAtUtc = null) + { + using IServiceScope scope = rootServices.CreateScope(); + EssentialCSharpWebContext context = scope.ServiceProvider.GetRequiredService(); + context.UserReadingProfiles.Add(new UserReadingProfile + { + UserId = userId, + TotalWordsRead = words, + TotalActiveSeconds = seconds, + UpdatedAtUtc = updatedAtUtc ?? DateTime.UtcNow + }); + await context.SaveChangesAsync(); + } + + // ---- credentials ---- + + /// The cookie header (sign-in plus antiforgery cookie) with the matching request token and header name. + public sealed record AntiforgeryCredentials(string CookieHeader, string HeaderName, string RequestToken); + + /// A Cookie header value that signs the request in as ; no antiforgery material. + public static async Task CreateSignedInCookieHeaderAsync( + TracedWebApplicationFactory factory, string userId, Func? transformPrincipal = null) + { + (string cookieName, string cookieValue) = + await McpTestHelper.CreateIdentityApplicationCookieAsync(factory.Services, userId, transformPrincipal); + return $"{cookieName}={cookieValue}"; + } + + /// + /// Mints the sign-in cookie, the antiforgery cookie and the request token for straight + /// from , using the names from the configured . + /// + public static async Task CreateSignedInAntiforgeryCredentialsAsync( + TracedWebApplicationFactory factory, string userId, Func? transformPrincipal = null) + { + string signInCookie = await CreateSignedInCookieHeaderAsync(factory, userId, transformPrincipal); + + using IServiceScope scope = factory.Services.CreateScope(); + IServiceProvider services = scope.ServiceProvider; + EssentialCSharpWebUser user = await services.GetRequiredService>() + .FindByIdAsync(userId) + ?? throw new InvalidOperationException($"User {userId} does not exist."); + var context = new DefaultHttpContext + { + RequestServices = services, + User = ApplyTransform(await services.GetRequiredService>().CreateAsync(user), transformPrincipal) + }; + + AntiforgeryTokenSet tokens = services.GetRequiredService().GetAndStoreTokens(context); + string antiforgeryCookieName = AntiforgeryCookieName(services); + string antiforgeryCookie = context.Response.Headers.SetCookie + .Select(value => value?.Split(';', 2)[0]) + .OfType() + .Single(value => value.StartsWith(antiforgeryCookieName + "=", StringComparison.Ordinal)); + return new AntiforgeryCredentials( + $"{signInCookie}; {antiforgeryCookie}", + tokens.HeaderName ?? throw new InvalidOperationException("The antiforgery header name is not configured."), + tokens.RequestToken ?? throw new InvalidOperationException("No antiforgery request token.")); + } + + /// Creates a client signed in as , optionally carrying the antiforgery cookie and header token. + public static async Task CreateSignedInClientAsync( + TracedWebApplicationFactory factory, string userId, bool includeAntiforgery = true) + { + HttpClient client = McpTestHelper.CreateClient(factory); + if (includeAntiforgery) + { + AntiforgeryCredentials credentials = await CreateSignedInAntiforgeryCredentialsAsync(factory, userId); + client.DefaultRequestHeaders.Add(credentials.HeaderName, credentials.RequestToken); + client.DefaultRequestHeaders.Add("Cookie", credentials.CookieHeader); + } + else + { + client.DefaultRequestHeaders.Add("Cookie", await CreateSignedInCookieHeaderAsync(factory, userId)); + } + + return client; + } + + private static ClaimsPrincipal ApplyTransform(ClaimsPrincipal principal, Func? transform) => + transform is null ? principal : transform(principal); + + public static string AntiforgeryCookieName(IServiceProvider services) => + services.GetRequiredService>().Value.Cookie.Name + ?? throw new InvalidOperationException("The antiforgery cookie name is not configured."); + + // ---- problem responses ---- + + /// A response read as a problem document; is null when the body is not JSON. + public sealed record ProblemResponse(HttpStatusCode Status, string? MediaType, string Raw, JsonElement? Root) + { + public bool HasErrorsMember => Root is { ValueKind: JsonValueKind.Object } root && root.TryGetProperty("errors", out _); + + public IReadOnlyList ErrorKeys => + Root is { ValueKind: JsonValueKind.Object } root + && root.TryGetProperty("errors", out JsonElement errors) + && errors.ValueKind == JsonValueKind.Object + ? errors.EnumerateObject().Select(p => p.Name).ToArray() + : []; + + public override string ToString() => + $"{(int)Status} {MediaType ?? ""}: {(Raw.Length == 0 ? "" : Raw)}"; + } + + public static async Task ReadProblemAsync(HttpResponseMessage response) + { + string raw = await response.Content.ReadAsStringAsync(); + JsonElement? root = null; + try + { + root = JsonDocument.Parse(raw).RootElement.Clone(); + } + catch (JsonException) + { + // Left null: callers assert on Root and report the raw body. + } + + return new ProblemResponse(response.StatusCode, response.Content.Headers.ContentType?.MediaType, raw, root); + } + + /// + /// Asserts a 400 application/problem+json validation problem whose errors member names + /// (every part must appear, case-insensitively, in a single error key, so a test can + /// require both the interval index and the field, e.g. "Intervals[1]" and "ActiveSeconds", without + /// pinning the exact path format). Message text is not asserted. + /// + public static async Task AssertValidationProblemAsync(HttpResponseMessage response, params string[] keyParts) + { + ProblemResponse problem = await ReadProblemAsync(response); + using (Assert.Multiple()) + { + await Assert.That(problem.Status).IsEqualTo(HttpStatusCode.BadRequest).Because(problem.ToString()); + await Assert.That(problem.MediaType).IsEqualTo("application/problem+json").Because(problem.ToString()); + await Assert.That(problem.Root).IsNotNull().Because($"the body is not JSON: {problem}"); + await Assert.That(problem.ErrorKeys) + .Contains(key => keyParts.All(part => key.Contains(part, StringComparison.OrdinalIgnoreCase))) + .Because($"expected an error key containing {string.Join(" and ", keyParts.Select(p => $"'{p}'"))} in {problem}"); + } + } + + // ---- rendered page helpers ---- + + /// Asserts a 200 response and returns its HTML. + public static async Task ReadOkHtmlAsync(HttpResponseMessage response) + { + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + return await response.Content.ReadAsStringAsync(); + } + + private static HtmlDocument LoadHtml(string html) + { + var document = new HtmlDocument(); + document.LoadHtml(html); + return document; + } + + /// + /// Parses the object assigned to window.READING_CONTEXT by an inline script, or returns null when no + /// script on the page assigns it. + /// + public static JsonDocument? TryParseReadingContext(string html) + { + const string Assignment = "window.READING_CONTEXT"; + foreach (HtmlNode script in LoadHtml(html).DocumentNode.SelectNodes("//script") ?? Enumerable.Empty()) + { + string text = script.InnerText; + int assignment = text.IndexOf(Assignment, StringComparison.Ordinal); + if (assignment < 0) + { + continue; + } + + string afterName = text[(assignment + Assignment.Length)..].TrimStart(); + if (afterName.StartsWith('=')) + { + return JsonDocument.Parse(afterName[1..].Trim().TrimEnd(';')); + } + } + + return null; + } + + public static JsonDocument ParseReadingContext(string html) => + TryParseReadingContext(html) + ?? throw new InvalidOperationException("The page has no script assigning window.READING_CONTEXT."); + + /// Returns the named field, failing with its name rather than a bare KeyNotFoundException. + public static JsonElement Field(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement value) + ? value + : throw new InvalidOperationException($"READING_CONTEXT has no '{name}' field."); + + public static string? CsrfToken(string html) => + LoadHtml(html).DocumentNode.SelectSingleNode("//meta[@name='csrf-token']")?.GetAttributeValue("content", null!); + + public static string? PageTitle(string html) => + LoadHtml(html).DocumentNode.SelectSingleNode("//title")?.InnerText.Trim(); +} diff --git a/EssentialCSharp.Web.Tests/SiteMappingTests.cs b/EssentialCSharp.Web.Tests/SiteMappingTests.cs index 457b2e9d..53cee3e8 100644 --- a/EssentialCSharp.Web.Tests/SiteMappingTests.cs +++ b/EssentialCSharp.Web.Tests/SiteMappingTests.cs @@ -136,6 +136,23 @@ public async Task FindPercentComplete_ValidKey_Success(string? key, string resul await Assert.That(percent).IsEqualTo(result); } + [Test] + public async Task FindPercentComplete_MappingsWithoutPrimaryKey_MatchOnKeys() + { + // The placeholder sitemap.json used by CI and local dev has no PrimaryKey. + IList siteMappings = + [ + new(keys: ["first"], primaryKey: null!, pagePath: ["a.html"], chapterNumber: 1, pageNumber: 1, orderOnPage: 1, + chapterTitle: "C", rawHeading: "First", anchorId: "first", indentLevel: 0), + new(keys: ["second"], primaryKey: null!, pagePath: ["b.html"], chapterNumber: 1, pageNumber: 2, orderOnPage: 1, + chapterTitle: "C", rawHeading: "Second", anchorId: "second", indentLevel: 0), + ]; + + string? percent = siteMappings.FindPercentComplete("first"); + + await Assert.That(percent).IsEqualTo("50.00"); + } + [Test] public async Task FindPercentComplete_EmptySiteMappings_ReturnsZeroPercent() { diff --git a/EssentialCSharp.Web.Tests/WordCountServiceTests.cs b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs new file mode 100644 index 00000000..253729e0 --- /dev/null +++ b/EssentialCSharp.Web.Tests/WordCountServiceTests.cs @@ -0,0 +1,439 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OS = TUnit.Core.Enums.OS; + +namespace EssentialCSharp.Web.Tests; + +public class WordCountServiceTests +{ + // Three prose words used as a constant baseline next to whatever is being included or excluded. + private const string Baseline = "

alpha beta gamma

"; + private const int BaselineWords = 3; + + [Test] + public async Task PlainProse_IsCountedByWhitespaceSeparatedWords() + { + using TempContentDirectory dir = new(); + dir.Write("page.html", "

Hello world, this\nis a test.

"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(6); + } + + [Test] + [Arguments("
public static void Main() { var x = 1; }
")] + [Arguments("
Console.WriteLine(x);
")] + [Arguments("")] + [Arguments("")] + [Arguments("
Listing 1.16: Using it
public class HeyYou
")] + [Arguments("
deeply nested code here
")] + [Arguments("
pre inside a generated listing
")] + [Arguments("
inner listing words
outer listing words
")] + [Arguments("
UPPERCASE TAG LISTING
")] + [Arguments("")] + [Arguments("")] + public async Task CodeListingsAndNonProseTags_DoNotAddToTheCount(string excludedHtml) + { + using TempContentDirectory dir = new(); + dir.Write("page.html", $"\n{Baseline}\n{excludedHtml}\n{Baseline}\n"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(2 * BaselineWords); + } + + [Test] + public async Task UnclosedPreAtEndOfPage_ExcludesItsContent() + { + using TempContentDirectory dir = new(); + dir.Write("page.html", $"{Baseline}
unclosed listing words here");
+
+        WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1));
+
+        await Assert.That(service.GetPageWordCount("page")).IsEqualTo(BaselineWords);
+    }
+
+    [Test]
+    public async Task InlineCodeInProse_IsCounted()
+    {
+        using TempContentDirectory dir = new();
+        dir.Write("page.html", "

Call Console.WriteLine now

"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(3); + } + + [Test] + public async Task HtmlEntities_AreDecodedBeforeCounting() + { + using TempContentDirectory dir = new(); + // Requirement: entities are decoded and the decoded text is counted like any other prose. Decoded this reads + // "a b AT&T List": a non-breaking space separates two words, while "AT&T" and "List" are one word each + // (undecoded, "a b" would be a single word and the total would be 3). + dir.Write("page.html", "

a b AT&T List<T>

"); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(4); + } + + [Test] + [Arguments("", 0)] + [Arguments("", 0)] + [Arguments(" \n ", 0)] + [Arguments("

one two

", 2)] // fragment without / + public async Task EmptyOrBodylessContent_IsCountedWithoutFailing(string html, int expected) + { + using TempContentDirectory dir = new(); + dir.Write("page.html", html); + + WordCountService service = await dir.BuildAsync(Map("page", "page.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("page")).IsEqualTo(expected); + } + + [Test] + public async Task UnknownKey_HasNoStats() + { + using TempContentDirectory dir = new(); + dir.Write("present.html", $"{Baseline}"); + + WordCountService service = await dir.BuildAsync(Map("present", "present.html", 1, 1)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageStats("not-a-key")).IsNull(); + await Assert.That(service.GetPageWordCount("not-a-key")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("present")).IsEqualTo(BaselineWords); + } + } + + [Test] + public async Task MappedKeyWithMissingFile_CountsZeroKeepsStatsAndLogsAWarning() + { + using TempContentDirectory dir = new(); + dir.Write("present.html", $"{Baseline}"); + CapturingLogger logger = new(); + + WordCountService service = await dir.BuildAsync( + logger, + Map("present", "present.html", 1, 1), + Map("missing", "nonexistent.html", 1, 2)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageStats("missing")).IsNotNull(); + await Assert.That(service.GetPageWordCount("missing")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("present")).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).Contains(w => w.Mentions("nonexistent.html")); + } + } + + // FileShare.None makes the page read fail with an IOException on Windows. Other platforms use advisory locks, + // so the read would succeed there and the failure cannot be provoked. + [Test] + [RunOn(OS.Windows)] + public async Task FileThatCannotBeRead_IsLoggedCountsZeroAndDoesNotAffectOtherPages() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + dir.Write("locked.html", $"{Baseline}"); + dir.Write("c.html", $"{Baseline}"); + CapturingLogger logger = new(); + + using FileStream exclusive = new(dir.PathOf("locked.html"), FileMode.Open, FileAccess.Read, FileShare.None); + WordCountService service = await dir.BuildAsync( + logger, + Map("a", "a.html", 1, 1), + Map("locked", "locked.html", 1, 2), + Map("c", "c.html", 1, 3)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords); + await Assert.That(service.GetPageWordCount("locked")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("c")).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).Contains(w => w.Mentions("locked.html")); + } + } + + [Test] + public async Task MappedPathThatIsADirectory_IsTreatedAsMissingLoggedAndDoesNotAffectOtherPages() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + dir.Write("c.html", $"{Baseline}"); + // A directory where a page file should be. File.Exists is false for it, so this covers the missing/non-file path, not the read-failure catch. + Directory.CreateDirectory(dir.PathOf("locked.html")); + CapturingLogger logger = new(); + + WordCountService service = await dir.BuildAsync( + logger, + Map("a", "a.html", 1, 1), + Map("locked", "locked.html", 1, 2), + Map("c", "c.html", 1, 3)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords); + await Assert.That(service.GetPageWordCount("locked")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("c")).IsEqualTo(BaselineWords); + await Assert.That(service.GetPageStats("c")?.WordsBeforePage).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).Contains(w => w.Mentions("locked.html")); + } + } + + private const string AbsolutePathToken = "{absolute path of the outside file}"; + + [Test] + [Arguments("../outside.html")] + [Arguments("..\\outside.html")] + [Arguments("sub/../../outside.html")] + [Arguments("sub\\..\\..\\outside.html")] + [Arguments("../content-evil/outside.html")] // sibling directory sharing the content root's name as a prefix + [Arguments("..\\content-evil\\outside.html")] + [Arguments(AbsolutePathToken)] + public async Task PagePathLeadingOutsideTheContentRoot_IsNeverCountedAndIsLogged(string pagePath) + { + using TempContentDirectory dir = new(); + dir.Write("ok.html", $"{Baseline}"); + // A real, readable file outside the content root with distinctive words: if it were read, the count would show it. + dir.WriteOutside("outside.html", "

outside words must never be counted

"); + dir.WriteOutside("content-evil/outside.html", "

outside words must never be counted

"); + string resolvedPath = pagePath == AbsolutePathToken ? dir.OutsidePathOf("outside.html") : pagePath; + CapturingLogger logger = new(); + + WordCountService service = await dir.BuildAsync( + logger, + Map("ok", "ok.html", 1, 1), + Map("escape", resolvedPath, 1, 2)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageWordCount("escape")).IsEqualTo(0); + await Assert.That(service.GetPageWordCount("ok")).IsEqualTo(BaselineWords); + await Assert.That(service.GetBookWordCount()).IsEqualTo(BaselineWords); + await Assert.That(logger.Warnings).IsNotEmpty().Because("a skipped mapping must not be silent"); + } + } + + [Test] + public async Task PageStats_ReportPageChapterAndBookOffsets() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two

"); // 2 + dir.Write("b.html", "

three

"); // 1 + dir.Write("c.html", "

four five six

"); // 3 (chapter 2) + + WordCountService service = await dir.BuildAsync( + Map("a", "a.html", 1, 1), + Map("b", "b.html", 1, 2), + Map("c", "c.html", 2, 1)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageStats("a")).IsEqualTo(new PageWordStats(2, 3, 0, 0, 6)); + await Assert.That(service.GetPageStats("b")).IsEqualTo(new PageWordStats(1, 3, 2, 2, 6)); + await Assert.That(service.GetPageStats("c")).IsEqualTo(new PageWordStats(3, 3, 3, 0, 6)); + await Assert.That(service.GetBookWordCount()).IsEqualTo(6); + } + } + + [Test] + public async Task MappingsInAnyInputOrder_YieldReadingOrderOffsets() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two

"); + dir.Write("b.html", "

three

"); + dir.Write("c.html", "

four five six

"); + + WordCountService service = await dir.BuildAsync( + Map("c", "c.html", 2, 1), + Map("b", "b.html", 1, 2), + Map("a", "a.html", 1, 1)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageStats("a")?.WordsBeforePage).IsEqualTo(0); + await Assert.That(service.GetPageStats("b")?.WordsBeforePage).IsEqualTo(2); + await Assert.That(service.GetPageStats("b")?.ChapterStartWords).IsEqualTo(2); + await Assert.That(service.GetPageStats("c")?.WordsBeforePage).IsEqualTo(3); + await Assert.That(service.GetPageStats("c")?.ChapterStartWords).IsEqualTo(0); + } + } + + [Test] + public async Task SeveralMappingsOfOneFile_CountOnceAndShareStatsAcrossAllKeys() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", "

one two three

"); + dir.Write("b.html", "

four five

"); + + WordCountService service = await dir.BuildAsync( + Map("page-one", "a.html", 1, 1, keys: ["page-one", "page-one-alias"]), + Map("page-one-heading", "./a.html", 1, 1, order: 2), + Map("page-two", "b.html", 1, 2)); + + PageWordStats expectedFirst = new(3, 5, 0, 0, 5); + using (Assert.Multiple()) + { + await Assert.That(service.GetPageStats("page-one")).IsEqualTo(expectedFirst); + await Assert.That(service.GetPageStats("page-one-alias")).IsEqualTo(expectedFirst); + await Assert.That(service.GetPageStats("page-one-heading")).IsEqualTo(expectedFirst); + await Assert.That(service.GetPageStats("page-two")).IsEqualTo(new PageWordStats(2, 5, 3, 3, 5)); + await Assert.That(service.GetBookWordCount()).IsEqualTo(5); + } + } + + [Test] + public async Task KeyCollision_FirstMappingInReadingOrderWins() + { + using TempContentDirectory dir = new(); + dir.Write("first.html", "

one two

"); + dir.Write("second.html", "

three four five

"); + + WordCountService service = await dir.BuildAsync( + Map("shared", "second.html", 1, 2), + Map("shared", "first.html", 1, 1)); + + await Assert.That(service.GetPageWordCount("shared")).IsEqualTo(2); + } + + [Test] + public async Task BeforeCacheIsBuilt_QueriesReturnNoData() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + + WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1)); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageStats("a")).IsNull(); + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(0); + await Assert.That(service.GetBookWordCount()).IsEqualTo(0); + } + } + + [Test] + public async Task HostStartup_BuildsTheCacheBeforeRequestsAreServed() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1)); + + await service.StartingAsync(CancellationToken.None); + + using (Assert.Multiple()) + { + await Assert.That(service.GetPageWordCount("a")).IsEqualTo(BaselineWords); + } + } + + [Test] + public async Task Build_HonorsCancellation() + { + using TempContentDirectory dir = new(); + dir.Write("a.html", $"{Baseline}"); + WordCountService service = dir.CreateService(NullLogger.Instance, Map("a", "a.html", 1, 1)); + using CancellationTokenSource cts = new(); + await cts.CancelAsync(); + + await Assert.That(async () => await service.BuildAsync(cts.Token)).Throws(); + } + + // ---- Helpers ---- + + private static SiteMapping Map(string key, string relativePath, int chapter, int page, int order = 1, string[]? keys = null) => + new( + keys: [.. keys ?? [key]], + primaryKey: key, + pagePath: relativePath.Split('/', '\\').Where(s => s.Length > 0).ToArray(), + chapterNumber: chapter, + pageNumber: page, + orderOnPage: order, + chapterTitle: $"Chapter {chapter}", + rawHeading: key, + anchorId: key, + indentLevel: 0); + + /// A throwaway content root that deletes itself when disposed. + private sealed class TempContentDirectory : IDisposable + { + // Content lives one level below the temp root so "../outside.html" stays inside a directory we own. + private readonly string _root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + private readonly string _contentRoot; + + public TempContentDirectory() + { + _contentRoot = Path.Combine(_root, "content"); + Directory.CreateDirectory(_contentRoot); + } + + public string PathOf(string relativePath) => Path.Combine(_contentRoot, relativePath); + + public void Write(string relativePath, string html) => File.WriteAllText(PathOf(relativePath), html); + + /// The same relative path resolved against the temp root, i.e. outside (beside) the content root. + public string OutsidePathOf(string relativePath) => Path.Combine(_root, relativePath); + + public void WriteOutside(string relativePath, string html) + { + string path = OutsidePathOf(relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, html); + } + + public WordCountService CreateService(ILogger logger, params SiteMapping[] mappings) + { + Mock siteMappings = new(); + siteMappings.Setup(s => s.SiteMappings).Returns(mappings); + Mock environment = new(); + environment.Setup(e => e.ContentRootPath).Returns(_contentRoot); + return new WordCountService(siteMappings.Object, environment.Object, logger); + } + + public Task BuildAsync(params SiteMapping[] mappings) => + BuildAsync(NullLogger.Instance, mappings); + + public async Task BuildAsync(ILogger logger, params SiteMapping[] mappings) + { + WordCountService service = CreateService(logger, mappings); + await service.BuildAsync(); + return service; + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + } + + private sealed record LogEntry(LogLevel Level, string Message, IReadOnlyList> Properties) + { + /// True when the message or any structured property value contains . + public bool Mentions(string text) => + Message.Contains(text, StringComparison.Ordinal) + || Properties.Any(p => p.Value?.ToString()?.Contains(text, StringComparison.Ordinal) == true); + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = []; + + public IEnumerable Warnings => Entries.Where(e => e.Level == LogLevel.Warning); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + IReadOnlyList> properties = + state as IReadOnlyList> ?? []; + Entries.Add(new LogEntry(logLevel, formatter(state, exception), properties)); + } + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index aaf81356..a56d4e9d 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -11,6 +11,7 @@ public class EssentialCSharpWebContext(DbContextOptions DataProtectionKeys { get; set; } = null!; public DbSet McpApiTokens { get; set; } = null!; + public DbSet UserReadingProfiles { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs index 671957b1..4debe28f 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs @@ -1,13 +1,17 @@ using System.Text.Json; using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; public partial class DownloadPersonalDataModel( UserManager userManager, + EssentialCSharpWebContext context, ILogger logger) : PageModel { public IActionResult OnGet() @@ -45,6 +49,16 @@ public async Task OnPostAsync() personalData.Add($"Authenticator Key", authenticatorKey); } + UserReadingProfile? readingProfile = await context.UserReadingProfiles + .AsNoTracking() + .FirstOrDefaultAsync(p => p.UserId == user.Id); + if (readingProfile is not null) + { + personalData.Add("Reading total words read", readingProfile.TotalWordsRead.ToString(System.Globalization.CultureInfo.InvariantCulture)); + personalData.Add("Reading total active seconds", readingProfile.TotalActiveSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)); + personalData.Add("Reading profile last updated (UTC)", readingProfile.UpdatedAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture)); + } + Response.Headers.Append("Content-Disposition", "attachment; filename=PersonalData.json"); return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json"); } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml index f63c961f..30022914 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -3,6 +3,7 @@ @{ ViewData["Title"] = "Profile"; ViewData["ActivePage"] = ManageNavPages.Index; + ViewData["NeedsReadingCsrf"] = true; }

@ViewData["Title"]

@@ -35,9 +36,22 @@ ViewData["ActivePage"] = ManageNavPages.Index; + +
+

Reading Speed

+

+ Essential C# tracks your reading speed to estimate how long chapters and the book will take you. + Resetting clears all recorded data and returns to the default estimate. +

+ +
@section Scripts { + } diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 1437d956..7b84cf56 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -60,11 +60,13 @@ public IActionResult Index() string headHtml = doc.DocumentNode.Element("html")?.Element("head")?.InnerHtml ?? string.Empty; string html = doc.DocumentNode.Element("html")?.Element("body")?.InnerHtml ?? string.Empty; + // Placeholder sitemaps (CI, local dev) may omit PrimaryKey, so fall back to the first key. + string pageKey = siteMapping.Keys.FirstOrDefault() ?? siteMapping.PrimaryKey ?? string.Empty; ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading; ViewData["Description"] = chapterDescriptionService.GetDescription(siteMapping); ViewData["CanonicalUrl"] = $"{siteSettings.Value.BaseUrl.TrimEnd('/')}{Request.PathBase}{canonicalPath}"; ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true); - ViewBag.CurrentPageKey = siteMapping.PrimaryKey; + ViewBag.CurrentPageKey = pageKey; ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false); ViewBag.HeadContents = headHtml; ViewBag.Contents = html; diff --git a/EssentialCSharp.Web/Controllers/ReadingAntiforgeryFailureHeaderAttribute.cs b/EssentialCSharp.Web/Controllers/ReadingAntiforgeryFailureHeaderAttribute.cs new file mode 100644 index 00000000..57f132df --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingAntiforgeryFailureHeaderAttribute.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Core.Infrastructure; + +namespace EssentialCSharp.Web.Controllers; + +/// +/// Marks a rejected antiforgery token on the response with : 1. This is the +/// explicit, stable signal the reading client uses to tell "the token is no longer valid, stop tracking" +/// from a 400 caused by the request body (or by a proxy/WAF/server in front of the app). +/// Always-run so it also sees the result produced by the global antiforgery authorization filter, which +/// short-circuits before the action executes; ordered first because [ApiController] otherwise replaces that +/// result with a problem document before this filter can recognize it. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class ReadingAntiforgeryFailureHeaderAttribute : Attribute, IAlwaysRunResultFilter, IOrderedFilter +{ + public const string HeaderName = "X-Antiforgery-Failure"; + + public int Order => int.MinValue; + + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is IAntiforgeryValidationFailedResult) + { + context.HttpContext.Response.Headers[HeaderName] = "1"; + } + } + + public void OnResultExecuted(ResultExecutedContext context) + { + } +} diff --git a/EssentialCSharp.Web/Controllers/ReadingController.cs b/EssentialCSharp.Web/Controllers/ReadingController.cs new file mode 100644 index 00000000..3d786e74 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingController.cs @@ -0,0 +1,58 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace EssentialCSharp.Web.Controllers; + +// The rate limit is class-level so every action is limited: /api/reading is exempt from the global limiter in Program.cs. +[ApiController] +[Authorize] +[Route("api/reading")] +[ReadingAntiforgeryFailureHeader] +[EnableRateLimiting(WriteRateLimitPolicy)] +public sealed class ReadingController(IReadingProfileService readingProfileService) : ControllerBase +{ + public const string WriteRateLimitPolicy = "ReadingWrite"; + + private const int MaxSessionRequestBytes = 32 * 1024; + + [HttpPost("session")] + [RequestSizeLimit(MaxSessionRequestBytes)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task> PostSession( + [FromBody] ReadingSessionRequest request, + CancellationToken cancellationToken) + { + if (GetUserId() is not { } userId) + { + return Unauthorized(); + } + + ReadingInterval[] intervals = request.Intervals + .Select(i => new ReadingInterval(i.PageKey, i.WordsRead, i.ActiveSeconds)) + .ToArray(); + + // Intervals for unknown pages are skipped by the service rather than failing the request. + return await readingProfileService.RecordSessionAsync(userId, intervals, cancellationToken); + } + + [HttpPost("reset")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Reset(CancellationToken cancellationToken) + { + if (GetUserId() is not { } userId) + { + return Unauthorized(); + } + + await readingProfileService.ResetAsync(userId, cancellationToken); + return NoContent(); + } + + private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier); +} diff --git a/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs new file mode 100644 index 00000000..a17a18ab --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingIntervalRequest.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Controllers; + +public sealed class ReadingIntervalRequest : IValidatableObject +{ + public const int PageKeyMaxLength = 256; + public const int MaxWordsRead = 100_000; + + /// The page's site-mapping key. + [Required] + [StringLength(PageKeyMaxLength, MinimumLength = 1)] + public string PageKey { get; set; } = string.Empty; + + /// + /// Active (non-idle, visible) seconds spent on the page. The upper bound is + /// ReadingOptions.MaxActiveSecondsPerInterval, enforced in . + /// + [Range(1, int.MaxValue)] + public int ActiveSeconds { get; set; } + + /// Prose words read during the interval. + [Range(1, MaxWordsRead)] + public int WordsRead { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + int maxActiveSeconds = validationContext.GetRequiredService>().Value.MaxActiveSecondsPerInterval; + if (ActiveSeconds > maxActiveSeconds) + { + yield return new ValidationResult( + $"An interval may contain at most {maxActiveSeconds} active seconds.", + [nameof(ActiveSeconds)]); + } + } +} diff --git a/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs b/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs new file mode 100644 index 00000000..376099d6 --- /dev/null +++ b/EssentialCSharp.Web/Controllers/ReadingSessionRequest.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using EssentialCSharp.Web.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Controllers; + +public sealed class ReadingSessionRequest : IValidatableObject +{ + /// + /// Reading intervals to record. The upper bound is configured by + /// ReadingOptions.MaxIntervalsPerRequest. + /// + [Required] + [MinLength(1)] + public List Intervals { get; set; } = []; + + public IEnumerable Validate(ValidationContext validationContext) + { + int maxIntervals = validationContext.GetRequiredService>().Value.MaxIntervalsPerRequest; + if (Intervals.Count > maxIntervals) + { + yield return new ValidationResult( + $"A session may contain at most {maxIntervals} intervals.", + [nameof(Intervals)]); + } + + for (int i = 0; i < Intervals.Count; i++) + { + if (Intervals[i] is null) + { + yield return new ValidationResult( + "Intervals must not contain null entries.", + [$"{nameof(Intervals)}[{i}]"]); + } + } + } +} diff --git a/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs b/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs index 90ba2e37..adad6a2d 100644 --- a/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs +++ b/EssentialCSharp.Web/Extensions/SiteMappingListExtensions.cs @@ -56,7 +56,7 @@ public static class SiteMappingListExtensions currentMappingCount++; } overallMappingCount++; - if (siteMapping.PrimaryKey == key) + if (siteMapping.Keys.Contains(key)) { currentPageFound = true; } diff --git a/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs new file mode 100644 index 00000000..71c36124 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.Designer.cs @@ -0,0 +1,398 @@ +// +using System; +using EssentialCSharp.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + [DbContext(typeof(EssentialCSharpWebContext))] + [Migration("20261106060723_AddUserReadingProfile")] + partial class AddUserReadingProfile + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.14") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("ReferralCount") + .HasColumnType("int"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("varbinary(32)"); + + b.Property("UsageCount") + .HasColumnType("bigint"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("McpApiTokens"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TotalActiveSeconds") + .HasColumnType("bigint"); + + b.Property("TotalWordsRead") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId"); + + b.ToTable("UserReadingProfiles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.McpApiToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs new file mode 100644 index 00000000..f94e245e --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20261106060723_AddUserReadingProfile.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class AddUserReadingProfile : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserReadingProfiles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TotalWordsRead = table.Column(type: "bigint", nullable: false), + TotalActiveSeconds = table.Column(type: "bigint", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserReadingProfiles", x => x.UserId); + table.ForeignKey( + name: "FK_UserReadingProfiles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserReadingProfiles"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index a7f7c2b2..edc3d757 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("ProductVersion", "10.0.14") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -141,6 +141,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("McpApiTokens"); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("TotalActiveSeconds") + .HasColumnType("bigint"); + + b.Property("TotalWordsRead") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId"); + + b.ToTable("UserReadingProfiles"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => { b.Property("Id") @@ -308,6 +328,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("EssentialCSharp.Web.Models.UserReadingProfile", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/EssentialCSharp.Web/Models/UserReadingProfile.cs b/EssentialCSharp.Web/Models/UserReadingProfile.cs new file mode 100644 index 00000000..a19673ea --- /dev/null +++ b/EssentialCSharp.Web/Models/UserReadingProfile.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.EntityFrameworkCore; + +namespace EssentialCSharp.Web.Models; + +/// +/// Aggregate reading profile for a single user. +/// WPM is always derived as TotalWordsRead / (TotalActiveSeconds / 60.0); +/// it is never stored directly so there is no floating-point drift over time. +/// +[PrimaryKey(nameof(UserId))] +public class UserReadingProfile +{ + /// Primary key; also the FK to . + [MaxLength(450)] + public required string UserId { get; set; } + + /// Cumulative prose words credited across all accepted reading intervals. + public long TotalWordsRead { get; set; } + + /// Cumulative active seconds credited across all accepted reading intervals. + public long TotalActiveSeconds { get; set; } + + public DateTime UpdatedAtUtc { get; set; } + + [ForeignKey(nameof(UserId))] + public EssentialCSharpWebUser? User { get; set; } + + /// Derives the words-per-minute estimate. Returns null if there is no active time. + public static double? DeriveWpm(long totalWordsRead, long totalActiveSeconds) => + totalActiveSeconds > 0 ? totalWordsRead / (totalActiveSeconds / 60.0) : null; +} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 1cb4de2a..ca4a3f17 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -6,6 +6,7 @@ using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; using EssentialCSharp.Web.Auth; +using EssentialCSharp.Web.Controllers; using EssentialCSharp.Web.Data; using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Helpers; @@ -271,6 +272,16 @@ private static void Main(string[] args) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // One instance serves both the query interface and the startup hook that builds the cache. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddOptions() + .BindConfiguration(ReadingOptions.SectionName) + .ValidateDataAnnotations() + .ValidateOnStart(); + builder.Services.AddSingleton, ReadingOptionsValidator>(); + builder.Services.AddScoped(); builder.Services.AddScoped(); // Add AI Chat services using configuration-driven backend selection. @@ -323,6 +334,10 @@ private static void Main(string[] args) if (IsMcpTransportRequest(httpContext.Request)) return RateLimitPartition.GetNoLimiter("mcp-transport"); + // Reading API endpoints are limited by their own named policies. + if (httpContext.Request.Path.StartsWithSegments("/api/reading")) + return RateLimitPartition.GetNoLimiter("reading-api"); + var partitionKey = httpContext.User.Identity?.IsAuthenticated == true ? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user" : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; @@ -356,6 +371,17 @@ private static void Main(string[] args) }); }); + // Reading tracker: per-user limit for the authenticated reading endpoints. + options.AddPolicy(ReadingController.WriteRateLimitPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"reading-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 20, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + // Combined per-minute burst (10/min) + per-hour cap (150/hr) for book content pages. // A scraper cycling through the full ~400-page book needs 2+ hours at minimum. // See Services/ContentRateLimiterPolicy.cs for implementation. diff --git a/EssentialCSharp.Web/Services/IReadingProfileService.cs b/EssentialCSharp.Web/Services/IReadingProfileService.cs new file mode 100644 index 00000000..881d2273 --- /dev/null +++ b/EssentialCSharp.Web/Services/IReadingProfileService.cs @@ -0,0 +1,20 @@ +namespace EssentialCSharp.Web.Services; + +/// A single reading interval reported by a client. +public sealed record ReadingInterval(string PageKey, int WordsRead, int ActiveSeconds); + +/// +/// A user's aggregate reading totals. Serialized as-is by the reading API +/// (totalWordsRead, totalActiveSeconds, wpm). +/// +public sealed record ReadingProfileSummary(long TotalWordsRead, long TotalActiveSeconds, double? Wpm); + +public interface IReadingProfileService +{ + Task RecordSessionAsync( + string userId, IReadOnlyList intervals, CancellationToken cancellationToken); + + Task GetProfileAsync(string userId, CancellationToken cancellationToken); + + Task ResetAsync(string userId, CancellationToken cancellationToken); +} diff --git a/EssentialCSharp.Web/Services/IWordCountService.cs b/EssentialCSharp.Web/Services/IWordCountService.cs new file mode 100644 index 00000000..8ab40420 --- /dev/null +++ b/EssentialCSharp.Web/Services/IWordCountService.cs @@ -0,0 +1,39 @@ +namespace EssentialCSharp.Web.Services; + +/// +/// Provides pre-computed prose word counts for content pages, enabling +/// Kindle-style reading time estimates. +/// +/// +/// The cache is built when the host starts, before requests are served. Before it is built +/// every query returns "no data": returns , +/// counts are 0. +/// +public interface IWordCountService +{ + /// + /// Gets all word statistics for a page key, or if the key is unknown + /// (or the cache is not built yet). A known key whose content file is missing or unreadable + /// yields stats with zero page words. + /// + PageWordStats? GetPageStats(string pageKey); + + /// Gets the prose word count for a specific page key (0 if unknown). + int GetPageWordCount(string pageKey); + + /// Gets the total prose word count for the entire book. + int GetBookWordCount(); +} + +/// Immutable prose word statistics for one page. +/// Prose words on the page. +/// Prose words in the page's whole chapter. +/// Prose words on all pages before this one across the book. +/// Prose words on pages before this one within its chapter. +/// Prose words in the whole book. +public sealed record PageWordStats( + int PageWords, + int ChapterWords, + int WordsBeforePage, + int ChapterStartWords, + int BookWords); diff --git a/EssentialCSharp.Web/Services/ReadingOptions.cs b/EssentialCSharp.Web/Services/ReadingOptions.cs new file mode 100644 index 00000000..21c12777 --- /dev/null +++ b/EssentialCSharp.Web/Services/ReadingOptions.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Services; + +/// +/// Tunables for the reading-time tracker (mirrors Kindle's ReadingTimer values). +/// This is the single source for every reading constant: the client receives the relevant +/// values through window.READING_CONTEXT and keeps no copies of its own. +/// +/// +/// The client splits unsent time into intervals of at most and flushes after +/// active seconds, so both must stay within +/// . The client outbox holds at most 20 intervals, so +/// must be at least 20 (a batch over the server maximum is rejected wholesale). +/// enforces these at startup. +/// +public sealed class ReadingOptions +{ + public const string SectionName = "Reading"; + + /// Intervals faster than this many words per minute are implausible and are ignored. + [Range(1, 10_000)] + public int HardCutoffWpm { get; init; } = 900; + + /// + /// Intervals slower than this fraction of the reader's reference WPM are credited + /// as if they had been read at exactly that fraction of the reference speed. + /// + [Range(0.0, 1.0)] + public double SlowOutlierFactor { get; init; } = 0.25; + + /// Maximum number of intervals accepted in a single request. + [Range(1, 1_000)] + public int MaxIntervalsPerRequest { get; init; } = 50; + + /// Reading speed (words per minute) the client assumes until the reader has a derived speed. + [Range(1, 10_000)] + public int DefaultWpm { get; init; } = 200; + + /// + /// Client-side ceiling on how fast scrolling may be credited as reading (words per minute). + /// Must not exceed , otherwise the server would discard intervals the client credits. + /// + [Range(1, 10_000)] + public int MaxCreditWpm { get; init; } = 800; + + /// Seconds without user activity after which the client stops counting active time. + [Range(1, 86_400)] + public int IdleThresholdSeconds { get; init; } = 300; + + /// Active seconds after which the client flushes an interval while the reader stays on the page. + [Range(1, 3_600)] + public int PeriodicFlushActiveSeconds { get; init; } = 60; + + /// Largest activeSeconds the server accepts for one interval; larger values are rejected. + [Range(1, 86_400)] + public int MaxActiveSecondsPerInterval { get; init; } = 1800; +} + +/// Cross-field validation that data annotations cannot express. +public sealed class ReadingOptionsValidator : IValidateOptions +{ + /// Size of the client's interval outbox. + public const int MinIntervalsPerRequest = 20; + + public ValidateOptionsResult Validate(string? name, ReadingOptions options) + { + List? failures = null; + if (options.MaxCreditWpm > options.HardCutoffWpm) + { + (failures ??= []).Add( + $"{nameof(ReadingOptions.MaxCreditWpm)} ({options.MaxCreditWpm}) must not exceed {nameof(ReadingOptions.HardCutoffWpm)} ({options.HardCutoffWpm})."); + } + + if (options.PeriodicFlushActiveSeconds > options.MaxActiveSecondsPerInterval) + { + (failures ??= []).Add( + $"{nameof(ReadingOptions.PeriodicFlushActiveSeconds)} must not exceed {nameof(ReadingOptions.MaxActiveSecondsPerInterval)}."); + } + + if (options.IdleThresholdSeconds > options.MaxActiveSecondsPerInterval) + { + (failures ??= []).Add( + $"{nameof(ReadingOptions.IdleThresholdSeconds)} must not exceed {nameof(ReadingOptions.MaxActiveSecondsPerInterval)}."); + } + + if (options.MaxIntervalsPerRequest < MinIntervalsPerRequest) + { + (failures ??= []).Add( + $"{nameof(ReadingOptions.MaxIntervalsPerRequest)} must be at least {MinIntervalsPerRequest} (the client outbox size)."); + } + + return failures is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } +} diff --git a/EssentialCSharp.Web/Services/ReadingProfileService.cs b/EssentialCSharp.Web/Services/ReadingProfileService.cs new file mode 100644 index 00000000..8ed9c2d6 --- /dev/null +++ b/EssentialCSharp.Web/Services/ReadingProfileService.cs @@ -0,0 +1,142 @@ +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.Services; + +public sealed partial class ReadingProfileService( + EssentialCSharpWebContext context, + IWordCountService wordCountService, + IOptions options, + ILogger logger) : IReadingProfileService +{ + private readonly ReadingOptions _options = options.Value; + + [LoggerMessage(Level = LogLevel.Debug, Message = "Ignored interval for {PageKey}: {Words} words in {Seconds}s is not credited")] + private static partial void LogIntervalIgnored(ILogger logger, string pageKey, int words, int seconds); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Ignored interval for page {PageKey}: unknown page or no countable words")] + private static partial void LogUnknownPage(ILogger logger, string pageKey); + + /// + /// Not idempotent: if a client retries a request whose response was lost, the same intervals are credited + /// twice. This is accepted (no idempotency store); the effect is a small skew in the derived WPM. + /// The reference WPM used for the slow-outlier rule is read once before the batch is written, so concurrent + /// requests from the same user may evaluate against a slightly stale reference. This is also accepted. + /// + public async Task RecordSessionAsync( + string userId, IReadOnlyList intervals, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(intervals); + + DateTime now = DateTime.UtcNow; + + // One consistent reference speed for the whole batch. + ReadingProfileSummary current = await GetProfileAsync(userId, cancellationToken); + // A reader without a derived speed yet is held to the default speed, so a first (or just-reset) batch of + // idle-inflated intervals cannot set their speed unchecked. + double referenceWpm = current.Wpm ?? _options.DefaultWpm; + + long deltaWords = 0; + long deltaSeconds = 0; + for (int i = 0; i < intervals.Count; i++) + { + ReadingInterval interval = intervals[i]; + // An unknown or empty page (for example a key renamed by a content deploy) skips only this interval: + // rejecting the batch would make the client drop every interval queued with it. + int pageWords = wordCountService.GetPageWordCount(interval.PageKey); + if (pageWords <= 0) + { + LogUnknownPage(logger, interval.PageKey); + continue; + } + + int words = Math.Min(interval.WordsRead, pageWords); + ReadingCredit? credit = ReadingSpeedPolicy.Evaluate(words, interval.ActiveSeconds, referenceWpm, _options); + if (credit is null) + { + LogIntervalIgnored(logger, interval.PageKey, words, interval.ActiveSeconds); + continue; + } + + deltaWords += credit.Value.Words; + deltaSeconds += credit.Value.Seconds; + } + + if (deltaWords > 0 || deltaSeconds > 0) + { + await AddToProfileAsync(userId, deltaWords, deltaSeconds, now, cancellationToken); + } + + return await GetProfileAsync(userId, cancellationToken); + } + + public async Task GetProfileAsync(string userId, CancellationToken cancellationToken) + { + var totals = await context.UserReadingProfiles + .AsNoTracking() + .Where(p => p.UserId == userId) + .Select(p => new { p.TotalWordsRead, p.TotalActiveSeconds }) + .FirstOrDefaultAsync(cancellationToken); + + return totals is null + ? new ReadingProfileSummary(0, 0, null) + : new ReadingProfileSummary( + totals.TotalWordsRead, + totals.TotalActiveSeconds, + UserReadingProfile.DeriveWpm(totals.TotalWordsRead, totals.TotalActiveSeconds)); + } + + public async Task ResetAsync(string userId, CancellationToken cancellationToken) => + await context.UserReadingProfiles + .Where(p => p.UserId == userId) + .ExecuteDeleteAsync(cancellationToken); + + /// + /// Adds the deltas to the user's profile, creating the row on first use, without a provider-specific upsert: + /// try an atomic UPDATE ... SET x = x + delta; if no row exists insert one; if a concurrent request + /// inserted it first (duplicate key), retry the update once. Concurrent requests therefore never lose an increment. + /// + private async Task AddToProfileAsync( + string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken) + { + if (await TryAddToExistingAsync(userId, words, seconds, now, cancellationToken) > 0) + { + return; + } + + var created = new UserReadingProfile + { + UserId = userId, + TotalWordsRead = words, + TotalActiveSeconds = seconds, + UpdatedAtUtc = now, + }; + context.UserReadingProfiles.Add(created); + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Most likely a concurrent request created the row between our UPDATE and INSERT. + context.Entry(created).State = EntityState.Detached; + if (await TryAddToExistingAsync(userId, words, seconds, now, cancellationToken) == 0) + { + throw; // Not a duplicate key (for example the user no longer exists). + } + } + } + + private Task TryAddToExistingAsync( + string userId, long words, long seconds, DateTime now, CancellationToken cancellationToken) => + context.UserReadingProfiles + .Where(p => p.UserId == userId) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(p => p.TotalWordsRead, p => p.TotalWordsRead + words) + .SetProperty(p => p.TotalActiveSeconds, p => p.TotalActiveSeconds + seconds) + .SetProperty(p => p.UpdatedAtUtc, now), + cancellationToken); +} diff --git a/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs new file mode 100644 index 00000000..6a18309e --- /dev/null +++ b/EssentialCSharp.Web/Services/ReadingSpeedPolicy.cs @@ -0,0 +1,44 @@ +namespace EssentialCSharp.Web.Services; + +/// Words and seconds credited to a reader for one accepted interval. +public readonly record struct ReadingCredit(int Words, int Seconds); + +/// +/// Pure outlier rules applied to each reported reading interval. +/// +public static class ReadingSpeedPolicy +{ + /// + /// Decides how much of an interval to credit. + /// Returns when the interval is not usable (non-positive input or faster + /// than ). Intervals slower than + /// times are credited + /// with fewer seconds than their wall-clock time so idle stretches do not drag the average down. + /// + /// The reader's current WPM, or 0 when unknown. + public static ReadingCredit? Evaluate(int words, int seconds, double referenceWpm, ReadingOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (words <= 0 || seconds <= 0) + { + return null; + } + + double intervalWpm = words / (seconds / 60.0); + if (intervalWpm > options.HardCutoffWpm) + { + return null; + } + + double slowThreshold = options.SlowOutlierFactor * referenceWpm; + if (referenceWpm > 0 && intervalWpm < slowThreshold) + { + // words >= 1 and slowThreshold > 0 here, so the ceiling is at least 1. + double clampedSeconds = Math.Ceiling(words / slowThreshold * 60.0); + return new ReadingCredit(words, (int)Math.Min(seconds, clampedSeconds)); + } + + return new ReadingCredit(words, seconds); + } +} diff --git a/EssentialCSharp.Web/Services/WordCountService.cs b/EssentialCSharp.Web/Services/WordCountService.cs new file mode 100644 index 00000000..7f9a33b5 --- /dev/null +++ b/EssentialCSharp.Web/Services/WordCountService.cs @@ -0,0 +1,185 @@ +using System.Collections.Frozen; +using HtmlAgilityPack; +using Microsoft.Extensions.Hosting; + +namespace EssentialCSharp.Web.Services; + +/// +/// Computes prose-only word counts for all content pages when the host starts and caches +/// them in an immutable snapshot. <pre>, <script>, <style> +/// and generated .code-block-section listings are excluded because they are read very +/// differently from prose and would skew WPM estimates. Inline <code> inside prose +/// is counted, since readers do read those identifiers as part of the sentence. +/// +public sealed partial class WordCountService( + ISiteMappingService siteMappingService, + IWebHostEnvironment hostingEnvironment, + ILogger logger) : IWordCountService, IHostedLifecycleService +{ + private const string ExcludedXPath = + "//pre | //script | //style | //*[contains(concat(' ', normalize-space(@class), ' '), ' code-block-section ')]"; + + private volatile Snapshot _snapshot = new(FrozenDictionary.Empty, 0); + + public PageWordStats? GetPageStats(string pageKey) => + _snapshot.Pages.GetValueOrDefault(pageKey); + + public int GetPageWordCount(string pageKey) => GetPageStats(pageKey)?.PageWords ?? 0; + + public int GetBookWordCount() => _snapshot.BookWords; + + /// + /// Reads every mapped content page and publishes the word-count snapshot. A page that is + /// missing or cannot be parsed is logged and counted as zero words; it never fails the build. + /// + public Task BuildAsync(CancellationToken cancellationToken = default) + { + // Walk mappings in canonical reading order: chapter -> page -> order-on-page. + IEnumerable orderedMappings = siteMappingService.SiteMappings + .OrderBy(m => m.ChapterNumber) + .ThenBy(m => m.PageNumber) + .ThenBy(m => m.OrderOnPage); + + // One entry per distinct file, in reading order. Later mappings of the same file only add keys. + Dictionary filesByPath = new(StringComparer.Ordinal); + List files = []; + Dictionary fileByKey = new(StringComparer.Ordinal); + + foreach (SiteMapping mapping in orderedMappings) + { + cancellationToken.ThrowIfCancellationRequested(); + + string? path = ResolvePath(mapping); + if (path is null) + { + LogPathEscapesRoot(logger, (mapping.Keys ?? []).FirstOrDefault() ?? mapping.PrimaryKey); + continue; + } + + if (!filesByPath.TryGetValue(path, out FileEntry? entry)) + { + entry = new FileEntry(mapping.ChapterNumber, CountProseWords(path)); + filesByPath[path] = entry; + files.Add(entry); + } + + foreach (string key in (mapping.Keys ?? []).Append(mapping.PrimaryKey)) + { + // First mapping wins if the sitemap contains a key collision. + if (!string.IsNullOrWhiteSpace(key)) + { + fileByKey.TryAdd(key, entry); + } + } + } + + Dictionary chapterTotals = []; + foreach (FileEntry file in files) + { + chapterTotals[file.Chapter] = chapterTotals.GetValueOrDefault(file.Chapter) + file.Words; + } + int bookWords = chapterTotals.Values.Sum(); + + Dictionary statsByFile = new(ReferenceEqualityComparer.Instance); + Dictionary chapterRunning = []; + int wordsBefore = 0; + foreach (FileEntry file in files) + { + int chapterStart = chapterRunning.GetValueOrDefault(file.Chapter); + statsByFile[file] = new PageWordStats(file.Words, chapterTotals[file.Chapter], wordsBefore, chapterStart, bookWords); + chapterRunning[file.Chapter] = chapterStart + file.Words; + wordsBefore += file.Words; + } + + _snapshot = new Snapshot( + fileByKey.ToFrozenDictionary(kv => kv.Key, kv => statsByFile[kv.Value], StringComparer.Ordinal), + bookWords); + + return Task.CompletedTask; + } + + // ---- Hosting: build before the host starts accepting requests ---- + + public Task StartingAsync(CancellationToken cancellationToken) => BuildAsync(cancellationToken); + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + // ---- Implementation ---- + + /// Returns a normalized, '/'-separated absolute path, or null if it leaves the content root. + private string? ResolvePath(SiteMapping mapping) + { + string root = Path.GetFullPath(hostingEnvironment.ContentRootPath); + string full = Path.GetFullPath(Path.Join(root, Path.Join(mapping.PagePath))); + string relative = Path.GetRelativePath(root, full); + return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) + ? null + : full.Replace('\\', '/'); + } + + private int CountProseWords(string filePath) + { + try + { + if (!File.Exists(filePath)) + { + LogFileMissing(logger, filePath); + return 0; + } + + HtmlDocument doc = new(); + doc.Load(filePath); + + HtmlNode root = doc.DocumentNode.SelectSingleNode("//body") ?? doc.DocumentNode; + + foreach (HtmlNode node in root.SelectNodes(ExcludedXPath)?.ToList() ?? []) + { + node.Remove(); + } + + return CountWords(HtmlEntity.DeEntitize(root.InnerText)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { + LogFileUnreadable(logger, ex, filePath); + return 0; + } + } + + private static int CountWords(string text) + { + int count = 0; + bool inWord = false; + foreach (char c in text) + { + if (char.IsWhiteSpace(c)) + { + inWord = false; + } + else if (!inWord) + { + inWord = true; + count++; + } + } + return count; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Skipping word count for mapping '{Key}': page path escapes the content root.")] + private static partial void LogPathEscapesRoot(ILogger logger, string? key); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Content file '{FilePath}' not found; counting it as 0 words.")] + private static partial void LogFileMissing(ILogger logger, string filePath); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not read content file '{FilePath}'; counting it as 0 words.")] + private static partial void LogFileUnreadable(ILogger logger, Exception exception, string filePath); + + private sealed record FileEntry(int Chapter, int Words); + + private sealed record Snapshot( + FrozenDictionary Pages, + int BookWords); +} diff --git a/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs new file mode 100644 index 00000000..f3809836 --- /dev/null +++ b/EssentialCSharp.Web/ViewComponents/ReadingContextViewComponent.cs @@ -0,0 +1,85 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace EssentialCSharp.Web.ViewComponents; + +/// +/// Emits window.READING_CONTEXT for the reading-time tracker. Renders nothing on pages that +/// are not book content (no ViewBag.CurrentPageKey), such as Identity pages and errors. +/// +public sealed partial class ReadingContextViewComponent( + IWordCountService wordCountService, + IReadingProfileService readingProfileService, + SignInManager signInManager, + IOptions options, + ILogger logger) : ViewComponent +{ + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not load the reading profile for the reading context; continuing without a personal WPM")] + private static partial void LogProfileUnavailable(ILogger logger, Exception exception); + + public async Task InvokeAsync() + { + if (ViewData["CurrentPageKey"] is not string { Length: > 0 } pageKey) + { + return Content(string.Empty); + } + + bool isAuthenticated = signInManager.IsSignedIn(UserClaimsPrincipal); + string? userId = isAuthenticated ? UserClaimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier) : null; + int? wpm = userId is null ? null : await GetWpmAsync(userId); + ReadingOptions reading = options.Value; + + PageWordStats? stats = wordCountService.GetPageStats(pageKey); + return View(new ReadingContextViewModel( + IsAuthenticated: isAuthenticated, + PageKey: pageKey, + PageWords: stats?.PageWords ?? 0, + ChapterWords: stats?.ChapterWords ?? 0, + WordsBeforePage: stats?.WordsBeforePage ?? 0, + ChapterStartWords: stats?.ChapterStartWords ?? 0, + BookWords: stats?.BookWords ?? wordCountService.GetBookWordCount(), + Wpm: wpm, + DefaultWpm: reading.DefaultWpm, + MaxCreditWpm: reading.MaxCreditWpm, + IdleThresholdSeconds: reading.IdleThresholdSeconds, + PeriodicFlushActiveSeconds: reading.PeriodicFlushActiveSeconds)); + } + + /// The signed-in reader's derived WPM rounded to whole words per minute, or null when unknown. + private async Task GetWpmAsync(string userId) + { + try + { + ReadingProfileSummary profile = await readingProfileService.GetProfileAsync(userId, HttpContext.RequestAborted); + return profile.Wpm is { } value ? (int)Math.Round(value) : null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A reading-profile outage must not break rendering of book pages. + LogProfileUnavailable(logger, ex); + return null; + } + } +} + +/// +/// Serialized (camelCase) as window.READING_CONTEXT; property names are a contract with the client. +/// is null for anonymous readers or readers with no recorded reading time. +/// +public record ReadingContextViewModel( + bool IsAuthenticated, + string PageKey, + int PageWords, + int ChapterWords, + int WordsBeforePage, + int ChapterStartWords, + int BookWords, + int? Wpm, + int DefaultWpm, + int MaxCreditWpm, + int IdleThresholdSeconds, + int PeriodicFlushActiveSeconds); diff --git a/EssentialCSharp.Web/Views/Home/Index.cshtml b/EssentialCSharp.Web/Views/Home/Index.cshtml index af43c343..0c03ced6 100644 --- a/EssentialCSharp.Web/Views/Home/Index.cshtml +++ b/EssentialCSharp.Web/Views/Home/Index.cshtml @@ -7,4 +7,6 @@

@ViewBag.PageTitle

} -@Html.Raw(ViewBag.Contents) +
+ @Html.Raw(ViewBag.Contents) +
diff --git a/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml b/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml new file mode 100644 index 00000000..585baea1 --- /dev/null +++ b/EssentialCSharp.Web/Views/Shared/Components/ReadingContext/Default.cshtml @@ -0,0 +1,4 @@ +@model EssentialCSharp.Web.ViewComponents.ReadingContextViewModel + diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index a855c9b4..debd66a5 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -6,10 +6,12 @@ @using EssentialCSharp.Common @using Microsoft.AspNetCore.Identity @using EssentialCSharp.Web.Areas.Identity.Data +@using Microsoft.AspNetCore.Antiforgery @using Microsoft.Extensions.Configuration @using Microsoft.Extensions.Options @inject ISiteMappingService _SiteMappings @inject SignInManager SignInManager +@inject IAntiforgery Antiforgery @inject IConfiguration Configuration @inject IOptions _CaptchaOptions @inject IOptions SiteSettings @@ -28,6 +30,12 @@ } + @if (SignInManager.IsSignedIn(User) && (ViewData["CurrentPageKey"] is string { Length: > 0 } || ViewData["NeedsReadingCsrf"] is true)) + { + // Only the reading tracker (content pages) and the reset button (ViewData["NeedsReadingCsrf"]) call the + // antiforgery-protected reading API; GetAndStoreTokens sets cookie/no-cache headers, so never call it elsewhere. + + } @if (Context.Response.StatusCode < 400 && !Context.Request.Path.StartsWithSegments("/Identity")) @@ -222,6 +230,7 @@ window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity")); window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey); + @await Component.InvokeAsync("ReadingContext") diff --git a/EssentialCSharp.Web/package-lock.json b/EssentialCSharp.Web/package-lock.json index 0d5f0b8a..a648b26d 100644 --- a/EssentialCSharp.Web/package-lock.json +++ b/EssentialCSharp.Web/package-lock.json @@ -18,7 +18,9 @@ }, "devDependencies": { "@vitejs/plugin-vue": "6.0.10", - "vite": "8.3.5" + "happy-dom": "21.0.4", + "vite": "8.3.5", + "vitest": "5.1.0" }, "engines": { "node": ">=22.0.0" @@ -70,12 +72,31 @@ "node": ">=6.9.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mdi/font": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", @@ -202,9 +223,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -222,9 +240,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -242,9 +257,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -262,9 +274,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -282,9 +291,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -302,9 +308,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -372,12 +375,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "dev": true, + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", @@ -395,6 +444,60 @@ "vue": "^3.2.25 || ^3.6.0-rc.10" } }, + "node_modules/@vitest/mocker": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.1.0.tgz", + "integrity": "sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.1.0.tgz", + "integrity": "sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.44", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", @@ -587,6 +690,36 @@ "@zxcvbn-ts/dictionary-compression": "^3.0.1" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -624,12 +757,27 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -670,6 +818,24 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/happy-dom": { + "version": "21.0.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.4.tgz", + "integrity": "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==", + "dev": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/jquery": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz", @@ -819,9 +985,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -843,9 +1006,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -867,9 +1027,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -891,9 +1048,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -988,6 +1142,19 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1077,6 +1244,30 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true + }, + "node_modules/tinybench": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.3.tgz", + "integrity": "sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==", + "dev": true, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1094,6 +1285,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", @@ -1172,6 +1369,97 @@ } } }, + "node_modules/vitest": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.1.0.tgz", + "integrity": "sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.1.0", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.1.0", + "@vitest/browser-preview": "5.1.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.1.0", + "@vitest/coverage-v8": "5.1.0", + "@vitest/ui": "5.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/vue": { "version": "3.5.44", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", @@ -1222,6 +1510,48 @@ "optional": true } } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/EssentialCSharp.Web/package.json b/EssentialCSharp.Web/package.json index 107fe10d..ccb798f3 100644 --- a/EssentialCSharp.Web/package.json +++ b/EssentialCSharp.Web/package.json @@ -7,7 +7,8 @@ }, "scripts": { "build": "vite build", - "dev": "vite" + "dev": "vite", + "test": "vitest run" }, "dependencies": { "@mdi/font": "7.4.47", @@ -22,6 +23,8 @@ }, "devDependencies": { "@vitejs/plugin-vue": "6.0.10", - "vite": "8.3.5" + "happy-dom": "21.0.4", + "vite": "8.3.5", + "vitest": "5.1.0" } } diff --git a/EssentialCSharp.Web/src/App.vue b/EssentialCSharp.Web/src/App.vue index ca7929b9..df994d3c 100644 --- a/EssentialCSharp.Web/src/App.vue +++ b/EssentialCSharp.Web/src/App.vue @@ -8,10 +8,18 @@ import SnackbarMessage from "./components/SnackbarMessage.vue"; import ChatWidget from "./components/ChatWidget.vue"; import CodeRunnerModal from "./components/CodeRunnerModal.vue"; import { useSiteShell } from "./composables/useSiteShell.js"; +import { READING_TRACKER_KEY, useReadingTracker } from "./composables/useReadingTracker.js"; +import { getReadingContext } from "./utils/readingContext.js"; const shell = reactive(useSiteShell()); provide("shell", shell); +// Only content pages emit window.READING_CONTEXT; elsewhere the tracker is not created at all. +const readingContext = getReadingContext(); +if (readingContext) { + provide(READING_TRACKER_KEY, useReadingTracker(readingContext)); +} + function updateLayoutClasses() { const siteLayout = document.getElementById("site-layout"); if (!siteLayout) { diff --git a/EssentialCSharp.Web/src/components/HeaderStatus.vue b/EssentialCSharp.Web/src/components/HeaderStatus.vue index 8f837b13..768ca491 100644 --- a/EssentialCSharp.Web/src/components/HeaderStatus.vue +++ b/EssentialCSharp.Web/src/components/HeaderStatus.vue @@ -1,5 +1,6 @@ + + + + diff --git a/EssentialCSharp.Web/src/composables/useActivityState.js b/EssentialCSharp.Web/src/composables/useActivityState.js new file mode 100644 index 00000000..4c95515b --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useActivityState.js @@ -0,0 +1,82 @@ +/** + * Activity detection (idle / visibility / input / focus) with no tracking logic. + * + * Rules: + * - Starts IDLE: an untouched page never accrues time until real input. + * - A bare window "focus" event is NOT input. + * - Scroll counts as activity only after the first real input (scrolls caused + * by anchors / scroll restoration before that are ignored). + * - READING requires the document to be visible AND focused. With several tabs + * or windows open only the focused one counts, which mitigates double + * counting across tabs. + */ +import { onScopeDispose } from "vue"; +import { MOUSEMOVE_THROTTLE_MS } from "../utils/readingConstants.js"; + +export const STATE_READING = "READING"; +export const STATE_IDLE = "IDLE"; +export const STATE_HIDDEN = "HIDDEN"; + +/** + * @param {{ visible: boolean, focused: boolean, lastInputAt: number | null, now: number, idleThresholdSeconds: number }} input + * timestamps in ms from the same clock + * @returns {"READING" | "IDLE" | "HIDDEN"} + */ +export function computeActivityState({ visible, focused, lastInputAt, now, idleThresholdSeconds }) { + if (!visible || !focused) return STATE_HIDDEN; + if (lastInputAt === null || (now - lastInputAt) / 1000 > idleThresholdSeconds) return STATE_IDLE; + return STATE_READING; +} + +/** @param {number} idleThresholdSeconds no input for longer than this switches READING to IDLE */ +export function useActivityState(idleThresholdSeconds) { + let lastInputAt = null; + let lastMousemoveAt = 0; + + function markInput() { + lastInputAt = performance.now(); + } + + function markScroll() { + if (lastInputAt !== null) markInput(); + } + + function onMousemove() { + const now = performance.now(); + if (now - lastMousemoveAt >= MOUSEMOVE_THROTTLE_MS) { + lastMousemoveAt = now; + markInput(); + } + } + + const listeners = [ + [document, "mousemove", onMousemove], + [document, "wheel", markInput], + [document, "touchstart", markInput], + [document, "keydown", markInput], + [document, "pointerdown", markInput], + [window, "scroll", markScroll] + ]; + for (const [target, type, handler] of listeners) { + target.addEventListener(type, handler, { passive: true }); + } + + onScopeDispose(() => { + for (const [target, type, handler] of listeners) { + target.removeEventListener(type, handler); + } + }); + + return { + getState: () => computeActivityState({ + visible: document.visibilityState === "visible", + focused: document.hasFocus(), + lastInputAt, + now: performance.now(), + idleThresholdSeconds + }), + hasUserInput: () => lastInputAt !== null, + /** Forget input (e.g. page restored from bfcache) so the page starts IDLE again. */ + reset: () => { lastInputAt = null; } + }; +} diff --git a/EssentialCSharp.Web/src/composables/useActivityState.test.js b/EssentialCSharp.Web/src/composables/useActivityState.test.js new file mode 100644 index 00000000..1047b262 --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useActivityState.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { computeActivityState } from "./useActivityState.js"; + +const base = { visible: true, focused: true, lastInputAt: 0, now: 0, idleThresholdSeconds: 300 }; + +describe("computeActivityState", () => { + it("is READING shortly after input in a visible, focused page", () => { + expect(computeActivityState({ ...base, now: 10_000 })).toBe("READING"); + }); + + it("is still READING exactly at the idle threshold", () => { + expect(computeActivityState({ ...base, now: 300_000 })).toBe("READING"); + }); + + it("is IDLE after the idle threshold without input", () => { + expect(computeActivityState({ ...base, now: 300_001 })).toBe("IDLE"); + }); + + it("honors the threshold it is given", () => { + expect(computeActivityState({ ...base, idleThresholdSeconds: 30, now: 31_000 })).toBe("IDLE"); + }); + + it("is IDLE before any input", () => { + expect(computeActivityState({ ...base, lastInputAt: null, now: 1000 })).toBe("IDLE"); + }); + + it("is HIDDEN when not visible or not focused, regardless of input", () => { + expect(computeActivityState({ ...base, visible: false })).toBe("HIDDEN"); + expect(computeActivityState({ ...base, focused: false })).toBe("HIDDEN"); + }); +}); diff --git a/EssentialCSharp.Web/src/composables/useReadingTracker.js b/EssentialCSharp.Web/src/composables/useReadingTracker.js new file mode 100644 index 00000000..fc7f69d1 --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useReadingTracker.js @@ -0,0 +1,385 @@ +/** + * useReadingTracker - thin orchestrator for the reading-time feature. + * + * Runs once at app level (see App.vue) and shares state with the header widget + * through provide/inject (READING_TRACKER_KEY). Pages are full server-rendered + * reloads, so flushing is driven by the page lifecycle (visibilitychange to + * hidden, pagehide), never by component unmount. + * + * - Anonymous visitors are not tracked: they only get a scroll fraction and the + * default WPM estimate. No timers, input listeners or requests. + * - Signed-in visitors: WPM comes from READING_CONTEXT (server-derived) and is + * refreshed from each POST response. Delta-based intervals are queued and sent. + * + * Failure handling (see postSession statuses): + * - ok: queue entry sent; WPM refreshed from the response. + * - auth-failure (401/403, antiforgery rejection, no CSRF token): tracking stops + * for the rest of the page. + * - bad-data: that batch is dropped, tracking continues. + * - transient: the batch goes back to the queue and is retried with exponential + * backoff on a later tick; lifecycle flushes (hidden/pagehide) ignore the + * backoff. The queue is also saved to localStorage (see readingOutboxStorage) + * so the next page load of the same user sends it. While a persisted batch is + * sent by a periodic flush its record stays stored (leased) until the response + * arrives. A lifecycle (keepalive) send removes its record first: the page is + * about to be destroyed, so the response can never acknowledge it, and keeping + * the record would resend (double count) every successful send. A keepalive + * request that is aborted with its page is therefore lost (accepted: a rare + * undercount is better than routine double counting). + * + * Pieces: useActivityState (idle/visibility/focus/input), services/readingApi + * (fetch + CSRF + typed results), utils/readingProgress (pure scroll math). + */ +import { onScopeDispose, readonly, ref } from "vue"; +import { STATE_READING, useActivityState } from "./useActivityState.js"; +import { + STATUS_AUTH_FAILURE, + STATUS_BAD_DATA, + STATUS_OK, + STATUS_TRANSIENT, + postSession +} from "../services/readingApi.js"; +import { + MAX_QUEUED_INTERVALS, + MAX_TICK_DELTA_S, + READING_RESET_CHANNEL, + RETRY_BASE_DELAY_MS, + RETRY_MAX_DELAY_MS, + TICK_INTERVAL_MS +} from "../utils/readingConstants.js"; +import { + CLAIM_CLAIMED, + CLAIM_GONE, + claimOutbox, + clearStoredIntervals, + isOutboxStored, + newOutboxKey, + removeOutbox, + saveOutbox, + takeStoredIntervals +} from "../utils/readingOutboxStorage.js"; +import { + creditInterval, + findContentElement, + fractionAboveViewportBottom, + fractionAboveViewportTop, + measureContent +} from "../utils/readingProgress.js"; + +/** Injection key for the tracker state consumed by ReadingTimeRemaining. */ +export const READING_TRACKER_KEY = Symbol("readingTracker"); + +/** + * @typedef {Object} ReadingTracker + * @property {import("vue").Ref} wpm words per minute used for estimates + * @property {import("vue").Ref} scrollFraction 0..1 progress through the page content + * @property {import("../utils/readingContext.js").ReadingContext} context + */ + +/** + * @param {import("../utils/readingContext.js").ReadingContext} context + * @returns {ReadingTracker} + */ +export function useReadingTracker(context) { + const wpm = ref(context.wpm ?? context.defaultWpm); + const scrollFraction = ref(0); + + const tracking = context.isAuthenticated && context.pageWords > 0; + const activity = tracking ? useActivityState(context.idleThresholdSeconds) : null; + + let disposed = false; + let stopped = false; + let inFlight = 0; + let rafId = 0; + let tickId = 0; + + // Unsent active time (fractional seconds). baseline..maxBottom is the + // not-yet-credited span of the page (fractions, see readingProgress.js). + let pendingSeconds = 0; + let baseline = 0; + let maxBottom = 0; + let lastTickAt = performance.now(); + + // Credited intervals awaiting delivery, and retry state. + /** @type {import("../services/readingApi.js").ReadingInterval[]} */ + let outbox = []; + // Key of the localStorage record mirroring the outbox, null while nothing is persisted. + // Persisting starts after a failed send; while it is set every outbox change is mirrored. + let storedKey = null; + let failures = 0; + let retryAt = 0; + // Bumped when another tab resets the profile; responses to batches sent before + // the reset are ignored (no restore, no wpm update). + let generation = 0; + + const contentElement = findContentElement(); + + function persistOutbox() { + if (!context.userId) return; + if (outbox.length === 0) { + if (storedKey) saveOutbox(storedKey, context.userId, outbox); + storedKey = null; + return; + } + const key = storedKey ?? newOutboxKey(); + if (saveOutbox(key, context.userId, outbox)) { + storedKey = key; + } else { + // Storage full: nothing reliable is stored, so keep the data in memory only + // (a stale record would later be mistaken for this tab's data). + removeOutbox(key); + storedKey = null; + } + } + + function discardPersistedOutbox() { + storedKey = null; + // Only this account's records: another signed-in account may share the browser. + if (context.userId) clearStoredIntervals(context.userId); + } + + // Intervals left by an earlier page load (never another user's) are sent like new ones. + if (tracking && context.userId) { + outbox = takeStoredIntervals(context.userId); + persistOutbox(); + } + + function updateProgress() { + const measure = measureContent(contentElement); + const bottom = fractionAboveViewportBottom(measure); + scrollFraction.value = bottom; + if (!activity?.hasUserInput()) { + // Anchors / scroll restoration before real input only move the baseline. + baseline = fractionAboveViewportTop(measure); + maxBottom = bottom; + } else if (bottom > maxBottom) { + maxBottom = bottom; + } + } + + function scheduleProgressUpdate() { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + if (!disposed) updateProgress(); + }); + } + + function tick() { + const now = performance.now(); + const deltaS = Math.min((now - lastTickAt) / 1000, MAX_TICK_DELTA_S); + lastTickAt = now; + if (activity.getState() === STATE_READING) pendingSeconds += deltaS; + updateProgress(); + if (pendingSeconds >= context.periodicFlushActiveSeconds) { + safeFlush(); + } else if (outbox.length > 0) { + safeFlush({ collect: false }); // retry of previously failed intervals + } + } + + /** + * Turns pending time + progress into a queued interval. Progress that time + * could not account for (the credit was capped) stays after the baseline and + * is credited by later time. + */ + function collect() { + try { + // The server rejects intervals above MaxActiveSecondsPerInterval (its options + // validator enforces idle threshold <= that cap), so never emit + // one longer than the idle threshold: split long pending time into several + // intervals of the same batch. + while (Math.floor(pendingSeconds) >= 1) { + const activeSeconds = Math.min(Math.floor(pendingSeconds), context.idleThresholdSeconds); + const { wordsRead, progressConsumed } = creditInterval({ + pageWords: context.pageWords, + progress: maxBottom - baseline, + activeSeconds, + maxWpm: context.maxCreditWpm + }); + if (wordsRead <= 0) { + // Nothing progressed. Keep the time (idle time was already excluded) so a slow + // reader's later scroll is credited at their real, slower speed. + return; + } + pendingSeconds -= activeSeconds; + baseline += progressConsumed; + outbox.push({ pageKey: context.pageKey, activeSeconds, wordsRead }); + } + } finally { + trimOutbox(); + if (storedKey) persistOutbox(); + } + } + + function trimOutbox() { + if (outbox.length > MAX_QUEUED_INTERVALS) outbox.splice(0, outbox.length - MAX_QUEUED_INTERVALS); + } + + /** + * Sends the queue. Lifecycle flushes (keepalive) always send; periodic ones + * yield to an in-flight request and to the retry backoff. + */ + async function flush({ keepalive = false, collect: doCollect = true } = {}) { + if (!tracking || stopped) return; + + // If another tab already took our persisted record (it sends those entries), drop them + // before collect() could write them back and have them sent twice. + if (storedKey && !isOutboxStored(storedKey)) { + storedKey = null; + outbox = []; + } + + if (doCollect) { + updateProgress(); + collect(); + } + if (outbox.length === 0) return; + if (!keepalive && (inFlight > 0 || performance.now() < retryAt)) return; + + // A persisted batch is sent only by the tab that holds its record: if another tab + // took it, that tab sends it and this copy is dropped. If storage itself fails nothing + // is known about the record, so the batch is still sent (never dropped on a guess). + // For a periodic send the record stays stored (leased) until the response is + // acknowledged; a keepalive send cannot be acknowledged, so its record is removed now. + let sentKey = null; + if (storedKey) { + const claim = claimOutbox(storedKey); + if (claim === CLAIM_GONE) { + storedKey = null; + outbox = []; + return; + } + if (claim === CLAIM_CLAIMED) { + if (keepalive) removeOutbox(storedKey); + else sentKey = storedKey; + } + storedKey = null; + } + const batch = outbox; + outbox = []; + const batchGeneration = generation; + + // A keepalive send has no timeout and can stay pending on a stalled network. Its batch has left the + // outbox already, so it must not hold back the periodic flushes (inFlight only gates those). + if (!keepalive) inFlight++; + let result; + try { + result = await postSession(batch, { keepalive }); + } finally { + if (!keepalive) inFlight--; + } + // Acknowledged (delivered or rejected for good): the record is no longer needed. + // Transient failures keep it, re-saved below. + if (sentKey && result.status !== STATUS_TRANSIENT) removeOutbox(sentKey); + if (disposed || batchGeneration !== generation) return; + + if (result.status === STATUS_OK) { + failures = 0; + retryAt = 0; + const serverWpm = result.data?.wpm; + if (typeof serverWpm === "number" && Number.isFinite(serverWpm) && serverWpm > 0) { + wpm.value = serverWpm; + } + } else if (result.status === STATUS_AUTH_FAILURE) { + stopped = true; + outbox = []; + pendingSeconds = 0; + discardPersistedOutbox(); + } else if (result.status === STATUS_BAD_DATA) { + failures = 0; // batch dropped; the data was the problem, not the server + } else { + // Transient: put the batch back in front of anything queued meanwhile. If another + // tab took our record meanwhile, it sends what that record held (everything queued + // since this send started); only this batch, which that record never held, is kept. + if (storedKey && !isOutboxStored(storedKey)) { + storedKey = null; + outbox = []; + } + outbox = batch.concat(outbox); + // Drop the leased record: persistOutbox re-saves everything (under a newer record + // if one exists, else a fresh one), and our own lease must not make the next retry + // treat the record as another tab's. + if (sentKey) removeOutbox(sentKey); + trimOutbox(); + persistOutbox(); + failures++; + retryAt = performance.now() + + Math.min(RETRY_BASE_DELAY_MS * 2 ** (failures - 1), RETRY_MAX_DELAY_MS); + } + } + + // Fire-and-forget flush for timers and page lifecycle events: never an unhandled rejection. + function safeFlush(options) { + flush(options).catch(() => {}); + } + + function onVisibilityChange() { + if (document.visibilityState === "hidden") { + safeFlush({ keepalive: true }); + } else { + lastTickAt = performance.now(); + } + } + + function onPageHide() { + safeFlush({ keepalive: true }); + } + + function onPageShow(event) { + if (!event.persisted) return; + // Restored from the back/forward cache: start fresh and IDLE. + activity?.reset(); + lastTickAt = performance.now(); + updateProgress(); + } + + // Another tab reset the reading profile: return to the default estimate and + // drop unsent data so it does not repopulate the profile that was just cleared. + function onReset() { + generation++; + wpm.value = context.defaultWpm; + if (!tracking) return; + pendingSeconds = 0; + outbox = []; + discardPersistedOutbox(); + failures = 0; + retryAt = 0; + updateProgress(); + baseline = maxBottom; + } + + const resetChannel = typeof BroadcastChannel === "function" + ? new BroadcastChannel(READING_RESET_CHANNEL) + : null; + if (resetChannel) resetChannel.onmessage = onReset; + + // Progress display works for everyone. + const onScrollOrResize = () => scheduleProgressUpdate(); + window.addEventListener("scroll", onScrollOrResize, { passive: true }); + window.addEventListener("resize", onScrollOrResize, { passive: true }); + window.addEventListener("load", onScrollOrResize); + updateProgress(); + + if (tracking) { + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("pagehide", onPageHide); + window.addEventListener("pageshow", onPageShow); + tickId = window.setInterval(tick, TICK_INTERVAL_MS); + } + + onScopeDispose(() => { + disposed = true; + resetChannel?.close(); + clearInterval(tickId); + if (rafId) cancelAnimationFrame(rafId); + window.removeEventListener("scroll", onScrollOrResize); + window.removeEventListener("resize", onScrollOrResize); + window.removeEventListener("load", onScrollOrResize); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("pageshow", onPageShow); + }); + + return { wpm: readonly(wpm), scrollFraction: readonly(scrollFraction), context }; +} diff --git a/EssentialCSharp.Web/src/composables/useReadingTracker.keepalive.test.js b/EssentialCSharp.Web/src/composables/useReadingTracker.keepalive.test.js new file mode 100644 index 00000000..2e1431ca --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useReadingTracker.keepalive.test.js @@ -0,0 +1,86 @@ +// A keepalive send (fired on every hide / pagehide) has no timeout; if it hangs it must not stop later +// periodic flushes or cause queued reading time to be trimmed away. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope } from "vue"; +import { useReadingTracker } from "./useReadingTracker.js"; +import { MAX_QUEUED_INTERVALS } from "../utils/readingConstants.js"; +import { installFakeBroadcastChannel } from "../test-utils/fakeBroadcastChannel.js"; + +const VIEWPORT = 500; +const CONTENT_HEIGHT = 200000; // long page so progress never limits credit +const context = () => ({ + isAuthenticated: true, userId: "user-1", pageKey: "c/p", pageWords: 400000, chapterWords: 400000, + wordsBeforePage: 0, chapterStartWords: 0, bookWords: 900000, wpm: 240, defaultWpm: 200, + maxCreditWpm: 800, idleThresholdSeconds: 300, periodicFlushActiveSeconds: 60 +}); + +describe("hung keepalive send", () => { + let scope, contentTop, visibility, calls, pending, scrollPos; + const advance = (s) => vi.advanceTimersByTimeAsync(s * 1000); + const hide = () => { visibility = "hidden"; document.dispatchEvent(new Event("visibilitychange")); }; + const show = () => { visibility = "visible"; document.dispatchEvent(new Event("visibilitychange")); }; + // keep reading: input + scroll further each second-chunk + const readFor = async (seconds) => { + for (let i = 0; i < seconds; i += 5) { + document.dispatchEvent(new Event("keydown")); + scrollPos += 400; + contentTop = -scrollPos; + await advance(5); + } + }; + const start = () => { scope = effectScope(); return scope.run(() => useReadingTracker(context())); }; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"] }); + document.head.innerHTML = ''; + document.body.innerHTML = '
'; + contentTop = 0; scrollPos = 0; visibility = "visible"; + document.querySelector("[data-reading-content]").getBoundingClientRect = () => ({ top: contentTop, height: CONTENT_HEIGHT }); + window.innerHeight = VIEWPORT; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); + calls = []; + pending = null; + installFakeBroadcastChannel(vi); + }); + afterEach(() => { + scope?.stop(); document.body.innerHTML = ""; vi.useRealTimers(); vi.unstubAllGlobals(); + localStorage.clear(); vi.restoreAllMocks(); + }); + + it("does not stop later periodic flushes", async () => { + vi.stubGlobal("fetch", vi.fn((url, init) => { + const intervals = JSON.parse(init.body).intervals; + calls.push({ keepalive: init.keepalive, intervals }); + if (init.keepalive) return new Promise(() => {}); // never settles + return Promise.resolve(new Response(JSON.stringify({ wpm: 240 }), { status: 200, headers: { "content-type": "application/json" } })); + })); + start(); + await readFor(30); + hide(); // keepalive flush with ~30s of data: hangs + await advance(0); + show(); + // 25 periodic periods of reading after coming back (60s each) + await readFor(25 * 60 + 30); + const periodic = calls.filter(c => !c.keepalive); + const sentSeconds = periodic.flatMap(c => c.intervals).reduce((t, i) => t + i.activeSeconds, 0); + // Expected: ~1500s of post-show reading delivered by periodic flushes. + expect(periodic.length).toBeGreaterThan(0); + expect(sentSeconds).toBeGreaterThan(1400); + }); + + it("does not lose reading time to outbox trimming", async () => { + vi.stubGlobal("fetch", vi.fn((url, init) => { + calls.push({ keepalive: init.keepalive, intervals: JSON.parse(init.body).intervals }); + return init.keepalive && calls.length === 1 ? new Promise(() => {}) : Promise.resolve(new Response(JSON.stringify({ wpm: 240 }), { status: 200, headers: { "content-type": "application/json" } })); + })); + start(); + await readFor(30); hide(); await advance(0); show(); + await readFor(40 * 60); // 40 minutes of reading + window.dispatchEvent(new Event("pagehide")); await advance(0); + const last = calls[calls.length - 1]; + const total = calls.slice(1).flatMap(c => c.intervals).reduce((t, i) => t + i.activeSeconds, 0); + expect(last.intervals.length).toBeLessThanOrEqual(MAX_QUEUED_INTERVALS); + expect(total).toBeGreaterThan(2300); // all ~2400s should eventually arrive + }); +}); diff --git a/EssentialCSharp.Web/src/composables/useReadingTracker.test.js b/EssentialCSharp.Web/src/composables/useReadingTracker.test.js new file mode 100644 index 00000000..a24aa644 --- /dev/null +++ b/EssentialCSharp.Web/src/composables/useReadingTracker.test.js @@ -0,0 +1,986 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope } from "vue"; +import { useReadingTracker } from "./useReadingTracker.js"; +import { + LEASE_MS, + MAX_QUEUED_INTERVALS, + MAX_TICK_DELTA_S, + READING_RESET_CHANNEL, + RETRY_BASE_DELAY_MS, + RETRY_MAX_DELAY_MS, + TICK_INTERVAL_MS +} from "../utils/readingConstants.js"; +import { newOutboxKey, saveOutbox, takeStoredIntervals } from "../utils/readingOutboxStorage.js"; +import { installFakeBroadcastChannel } from "../test-utils/fakeBroadcastChannel.js"; + +// These tests assert requirements (what is credited, delivered, dropped or stopped), +// not timer arithmetic: waits are derived from the real constants and the server +// tuning in the context below, and totals are compared with the active time that +// actually elapsed. + +const VIEWPORT = 500; +const CONTENT_HEIGHT = 2000; +const DEFAULT_WPM = 200; + +const context = (overrides = {}) => ({ + isAuthenticated: true, + userId: "user-1", + pageKey: "chapter-1/page-1", + pageWords: 2000, + chapterWords: 8000, + wordsBeforePage: 0, + chapterStartWords: 0, + bookWords: 90000, + wpm: 240, + defaultWpm: DEFAULT_WPM, + maxCreditWpm: 800, + idleThresholdSeconds: 300, + periodicFlushActiveSeconds: 60, + ...overrides +}); +// Slower credit cap for tests that read for several minutes, so the page's words are not exhausted +// (once all progress is credited, further stationary time is deliberately not credited). +const SLOW = { maxCreditWpm: 100 }; +const FLUSH_S = context().periodicFlushActiveSeconds; +const BASE_DELAY_S = RETRY_BASE_DELAY_MS / 1000; +const PAST_LEASE_S = LEASE_MS / 1000 + 60; // long enough for any lease to have expired + +const PROBLEM_JSON = "application/problem+json"; +const jsonResponse = (status, body, contentType = "application/json") => new Response( + body === undefined ? null : JSON.stringify(body), + { status, headers: body === undefined ? {} : { "content-type": contentType } }); +const ok = (wpm = 240) => jsonResponse(200, { wpm }); +const antiforgeryRejection = () => new Response(null, { status: 400, headers: { "X-Antiforgery-Failure": "1" } }); +const VALIDATION_BODY = { title: "Invalid", status: 400, errors: { x: ["bad"] }, traceId: "t" }; + +const sum = (intervals, key) => intervals.reduce((total, i) => total + i[key], 0); + +describe("useReadingTracker", () => { + let scope; + let contentTop; + let originalInnerHeight; + // Fake server: every request is recorded; intervals in an ok response count as delivered. + let calls; + let delivered; + let handler; + let visibility; + let focused; + let timeOffsetMs; + let elapsed; // seconds of fake time advanced + + function start(ctx = context()) { + scope = effectScope(); + return scope.run(() => useReadingTracker(ctx)); + } + + const scrollTo = (top) => { contentTop = top; }; + const advance = async (seconds) => { elapsed += seconds; await vi.advanceTimersByTimeAsync(seconds * 1000); }; + const press = () => document.dispatchEvent(new Event("keydown")); + const hide = () => { visibility = "hidden"; document.dispatchEvent(new Event("visibilitychange")); }; + const show = () => { visibility = "visible"; document.dispatchEvent(new Event("visibilitychange")); }; + const leavePage = async () => { window.dispatchEvent(new Event("pagehide")); await advance(0); }; + const deliveredSeconds = () => sum(delivered, "activeSeconds"); + + /** Real input, then scroll to the end of the page, so progress is not the limiting factor. */ + function readThroughPage() { + press(); + scrollTo(-(CONTENT_HEIGHT - VIEWPORT)); + } + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"] }); + document.head.innerHTML = ''; + document.body.innerHTML = '
'; + contentTop = 0; + document.querySelector("[data-reading-content]").getBoundingClientRect = + () => ({ top: contentTop, height: CONTENT_HEIGHT }); + originalInnerHeight = window.innerHeight; + window.innerHeight = VIEWPORT; + + visibility = "visible"; + focused = true; + timeOffsetMs = 0; + elapsed = 0; + const fakeNow = performance.now.bind(performance); + vi.spyOn(performance, "now").mockImplementation(() => fakeNow() + timeOffsetMs); + vi.spyOn(document, "hasFocus").mockImplementation(() => focused); + vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility); + + calls = []; + delivered = []; + handler = () => ok(); + vi.stubGlobal("fetch", vi.fn(async (url, init) => { + const intervals = JSON.parse(init.body).intervals; + calls.push({ at: performance.now(), intervals, keepalive: init.keepalive }); + const response = await handler(calls.length, intervals); + if (response.ok) delivered.push(...intervals); + return response; + })); + installFakeBroadcastChannel(vi); + }); + + afterEach(() => { + scope?.stop(); + scope = undefined; + window.innerHeight = originalInnerHeight; + document.body.innerHTML = ""; + document.head.innerHTML = ""; + vi.useRealTimers(); + vi.unstubAllGlobals(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + describe("who is tracked", () => { + it("does not track anonymous visitors at all", async () => { + start(context({ isAuthenticated: false, wpm: null })); + readThroughPage(); + await advance(300); + await leavePage(); + hide(); + expect(calls).toHaveLength(0); + }); + + it("estimates with the signed-in user's wpm, or the default when there is none", () => { + expect(start(context({ wpm: 240 })).wpm.value).toBe(240); + scope.stop(); + expect(start(context({ wpm: null })).wpm.value).toBe(DEFAULT_WPM); + }); + + it("never posts when there is no CSRF token", async () => { + document.head.innerHTML = ""; + start(); + readThroughPage(); + await advance(200); + await leavePage(); + expect(calls).toHaveLength(0); + }); + }); + + describe("crediting", () => { + it("credits every active second exactly once across all posted intervals", async () => { + start(context(SLOW)); + readThroughPage(); + await advance(2.5 * FLUSH_S); + await leavePage(); + + expect(calls.length).toBeGreaterThan(1); // periodic flushes happened, not only the final one + expect(deliveredSeconds()).toBe(elapsed); + }); + + it("never credits more words than the max credit wpm allows for the time spent", async () => { + const ctx = context(); + start(ctx); + readThroughPage(); + await advance(100); + await leavePage(); + + for (const interval of delivered) { + expect(interval.wordsRead).toBeLessThanOrEqual(interval.activeSeconds * ctx.maxCreditWpm / 60); + expect(interval.pageKey).toBe(ctx.pageKey); + } + expect(sum(delivered, "wordsRead")).toBeLessThanOrEqual(deliveredSeconds() * ctx.maxCreditWpm / 60); + }); + + it("carries uncredited progress forward until time accounts for it, never beyond the page", async () => { + const ctx = context(); + start(ctx); + readThroughPage(); + + await advance(FLUSH_S); + await leavePage(); + const firstWords = sum(delivered, "wordsRead"); + expect(firstWords).toBeLessThan(ctx.pageWords); // time was the limit, so progress is left over + + // Enough further reading time to cover the rest of the page at the credit cap. + await advance(Math.ceil(ctx.pageWords * 60 / ctx.maxCreditWpm)); + await leavePage(); + + const total = sum(delivered, "wordsRead"); + expect(total).toBeGreaterThan(firstWords); + expect(total).toBeLessThanOrEqual(ctx.pageWords); + // Only rounding (under one second of credit) may stay unsent. + expect(ctx.pageWords - total).toBeLessThanOrEqual(ctx.maxCreditWpm / 60 + 1); + }); + + it("keeps stationary reading time so a slow reader is credited at their real speed", async () => { + const ctx = context(); + start(ctx); + press(); + const stationaryS = FLUSH_S * 3; + for (let waited = 0; waited < stationaryS; waited += 30) { + await advance(30); + press(); // still reading, not idle + } + scrollTo(-(CONTENT_HEIGHT - VIEWPORT)); // finally scrolls + await advance(1); + await leavePage(); + + expect(deliveredSeconds()).toBeGreaterThanOrEqual(stationaryS); + }); + + it("credits only the first screen when the reader never scrolls", async () => { + const ctx = context(); + start(ctx); + press(); + await advance(FLUSH_S + 100); + await leavePage(); + expect(sum(delivered, "wordsRead")).toBe(Math.round(ctx.pageWords * VIEWPORT / CONTENT_HEIGHT)); + }); + + it("does not credit content that was already scrolled past when the page loaded", async () => { + const ctx = context(); + const alreadyPast = 1000; + scrollTo(-alreadyPast); + start(ctx); + readThroughPage(); + await advance(250); + await leavePage(); + const creditable = ctx.pageWords * (1 - alreadyPast / CONTENT_HEIGHT); + expect(sum(delivered, "wordsRead")).toBeLessThanOrEqual(creditable); + expect(sum(delivered, "wordsRead")).toBeGreaterThan(0); + }); + + it("splits long pending time so no interval exceeds the idle threshold", async () => { + const ctx = context({ idleThresholdSeconds: 30 }); + start(ctx); + readThroughPage(); + for (let i = 0; i < 2 * FLUSH_S + 1; i++) { + press(); // keep the reader active across the short idle threshold + await advance(1); + } + await leavePage(); + expect(Math.max(...delivered.map((i) => i.activeSeconds))).toBeLessThanOrEqual(ctx.idleThresholdSeconds); + expect(deliveredSeconds()).toBe(elapsed); + }); + + it("takes the new wpm from the server response", async () => { + const serverWpm = 275; + handler = () => ok(serverWpm); + const tracker = start(); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls.length).toBeGreaterThan(0); + expect(tracker.wpm.value).toBe(serverWpm); + }); + }); + + describe("activity", () => { + it("never accrues time before the first real input", async () => { + start(); + scrollTo(-(CONTENT_HEIGHT - VIEWPORT)); + await advance(200); + await leavePage(); + expect(calls).toHaveLength(0); + }); + + it("does not treat a bare focus event or a pre-input scroll as input", async () => { + start(); + window.dispatchEvent(new Event("focus")); + window.dispatchEvent(new Event("scroll")); + await advance(200); + await leavePage(); + expect(calls).toHaveLength(0); + }); + + it("counts scrolling as activity once there has been real input", async () => { + const ctx = context(SLOW); + start(ctx); + readThroughPage(); + // Two waits that are each inside the idle threshold but together exceed it. + const wait = ctx.idleThresholdSeconds - 10; + await advance(wait); + window.dispatchEvent(new Event("scroll")); + await advance(wait); + await leavePage(); + expect(deliveredSeconds()).toBe(2 * wait); + }); + + it("stops crediting once the reader is idle for longer than the idle threshold", async () => { + const ctx = context(SLOW); + start(ctx); + readThroughPage(); + await advance(ctx.idleThresholdSeconds + 200); + await leavePage(); + // About the idle threshold is credited (within a tick of rounding), not half of it, not more. + expect(deliveredSeconds()).toBeLessThanOrEqual(ctx.idleThresholdSeconds); + expect(deliveredSeconds()).toBeGreaterThanOrEqual(ctx.idleThresholdSeconds - TICK_INTERVAL_MS / 1000 - 1); + }); + + it("stops crediting when the window loses focus", async () => { + start(); + readThroughPage(); + const focusedSeconds = 20; + await advance(focusedSeconds); + focused = false; + await advance(100); + await leavePage(); + expect(deliveredSeconds()).toBeLessThanOrEqual(focusedSeconds); + expect(deliveredSeconds()).toBeGreaterThanOrEqual(focusedSeconds - TICK_INTERVAL_MS / 1000 - 1); + }); + + it("does not credit time while hidden, sends nothing then, and resumes when visible again", async () => { + start(); + readThroughPage(); + const before = 20; + await advance(before); + hide(); + await advance(0); + const callsAfterHide = calls.length; + await advance(100); + expect(calls).toHaveLength(callsAfterHide); + expect(deliveredSeconds()).toBe(before); + + show(); + press(); + const after = 15; + await advance(after); + await leavePage(); + expect(deliveredSeconds()).toBe(before + after); + }); + + it("does not credit a sleep/resume gap as reading", async () => { + const ctx = context(); + start(ctx); + readThroughPage(); + const before = 10; + await advance(before); + timeOffsetMs += (ctx.idleThresholdSeconds / 2) * 1000; // machine slept, still inside the idle threshold + await advance(TICK_INTERVAL_MS / 1000); + await leavePage(); + expect(deliveredSeconds()).toBeLessThanOrEqual(before + MAX_TICK_DELTA_S); + expect(deliveredSeconds()).toBeGreaterThanOrEqual(before - TICK_INTERVAL_MS / 1000 - 1); + }); + }); + + describe("lifecycle flushes", () => { + it.each([ + ["the page becomes hidden", hide], + ["pagehide", () => window.dispatchEvent(new Event("pagehide"))] + ])("flushes pending data with keepalive when %s", async (_name, trigger) => { + start(); + readThroughPage(); + const pending = 10; + await advance(pending); + trigger(); + await advance(0); + expect(calls).toHaveLength(1); + expect(calls[0].keepalive).toBe(true); + expect(sum(calls[0].intervals, "activeSeconds")).toBe(pending); + }); + + it("releases all listeners and timers when the scope stops", async () => { + start(); + readThroughPage(); + await advance(10); + scope.stop(); + scope = undefined; + press(); + await advance(300); + await leavePage(); + hide(); + await advance(0); + expect(calls).toHaveLength(0); + }); + }); + + describe("failures", () => { + it("keeps data after a transient failure and delivers it after the backoff, not before", async () => { + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(context(SLOW)); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls).toHaveLength(1); + + await advance(BASE_DELAY_S - 2); // still inside the backoff window + expect(calls).toHaveLength(1); + + await advance(BASE_DELAY_S); + expect(calls.length).toBeGreaterThan(1); + expect(calls[1].at - calls[0].at).toBeGreaterThanOrEqual(RETRY_BASE_DELAY_MS); + expect(calls[1].intervals).toEqual(expect.arrayContaining(calls[0].intervals)); + + await leavePage(); + expect(deliveredSeconds()).toBe(elapsed); // the failed batch was neither lost nor duplicated + }); + + it("retries after a network error", async () => { + handler = (n) => { + if (n === 1) throw new TypeError("offline"); + return ok(); + }; + start(); + readThroughPage(); + await advance(FLUSH_S + 2 * BASE_DELAY_S); + await leavePage(); + expect(calls.length).toBeGreaterThan(1); + expect(deliveredSeconds()).toBe(elapsed); + }); + + it("backs off further after repeated failures, then stays at the maximum delay", async () => { + handler = () => jsonResponse(429); + start(); + readThroughPage(); + await advance(FLUSH_S); + // Long enough for the delays to reach the maximum and stay there for several attempts. + for (let i = 0; i < 6 * RETRY_MAX_DELAY_MS / 1000 && calls.length < 12; i++) await advance(1); + + const gaps = calls.slice(1).map((c, i) => c.at - calls[i].at); + expect(gaps[0]).toBeGreaterThanOrEqual(RETRY_BASE_DELAY_MS); + const plateau = gaps.slice(-3); + expect(plateau).toHaveLength(3); + for (const gap of plateau) { + expect(gap).toBeGreaterThanOrEqual(RETRY_MAX_DELAY_MS); + expect(gap).toBeLessThanOrEqual(RETRY_MAX_DELAY_MS + TICK_INTERVAL_MS); + } + for (const gap of gaps) expect(gap).toBeLessThanOrEqual(RETRY_MAX_DELAY_MS + TICK_INTERVAL_MS); + }); + + it.each([401, 403])("stops tracking for the page after an auth failure (%s) and never sends again", async (status) => { + handler = () => jsonResponse(status); + start(); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls).toHaveLength(1); + await advance(300); + press(); + await advance(100); + await leavePage(); + hide(); + await advance(0); + expect(calls).toHaveLength(1); + }); + + it("stops tracking when the server flags a 400 as an antiforgery rejection", async () => { + handler = () => antiforgeryRejection(); + start(); + readThroughPage(); + await advance(FLUSH_S + 1); + await advance(200); + await leavePage(); + expect(calls).toHaveLength(1); + expect(localStorage.length).toBe(0); // nothing kept for a later page load + }); + + it("treats an unflagged 400 (e.g. from a proxy) as bad data and keeps tracking", async () => { + handler = (n) => (n === 1 ? jsonResponse(400, undefined) : ok()); + start(); + readThroughPage(); + await advance(2 * FLUSH_S + 5); + await leavePage(); + + const rejectedSeconds = sum(calls[0].intervals, "activeSeconds"); + expect(deliveredSeconds()).toBeGreaterThan(0); // tracking continued + expect(deliveredSeconds() + rejectedSeconds).toBe(elapsed); // the rejected batch was dropped, not resent + }); + + it.each([ + ["is missing", {}], + ["is null", { wpm: null }], + ["is not a number", { wpm: "fast" }], + ["is zero", { wpm: 0 }], + ["is negative", { wpm: -5 }] + ])("keeps the current wpm when the server's wpm %s", async (_name, body) => { + handler = () => jsonResponse(200, body); + const tracker = start(context({ wpm: 240 })); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls.length).toBeGreaterThan(0); + expect(tracker.wpm.value).toBe(240); + }); + + describe("a failed lifecycle flush", () => { + it.each([ + ["the page becomes hidden", hide], + ["pagehide", () => window.dispatchEvent(new Event("pagehide"))] + ])("is not lost when %s: the next page load delivers it", async (_name, trigger) => { + handler = (n) => { if (n === 1) throw new TypeError("aborted"); return ok(); }; + start(); + readThroughPage(); + await advance(10); + trigger(); + await advance(0); + expect(calls).toHaveLength(1); + expect(delivered).toHaveLength(0); + scope.stop(); + + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(delivered).toEqual(calls[0].intervals); + }); + }); + + it("drops a batch rejected as invalid, never resends it, but keeps tracking", async () => { + handler = (n) => (n === 1 ? jsonResponse(400, VALIDATION_BODY, PROBLEM_JSON) : ok()); + start(); + readThroughPage(); + await advance(2 * FLUSH_S + 5); + await leavePage(); + + const rejectedSeconds = sum(calls[0].intervals, "activeSeconds"); + expect(deliveredSeconds()).toBeGreaterThan(0); + // Delivered + rejected account for all time: nothing resent, nothing else lost. + expect(deliveredSeconds() + rejectedSeconds).toBe(elapsed); + }); + + it("sends no second request while one is in flight, and neither loses nor duplicates data", async () => { + let release; + handler = (n) => (n === 1 ? new Promise((resolve) => { release = () => resolve(ok()); }) : ok()); + start(context(SLOW)); + readThroughPage(); + await advance(FLUSH_S + 200); + expect(calls).toHaveLength(1); + + release(); + await advance(5); + await leavePage(); + expect(deliveredSeconds()).toBe(elapsed); + }); + + it("a lifecycle flush during an in-flight request sends only new data, so nothing is sent twice", async () => { + let release; + handler = (n) => (n === 1 ? new Promise((resolve) => { release = () => resolve(ok()); }) : ok()); + start(); + readThroughPage(); + await advance(FLUSH_S + 1); + await advance(5); + await leavePage(); // keepalive flush, concurrent by design + expect(calls).toHaveLength(2); + expect(calls[1].keepalive).toBe(true); + expect(sum(calls[0].intervals, "activeSeconds") + sum(calls[1].intervals, "activeSeconds")) + .toBeLessThanOrEqual(elapsed); + + release(); + await advance(30); + await leavePage(); + expect(deliveredSeconds()).toBe(elapsed); + }); + + it("when the server stays down, trims the oldest unsent intervals and keeps the newest", async () => { + // One interval per second. Scrolling i px at second i earns i words on this page + // (1px = 1 word), so every queued interval is identifiable. + const ctx = context({ periodicFlushActiveSeconds: 1, maxCreditWpm: 60000 }); + handler = () => jsonResponse(503); + start(ctx); + press(); + const seconds = MAX_QUEUED_INTERVALS + 10; + for (let i = 1; i <= seconds; i++) { + scrollTo(-(i * (i + 1)) / 2); + await advance(1); + } + handler = () => ok(); + await leavePage(); + + expect(delivered.length).toBeGreaterThan(0); + expect(delivered.length).toBeLessThanOrEqual(MAX_QUEUED_INTERVALS); + const words = delivered.map((i) => i.wordsRead); + expect(words.at(-1)).toBe(seconds); // newest kept + // Oldest dropped: the first interval (which carried the whole first screen) is gone. + expect(Math.max(...words)).toBeLessThanOrEqual(seconds); + expect(words).toEqual([...words].sort((a, b) => a - b)); + }); + }); + + describe("unsent data across page loads", () => { + const interval = (n) => ({ pageKey: "chapter-1/page-1", activeSeconds: 10, wordsRead: n }); + const seed = (intervals, userId = "user-1") => saveOutbox(newOutboxKey(), userId, intervals); + const nextPageLoad = async (ctx = context()) => { + scope.stop(); + const tracker = start(ctx); + await advance(TICK_INTERVAL_MS / 1000 + 1); + return tracker; + }; + + it("delivers on the next page load what a failed send could not deliver", async () => { + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls).toHaveLength(1); + expect(delivered).toHaveLength(0); + + await nextPageLoad(); + expect(delivered).toEqual(calls[0].intervals); + }); + + it("sends persisted data once even when two tabs load", async () => { + seed([interval(5), interval(6)]); + start(); + const otherTab = effectScope(); + otherTab.run(() => useReadingTracker(context())); + await advance(TICK_INTERVAL_MS / 1000 + 1); + otherTab.stop(); + + expect(delivered).toEqual([interval(5), interval(6)]); + }); + + it("does not lose a persisted batch whose request never completed (page destroyed mid-send)", async () => { + seed([interval(5)]); + handler = (n) => (n === 1 ? new Promise(() => {}) : ok()); + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(calls).toHaveLength(1); + scope.stop(); // page destroyed, response never seen + + // A page loaded while the request may still be running does not send it a second time. + await nextPageLoad(); + expect(delivered).toHaveLength(0); + + // Much later it is recovered. + scope.stop(); + await advance(PAST_LEASE_S); + await nextPageLoad(); + expect(delivered).toEqual([interval(5)]); + }); + + it("does not resend a persisted batch once its request was acknowledged", async () => { + seed([interval(5)]); + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(delivered).toEqual([interval(5)]); + + scope.stop(); + await advance(PAST_LEASE_S); + await nextPageLoad(); + expect(calls).toHaveLength(1); + }); + + it("never resends a batch whose lifecycle (keepalive) request was in flight when the page was destroyed", async () => { + // A keepalive request cannot be acknowledged by a page that is going away, and it + // may well have succeeded: it must not be sent again later (that would double count). + seed([interval(5)]); + handler = (n) => (n === 1 ? new Promise(() => {}) : ok()); + start(); + window.dispatchEvent(new Event("pagehide")); + await advance(0); + expect(calls).toHaveLength(1); + expect(calls[0].keepalive).toBe(true); + scope.stop(); + + await advance(PAST_LEASE_S); + await nextPageLoad(); + expect(calls).toHaveLength(1); + }); + + // Simulates a full storage: writes of matching values throw, everything else works. + function failWritesWhen(matches) { + const real = localStorage; + vi.stubGlobal("localStorage", { + get length() { return real.length; }, + key: (i) => real.key(i), + getItem: (k) => real.getItem(k), + removeItem: (k) => real.removeItem(k), + setItem: (k, v) => { + if (matches(String(v))) throw new DOMException("full", "QuotaExceededError"); + real.setItem(k, v); + } + }); + } + + // Starts a tab whose periodic send of a persisted batch (interval 5) hangs; while it + // hangs, a lifecycle send of newer data fails and is persisted in a second record. + async function startWithHungSendAndNewerRecord() { + seed([interval(5)]); + let release; + handler = (n) => { + if (n === 1) return new Promise((resolve) => { release = resolve; }); + if (n === 2) throw new TypeError("aborted"); + return ok(); + }; + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(calls).toHaveLength(1); + readThroughPage(); + await advance(10); + hide(); + await advance(0); + expect(calls).toHaveLength(2); + expect(localStorage.length).toBeGreaterThan(0); + return release; + } + + it("does not leave a stale copy of a batch behind when its send fails while newer data is persisted", async () => { + const release = await startWithHungSendAndNewerRecord(); + release(jsonResponse(503)); + await advance(0); + scope.stop(); + + await advance(PAST_LEASE_S); + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(delivered.filter((i) => i.wordsRead === 5)).toHaveLength(1); + expect(sum(delivered, "activeSeconds")).toBe(10 + 10); // the batch (10s) and the newer data (10s) + }); + + it("sends newer data taken by another tab once, while still keeping a batch that failed after that", async () => { + const release = await startWithHungSendAndNewerRecord(); + // Another tab loads and takes the newer record (the lifecycle send that failed), then sends it. + const otherTab = effectScope(); + otherTab.run(() => useReadingTracker(context({ pageKey: "chapter-9/page-9" }))); + + // The hung send fails right away, before this tab's next tick can notice the record is gone. + release(jsonResponse(503)); + await advance(0); + await advance(2 * BASE_DELAY_S); + otherTab.stop(); + scope.stop(); + await advance(PAST_LEASE_S); + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + + // The failed batch (10s) was kept and delivered, the newer data (10s) was delivered once. + expect(sum(delivered.filter((i) => i.pageKey === context().pageKey), "activeSeconds")).toBe(10 + 10); + }); + + it("keeps no stale record when re-saving a grown batch fails (storage full)", async () => { + const ctx = context({ periodicFlushActiveSeconds: 1 }); + seed([interval(5)]); + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(ctx); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(calls).toHaveLength(1); // failed: [5] stays persisted + + failWritesWhen((value) => !value.includes("leaseUntil") && JSON.parse(value).intervals.length > 1); + readThroughPage(); + await advance(3); // new data is queued during the backoff, its record no longer fits + await advance(2 * BASE_DELAY_S); + expect(delivered.filter((i) => i.wordsRead === 5)).toHaveLength(1); // sent from memory + + scope.stop(); + await advance(PAST_LEASE_S); + await nextPageLoad(); + expect(delivered.filter((i) => i.wordsRead === 5)).toHaveLength(1); // the old record was not left behind + }); + + it("auth failure discards only this account's records, not another account's", async () => { + const release = await startWithHungSendAndNewerRecord(); + seed([interval(7)], "someone-else"); // another account signed in in another tab + release(jsonResponse(401)); + await advance(0); + expect(takeStoredIntervals("someone-else")).toEqual([interval(7)]); + expect(localStorage.length).toBe(0); // and nothing of this account is left either + }); + + it("discards every persisted record when the server rejects the session (auth failure)", async () => { + const release = await startWithHungSendAndNewerRecord(); + release(jsonResponse(401)); + await advance(0); + expect(localStorage.length).toBe(0); + }); + + it("sends a batch taken by another tab only once, even if this tab keeps collecting", async () => { + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(context(SLOW)); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls).toHaveLength(1); + + // Another tab loads, takes the stored batch and sends it. + const otherTab = effectScope(); + // (It reads another page, so its own reading is distinguishable by page key.) + otherTab.run(() => useReadingTracker(context({ pageKey: "chapter-9/page-9" }))); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(delivered).toHaveLength(calls[0].intervals.length); // the other tab sent it + + // This tab flushes (hidden, then visible again) while still holding its failed batch + // in the retry backoff, then goes on reading. + hide(); + await advance(0); + show(); + press(); + await advance(FLUSH_S + 2 * BASE_DELAY_S); + await leavePage(); + otherTab.stop(); + + // Every second read here is counted exactly once: a resent batch would exceed the time elapsed. + expect(sum(delivered.filter((i) => i.pageKey === context().pageKey), "activeSeconds")).toBe(elapsed); + }); + + it("sends the batch once and leaves no record behind when marking it as being sent fails (storage full)", async () => { + seed([interval(5)]); + failWritesWhen((value) => value.includes("leaseUntil")); + start(); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(delivered).toEqual([interval(5)]); + + scope.stop(); + await advance(PAST_LEASE_S); + await nextPageLoad(); + expect(calls).toHaveLength(1); + }); + + it("does not drop its own data when storing a failed batch fails (storage full)", async () => { + seed([interval(5)]); + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(); + failWritesWhen((value) => !value.includes("leaseUntil")); + await advance(TICK_INTERVAL_MS / 1000 + 1); + expect(calls).toHaveLength(1); + + await advance(2 * BASE_DELAY_S); + expect(delivered).toEqual([interval(5)]); // retried from memory, not dropped + }); + + it("never sends data persisted for a different account", async () => { + seed([interval(5)], "someone-else"); + start(); + await advance(10); + expect(calls).toHaveLength(0); + }); + + it("ignores malformed storage and malformed entries", async () => { + localStorage.setItem(newOutboxKey(), "{not json"); + localStorage.setItem(newOutboxKey(), JSON.stringify({ userId: "user-1", intervals: "nope" })); + seed([ + interval(5), + { pageKey: "", activeSeconds: 10, wordsRead: 5 }, + { pageKey: "p", activeSeconds: -1, wordsRead: 5 }, + { pageKey: "p", activeSeconds: 10, wordsRead: "many" }, + { pageKey: "p", activeSeconds: Infinity, wordsRead: 5 }, + { pageKey: "p", activeSeconds: 10.5, wordsRead: 5 }, + { pageKey: "p", activeSeconds: 10, wordsRead: 5.5 }, + null + ]); + start(); + await advance(10); + expect(delivered).toEqual([interval(5)]); + }); + + it("keeps tracking without throwing when storage is unavailable", async () => { + const broken = () => { throw new Error("storage unavailable"); }; + vi.stubGlobal("localStorage", { + get length() { return broken(); }, + key: broken, + getItem: broken, + setItem: broken, + removeItem: broken + }); + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(); + readThroughPage(); + await advance(2 * FLUSH_S + 5); + await leavePage(); + // The failed batch is still retried from memory. + expect(delivered).toEqual(expect.arrayContaining(calls[0].intervals)); + }); + + it("keeps only the newest intervals when more were persisted than may be queued", async () => { + seed(Array.from({ length: MAX_QUEUED_INTERVALS + 5 }, (_, i) => interval(i + 1))); + start(); + await advance(10); + expect(delivered).toHaveLength(MAX_QUEUED_INTERVALS); + expect(delivered.at(-1).wordsRead).toBe(MAX_QUEUED_INTERVALS + 5); + expect(delivered[0].wordsRead).toBe(6); + }); + + it("discards persisted data when the profile is reset in another tab", async () => { + handler = (n) => (n === 1 ? jsonResponse(503) : ok()); + start(); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls).toHaveLength(1); + + new BroadcastChannel(READING_RESET_CHANNEL).postMessage({ type: "reset" }); + await nextPageLoad(); + expect(calls).toHaveLength(1); + }); + + it("clears persisted data the page was loaded with when it is reset", async () => { + seed([interval(5)]); + handler = () => jsonResponse(503); + start(); // takes the data over; send fails, so it is persisted again + await advance(2); + expect(calls).toHaveLength(1); + + new BroadcastChannel(READING_RESET_CHANNEL).postMessage({ type: "reset" }); + handler = () => ok(); + await nextPageLoad(); + expect(delivered).toHaveLength(0); + }); + + it("drops persisted data the server rejects as invalid and does not send it again", async () => { + seed([interval(5)]); + handler = () => jsonResponse(400, VALIDATION_BODY, PROBLEM_JSON); + start(); + await advance(2); + expect(calls).toHaveLength(1); + + handler = () => ok(); + await nextPageLoad(); + expect(delivered).toHaveLength(0); + }); + + it("drops persisted data after an auth failure", async () => { + seed([interval(5)]); + handler = () => jsonResponse(403); + start(); + await advance(2); + expect(calls).toHaveLength(1); + + handler = () => ok(); + await nextPageLoad(); + expect(delivered).toHaveLength(0); + }); + }); + + describe("reset in another tab", () => { + // Deterministic: the fake channel delivers synchronously. + function resetFromOtherTab() { + const other = new BroadcastChannel(READING_RESET_CHANNEL); + other.postMessage({ type: "reset" }); + other.close(); + } + + it("does not restore a batch that was in flight at reset time when it fails", async () => { + let fail; + handler = (n) => (n === 1 ? new Promise((resolve) => { fail = () => resolve(jsonResponse(503)); }) : ok()); + start(); + readThroughPage(); + await advance(FLUSH_S + 1); + expect(calls).toHaveLength(1); + const resetAt = elapsed; + + resetFromOtherTab(); + fail(); + await advance(RETRY_MAX_DELAY_MS / 1000 + 10); + await leavePage(); + // The pre-reset interval is gone: only time earned after the reset can be delivered. + expect(deliveredSeconds()).toBeLessThanOrEqual(elapsed - resetAt); + expect(calls.slice(1).flatMap((c) => c.intervals)).not.toEqual(expect.arrayContaining(calls[0].intervals)); + }); + + it("ignores the wpm of a response to a batch sent before the reset", async () => { + let succeed; + handler = (n) => (n === 1 ? new Promise((resolve) => { succeed = () => resolve(ok(300)); }) : ok()); + const tracker = start(context({ wpm: 300 })); + readThroughPage(); + await advance(FLUSH_S + 1); + + resetFromOtherTab(); + succeed(); + await advance(1); + expect(tracker.wpm.value).toBe(DEFAULT_WPM); + }); + + it("returns to the default wpm and discards unsent data", async () => { + const tracker = start(context({ wpm: 300 })); + readThroughPage(); + await advance(FLUSH_S / 2); + + resetFromOtherTab(); + expect(tracker.wpm.value).toBe(DEFAULT_WPM); + await leavePage(); + expect(calls).toHaveLength(0); + }); + + it("does not re-credit progress that was already made before the reset", async () => { + start(); + readThroughPage(); + await advance(FLUSH_S / 2); + resetFromOtherTab(); + await advance(FLUSH_S); + await leavePage(); + expect(sum(delivered, "wordsRead")).toBe(0); + }); + }); +}); diff --git a/EssentialCSharp.Web/src/reading-reset.js b/EssentialCSharp.Web/src/reading-reset.js new file mode 100644 index 00000000..d3d33cec --- /dev/null +++ b/EssentialCSharp.Web/src/reading-reset.js @@ -0,0 +1,56 @@ +/** + * Behavior for the "Reset reading speed" button on the Manage Profile page. + * Loaded as its own Vite entry (no inline script, CSP-safe). + */ +import { STATUS_AUTH_FAILURE, STATUS_OK, resetProfile } from "./services/readingApi.js"; +import { READING_RESET_CHANNEL } from "./utils/readingConstants.js"; +import { getUserId } from "./utils/readingContext.js"; +import { clearStoredIntervals } from "./utils/readingOutboxStorage.js"; + +const STATUS_CLASSES = ["text-muted", "text-success", "text-danger"]; + +// The status element is an always-present (never hidden) live region, so +// setting its text is announced. +function showStatus(element, message, tone) { + element.textContent = message; + element.classList.remove(...STATUS_CLASSES); + element.classList.add(tone); +} + +/** Tells other open tabs (content pages) to drop their cached WPM and unsent data. */ +function broadcastReset() { + if (typeof BroadcastChannel !== "function") return; + const channel = new BroadcastChannel(READING_RESET_CHANNEL); + channel.postMessage({ type: "reset" }); + channel.close(); +} + +function init() { + const button = document.getElementById("reset-reading-speed-button"); + const status = document.getElementById("reset-reading-status"); + if (!button || !status) return; + + button.addEventListener("click", async () => { + if (!window.confirm(button.dataset.confirm)) return; + + button.disabled = true; + showStatus(status, "Resetting…", "text-muted"); + // resetProfile reports failures as a status and never throws. + const result = await resetProfile(); + button.disabled = false; + if (result.status === STATUS_OK) { + // Unsent data of tabs that are no longer open. Only this account's: others may share the browser. + const userId = getUserId(window); + if (userId) clearStoredIntervals(userId); + broadcastReset(); + showStatus(status, "Reading speed reset successfully.", "text-success"); + return; + } + const message = result.status === STATUS_AUTH_FAILURE + ? "Your session has expired. Please sign in again." + : "Reset failed. Please try again."; + showStatus(status, message, "text-danger"); + }); +} + +init(); diff --git a/EssentialCSharp.Web/src/reading-reset.test.js b/EssentialCSharp.Web/src/reading-reset.test.js new file mode 100644 index 00000000..344678c0 --- /dev/null +++ b/EssentialCSharp.Web/src/reading-reset.test.js @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { READING_RESET_CHANNEL, READING_RESET_URL } from "./utils/readingConstants.js"; +import { isOutboxStored, newOutboxKey, saveOutbox } from "./utils/readingOutboxStorage.js"; +import { installFakeBroadcastChannel } from "./test-utils/fakeBroadcastChannel.js"; + +describe("reading-reset (Manage Profile button)", () => { + let fetchMock; + let confirmMock; + let received; + let button; + let status; + + // The module wires itself up on import, so load a fresh copy against each test's DOM. + async function load() { + vi.resetModules(); + await import("./reading-reset.js"); + } + const click = () => button.dispatchEvent(new Event("click")); + const settled = () => vi.waitFor(() => expect(button.disabled).toBe(false)); + + beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = + '' + + ''; + button = document.getElementById("reset-reading-speed-button"); + status = document.getElementById("reset-reading-status"); + + confirmMock = vi.fn(() => true); + vi.stubGlobal("confirm", confirmMock); + fetchMock = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + installFakeBroadcastChannel(vi); + received = []; + const listener = new BroadcastChannel(READING_RESET_CHANNEL); + listener.onmessage = (event) => received.push(event.data); + }); + + afterEach(() => { + document.body.innerHTML = ""; + document.head.innerHTML = ""; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("sends no request when the confirmation is declined", async () => { + confirmMock.mockReturnValue(false); + await load(); + click(); + await new Promise((resolve) => setTimeout(resolve, 0)); // let any async handler work run + expect(confirmMock).toHaveBeenCalledWith("Really reset?"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(status.textContent).toBe(""); + expect(received).toHaveLength(0); + }); + + it("shows a success message and tells other tabs when the reset succeeds", async () => { + await load(); + click(); + await settled(); + expect(fetchMock.mock.calls[0][0]).toBe(READING_RESET_URL); + expect(status.textContent).toMatch(/success/i); + expect(status.classList.contains("text-success")).toBe(true); + expect(status.classList.contains("text-muted")).toBe(false); + expect(received).toEqual([{ type: "reset" }]); + }); + + it("clears only the signed-in account's unsent intervals", async () => { + const meta = document.createElement("meta"); + meta.name = "ecs-auth-user-id"; + meta.content = "user-1"; + document.head.appendChild(meta); + const key = (userId) => { + const k = newOutboxKey(); + saveOutbox(k, userId, [{ pageKey: "p", activeSeconds: 5, wordsRead: 5 }]); + return k; + }; + const mine = key("user-1"); + const theirs = key("user-2"); + + await load(); + click(); + await settled(); + + expect(isOutboxStored(mine)).toBe(false); + expect(isOutboxStored(theirs)).toBe(true); + }); + + it("shows an error and does not broadcast when the server fails", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 503 })); + await load(); + click(); + await settled(); + expect(status.textContent).toMatch(/failed/i); + expect(status.classList.contains("text-danger")).toBe(true); + expect(received).toHaveLength(0); + }); + + it("tells the user to sign in again when the session has expired", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 401 })); + await load(); + click(); + await settled(); + expect(status.textContent).toMatch(/sign in/i); + expect(received).toHaveLength(0); + }); + + it("shows an error when the request throws", async () => { + fetchMock.mockRejectedValue(new TypeError("offline")); + await load(); + click(); + await settled(); + expect(status.classList.contains("text-danger")).toBe(true); + expect(received).toHaveLength(0); + }); + + it("disables the button while the request is pending", async () => { + let finish; + fetchMock.mockReturnValue(new Promise((resolve) => { finish = () => resolve(new Response(null, { status: 200 })); })); + await load(); + click(); + await vi.waitFor(() => expect(button.disabled).toBe(true)); + finish(); + await settled(); + }); +}); diff --git a/EssentialCSharp.Web/src/services/readingApi.js b/EssentialCSharp.Web/src/services/readingApi.js new file mode 100644 index 00000000..53f292b0 --- /dev/null +++ b/EssentialCSharp.Web/src/services/readingApi.js @@ -0,0 +1,108 @@ +import { READING_RESET_URL, READING_SESSION_URL, SEND_TIMEOUT_MS } from "../utils/readingConstants.js"; + +/** + * - ok: request succeeded + * - auth-failure: session expired/forbidden, or antiforgery rejected, or no CSRF token: stop trying + * - bad-data: the server rejected this payload as invalid: drop it, never retry + * - transient: 408/429/5xx/network error: keep the data and retry later + * @typedef {"ok" | "auth-failure" | "bad-data" | "transient"} ApiStatus + */ + +/** + * @typedef {Object} ReadingInterval + * @property {string} pageKey + * @property {number} activeSeconds + * @property {number} wordsRead + */ + +/** + * @template T + * @typedef {{ status: ApiStatus, data?: T }} ApiResult + */ + +export const STATUS_OK = "ok"; +export const STATUS_AUTH_FAILURE = "auth-failure"; +export const STATUS_BAD_DATA = "bad-data"; +export const STATUS_TRANSIENT = "transient"; + +/** Header (value "1") the server sets on a 400 caused by a rejected antiforgery token. */ +export const ANTIFORGERY_FAILURE_HEADER = "X-Antiforgery-Failure"; + +/** Antiforgery token; the layout emits the meta tag for signed-in users only. */ +export function getCsrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.content ?? ""; +} + +/** + * Classifies a response. A 400 is an antiforgery rejection only when the server + * says so explicitly with the ANTIFORGERY_FAILURE_HEADER response header (set by + * the server's ReadingAntiforgeryFailureHeaderAttribute). Any other 400, such as a + * validation failure or one produced by a proxy, is bad data. + * @param {Response} response + * @returns {ApiStatus} + */ +export function statusFor(response) { + if (response.ok) return STATUS_OK; + const { status } = response; + if (status === 401 || status === 403) return STATUS_AUTH_FAILURE; + if (status === 400 && response.headers.get(ANTIFORGERY_FAILURE_HEADER) === "1") return STATUS_AUTH_FAILURE; + if (status === 408 || status === 429 || status >= 500) return STATUS_TRANSIENT; + return STATUS_BAD_DATA; +} + +/** + * Sends a CSRF-protected POST and classifies the outcome. Never sends a request + * without a CSRF token (it cannot succeed): that is reported as an auth failure. + * @param {string} url + * @param {{ headers?: Record, body?: string, keepalive?: boolean, readJson?: boolean, timeoutMs?: number }} [options] + * @returns {Promise>} + */ +async function postWithCsrf(url, { headers = {}, body, keepalive, readJson = false, timeoutMs } = {}) { + const token = getCsrfToken(); + if (!token) return { status: STATUS_AUTH_FAILURE }; + try { + const res = await fetch(url, { + method: "POST", + headers: { ...headers, RequestVerificationToken: token }, + body, + keepalive, + signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined // a timeout is transient + }); + const status = statusFor(res); + if (status !== STATUS_OK || !readJson) return { status }; + let data; + try { + data = await res.json(); + } catch { + data = undefined; + } + return { status, data }; + } catch { + return { status: STATUS_TRANSIENT }; + } +} + +/** + * Posts intervals. + * Retrying a request whose response was lost can double count the interval on + * the server (accepted trade-off; there is no idempotency key). + * @param {ReadingInterval[]} intervals + * @param {{ keepalive?: boolean }} [options] + * @returns {Promise>} + */ +export function postSession(intervals, { keepalive = false } = {}) { + return postWithCsrf(READING_SESSION_URL, { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ intervals }), + keepalive, + readJson: true, + // A hung periodic request must not outlive the lease on its persisted record. + // A lifecycle request is fire-and-forget and keeps running after the page is gone. + timeoutMs: keepalive ? undefined : SEND_TIMEOUT_MS + }); +} + +/** @returns {Promise>} */ +export function resetProfile() { + return postWithCsrf(READING_RESET_URL); +} diff --git a/EssentialCSharp.Web/src/services/readingApi.test.js b/EssentialCSharp.Web/src/services/readingApi.test.js new file mode 100644 index 00000000..115c29a8 --- /dev/null +++ b/EssentialCSharp.Web/src/services/readingApi.test.js @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + STATUS_AUTH_FAILURE, STATUS_BAD_DATA, STATUS_OK, STATUS_TRANSIENT, + getCsrfToken, postSession, resetProfile, statusFor +} from "./readingApi.js"; +import { READING_RESET_URL } from "../utils/readingConstants.js"; + +const response = (status, body, headers = {}) => new Response(body === undefined ? null : body, { status, headers }); + +describe("statusFor", () => { + it("maps 2xx to ok", () => { + expect(statusFor(response(200))).toBe(STATUS_OK); + expect(statusFor(response(204))).toBe(STATUS_OK); + }); + + it.each([401, 403])("treats %s as an auth failure", (status) => { + expect(statusFor(response(status))).toBe(STATUS_AUTH_FAILURE); + }); + + it("treats a 400 flagged with the antiforgery failure header as an auth failure (stop)", () => { + expect(statusFor(response(400, undefined, { "X-Antiforgery-Failure": "1" }))).toBe(STATUS_AUTH_FAILURE); + }); + + it("treats any other 400 as bad data (drop), whatever its body", () => { + expect(statusFor(response(400, JSON.stringify({ errors: { x: ["bad"] } }), { "content-type": "application/problem+json" }))).toBe(STATUS_BAD_DATA); + expect(statusFor(response(400))).toBe(STATUS_BAD_DATA); + expect(statusFor(response(400, "bad", { "content-type": "text/html" }))).toBe(STATUS_BAD_DATA); + }); + + it.each([408, 429, 500, 502, 503])("treats %s as transient (retry)", (status) => { + expect(statusFor(response(status))).toBe(STATUS_TRANSIENT); + }); + + it("drops other client errors", () => { + expect(statusFor(response(404))).toBe(STATUS_BAD_DATA); + expect(statusFor(response(413))).toBe(STATUS_BAD_DATA); + }); +}); + +describe("postSession / resetProfile", () => { + let fetchMock; + + beforeEach(() => { + document.head.innerHTML = ''; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + document.head.innerHTML = ""; + }); + + it("reads the CSRF token from the meta tag", () => { + expect(getCsrfToken()).toBe("tok123"); + }); + + it("posts { intervals } as JSON with the antiforgery header and no completed field", async () => { + fetchMock.mockResolvedValue(new Response('{"wpm":250}', { status: 200 })); + const intervals = [{ pageKey: "p", activeSeconds: 30, wordsRead: 100 }]; + + const result = await postSession(intervals, { keepalive: true }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/reading/session"); + expect(init.method).toBe("POST"); + expect(init.keepalive).toBe(true); + expect(init.headers.RequestVerificationToken).toBe("tok123"); + expect(JSON.parse(init.body)).toEqual({ intervals }); + expect(init.body).not.toContain("completed"); + expect(result).toEqual({ status: STATUS_OK, data: { wpm: 250 } }); + }); + + it("still reports ok when the response body is not JSON", async () => { + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + expect((await postSession([])).status).toBe(STATUS_OK); + }); + + it("never sends a request without a CSRF token and reports an auth failure", async () => { + document.head.innerHTML = ""; + expect((await postSession([{ pageKey: "p", activeSeconds: 1, wordsRead: 1 }])).status).toBe(STATUS_AUTH_FAILURE); + expect((await resetProfile()).status).toBe(STATUS_AUTH_FAILURE); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports a network error as transient", async () => { + fetchMock.mockRejectedValue(new TypeError("offline")); + expect((await postSession([])).status).toBe(STATUS_TRANSIENT); + expect((await resetProfile()).status).toBe(STATUS_TRANSIENT); + }); + + it("aborts a periodic send that hangs, but never a lifecycle (keepalive) send", async () => { + fetchMock.mockResolvedValue(new Response("{}", { status: 200 })); + await postSession([], { keepalive: false }); + await postSession([], { keepalive: true }); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + expect(fetchMock.mock.calls[1][1].signal).toBeUndefined(); + }); + + it("resets with a POST carrying the antiforgery header and no body", async () => { + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + + const result = await resetProfile(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(READING_RESET_URL); + expect(init.method).toBe("POST"); + expect(init.headers.RequestVerificationToken).toBe("tok123"); + expect(init.body).toBeUndefined(); + expect(result).toEqual({ status: STATUS_OK }); + }); +}); diff --git a/EssentialCSharp.Web/src/test-utils/fakeBroadcastChannel.js b/EssentialCSharp.Web/src/test-utils/fakeBroadcastChannel.js new file mode 100644 index 00000000..04516f99 --- /dev/null +++ b/EssentialCSharp.Web/src/test-utils/fakeBroadcastChannel.js @@ -0,0 +1,22 @@ +/** + * Deterministic in-memory BroadcastChannel: postMessage delivers synchronously to + * every other open instance with the same name (like the real one, never to the + * sender). Avoids real timers/event-loop waits in tests. + */ +export function installFakeBroadcastChannel(vi) { + const open = new Set(); + class FakeBroadcastChannel { + constructor(name) { + this.name = name; + this.onmessage = null; + open.add(this); + } + postMessage(data) { + for (const other of open) { + if (other !== this && other.name === this.name) other.onmessage?.({ data }); + } + } + close() { open.delete(this); } + } + vi.stubGlobal("BroadcastChannel", FakeBroadcastChannel); +} diff --git a/EssentialCSharp.Web/src/utils/readingConstants.js b/EssentialCSharp.Web/src/utils/readingConstants.js new file mode 100644 index 00000000..92538208 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingConstants.js @@ -0,0 +1,36 @@ +// Tuning values (default WPM, credit cap, idle threshold, flush period) have ONE +// source: the server's ReadingOptions, delivered through window.READING_CONTEXT. +// Only client-mechanics constants live here. + +/** Tick period of the active-time timer (ms). */ +export const TICK_INTERVAL_MS = 1000; + +/** Longest elapsed time one tick may add (s); guards against throttled timers and sleep/resume. */ +export const MAX_TICK_DELTA_S = 2; + +/** Minimum gap between mousemove events treated as input (ms). */ +export const MOUSEMOVE_THROTTLE_MS = 1000; + +/** Retry backoff after a transient failure: base * 2^(failures-1), capped (ms). */ +export const RETRY_BASE_DELAY_MS = 5000; +export const RETRY_MAX_DELAY_MS = 120000; + +/** + * Unsent intervals kept while the server is unreachable; the oldest are dropped beyond this. + * Must stay within the server's MaxIntervalsPerRequest (enforced by its options validator). + */ +export const MAX_QUEUED_INTERVALS = 20; + +/** + * How long a persisted batch being sent is reserved for its tab before another tab may take + * it over. A periodic request is aborted after half of it, so a hung request never outlives + * its lease. + */ +export const LEASE_MS = 2 * 60 * 1000; +export const SEND_TIMEOUT_MS = LEASE_MS / 2; + +/** Cross-tab notification that the reading profile was reset. */ +export const READING_RESET_CHANNEL = "essentialcsharp-reading-reset"; + +export const READING_SESSION_URL = "/api/reading/session"; +export const READING_RESET_URL = "/api/reading/reset"; diff --git a/EssentialCSharp.Web/src/utils/readingContext.js b/EssentialCSharp.Web/src/utils/readingContext.js new file mode 100644 index 00000000..0b68a1f6 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingContext.js @@ -0,0 +1,58 @@ +/** + * @typedef {Object} ReadingContext + * @property {boolean} isAuthenticated + * @property {string | null} userId signed-in user id (from the layout's meta tag), scopes persisted unsent intervals to one account + * @property {string} pageKey + * @property {number} pageWords + * @property {number} chapterWords + * @property {number} wordsBeforePage + * @property {number} chapterStartWords + * @property {number} bookWords + * @property {number | null} wpm signed-in user's server-derived WPM, null when none + * @property {number} defaultWpm + * @property {number} maxCreditWpm + * @property {number} idleThresholdSeconds + * @property {number} periodicFlushActiveSeconds + */ + +function toCount(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +const isPositiveNumber = (value) => typeof value === "number" && Number.isFinite(value) && value > 0; + +/** The layout exposes the signed-in user id in a meta tag (not a window global); anonymous has none. */ +export function getUserId(win) { + const id = win.document?.querySelector('meta[name="ecs-auth-user-id"]')?.content; + return id ? id : null; +} + +/** + * Reads window.READING_CONTEXT (emitted by the layout on content pages only). + * The tuning values come from the server (ReadingOptions) and have no client + * fallback: if any is missing or invalid the feature is disabled. + * @param {Window} [win] + * @returns {ReadingContext | null} null when this is not a content page or the context is incomplete. + */ +export function getReadingContext(win = window) { + const raw = win.READING_CONTEXT; + if (!raw || typeof raw !== "object" || !raw.pageKey) return null; + const required = [raw.defaultWpm, raw.maxCreditWpm, raw.idleThresholdSeconds, raw.periodicFlushActiveSeconds]; + if (!required.every(isPositiveNumber)) return null; + return { + isAuthenticated: raw.isAuthenticated === true, + userId: getUserId(win), + pageKey: String(raw.pageKey), + pageWords: toCount(raw.pageWords), + chapterWords: toCount(raw.chapterWords), + wordsBeforePage: toCount(raw.wordsBeforePage), + chapterStartWords: toCount(raw.chapterStartWords), + bookWords: toCount(raw.bookWords), + wpm: isPositiveNumber(raw.wpm) ? raw.wpm : null, + defaultWpm: raw.defaultWpm, + maxCreditWpm: raw.maxCreditWpm, + idleThresholdSeconds: raw.idleThresholdSeconds, + periodicFlushActiveSeconds: raw.periodicFlushActiveSeconds + }; +} diff --git a/EssentialCSharp.Web/src/utils/readingContext.test.js b/EssentialCSharp.Web/src/utils/readingContext.test.js new file mode 100644 index 00000000..a74da529 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingContext.test.js @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { getReadingContext } from "./readingContext.js"; + +// NOTE: this fixture is hand-written, so it cannot detect drift in what the server emits. +// The server side of the contract is owned by a C# contract test. Here we only make sure +// the parser and this fixture cannot silently drift apart (see the key-set test below). +const complete = () => ({ + isAuthenticated: true, + pageKey: "chapter-1/page-2", + pageWords: 1200, + chapterWords: 5000, + wordsBeforePage: 300, + chapterStartWords: 100, + bookWords: 90000, + wpm: 240, + defaultWpm: 200, + maxCreditWpm: 800, + idleThresholdSeconds: 300, + periodicFlushActiveSeconds: 60 +}); + +const raw = (overrides = {}) => ({ READING_CONTEXT: { ...complete(), ...overrides } }); + +// The user id is not part of READING_CONTEXT: the layout exposes it in a meta tag. +const withUserIdMeta = (content) => { + const doc = document.implementation.createHTMLDocument(""); + if (content !== undefined) { + const meta = doc.createElement("meta"); + meta.name = "ecs-auth-user-id"; + meta.content = content; + doc.head.append(meta); + } + return { ...raw(), document: doc }; +}; + +describe("getReadingContext", () => { + it("returns null when there is no context (not a content page)", () => { + expect(getReadingContext({})).toBeNull(); + }); + + it("reads the server-provided values", () => { + expect(getReadingContext(raw())).toEqual({ ...complete(), userId: null }); + }); + + it("reads the signed-in user id from the layout's meta tag", () => { + expect(getReadingContext(withUserIdMeta("user-1")).userId).toBe("user-1"); + }); + + it("has no user id for anonymous visitors (no meta tag) or an empty one", () => { + expect(getReadingContext(withUserIdMeta()).userId).toBeNull(); + expect(getReadingContext(withUserIdMeta("")).userId).toBeNull(); + }); + + it("ignores a userId in READING_CONTEXT: the meta tag is the only source", () => { + expect(getReadingContext(raw({ userId: "from-window" })).userId).toBeNull(); + }); + + it("parses exactly the fields of the fixture (plus the meta-tag user id): a field added to one side but not the other fails here", () => { + expect(Object.keys(getReadingContext(raw())).sort()).toEqual([...Object.keys(complete()), "userId"].sort()); + }); + + it("uses null wpm when the server has none", () => { + expect(getReadingContext(raw({ wpm: null })).wpm).toBeNull(); + }); + + // Derived from the fixture, so every field is covered automatically: a missing field + // must either disable the feature (null) or degrade without throwing. + it.each(Object.keys(complete()))("handles %s being missing without throwing", (field) => { + const context = complete(); + delete context[field]; + expect(() => getReadingContext({ READING_CONTEXT: context })).not.toThrow(); + }); + + it("has no client fallbacks: each server tuning value is required", () => { + for (const field of ["defaultWpm", "maxCreditWpm", "idleThresholdSeconds", "periodicFlushActiveSeconds"]) { + const context = complete(); + delete context[field]; + expect(getReadingContext({ READING_CONTEXT: context }), field).toBeNull(); + } + }); + + it("is disabled when a tuning value is not a positive number", () => { + expect(getReadingContext(raw({ maxCreditWpm: 0 }))).toBeNull(); + expect(getReadingContext(raw({ idleThresholdSeconds: "300" }))).toBeNull(); + }); + + it("coerces bad word counts to 0", () => { + const ctx = getReadingContext(raw({ pageWords: "x", bookWords: -5 })); + expect(ctx.pageWords).toBe(0); + expect(ctx.bookWords).toBe(0); + }); +}); diff --git a/EssentialCSharp.Web/src/utils/readingFormat.js b/EssentialCSharp.Web/src/utils/readingFormat.js new file mode 100644 index 00000000..50dafeec --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingFormat.js @@ -0,0 +1,49 @@ +const MINUTES_PER_HOUR = 60; + +function isValidMinutes(minutes) { + return typeof minutes === "number" && Number.isFinite(minutes) && minutes >= 0; +} + +/** + * Compact label, e.g. "less than a minute", "12 min", "2h 5m". Null for invalid input. + * @param {number | null | undefined} minutes + * @returns {string | null} + */ +export function formatMinutes(minutes) { + if (!isValidMinutes(minutes)) return null; + const rounded = Math.round(minutes); + if (rounded < 1) return "less than a minute"; + if (rounded < MINUTES_PER_HOUR) return `${rounded} min`; + const hours = Math.floor(rounded / MINUTES_PER_HOUR); + const mins = rounded % MINUTES_PER_HOUR; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +const plural = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`; + +/** + * Spoken wording for screen readers, e.g. "about 12 minutes". + * @param {number | null | undefined} minutes + * @returns {string | null} + */ +export function formatMinutesSpoken(minutes) { + if (!isValidMinutes(minutes)) return null; + const rounded = Math.round(minutes); + if (rounded < 1) return "less than a minute"; + if (rounded < MINUTES_PER_HOUR) return `about ${plural(rounded, "minute")}`; + const hours = Math.floor(rounded / MINUTES_PER_HOUR); + const mins = rounded % MINUTES_PER_HOUR; + return mins > 0 + ? `about ${plural(hours, "hour")} ${plural(mins, "minute")}` + : `about ${plural(hours, "hour")}`; +} + +/** + * @param {number} wordsLeft + * @param {number} wpm + * @returns {number | null} minutes, or null when it cannot be computed + */ +export function minutesForWords(wordsLeft, wpm) { + if (!Number.isFinite(wordsLeft) || !Number.isFinite(wpm) || wpm <= 0) return null; + return Math.max(0, wordsLeft) / wpm; +} diff --git a/EssentialCSharp.Web/src/utils/readingFormat.test.js b/EssentialCSharp.Web/src/utils/readingFormat.test.js new file mode 100644 index 00000000..fac1a1b1 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingFormat.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { formatMinutes, formatMinutesSpoken, minutesForWords } from "./readingFormat.js"; + +describe("formatMinutes", () => { + it.each([ + [0, "less than a minute"], + [0.4, "less than a minute"], + [1, "1 min"], + [12.4, "12 min"], + [59.4, "59 min"], + [60, "1h"], + [125, "2h 5m"] + ])("formats %s minutes as %s", (minutes, expected) => { + expect(formatMinutes(minutes)).toBe(expected); + }); + + it.each([null, undefined, NaN, -1, Infinity, "5"])("returns null for invalid input %s", (v) => { + expect(formatMinutes(v)).toBeNull(); + }); +}); + +describe("formatMinutesSpoken", () => { + it.each([ + [0.2, "less than a minute"], + [1, "about 1 minute"], + [12, "about 12 minutes"], + [60, "about 1 hour"], + [125, "about 2 hours 5 minutes"], + [61, "about 1 hour 1 minute"] + ])("speaks %s minutes as %s", (minutes, expected) => { + expect(formatMinutesSpoken(minutes)).toBe(expected); + }); + + it("returns null for invalid input", () => { + expect(formatMinutesSpoken(NaN)).toBeNull(); + }); +}); + +describe("minutesForWords", () => { + it("divides words by words-per-minute", () => { + expect(minutesForWords(1000, 200)).toBe(5); + }); + + it("never goes negative", () => { + expect(minutesForWords(-50, 200)).toBe(0); + }); + + it("is null when wpm is unusable", () => { + expect(minutesForWords(1000, 0)).toBeNull(); + expect(minutesForWords(1000, NaN)).toBeNull(); + }); +}); diff --git a/EssentialCSharp.Web/src/utils/readingOutboxStorage.js b/EssentialCSharp.Web/src/utils/readingOutboxStorage.js new file mode 100644 index 00000000..3e41db21 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingOutboxStorage.js @@ -0,0 +1,162 @@ +/** + * Persistence for intervals that could not be delivered, so a failed send is not lost + * when the next full page load replaces the in-memory queue (the site is a multi-page app). + * + * Every function tolerates unavailable, full or corrupt storage (it behaves as empty). + * Safety rules: + * - A record belongs to one user (the id in the layout's ecs-auth-user-id meta tag); records of any other user + * are discarded, so data from another account is never sent. + * - Each tab writes its own record (its own key), so tabs never overwrite each other. + * - A record is taken at page load by removal, or leased by its own tab while that tab + * sends it (periodic sends). A leased record stays stored until the send is acknowledged, + * so a request lost with its page is recovered once the lease expires (a send whose + * response was lost can then be counted twice, there is no idempotency key). Other tabs + * skip a record whose lease is still active, so two tabs do not send the same entries. + * Lifecycle (keepalive) sends are not leased by their caller: they remove the record + * before sending, because they can never be acknowledged (at-most-once). + */ +import { LEASE_MS, MAX_QUEUED_INTERVALS } from "./readingConstants.js"; + +const KEY_PREFIX = "essentialcsharp-reading-outbox:"; + +// The server DTO uses integers: a fractional value would be rejected (400) and drop the whole batch. +const isPositiveInteger = (value) => Number.isInteger(value) && value > 0; + +/** @returns {import("../services/readingApi.js").ReadingInterval | null} */ +function toInterval(raw) { + if (!raw || typeof raw !== "object") return null; + const { pageKey, activeSeconds, wordsRead } = raw; + if (typeof pageKey !== "string" || !pageKey) return null; + if (!isPositiveInteger(activeSeconds) || !isPositiveInteger(wordsRead)) return null; + return { pageKey, activeSeconds, wordsRead }; +} + +const isLeased = (record) => typeof record?.leaseUntil === "number" && record.leaseUntil > Date.now(); + +function storedKeys() { + const keys = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(KEY_PREFIX)) keys.push(key); + } + return keys; +} + +/** @returns {string} a key for a new record owned by the calling tab */ +export function newOutboxKey() { + const id = typeof crypto?.randomUUID === "function" + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + Date.now().toString(36); + return KEY_PREFIX + id; +} + +/** + * Replaces the record under `key` (removes it when there is nothing to keep). + * @returns {boolean} false when storage failed, so the record may not hold `intervals` + */ +export function saveOutbox(key, userId, intervals) { + try { + if (intervals.length === 0) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify({ userId, intervals })); + return true; + } catch { + // Storage unavailable or full: persistence is best effort. + return false; + } +} + +/** @returns {boolean} false only when the record is confirmed gone (taken by another tab) */ +export function isOutboxStored(key) { + try { + return localStorage.getItem(key) !== null; + } catch { + return true; + } +} + +/** Outcomes of claimOutbox. */ +export const CLAIM_CLAIMED = "claimed"; +export const CLAIM_GONE = "gone"; +export const CLAIM_ERROR = "error"; + +/** + * Reserves the record under `key` for sending: it stays stored (leased) until the caller + * acknowledges it with removeOutbox or re-saves it with saveOutbox. + * @returns {"claimed" | "gone" | "error"} gone: confirmed absent, or leased by another sender; + * error: storage failed, so nothing is known about the record; the record is removed on a + * best-effort basis (this also frees space), so the caller sends at most once + */ +export function claimOutbox(key) { + try { + const record = JSON.parse(localStorage.getItem(key) ?? "null"); + if (!record || isLeased(record)) return CLAIM_GONE; + localStorage.setItem(key, JSON.stringify({ ...record, leaseUntil: Date.now() + LEASE_MS })); + return CLAIM_CLAIMED; + } catch { + removeOutbox(key); + return CLAIM_ERROR; + } +} + +/** Removes the record under `key` (the send was acknowledged). */ +export function removeOutbox(key) { + try { + localStorage.removeItem(key); + } catch { + // Storage unavailable: nothing to remove. + } +} + +/** + * Takes every stored record of `userId` (removing them) and returns their valid + * intervals, oldest first and limited to the newest MAX_QUEUED_INTERVALS. + * Records of other users and unreadable records are removed and ignored. + * @returns {import("../services/readingApi.js").ReadingInterval[]} + */ +export function takeStoredIntervals(userId) { + const intervals = []; + try { + for (const key of storedKeys()) { + let record = null; + try { + record = JSON.parse(localStorage.getItem(key) ?? "null"); + } catch { + // Corrupt: removed below. + } + // Being sent by a live tab: leave it until acknowledged or the lease expires. + if (isLeased(record)) continue; + localStorage.removeItem(key); + if (record?.userId !== userId || !Array.isArray(record.intervals)) continue; + for (const raw of record.intervals) { + const interval = toInterval(raw); + if (interval) intervals.push(interval); + } + } + } catch { + // Storage unavailable: nothing to recover. + } + return intervals.slice(-MAX_QUEUED_INTERVALS); +} + +/** + * Removes stored records: every record, or only those of `userId` (and unreadable ones) + * when given, so one account's cleanup never deletes another account's unsent data. + */ +export function clearStoredIntervals(userId) { + try { + for (const key of storedKeys()) { + if (userId !== undefined) { + let record = null; + try { + record = JSON.parse(localStorage.getItem(key) ?? "null"); + } catch { + // Unreadable: removed below. + } + if (record && record.userId !== userId) continue; + } + localStorage.removeItem(key); + } + } catch { + // Storage unavailable: nothing to clear. + } +} diff --git a/EssentialCSharp.Web/src/utils/readingProgress.js b/EssentialCSharp.Web/src/utils/readingProgress.js new file mode 100644 index 00000000..2f304b51 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingProgress.js @@ -0,0 +1,84 @@ +/** + * Scroll progress measured against the article/content element rather than the + * whole document, so header/footer/chrome do not skew it. + * + * Single definition of "progress": the fraction of the content at or above the + * viewport BOTTOM edge (content the reader has had on screen). Both the header + * estimate (words left) and the credit model use it, so what is displayed and + * what is credited cannot disagree. The credit baseline is the same quantity + * measured at the viewport TOP when the page loaded (content already scrolled + * past is never credited, the first screen is). + * + * Limitation: the content element includes non-prose blocks (code, images), so + * the fraction is by height, not by words. + */ + +const clamp01 = (n) => Math.min(1, Math.max(0, Number.isFinite(n) ? n : 0)); + +/** + * @typedef {Object} ContentMeasure + * @property {number} top Distance from viewport top to content top (px, may be negative). + * @property {number} height Content height (px). + * @property {number} viewportHeight + */ + +/** + * Progress: fraction of the content that has entered the viewport. + * @param {ContentMeasure} m + */ +export function fractionAboveViewportBottom({ top, height, viewportHeight }) { + if (!(height > 0)) return 0; + return clamp01((viewportHeight - top) / height); +} + +/** + * Fraction of the content that has scrolled above the viewport top; the credit + * baseline at load time. + * @param {ContentMeasure} m + */ +export function fractionAboveViewportTop({ top, height }) { + if (!(height > 0)) return 0; + return clamp01(-top / height); +} + +/** + * Finds the element whose extent represents the readable content. + * Prefers an explicit [data-reading-content] marker, then the layout container. + * @param {Document} [doc] + * @returns {Element | null} + */ +export function findContentElement(doc = document) { + return doc.querySelector("[data-reading-content]") + ?? doc.querySelector("#site-layout > .container") + ?? doc.getElementById("site-layout"); +} + +/** + * @param {Element | null} element + * @returns {ContentMeasure} + */ +export function measureContent(element) { + const viewportHeight = window.innerHeight; + if (!element) return { top: 0, height: 0, viewportHeight }; + const { top, height } = element.getBoundingClientRect(); + return { top, height, viewportHeight }; +} + +/** + * Credit for one interval: progress-based, capped by what is plausible for the + * active time spent. When time is the binding limit only the matching share of + * progress is consumed, so the remainder stays creditable later. + * @param {{ pageWords: number, progress: number, activeSeconds: number, maxWpm: number }} input + * progress: fraction of the page progressed since the last credited point (0..1) + * @returns {{ wordsRead: number, progressConsumed: number }} + */ +export function creditInterval({ pageWords, progress, activeSeconds, maxWpm }) { + const available = Math.max(0, progress); + const byProgress = Math.round(pageWords * available); + const byTime = Math.floor(Math.max(0, activeSeconds) * maxWpm / 60); + const wordsRead = Math.max(0, Math.min(byProgress, byTime)); + const progressConsumed = byProgress <= byTime || !(pageWords > 0) + ? available + : wordsRead / pageWords; + return { wordsRead, progressConsumed }; +} diff --git a/EssentialCSharp.Web/src/utils/readingProgress.test.js b/EssentialCSharp.Web/src/utils/readingProgress.test.js new file mode 100644 index 00000000..8c8eb7f7 --- /dev/null +++ b/EssentialCSharp.Web/src/utils/readingProgress.test.js @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + creditInterval, + fractionAboveViewportBottom, + fractionAboveViewportTop +} from "./readingProgress.js"; + +describe("progress fraction (viewport bottom edge)", () => { + it("counts the first screen at the top of the page", () => { + // 1000px content starting at the viewport top, 500px viewport: half has been on screen. + expect(fractionAboveViewportBottom({ top: 0, height: 1000, viewportHeight: 500 })).toBe(0.5); + }); + + it("reaches 1 once the content bottom reaches the viewport bottom", () => { + expect(fractionAboveViewportBottom({ top: -500, height: 1000, viewportHeight: 500 })).toBe(1); + }); + + it("is 1 for content shorter than the viewport and 0 for empty content", () => { + expect(fractionAboveViewportBottom({ top: 0, height: 100, viewportHeight: 500 })).toBe(1); + expect(fractionAboveViewportBottom({ top: 0, height: 0, viewportHeight: 500 })).toBe(0); + }); + + it("is 0 while the content is still below the viewport", () => { + expect(fractionAboveViewportBottom({ top: 800, height: 1000, viewportHeight: 500 })).toBe(0); + }); +}); + +describe("credit baseline (viewport top edge)", () => { + it("is the share of content already scrolled past", () => { + expect(fractionAboveViewportTop({ top: -250, height: 1000, viewportHeight: 500 })).toBe(0.25); + }); + + it("is 0 when the content top is on screen", () => { + expect(fractionAboveViewportTop({ top: 40, height: 1000, viewportHeight: 500 })).toBe(0); + }); +}); + +describe("creditInterval", () => { + it("credits words for the progress made when time allows", () => { + // 2000-word page, a quarter read in 60s with an 800 wpm cap (800 words allowed). + const r = creditInterval({ pageWords: 2000, progress: 0.25, activeSeconds: 60, maxWpm: 800 }); + expect(r.wordsRead).toBe(500); + expect(r.progressConsumed).toBe(0.25); + }); + + it("limits credit to what the time could plausibly cover and keeps the remainder", () => { + // Half a 2000-word page in 6s at an 800 wpm cap => only 80 words; 4% of the page consumed. + const r = creditInterval({ pageWords: 2000, progress: 0.5, activeSeconds: 6, maxWpm: 800 }); + expect(r.wordsRead).toBe(80); + expect(r.progressConsumed).toBeCloseTo(0.04, 10); + }); + + it("credits nothing without progress or without time", () => { + expect(creditInterval({ pageWords: 2000, progress: 0, activeSeconds: 60, maxWpm: 800 }).wordsRead).toBe(0); + expect(creditInterval({ pageWords: 2000, progress: 0.5, activeSeconds: 0, maxWpm: 800 }).wordsRead).toBe(0); + }); + + it("treats negative progress as none", () => { + expect(creditInterval({ pageWords: 2000, progress: -0.3, activeSeconds: 60, maxWpm: 800 }).wordsRead).toBe(0); + }); +}); diff --git a/EssentialCSharp.Web/vite.config.js b/EssentialCSharp.Web/vite.config.js index d7ce0bbb..e884225a 100644 --- a/EssentialCSharp.Web/vite.config.js +++ b/EssentialCSharp.Web/vite.config.js @@ -14,6 +14,10 @@ export default defineConfig({ "@": resolve(rootDir, "src") } }, + test: { + environment: "happy-dom", + include: ["src/**/*.test.js"] + }, build: { outDir: resolve(rootDir, "wwwroot", "dist"), emptyOutDir: true, @@ -21,6 +25,7 @@ export default defineConfig({ rollupOptions: { input: { "site-shell": resolve(rootDir, "src", "main.js"), + "reading-reset": resolve(rootDir, "src", "reading-reset.js"), "password-strength": resolve(rootDir, "wwwroot", "js", "password-strength.js") }, output: {