feat: add blog feed (#3)

* chore: begin working on post service

* chore: add debug assets

* chore: add whitelist words

* feat: add prism for syntax highlighting

* feat: updated favicon

* feat: add blog feed

* fix: add link to written by image

* feat: add custom error pages

* fix: focus on h2 since h1 is always the journal site title

* feat: use status code pages with redirects

* fix: add discernible text to link

* feat: add support for light and dark theme

* fix: wrap body with main tag

* fix: style main element in pages to take up full width of container

* fix: add heading to home page

* feat: add not-found-corgi image

* chore: add robots.txt file

* chore: clean up place holder posts

* fix: namespace header in layouts

* fix: use logger instead of console

* fix: use new text context in each test when registering services

* chore: update workflow to debug failing tests

* chore: install playwright deps

* fix: extend expect timeout

* fix: extend expect timeout

* fix: capture traces

* fix: capture traces

* fix: index file name
This commit is contained in:
Stevan Freeborn
2024-04-10 01:28:38 -05:00
committed by GitHub
parent d25e5d4944
commit c4795dfdae
42 changed files with 16676 additions and 63 deletions
+1
View File
@@ -2,6 +2,7 @@
<RunSettings>
<Playwright>
<BrowserName>chromium</BrowserName>
<ExpectTimeout>30000</ExpectTimeout>
<LaunchOptions>
<Headless>false</Headless>
<Channel>msedge</Channel>
+8
View File
@@ -19,6 +19,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.42.0" />
<PackageReference Include="moq" Version="4.20.70" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
@@ -45,4 +46,11 @@
<ProjectReference Include="..\..\src\Blog\Blog.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include=".\EndToEnd\TestPosts\**">
<Link>TestPosts\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+23 -14
View File
@@ -1,6 +1,10 @@
using Blog.Posts;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Blog.Tests.EndToEnd;
@@ -31,13 +35,18 @@ public class BlogHostFactory<TProgram> : WebApplicationFactory<TProgram> where T
}
protected override IHost CreateHost(IHostBuilder builder)
{
{
var testHost = builder
.ConfigureWebHost(
webHostBuilder => webHostBuilder.ConfigureLogging(
config => config.ClearProviders()
)
)
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder.ConfigureLogging(config => config.ClearProviders());
webHostBuilder.ConfigureTestServices(services =>
{
var postsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "TestPosts");
var postServiceOptions = new FilePostServiceOptions() { PostsDirectory = postsDirectory };
services.AddSingleton(Options.Create(postServiceOptions));
});
})
.Build();
builder.ConfigureWebHost(
@@ -46,17 +55,17 @@ public class BlogHostFactory<TProgram> : WebApplicationFactory<TProgram> where T
)
);
_host = builder.Build();
_host.Start();
_host = builder.Build();
_host.Start();
var server = _host.Services.GetRequiredService<IServer>();
var addresses = server.Features.GetRequiredFeature<IServerAddressesFeature>();
var server = _host.Services.GetRequiredService<IServer>();
var addresses = server.Features.GetRequiredFeature<IServerAddressesFeature>();
ClientOptions.BaseAddress = addresses.Addresses
.Select(x => new Uri(x))
ClientOptions.BaseAddress = addresses.Addresses
.Select(x => new Uri(x))
.Last();
testHost.Start();
return testHost;
testHost.Start();
return testHost;
}
}
+25
View File
@@ -14,4 +14,29 @@ public class BlogTest : PageTest
options.BaseURL = _factory.ServerAddress;
return options;
}
[SetUp]
public async Task Setup()
{
await Context.Tracing.StartAsync(new()
{
Title = TestContext.CurrentContext.Test.ClassName + "." + TestContext.CurrentContext.Test.Name,
Screenshots = true,
Snapshots = true,
Sources = true
});
}
[TearDown]
public async Task TearDown()
{
await Context.Tracing.StopAsync(new()
{
Path = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"playwright-traces",
$"{TestContext.CurrentContext.Test.ClassName}.{TestContext.CurrentContext.Test.Name}.zip"
)
});
}
}
+17 -1
View File
@@ -15,7 +15,7 @@ public class HomeTests : BlogTest
public async Task Home_WhenNavigatedTo_ItShouldCorrectSiteTitle()
{
await Page.GotoAsync("/");
var message = Page.GetByRole(AriaRole.Heading, new () { Name = "journal" });
var message = Page.GetByRole(AriaRole.Heading, new() { Name = "journal" });
await Expect(message).ToBeVisibleAsync();
}
@@ -29,4 +29,20 @@ public class HomeTests : BlogTest
await Expect(message).ToBeVisibleAsync();
await Expect(image).ToBeVisibleAsync();
}
[Test]
public async Task Home_WhenNavigatedTo_ItShouldDisplayPostFeed()
{
await Page.GotoAsync("/");
var feed = Page.GetByRole(AriaRole.Feed);
await Expect(feed).ToBeVisibleAsync();
}
[Test]
public async Task Home_WhenNavigatedTo_ItShouldDisplayPosts()
{
await Page.GotoAsync("/");
var posts = Page.GetByRole(AriaRole.Article);
await Expect(posts).ToHaveCountAsync(2);
}
}
@@ -0,0 +1,76 @@
# Test Blog
```json meta
{
"title": "Another Test Blog",
"lead": "This is a test blog post",
"isPublished": true,
"publishedAt": "2020-01-01"
}
```
Welcome to my Markdown blog post! In this post, I'll cover various Markdown elements to help you test styling.
## Text Formatting
Here are some examples of text formatting:
- *Italic Text*
- **Bold Text**
- ***Bold Italic Text***
- ~~Strikethrough Text~~
## Lists
### Unordered List
- Item 1
- Item 2
- Subitem A
- Subitem B
- Item 3
### Ordered List
1. First item
2. Second item
3. Third item
## Links and Images
### Link
[OpenAI](https://openai.com) - An amazing AI research organization.
### Image
![Markdown Logo](https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Markdown-mark.svg/1920px-Markdown-mark.svg.png)
## Blockquotes
> Markdown is a lightweight markup language with plain-text formatting syntax.
## Code Blocks
```python
def greet(name):
print(f"Hello, {name}!")
greet("World")
```
## Tables
| Name | Age |
|-------|-----|
| Alice | 30 |
| Bob | 25 |
| Carol | 35 |
## Horizontal Rule
---
## Conclusion
That's it for this Markdown blog post! Feel free to experiment with styling these elements further.
@@ -0,0 +1,76 @@
# Test Blog
```json meta
{
"title": "Test Blog",
"lead": "This is a test blog post.",
"isPublished": true,
"publishedAt": "2021-01-01"
}
```
Welcome to my Markdown blog post! In this post, I'll cover various Markdown elements to help you test styling.
## Text Formatting
Here are some examples of text formatting:
- *Italic Text*
- **Bold Text**
- ***Bold Italic Text***
- ~~Strikethrough Text~~
## Lists
### Unordered List
- Item 1
- Item 2
- Subitem A
- Subitem B
- Item 3
### Ordered List
1. First item
2. Second item
3. Third item
## Links and Images
### Link
[OpenAI](https://openai.com) - An amazing AI research organization.
### Image
![Markdown Logo](https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Markdown-mark.svg/1920px-Markdown-mark.svg.png)
## Blockquotes
> Markdown is a lightweight markup language with plain-text formatting syntax.
## Code Blocks
```python
def greet(name):
print(f"Hello, {name}!")
greet("World")
```
## Tables
| Name | Age |
|-------|-----|
| Alice | 30 |
| Bob | 25 |
| Carol | 35 |
## Horizontal Rule
---
## Conclusion
That's it for this Markdown blog post! Feel free to experiment with styling these elements further.
+17 -7
View File
@@ -1,10 +1,20 @@
global using NUnit.Framework;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.Hosting;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.Playwright.NUnit;
global using Microsoft.Playwright;
global using System.IO.Abstractions;
global using System.Net;
global using Blog.Posts;
global using FluentAssertions;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Hosting.Server;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.Extensions.DependencyInjection;
global using System.Net;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Options;
global using Microsoft.Playwright;
global using Microsoft.Playwright.NUnit;
global using Microsoft.Extensions.Logging;
global using Moq;
global using NUnit.Framework;
+44
View File
@@ -0,0 +1,44 @@
@inherits Bunit.TestContext
@code {
private Mock<IPostService> _postServiceMock = new();
[Test]
public void Feed_WhenRendedAndNoPosts_ItShouldRenderNoPostsMessage()
{
_postServiceMock
.Setup(x => x.GetPostsAsync())
.ReturnsAsync(new List<Post>());
var ctx = new TestContext();
ctx.Services.AddSingleton(_postServiceMock.Object);
var cut = ctx.Render(@<Feed />);
cut.Find("p").MarkupMatches("<p>Looks like writers block. Check back later.</p>");
}
[Test]
public void Feed_WhenRendedAndPosts_ItShouldRenderPosts()
{
List<Post> posts = [
new Post() { Title = "Post 1", },
new Post() { Title = "Post 2", },
];
_postServiceMock
.Setup(x => x.GetPostsAsync())
.ReturnsAsync(posts);
var ctx = new TestContext();
ctx.Services.AddScoped(_ => _postServiceMock.Object);
var cut = ctx.Render(@<Feed />);
var articles = cut.FindAll("article");
articles.Count.Should().Be(2);
}
}
@@ -0,0 +1,423 @@
namespace Blog.Tests.Unit;
[TestFixture]
class FilePostServiceTests
{
private readonly Mock<IFileSystem> _mockFileSystem = new();
private readonly Mock<IOptions<FilePostServiceOptions>> _mockOptions = new();
private readonly Mock<ILogger<FilePostService>> _mockLogger = new();
private readonly FilePostService _sut;
public FilePostServiceTests()
{
_mockOptions
.Setup(x => x.Value)
.Returns(new FilePostServiceOptions { PostsDirectory = "posts" });
_sut = new FilePostService(
_mockOptions.Object,
_mockFileSystem.Object,
_mockLogger.Object
);
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostsDirectoryDoesNotExist_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(false);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostsDirectoryContainsNoSubDirectories_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns([]);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostsDirectoryContainsSubDirectoryWithNoIndexFile_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(false);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileIsEmpty_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(string.Empty);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileContainsNoMetadata_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
Post content
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileOnlyContainsMetadata_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
```json meta
{
"title": "Post Title",
"date": "2021-01-01"
}
```
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileHasInvalidMetadata_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"isPublished": "true",
"date": "2021-01-01"
}
```
Post content
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileIsNotPublished_ItShouldReturnEmptyList()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"lead": "Post lead",
"isPublished": false,
"publishedAt": "2021-01-01"
}
```
Post content
"""
);
var result = await _sut.GetPostsAsync();
result.Should().BeEmpty();
}
[Test]
public async Task GetPostsAsync_WhenCalledAndPostIndexFileContainsMetadataAndContent_ItShouldReturnPosts()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns(["subdirectory"]);
_mockFileSystem
.Setup(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("subdirectory/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Post Title
```json meta
{
"title": "Post Title",
"lead": "Post lead",
"isPublished": true,
"publishedAt": "2021-01-01",
"slug": "subdirectory"
}
```
Post content
"""
);
_mockFileSystem
.Setup(x => x.Path.GetFileName(It.IsAny<string>()))
.Returns("subdirectory");
var result = await _sut.GetPostsAsync();
result.Should().NotBeEmpty();
result.Should().HaveCount(1);
result.Should().BeEquivalentTo([
new Post
{
Title = "Post Title",
Lead = "Post lead",
IsPublished = true,
PublishedAt = new DateTime(2021, 1, 1),
Slug = "subdirectory",
}
]);
}
[Test]
public async Task GetPostsAsync_WhenCalled_ItShouldValidPostsInDescendingOrder()
{
_mockFileSystem
.Setup(x => x.Directory.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.Setup(x => x.Directory.GetDirectories(It.IsAny<string>()))
.Returns([
"valid-blog-post",
"another-valid-blog-post",
"invalid-blog-post"
]);
_mockFileSystem
.SetupSequence(x => x.Path.Combine(It.IsAny<string>(), It.IsAny<string>()))
.Returns("valid-blog-post/INDEX.md")
.Returns("another-valid-blog-post/INDEX.md")
.Returns("invalid-blog-post/INDEX.md");
_mockFileSystem
.Setup(x => x.File.Exists(It.IsAny<string>()))
.Returns(true);
_mockFileSystem
.SetupSequence(x => x.File.ReadAllTextAsync(It.IsAny<string>(), default))
.ReturnsAsync(
"""
# Valid Blog Post
```json meta
{
"title": "Valid Blog Post",
"lead": "Valid post lead",
"isPublished": true,
"publishedAt": "2021-01-01",
"slug": "valid-blog-post"
}
```
Post content
"""
)
.ReturnsAsync(
"""
# Another Valid Blog Post
```json meta
{
"title": "Another Valid Blog Post",
"lead": "Another valid post lead",
"isPublished": true,
"publishedAt": "2022-01-02",
"slug": "another-valid-blog-post"
}
```
Post content
"""
)
.ReturnsAsync(
"""
```json meta
{
"title": "Invalid Blog Post",
"lead": "Invalid post lead",
"isPublished": false,
"publishedAt": "2023-01-03",
"slug": "invalid-blog-post"
}
```
"""
);
_mockFileSystem
.SetupSequence(x => x.Path.GetFileName(It.IsAny<string>()))
.Returns("valid-blog-post")
.Returns("another-valid-blog-post")
.Returns("invalid-blog-post");
var result = await _sut.GetPostsAsync();
result.Should().NotBeEmpty();
result.Should().HaveCount(2);
result.Should().BeEquivalentTo([
new Post
{
Title = "Another Valid Blog Post",
Lead = "Another valid post lead",
IsPublished = true,
PublishedAt = new DateTime(2022, 1, 2),
Slug = "another-valid-blog-post",
},
new Post
{
Title = "Valid Blog Post",
Lead = "Valid post lead",
IsPublished = true,
PublishedAt = new DateTime(2021, 1, 1),
Slug = "valid-blog-post",
}
]);
}
}
+1
View File
@@ -1,3 +1,4 @@
@using Bunit
@using Blog.Components
@using Blog.Components.Layout
@using FluentAssertions
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<Playwright>
<BrowserName>chromium</BrowserName>
<ExpectTimeout>10000</ExpectTimeout>
<LaunchOptions>
<Headless>true</Headless>
<Channel>msedge</Channel>
</LaunchOptions>
</Playwright>
</RunSettings>