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
3 changes: 3 additions & 0 deletions docs/news.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# News & Updates

Welcome to NoteBookmark! Check back here for product updates.
185 changes: 185 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/NewsFeedServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NoteBookmark.MauiApp.Data;

namespace NoteBookmark.MauiApp.Tests;

public sealed class NewsFeedServiceTests : IDisposable
{
private readonly string _appDataDirectory = Path.Combine(Path.GetTempPath(), $"NoteBookmark-News-{Guid.NewGuid():N}");

[Fact]
public async Task RefreshAsync_CachesMarkdownAndReferencedImagesForOfflineRendering()
{
var handler = new StubHttpMessageHandler(request =>
{
if (request.RequestUri!.AbsolutePath.EndsWith("/docs/news.md", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Updates\n\n![Logo](assets/logo.png)\n\n[Unsafe](javascript:alert(1))", Encoding.UTF8, "text/plain")
};
}

if (request.RequestUri.AbsolutePath.EndsWith("/docs/assets/logo.png", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
};
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var service = CreateService(handler);

await service.RefreshAsync();
var html = await service.GetHtmlAsync();

html.Should().Contain("<h1>Updates</h1>");
html.Should().Contain("src=\"data:image/png;base64,AQID\"");
html.Should().Contain("href=\"#\"");
File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue();
File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue();
}

[Fact]
public async Task GetHtmlAsync_WhenNoFeedIsCached_LoadsRemoteFeed()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Online feed", Encoding.UTF8, "text/plain")
});
var service = CreateService(handler);

(await service.GetHtmlAsync()).Should().Contain("<h1>Online feed</h1>");
File.Exists(Path.Combine(_appDataDirectory, "docs", "news.md")).Should().BeTrue();
}

[Fact]
public async Task RefreshAsync_WhenRemoteRefreshFails_KeepsCachedFeed()
{
var requestCount = 0;
var handler = new StubHttpMessageHandler(_ =>
{
requestCount++;
if (requestCount == 1)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("# Existing feed", Encoding.UTF8, "text/plain")
};
}

throw new HttpRequestException("Network unavailable.");
});
var service = CreateService(handler);
await service.RefreshAsync();

var act = () => service.RefreshAsync();
await act.Should().ThrowAsync<HttpRequestException>();
(await service.GetHtmlAsync()).Should().Contain("<h1>Existing feed</h1>");
}

[Fact]
public async Task GetHtmlAsync_WhenNoFeedIsCached_ReturnsFriendlyFallback()
{
var service = CreateService(new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));

(await service.GetHtmlAsync()).Should().Contain("Welcome to NoteBookmark");
}

[Fact]
public async Task RefreshAsync_CachesReferenceStyleImagesAndPathsWithParentheses()
{
var service = CreateService(CreateFeedHandler(
"# Updates\n\n![Logo][logo]\n\n![Chart](assets/chart(1).png)\n\n[logo]: assets/logo.png",
"/docs/assets/logo.png",
"/docs/assets/chart%281%29.png"));

await service.RefreshAsync();
var html = await service.GetHtmlAsync();

html.Should().NotContain("assets/");
html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Logo\"");
html.Should().Contain("src=\"data:image/png;base64,AQID\" alt=\"Chart\"");
File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "logo.png")).Should().BeTrue();
File.Exists(Path.Combine(_appDataDirectory, "docs", "assets", "chart(1).png")).Should().BeTrue();
}

[Fact]
public async Task RefreshAsync_IgnoresImageSyntaxInsideCodeBlocks()
{
var service = CreateService(CreateFeedHandler(
"# Updates\n\n```markdown\n![Example](assets/missing.png)\n```\n\n`![Inline](assets/also-missing.png)`"));

await service.RefreshAsync();
var html = await service.GetHtmlAsync();

html.Should().Contain("![Example](assets/missing.png)");
Directory.Exists(Path.Combine(_appDataDirectory, "docs", "assets")).Should().BeFalse();
}

[Fact]
public async Task RefreshAsync_RaisesFeedUpdatedOnlyAfterSuccessfulRefresh()
{
var fail = false;
var handler = new StubHttpMessageHandler(_ => fail
? throw new HttpRequestException("Network unavailable.")
: new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("# Feed", Encoding.UTF8, "text/plain") });
var service = CreateService(handler);
var updates = 0;
service.FeedUpdated += (_, _) => updates++;

await service.RefreshAsync();
fail = true;
var act = () => service.RefreshAsync();

await act.Should().ThrowAsync<HttpRequestException>();
updates.Should().Be(1);
}

public void Dispose()
{
if (Directory.Exists(_appDataDirectory))
{
Directory.Delete(_appDataDirectory, recursive: true);
}
}

private NewsFeedService CreateService(HttpMessageHandler handler)
{
var logger = new Mock<ILogger<NewsFeedService>>();
return new NewsFeedService(new HttpClient(handler), logger.Object, _appDataDirectory);
}

private static StubHttpMessageHandler CreateFeedHandler(string markdown, params string[] assetPaths) =>
new(request =>
{
var path = request.RequestUri!.AbsolutePath;
if (path.EndsWith("/docs/news.md", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(markdown, Encoding.UTF8, "text/plain")
};
}

return Array.Exists(assetPaths, assetPath => path.EndsWith(assetPath, StringComparison.Ordinal))
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) }
: new HttpResponseMessage(HttpStatusCode.NotFound);
});

private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(responseFactory(request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
<Compile Include="..\NoteBookmark.MauiApp\Data\LocalHtmlStorageService.cs" Link="Data\LocalHtmlStorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\StorageService.cs" Link="Data\StorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncService.cs" Link="Data\SyncService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\INewsFeedService.cs" Link="Data\INewsFeedService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\NewsFeedService.cs" Link="Data\NewsFeedService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncApiClient.cs" Link="Data\SyncApiClient.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\MauiUrlLauncher.cs" Link="Data\MauiUrlLauncher.cs" />
</ItemGroup>
Expand Down
18 changes: 17 additions & 1 deletion src/NoteBookmark.MauiApp.Tests/SyncServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class SyncServiceTests
private readonly Mock<ILocalDataService> _localDataServiceMock;
private readonly Mock<ILogger<SyncService>> _loggerMock;
private readonly Mock<ILocalHtmlStorageService> _localHtmlStorageServiceMock;
private readonly Mock<INewsFeedService> _newsFeedServiceMock;
private readonly SyncService _sut;

public SyncServiceTests()
Expand All @@ -27,13 +28,15 @@ public SyncServiceTests()
_localDataServiceMock = new Mock<ILocalDataService>();
_loggerMock = new Mock<ILogger<SyncService>>();
_localHtmlStorageServiceMock = new Mock<ILocalHtmlStorageService>();
_newsFeedServiceMock = new Mock<INewsFeedService>();

_apiClientMock.Setup(c => c.GetNote(It.IsAny<string>())).ReturnsAsync((Note?)null);
_apiClientMock.Setup(c => c.GetPostHtmlAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
_localDataServiceMock.Setup(c => c.GetPostsAsync()).ReturnsAsync(new List<Post>());
_localHtmlStorageServiceMock.Setup(s => s.GetCachedPostIds()).Returns(new List<string>());
_newsFeedServiceMock.Setup(s => s.RefreshAsync()).Returns(Task.CompletedTask);

_sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object);
_sut = new SyncService(_apiClientMock.Object, _localDataServiceMock.Object, _loggerMock.Object, _localHtmlStorageServiceMock.Object, _newsFeedServiceMock.Object);

SyncService.ClearInMemoryPreferences();
}
Expand Down Expand Up @@ -249,6 +252,19 @@ public async Task SyncAsync_ShouldNotRunConcurrently()
_localDataServiceMock.Verify(c => c.GetPendingSyncNotesAsync(), Times.Once);
}

[Fact]
public async Task SyncAsync_ShouldRefreshNewsFeed()
{
_localDataServiceMock.Setup(c => c.GetPendingSyncPostsAsync()).ReturnsAsync(new List<Post>());
_localDataServiceMock.Setup(c => c.GetPendingSyncNotesAsync()).ReturnsAsync(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 _sut.SyncAsync();

_newsFeedServiceMock.Verify(s => s.RefreshAsync(), Times.Once);
}

[Fact]
public async Task PullPhase_ShouldDeleteLocalPosts_WhenDeletedOnServer()
{
Expand Down
31 changes: 26 additions & 5 deletions src/NoteBookmark.MauiApp/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
@page "/"
@inject IAuthService AuthService
@inject NavigationManager Nav
@inject NoteBookmark.MauiApp.Data.INewsFeedService NewsFeed
@implements IDisposable

<div class="home-container">
<h2>Hello, @(AuthService.Username ?? "there")!</h2>
<p>You are signed in to NoteBookmark.</p>

<section class="news-section" aria-labelledby="news-heading">
<h3 id="news-heading">News &amp; Updates</h3>
<div class="news-content">@((MarkupString)newsHtml)</div>
</section>
</div>

@code {
private string newsHtml = "<p>Welcome to NoteBookmark! Check back here for product updates.</p>";

protected override void OnInitialized()
{
// No-op
NewsFeed.FeedUpdated += OnFeedUpdated;
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}

await LoadNewsAsync();
}

private async Task LogoutAsync()
// Sync refreshes the feed on a background thread, so marshal the reload back to the renderer.
private void OnFeedUpdated(object? sender, EventArgs e) => _ = InvokeAsync(LoadNewsAsync);

private async Task LoadNewsAsync()
{
await AuthService.LogoutAsync();
newsHtml = await NewsFeed.GetHtmlAsync();
StateHasChanged();
}

void IDisposable.Dispose()
{
// Cleanup resources if needed
NewsFeed.FeedUpdated -= OnFeedUpdated;
}
}

13 changes: 13 additions & 0 deletions src/NoteBookmark.MauiApp/Data/INewsFeedService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;
using System.Threading.Tasks;

namespace NoteBookmark.MauiApp.Data;

public interface INewsFeedService
{
/// <summary>Raised after a refresh has downloaded and cached a new copy of the feed.</summary>
event EventHandler? FeedUpdated;

Task RefreshAsync();
Task<string> GetHtmlAsync();
}
Loading
Loading