Skip to content
Open
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.BlazorApp.Tests/Tests/PostsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,5 +162,35 @@ public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired()

cut.Markup.Should().Contain("Cleaning...");
}

[Fact]
public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts()
{
var cut = Render<Posts>();

_dataServiceMock.Invocations.Clear();

cut.InvokeAsync(() =>
{
_dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true));
});

_dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce);
}

[Fact]
public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing()
{
_dataServiceMock.SetupGet(s => s.CanSync).Returns(true);
_dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true);

var cut = Render<Posts>();

var buttons = cut.FindComponents<FluentButton>();
var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments");
syncButton.Should().NotBeNull();
syncButton!.Instance.Disabled.Should().BeTrue();
syncButton.Instance.Loading.Should().BeTrue();
}
}

4 changes: 3 additions & 1 deletion src/NoteBookmark.Domain/SyncProgressEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ public class SyncProgressEventArgs : EventArgs
public int Current { get; }
public int Total { get; }
public string Status { get; }
public bool IsComplete { get; }
public double Percentage => Total > 0 ? (double)Current / Total * 100 : 0;

public SyncProgressEventArgs(int current, int total, string status)
public SyncProgressEventArgs(int current, int total, string status, bool isComplete = false)
{
Current = current;
Total = total;
Status = status;
IsComplete = isComplete;
}
}
110 changes: 110 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,116 @@ public async Task SyncAsync_ShouldRaiseSyncProgressChanged_WhenDownloadingPostHt
progressEvents.Should().Contain(e => e.Status == "Downloading 1 of 2 posts..." && e.Current == 1 && e.Total == 2);
progressEvents.Should().Contain(e => e.Status == "Downloading 2 of 2 posts..." && e.Current == 2 && e.Total == 2);
progressEvents.Last().Status.Should().Be("Synchronization complete!");
progressEvents.Last().IsComplete.Should().BeTrue();
}

[Fact]
public async Task PullPhase_ReadPosts_ShouldNotCallGetPost_AndShouldSaveDirectly()
{
var readPostL = new PostL
{
Id = "read1",
RowKey = "read1",
PartitionKey = "pk",
Title = "Read Post",
is_read = true,
DateModified = DateTime.UtcNow
};

_localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List<Note>());
_localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List<Post>());
_apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(new List<PostL> { readPostL });
_apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny<DateTime>())).ReturnsAsync(new List<Note>());

await _sut.SyncAsync();

// GetPost should NOT be called for read posts
_apiClientMock.Verify(c => c.GetPost("read1"), Times.Never);
_localDataServiceMock.Verify(c => c.SavePostAsync(It.Is<Post>(p => p.Id == "read1" && p.is_read == true), false), Times.Once);
}

[Fact]
public async Task PullPhase_UnreadPost_WhenGetPostFails_ShouldFallbackToBasicPost()
{
var unreadPostL = new PostL
{
Id = "unread1",
RowKey = "unread1",
PartitionKey = "pk",
Title = "Unread Post",
is_read = false,
DateModified = DateTime.UtcNow
};

_localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List<Note>());
_localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List<Post>());
_apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(new List<PostL> { unreadPostL });
_apiClientMock.Setup(c => c.GetPost("unread1")).ThrowsAsync(new System.Net.Http.HttpRequestException("404 Not Found"));
_apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny<DateTime>())).ReturnsAsync(new List<Note>());

await _sut.SyncAsync();

// Should fall back and save basic post without throwing
_localDataServiceMock.Verify(c => c.SavePostAsync(It.Is<Post>(p => p.Id == "unread1" && p.Title == "Unread Post"), false), Times.Once);
}

[Fact]
public async Task PullPhase_ShouldReportProgress_WhenPullingPosts()
{
var postL1 = new PostL { Id = "p1", RowKey = "p1", PartitionKey = "pk", Title = "Post 1", is_read = true, DateModified = DateTime.UtcNow };
var postL2 = new PostL { Id = "p2", RowKey = "p2", PartitionKey = "pk", Title = "Post 2", is_read = true, DateModified = DateTime.UtcNow };

_localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(new List<Note>());
_localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List<Post>());
_apiClientMock.Setup(c => c.GetPostsModifiedAfter(DateTime.MinValue)).ReturnsAsync(new List<PostL> { postL1, postL2 });
_apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny<DateTime>())).ReturnsAsync(new List<Note>());

var progressEvents = new List<SyncProgressEventArgs>();
_sut.SyncProgressChanged += (sender, args) => progressEvents.Add(args);

await _sut.SyncAsync();

progressEvents.Should().Contain(e => e.Status == "Pulling 0 of 2 posts..." && e.Current == 0 && e.Total == 2);
progressEvents.Should().Contain(e => e.Status == "Pulling 1 of 2 posts..." && e.Current == 1 && e.Total == 2);
progressEvents.Should().Contain(e => e.Status == "Pulling 2 of 2 posts..." && e.Current == 2 && e.Total == 2);
}

[Fact]
public async Task SyncAsync_WhenFails_ShouldRaiseSyncProgressChangedWithIsCompleteAndFailureStatus()
{
_localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ThrowsAsync(new InvalidOperationException("DB error"));

var progressEvents = new List<SyncProgressEventArgs>();
_sut.SyncProgressChanged += (sender, args) => progressEvents.Add(args);

Func<Task> act = async () => await _sut.SyncAsync();
await act.Should().ThrowAsync<InvalidOperationException>();

progressEvents.Should().NotBeEmpty();
var lastEvent = progressEvents.Last();
lastEvent.IsComplete.Should().BeTrue();
lastEvent.Status.Should().Contain("Sync failed: DB error");
}

[Fact]
public async Task IsSyncing_ShouldReflectActiveSyncTask()
{
var tcs = new TaskCompletionSource<List<Note>>();
_localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).Returns(tcs.Task);

_sut.IsSyncing.Should().BeFalse();

var syncTask = _sut.SyncAsync();

_sut.IsSyncing.Should().BeTrue();

tcs.SetResult(new List<Note>());
_apiClientMock.Setup(c => c.GetPostsModifiedAfter(It.IsAny<DateTime>())).ReturnsAsync(new List<PostL>());
_apiClientMock.Setup(c => c.GetNotesModifiedAfter(It.IsAny<DateTime>())).ReturnsAsync(new List<Note>());

await syncTask;

_sut.IsSyncing.Should().BeFalse();
}
}

47 changes: 39 additions & 8 deletions src/NoteBookmark.MauiApp/Data/OfflineDataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,16 @@ public async Task<bool> DeleteNote(string noteId)
{
if (IsOnline)
{
var post = await apiClient.GetPost(id);
if (post != null) await localDataService.SavePostAsync(post);
return post;
try
{
var post = await apiClient.GetPost(id);
if (post != null) await localDataService.SavePostAsync(post);
return post;
}
catch
{
return await localDataService.GetPostAsync(id);
}
}
else
{
Expand Down Expand Up @@ -243,6 +250,34 @@ public async Task<bool> ExtractPostDetailsAndSave(string url)
return false; // Can't extract offline
}

public async Task<string?> GetPostHtmlAsync(string postId)
{
var localHtml = await localHtmlStorageService.GetPostHtmlAsync(postId);
if (!string.IsNullOrEmpty(localHtml))
{
return localHtml;
}

if (IsOnline)
{
try
{
var remoteHtml = await apiClient.GetPostHtmlAsync(postId);
if (!string.IsNullOrEmpty(remoteHtml))
{
await localHtmlStorageService.SavePostHtmlAsync(postId, remoteHtml);
return remoteHtml;
}
}
catch
{
// Fall back to null if remote fetch fails
}
}

return null;
}

public async Task<bool> DeletePost(string id)
{
if (IsOnline)
Expand Down Expand Up @@ -273,17 +308,13 @@ public async Task<bool> DeletePost(string id)
}
}

public Task<bool> SaveReadingNotesMarkdown(string markdown, string number) => apiClient.SaveReadingNotesMarkdown(markdown, number);

public Task<string?> GetPostHtmlAsync(string postId)
=> localHtmlStorageService.GetPostHtmlAsync(postId);

public Task SyncAsync() => syncService.SyncAsync();
public event EventHandler<SyncProgressEventArgs>? SyncProgressChanged
{
add => syncService.SyncProgressChanged += value;
remove => syncService.SyncProgressChanged -= value;
}
public bool IsSyncing => syncService.IsSyncing;
public bool IsOffline => connectivity.NetworkAccess != NetworkAccess.Internet;
public bool CanSync => true;

Expand Down
Loading
Loading