diff --git a/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs b/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs index 9372cac..3e6c1ec 100644 --- a/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs +++ b/src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs @@ -170,6 +170,36 @@ public async Task ExtractPostDetails_WithValidUrl_ReturnsOkWithPost() post.Title.Should().NotBeNullOrEmpty(); } + [Fact] + public async Task ExtractPostDetails_CalledTwiceForSameUrl_StoresSinglePostAndReturnsExisting() + { + // Arrange + var extractRequest = new + { + url = "https://example.com/blog/duplicate-check-" + Guid.NewGuid(), + tags = "test", + category = "Test" + }; + + // Act + var firstResponse = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest); + var secondResponse = await _client.PostAsJsonAsync("/api/posts/extractPostDetails", extractRequest); + + // Assert + firstResponse.StatusCode.Should().Be(HttpStatusCode.OK); + secondResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var firstPost = await firstResponse.Content.ReadFromJsonAsync(); + var secondPost = await secondResponse.Content.ReadFromJsonAsync(); + firstPost.Should().NotBeNull(); + secondPost.Should().NotBeNull(); + secondPost!.RowKey.Should().Be(firstPost!.RowKey); + + var unreadPosts = await _client.GetFromJsonAsync>("/api/posts/"); + unreadPosts.Should().NotBeNull(); + unreadPosts!.Count(p => p.Url == extractRequest.url).Should().Be(1); + } + [Fact] public async Task ExtractPostDetails_WithInvalidUrl_ReturnsBadRequest() { diff --git a/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs b/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs index 3642524..1802d66 100644 --- a/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs +++ b/src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs @@ -53,6 +53,34 @@ public void GetPost_WhenPostDoesNotExist_ReturnsNull() result.Should().BeNull(); } + [Fact] + public void GetPostByUrl_WhenPostExists_ReturnsPost() + { + // Arrange + var testPost = CreateTestPost(); + testPost.Url = "https://example.com/by-url-" + Guid.NewGuid(); + var table = _fixture.TableServiceClient!.GetTableClient("Posts"); + table.CreateIfNotExists(); + table.AddEntity(testPost); + + // Act + var result = _dataStorageService.GetPostByUrl(testPost.Url); + + // Assert + result.Should().NotBeNull(); + result!.RowKey.Should().Be(testPost.RowKey); + } + + [Fact] + public void GetPostByUrl_WhenNoPostHasUrl_ReturnsNull() + { + // Act + var result = _dataStorageService.GetPostByUrl("https://example.com/missing-" + Guid.NewGuid()); + + // Assert + result.Should().BeNull(); + } + [Fact] public void SavePost_WhenNewPost_AddsPostSuccessfully() { diff --git a/src/NoteBookmark.Api/DataStorageService.cs b/src/NoteBookmark.Api/DataStorageService.cs index 6bfc50f..778dbce 100644 --- a/src/NoteBookmark.Api/DataStorageService.cs +++ b/src/NoteBookmark.Api/DataStorageService.cs @@ -141,6 +141,12 @@ public List GetPostIds() return post; } + public Post? GetPostByUrl(string url) + { + var tblPost = GetPostTable(); + return tblPost.Query(p => p.Url == url).FirstOrDefault(); + } + public bool SavePost(Post post) { var tblPost = GetPostTable(); diff --git a/src/NoteBookmark.Api/IDataStorageService.cs b/src/NoteBookmark.Api/IDataStorageService.cs index fbc8704..72b08ab 100644 --- a/src/NoteBookmark.Api/IDataStorageService.cs +++ b/src/NoteBookmark.Api/IDataStorageService.cs @@ -12,6 +12,8 @@ public interface IDataStorageService public Post? GetPost(string rowKey); + public Post? GetPostByUrl(string url); + public bool SavePost(Post post); public List GetSummaries(); diff --git a/src/NoteBookmark.Api/PostEndpoints.cs b/src/NoteBookmark.Api/PostEndpoints.cs index ec85fd2..ef5ebec 100644 --- a/src/NoteBookmark.Api/PostEndpoints.cs +++ b/src/NoteBookmark.Api/PostEndpoints.cs @@ -113,6 +113,15 @@ static async Task, BadRequest>> ExtractPostDetails( try { var decodeUrl = System.Net.WebUtility.UrlDecode(request.url); + + // The same URL can be submitted more than once (e.g. a prerendered page followed + // by its interactive render), so return the existing post instead of adding a duplicate. + var existingPost = dataStorageService.GetPostByUrl(decodeUrl); + if (existingPost != null) + { + return TypedResults.Ok(existingPost); + } + var post = await ExtractPostDetailsFromUrl(decodeUrl); if (post != null) { diff --git a/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs index f51e4c3..52cf970 100644 --- a/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs +++ b/src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs @@ -15,6 +15,9 @@ namespace NoteBookmark.BlazorApp.Tests.Tests; public sealed class QuickAddTests : BunitContext { + private static readonly RendererInfo Prerendering = new("Static", isInteractive: false); + private static readonly RendererInfo Interactive = new("Server", isInteractive: true); + private readonly Mock _dataServiceMock; public QuickAddTests() @@ -28,6 +31,8 @@ public QuickAddTests() Services.AddSingleton(_dataServiceMock.Object); Services.AddSingleton(new Mock().Object); Services.AddSingleton(new Mock().Object); + + SetRendererInfo(Interactive); } [Fact] @@ -128,4 +133,52 @@ public void QuickAdd_WithoutUrlParameter_DisplaysManualInput() cut.Markup.Should().Contain("Enter or paste URL"); cut.Markup.Should().Contain("Close Window"); } + + [Fact] + public void QuickAdd_WhenPrerenderedThenRenderedInteractively_SavesPostOnlyOnce() + { + const string testUrl = "https://example.com/bookmarklet-post"; + var samplePost = new Post + { + PartitionKey = "p", + RowKey = "bookmarklet-row-key", + Title = "Bookmarklet Post", + Author = "Jane Doe", + Url = testUrl + }; + + _dataServiceMock.Setup(s => s.ExtractPostDetailsAndSave(testUrl)) + .ReturnsAsync(samplePost); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + // A page visit under InteractiveServer creates one instance for the prerender + // and a second, fresh instance once the circuit is interactive. + SetRendererInfo(Prerendering); + Render(); + + SetRendererInfo(Interactive); + var cut = Render(); + + cut.Markup.Should().Contain("Post saved successfully!"); + cut.Markup.Should().Contain("Bookmarklet Post"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(testUrl), Times.Once); + } + + [Fact] + public void QuickAdd_WhenPrerendering_ShowsProgressWithoutSaving() + { + const string testUrl = "https://example.com/prerender"; + SetRendererInfo(Prerendering); + + var nav = Services.GetRequiredService(); + nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}"); + + var cut = Render(); + + cut.Markup.Should().Contain("Extracting and saving post details..."); + cut.Markup.Should().NotContain("Enter or paste URL"); + _dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny()), Times.Never); + } } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor index 7d836ad..27e1b99 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor @@ -91,6 +91,15 @@ var targetUrl = !string.IsNullOrWhiteSpace(Url) ? Url : AddUrl; if (!string.IsNullOrWhiteSpace(targetUrl) && targetUrl != _processedUrl) { + // During prerendering this instance is discarded and a new one is created once the + // interactive circuit starts, so saving here would add the post twice. Show the + // spinner and let the interactive render do the save. + if (!RendererInfo.IsInteractive) + { + isLoading = true; + return; + } + _processedUrl = targetUrl; await ProcessUrlAsync(targetUrl); }