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
+11 -2
View File
@@ -30,12 +30,21 @@ jobs:
- name: Build project
run: dotnet build --no-restore
- name: Install playwright dependencies
shell: pwsh
run: ./test/Blog.Tests/bin/Debug/net8.0/playwright.ps1 install-deps
- name: Install playwright
shell: pwsh
run: ./test/Blog.Tests/bin/Debug/net8.0/playwright.ps1 install
- name: Test project
run: dotnet test -e DOTNET_ENVIRONMENT=CI --no-build --verbosity normal
run: dotnet test -e DOTNET_ENVIRONMENT=CI --no-build --verbosity normal -s ./test/Blog.Tests/ci.runsettings
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: ./test/Blog.Tests/TestResults
path: ./test/Blog.Tests/TestResults
- name: Upload playwright traces
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: ./test/Blog.Tests/bin/Debug/net8.0/playwright-traces
+46
View File
@@ -0,0 +1,46 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Blog - Docker",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickRemoteProcess}",
"pipeTransport": {
"pipeProgram": "docker",
"pipeArgs": [ "exec", "-i", "blog.stevanfreeborn.com-development" ],
"debuggerPath": "/root/vsdbg/vsdbg",
"pipeCwd": "${workspaceRoot}",
"quoteArgs": false
},
"sourceFileMap": {
"/app": "${workspaceRoot}/src/Blog/"
}
},
{
"name": "Blog",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build_blog",
"program": "${workspaceFolder}/src/Blog/bin/Debug/net8.0/Blog.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Blog",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
+3 -1
View File
@@ -2,7 +2,9 @@
"cSpell.words": [
"Antiforgery",
"blazor",
"Hsts"
"Hsts",
"Markdig",
"msedge"
],
"dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings"
}
+41
View File
@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build_blog",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Blog.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish_blog",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/Blog.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch_blog",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/Blog.sln"
],
"problemMatcher": "$msCompile"
}
]
}
+2
View File
@@ -9,6 +9,8 @@
<ItemGroup>
<PackageReference Include="Markdig" Version="0.36.2" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
<PackageReference Include="WebStoating.Markdig.Prism" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
+5 -2
View File
@@ -1,4 +1,4 @@
@namespace Blog.Components
@namespace Blog.Components.Layout
<header class="container">
<div class="title-container">
@@ -8,6 +8,9 @@
</div>
<div class="author-container">
<span>by</span>
<img src="https://github.com/StevanFreeborn.png" alt="Stevan Freeborn" />
<a href="https://stevanfreeborn.com" target="_blank">
<img src="https://github.com/StevanFreeborn.png" alt="Stevan Freeborn" />
<span class="sr-only">Stevan Freeborn</span>
</a>
</div>
</header>
+3 -1
View File
@@ -3,7 +3,9 @@
<div class="container">
<Header />
@Body
<main>
@Body
</main>
</div>
<div id="blazor-error-ui">
@@ -6,10 +6,17 @@
max-width: 42rem;
padding: 1.25rem;
width: 100%;
gap: 4rem;
}
main {
display: flex;
flex-direction: column;
width: 100%;
}
#blazor-error-ui {
background: lightyellow;
background: var(--color-background-soft);
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
+14 -23
View File
@@ -3,34 +3,25 @@
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<div class="container">
<h2>Error</h2>
<p>Sorry it's me, not you. It looks like an unhandled error occurred while processing your request.</p>
@if (ShowRequestId)
{
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
}
</div>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,5 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
}
+3 -4
View File
@@ -3,9 +3,8 @@
<PageTitle>journal - A blog by Stevan Freeborn</PageTitle>
<HeadContent>
<meta name="description" content="A blog by Stevan Freeborn" />
<meta name="description" content="A blog by Stevan Freeborn" />
</HeadContent>
<h1>Hello, world!</h1>
Welcome to your new app.
<h2 class="sr-only">Posts</h2>
<Feed />
@@ -0,0 +1,8 @@
@page "/Error/404"
<div class="container">
<h2>Not Found</h2>
<p>Hmm...it doesn't look like the page you were looking for exists, but we did find this adorable corgi.</p>
<img src="not-found-corgi.jpg" alt="A cute corgi" />
</div>
@@ -0,0 +1,9 @@
.container {
display: flex;
flex-direction: column;
gap: 1rem;
& img {
border-radius: 0.5rem;
}
}
+34
View File
@@ -0,0 +1,34 @@
@namespace Blog.Components
@inject IPostService postService
@inject IJSRuntime JS
@if (posts.Count == 0)
{
<div class="container">
<p>Looks like writers block. Check back later.</p>
</div>
}
else
{
<section role="feed" class="container">
@foreach (var post in posts)
{
<a href="/@post.Slug">
<article>
<h3 title="@post.Title" >@post.Title</h3>
<p class="date">@post.PublishedAt.ToString("MMMM d, yyyy")</p>
<p class="lead">@post.Lead</p>
</article>
</a>
}
</section>
}
@code {
private List<Post> posts = [];
protected override async Task OnInitializedAsync()
{
posts = await postService.GetPostsAsync();
}
}
+46
View File
@@ -0,0 +1,46 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
gap: 2rem;
& a {
padding: 1rem;
border-radius: 1rem;
background-color: var(--color-background-soft);
transition-property: transform;
transition-duration: 100ms;
transition-timing-function: ease-in-out
}
& a:active {
transform: scale(0.95)
}
& article {
display: flex;
flex-direction: column;
gap: 0.25rem;
& h3 {
background: linear-gradient(to right top, #db3e3e, #df3741, #e32e44, #e72347, #eb124b);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 2rem;
font-weight: 900;
}
& .date {
font-size: 0.85rem;
}
& h3,
& .date,
& .lead {
text-wrap: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
<FocusOnNavigate RouteData="routeData" Selector="h2" />
</Found>
</Router>
+11
View File
@@ -0,0 +1,11 @@
global using System.IO.Abstractions;
global using System.Text.Json;
global using Blog.Components;
global using Blog.Posts;
global using Markdig;
global using Markdig.Prism;
global using Markdig.Syntax;
global using Microsoft.Extensions.Options;
+108
View File
@@ -0,0 +1,108 @@
namespace Blog.Posts;
class FilePostService(
IOptions<FilePostServiceOptions> options,
IFileSystem fileSystem,
ILogger<FilePostService> logger
) : IPostService
{
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
};
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UsePrism()
.Build();
private const string MetaFence = "meta";
private const string IndexFile = "index.md";
private readonly FilePostServiceOptions _options = options.Value;
private readonly IFileSystem _fileSystem = fileSystem;
private readonly ILogger<FilePostService> _logger = logger;
private static (string? metadata, MarkdownDocument document) ParsePost(string postText)
{
var markDoc = Markdown.Parse(postText, MarkdownPipeline);
var postMetadata = markDoc
.Where(
x =>
x is FencedCodeBlock fencedCodeBlock &&
fencedCodeBlock.Arguments is not null &&
fencedCodeBlock.Arguments.Contains(MetaFence)
)
.Select(x => x as FencedCodeBlock)
.FirstOrDefault();
if (postMetadata is not null)
{
markDoc.Remove(postMetadata);
}
var metaContent = postMetadata?.Lines.ToString();
return (metaContent, markDoc);
}
public async Task<List<Post>> GetPostsAsync()
{
var posts = new List<Post>();
if (_fileSystem.Directory.Exists(_options.PostsDirectory) is false)
{
return posts;
}
var subDirectories = _fileSystem.Directory.GetDirectories(_options.PostsDirectory);
if (subDirectories.Length is 0)
{
return posts;
}
foreach (var subDirectory in subDirectories)
{
try
{
var indexFilePath = _fileSystem.Path.Combine(subDirectory, IndexFile);
if (_fileSystem.File.Exists(indexFilePath) is false)
{
continue;
}
var postText = await _fileSystem.File.ReadAllTextAsync(indexFilePath);
var (metaContent, document) = ParsePost(postText);
if (metaContent is null || document.Count is 0)
{
continue;
}
var post = JsonSerializer.Deserialize<Post>(metaContent, JsonSerializerOptions);
if (post is null || post.IsPublished is false)
{
continue;
}
var finalPost = post with
{
Slug = _fileSystem.Path.GetFileName(subDirectory),
};
posts.Add(finalPost);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while processing post in {SubDirectory}", subDirectory);
}
}
return [.. posts.OrderByDescending(x => x.PublishedAt)];
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace Blog.Posts;
class FilePostServiceOptions
{
public string PostsDirectory { get; set; } = string.Empty;
}
class FilePostServiceOptionsSetup(IConfiguration configuration) : IConfigureOptions<FilePostServiceOptions>
{
private const string SectionName = nameof(FilePostServiceOptions);
private readonly IConfiguration _configuration = configuration;
public void Configure(FilePostServiceOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
+6
View File
@@ -0,0 +1,6 @@
namespace Blog.Posts;
interface IPostService
{
Task<List<Post>> GetPostsAsync();
}
+15
View File
@@ -0,0 +1,15 @@
namespace Blog.Posts;
record Post
{
public string Title { get; init; } = string.Empty;
public string Lead { get; init; } = string.Empty;
public bool IsPublished { get; init; } = false;
public DateTime PublishedAt { get; init; }
public string Slug { get; init; } = string.Empty;
}
record PostWithContent : Post
{
public string Content { get; init; } = string.Empty;
}
+7 -3
View File
@@ -1,7 +1,9 @@
using Blog.Components;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureOptions<FilePostServiceOptionsSetup>();
builder.Services.AddSingleton<IFileSystem, FileSystem>();
builder.Services.AddScoped<IPostService, FilePostService>();
builder.Services
.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -23,6 +25,8 @@ app
.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.UseStatusCodePagesWithRedirects("/Error/{0}");
app.Run();
public partial class Program {}
public partial class Program { }
+5 -1
View File
@@ -1,3 +1,7 @@
{
"Urls": "https://localhost:4500;http://localhost:4501"
"Urls": "https://localhost:4500;http://localhost:4501",
"FilePostServiceOptions": {
"PostsDirectory": "wwwroot/posts"
},
"DetailedErrors": "true"
}
+3 -1
View File
@@ -12,4 +12,6 @@ ASPNETCORE_Kestrel__Certificates__Default__Password=
# The first url in the list is the port that the browser will attempt to connect to
# The second url in the list is the port that the dotnet watch process will be running
# on in the container. This is necessary for hot reload to work.
DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME=localhost:1234;http://0.0.0.0:5555;http://127.0.0.1
DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME=localhost:1234;http://0.0.0.0:5555;http://127.0.0.1
DETAILED_ERRORS=true
DOTNET_WATCH_RESTART_ON_RUDE_EDIT=true
+36 -1
View File
@@ -41,6 +41,39 @@ table {
/* Site */
:root {
--primary-white: #ffffff;
--secondary-white: #f8f8f8;
--primary-black: #121212;
--secondary-black: #1e1e1e;
}
:root {
--color-background: var(--primary-white);
--color-background-soft: var(--secondary-white);
--color-text: var(--primary-black);
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--primary-black);
--color-background-soft: var(--secondary-black);
--color-text: var(--primary-white);
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
html,
body {
height: 100%;
@@ -56,9 +89,11 @@ body {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--color-background);
color: var(--color-text);
}
h1:focus {
h2:focus {
outline: none;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+114
View File
@@ -0,0 +1,114 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig */
code[class*=language-],
pre[class*=language-] {
color: #ccc;
background: 0 0;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none
}
pre[class*=language-] {
padding: 1em;
margin: .5em 0;
overflow: auto
}
:not(pre)>code[class*=language-],
pre[class*=language-] {
background: #2d2d2d
}
:not(pre)>code[class*=language-] {
padding: .1em;
border-radius: .3em;
white-space: normal
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #999
}
.token.punctuation {
color: #ccc
}
.token.attr-name,
.token.deleted,
.token.namespace,
.token.tag {
color: #e2777a
}
.token.function-name {
color: #6196cc
}
.token.boolean,
.token.function,
.token.number {
color: #f08d49
}
.token.class-name,
.token.constant,
.token.property,
.token.symbol {
color: #f8c555
}
.token.atrule,
.token.builtin,
.token.important,
.token.keyword,
.token.selector {
color: #cc99cd
}
.token.attr-value,
.token.char,
.token.regex,
.token.string,
.token.variable {
color: #7ec699
}
.token.entity,
.token.operator,
.token.url {
color: #67cdcc
}
.token.bold,
.token.important {
font-weight: 700
}
.token.italic {
font-style: italic
}
.token.entity {
cursor: help
}
.token.inserted {
color: green
}
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
+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>