Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/NoteBookmark.Api.Tests/Endpoints/PostEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Post>();
var secondPost = await secondResponse.Content.ReadFromJsonAsync<Post>();
firstPost.Should().NotBeNull();
secondPost.Should().NotBeNull();
secondPost!.RowKey.Should().Be(firstPost!.RowKey);

var unreadPosts = await _client.GetFromJsonAsync<List<PostL>>("/api/posts/");
unreadPosts.Should().NotBeNull();
unreadPosts!.Count(p => p.Url == extractRequest.url).Should().Be(1);
}

[Fact]
public async Task ExtractPostDetails_WithInvalidUrl_ReturnsBadRequest()
{
Expand Down
28 changes: 28 additions & 0 deletions src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
public DataStorageServiceTests(AzureStorageTestFixture fixture)
{
_fixture = fixture;
_dataStorageService = new DataStorageService(_fixture.TableServiceClient, _fixture.BlobServiceClient);

Check warning on line 21 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Possible null reference argument for parameter 'tblClient' in 'DataStorageService.DataStorageService(TableServiceClient tblClient, BlobServiceClient blobClient)'.

Check warning on line 21 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Possible null reference argument for parameter 'blobClient' in 'DataStorageService.DataStorageService(TableServiceClient tblClient, BlobServiceClient blobClient)'.

Check warning on line 21 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Possible null reference argument for parameter 'tblClient' in 'DataStorageService.DataStorageService(TableServiceClient tblClient, BlobServiceClient blobClient)'.
}

[Fact]
Expand All @@ -26,7 +26,7 @@
{
// Arrange
var testPost = CreateTestPost();
var table = _fixture.TableServiceClient.GetTableClient("Posts");

Check warning on line 29 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Dereference of a possibly null reference.
table.CreateIfNotExists();
table.AddEntity(testPost);

Expand All @@ -53,6 +53,34 @@
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()
{
Expand Down Expand Up @@ -282,7 +310,7 @@
var summary1 = CreateTestSummary("summary1");
var summary2 = CreateTestSummary("summary2");

var table = _fixture.TableServiceClient.GetTableClient("Summary");

Check warning on line 313 in src/NoteBookmark.Api.Tests/Services/DataStorageServiceTests.cs

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Dereference of a possibly null reference.
table.CreateIfNotExists();
table.AddEntity(summary1);
table.AddEntity(summary2);
Expand Down
6 changes: 6 additions & 0 deletions src/NoteBookmark.Api/DataStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ public List<string> GetPostIds()
return post;
}

public Post? GetPostByUrl(string url)
{
var tblPost = GetPostTable();
return tblPost.Query<Post>(p => p.Url == url).FirstOrDefault();
}

public bool SavePost(Post post)
{
var tblPost = GetPostTable();
Expand Down
2 changes: 2 additions & 0 deletions src/NoteBookmark.Api/IDataStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface IDataStorageService

public Post? GetPost(string rowKey);

public Post? GetPostByUrl(string url);

public bool SavePost(Post post);

public List<Summary> GetSummaries();
Expand Down
9 changes: 9 additions & 0 deletions src/NoteBookmark.Api/PostEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ static async Task<Results<Ok<Post>, 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)
{
Expand Down
53 changes: 53 additions & 0 deletions src/NoteBookmark.BlazorApp.Tests/Tests/QuickAddTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDataService> _dataServiceMock;

public QuickAddTests()
Expand All @@ -28,6 +31,8 @@ public QuickAddTests()
Services.AddSingleton(_dataServiceMock.Object);
Services.AddSingleton(new Mock<IToastService>().Object);
Services.AddSingleton(new Mock<IDialogService>().Object);

SetRendererInfo(Interactive);
}

[Fact]
Expand Down Expand Up @@ -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<NavigationManager>();
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<QuickAdd>();

SetRendererInfo(Interactive);
var cut = Render<QuickAdd>();

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<NavigationManager>();
nav.NavigateTo($"http://localhost/quickadd?url={Uri.EscapeDataString(testUrl)}");

var cut = Render<QuickAdd>();

cut.Markup.Should().Contain("Extracting and saving post details...");
cut.Markup.Should().NotContain("Enter or paste URL");
_dataServiceMock.Verify(s => s.ExtractPostDetailsAndSave(It.IsAny<string>()), Times.Never);
}
}
9 changes: 9 additions & 0 deletions src/NoteBookmark.SharedUI/Components/Pages/QuickAdd.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading