Merge branch 'main' of https://github.com/StevanFreeborn/blog.stevanfreeborn.com
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
name: Pull Request
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '.github/**'
|
||||||
|
- '.vscode/**'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.editorconfig'
|
||||||
|
- 'LICENSE.md'
|
||||||
|
- 'docker-compose.dev.yml'
|
||||||
|
- '**/*/README.md'
|
||||||
|
- '**/*/Dockerfile'
|
||||||
|
jobs:
|
||||||
|
run_tests:
|
||||||
|
name: Run tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
- name: Install report generator
|
||||||
|
run: dotnet tool install --global dotnet-reportgenerator-globaltool
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
- 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 -s ./test/Blog.Tests/ci.runsettings
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
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
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
# appsettings
|
# appsettings
|
||||||
appsettings*.json
|
appsettings*.json
|
||||||
|
!appsettings.example.json
|
||||||
|
|
||||||
# dotenv files
|
# dotenv files
|
||||||
.env*
|
.env*
|
||||||
|
|||||||
Vendored
+46
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"Antiforgery",
|
"Antiforgery",
|
||||||
"blazor",
|
"blazor",
|
||||||
"Hsts"
|
"Hsts",
|
||||||
|
"Markdig",
|
||||||
|
"msedge"
|
||||||
],
|
],
|
||||||
"dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings"
|
"dotnet.unitTests.runSettingsPath": "./test/Blog.Tests/.runsettings"
|
||||||
}
|
}
|
||||||
Vendored
+41
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2024 Stevan Freeborn
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Markdig" Version="0.36.2" />
|
<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>
|
||||||
|
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||||
|
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
<link rel="stylesheet" href="Blog.styles.css" />
|
<link rel="stylesheet" href="Blog.styles.css" />
|
||||||
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@namespace Blog.Components.Layout
|
||||||
|
|
||||||
|
<header class="container">
|
||||||
|
<div class="title-container">
|
||||||
|
<a href="/">
|
||||||
|
<h1>journal</h1>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="author-container">
|
||||||
|
<span>by</span>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
& .title-container h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .author-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@Body
|
<div class="container">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="blazor-error-ui">
|
<div id="blazor-error-ui">
|
||||||
An unhandled error has occurred.
|
An unhandled error has occurred.
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 42rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
gap: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#blazor-error-ui {
|
#blazor-error-ui {
|
||||||
background: lightyellow;
|
background: var(--color-background-soft);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -3,34 +3,25 @@
|
|||||||
|
|
||||||
<PageTitle>Error</PageTitle>
|
<PageTitle>Error</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
<div class="container">
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
<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>
|
<p>
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
@code{
|
@code{
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
private string? RequestId { get; set; }
|
private string? RequestId { get; set; }
|
||||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
|
||||||
protected override void OnInitialized() =>
|
protected override void OnInitialized() =>
|
||||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
<PageTitle>journal - A blog by Stevan Freeborn</PageTitle>
|
||||||
|
|
||||||
<h1>Hello, world!</h1>
|
<HeadContent>
|
||||||
|
<meta name="description" content="A blog by Stevan Freeborn" />
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
<Router AppAssembly="typeof(Program).Assembly">
|
<Router AppAssembly="typeof(Program).Assembly">
|
||||||
<Found Context="routeData">
|
<Found Context="routeData">
|
||||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
<FocusOnNavigate RouteData="routeData" Selector="h2" />
|
||||||
</Found>
|
</Found>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Blog.Posts;
|
||||||
|
|
||||||
|
interface IPostService
|
||||||
|
{
|
||||||
|
Task<List<Post>> GetPostsAsync();
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,9 @@
|
|||||||
using Blog.Components;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.ConfigureOptions<FilePostServiceOptionsSetup>();
|
||||||
|
builder.Services.AddSingleton<IFileSystem, FileSystem>();
|
||||||
|
builder.Services.AddScoped<IPostService, FilePostService>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddRazorComponents()
|
.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
@@ -23,6 +25,8 @@ app
|
|||||||
.MapRazorComponents<App>()
|
.MapRazorComponents<App>()
|
||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
app.UseStatusCodePagesWithRedirects("/Error/{0}");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public partial class Program {}
|
public partial class Program { }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Blog
|
||||||
|
|
||||||
|
## Setup for development
|
||||||
|
|
||||||
|
### Clone the repository
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/StevanFreeborn/blog.stevanfreeborn.com.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate development SSL certificates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet dev-certs https -ep ${HOME}/.aspnet/https/Blog.pfx -p <password>
|
||||||
|
dotnet dev-certs https --trust
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet dev-certs https -ep $env:USERPROFILE\.aspnet\https\Blog.pfx -p <password> --trust
|
||||||
|
```
|
||||||
|
|
||||||
|
### Developing with Docker
|
||||||
|
|
||||||
|
#### Create `.env.development` file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp src/Blog/example.env.development src/Blog/.env.development
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Build and run the Docker container
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker-compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Developing without Docker
|
||||||
|
|
||||||
|
#### Create `appsettings.Development.json` file
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp src/Blog/appsettings.example.json src/Blog/appsettings.Development.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run the application
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dotnet run --project src/Blog/Blog.csproj
|
||||||
|
```
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"Urls": "https://localhost:4500;http://localhost:4501",
|
||||||
|
"FilePostServiceOptions": {
|
||||||
|
"PostsDirectory": "wwwroot/posts"
|
||||||
|
},
|
||||||
|
"DetailedErrors": "true"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
URLS=https://0.0.0.0:4500;http://0.0.0.0:4501
|
||||||
|
|
||||||
|
# Path needs to match the path where the cert file
|
||||||
|
# is mounted in the container
|
||||||
|
ASPNETCORE_Kestrel__Certificates__Default__Path=
|
||||||
|
|
||||||
|
# Password needs to match the password for the cert file
|
||||||
|
# you generated
|
||||||
|
ASPNETCORE_Kestrel__Certificates__Default__Password=
|
||||||
|
|
||||||
|
# This is the hostname that the dotnet watch process will use to connect to the browser
|
||||||
|
# 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
|
||||||
|
DETAILED_ERRORS=true
|
||||||
|
DOTNET_WATCH_RESTART_ON_RUDE_EDIT=true
|
||||||
@@ -1,4 +1,99 @@
|
|||||||
h1:focus {
|
@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700;1,900&display=swap');
|
||||||
|
|
||||||
|
/* Resets */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-family: 'Merriweather', serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<RunSettings>
|
<RunSettings>
|
||||||
<Playwright>
|
<Playwright>
|
||||||
<BrowserName>chromium</BrowserName>
|
<BrowserName>chromium</BrowserName>
|
||||||
|
<ExpectTimeout>30000</ExpectTimeout>
|
||||||
<LaunchOptions>
|
<LaunchOptions>
|
||||||
<Headless>false</Headless>
|
<Headless>false</Headless>
|
||||||
<Channel>msedge</Channel>
|
<Channel>msedge</Channel>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
@@ -11,17 +11,46 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="bunit" Version="1.27.17" />
|
<PackageReference Include="bunit" Version="1.27.17" />
|
||||||
|
<PackageReference Include="coverlet.msbuild" Version="6.0.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||||
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.42.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="NUnit" Version="3.13.3" />
|
||||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||||
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
|
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<CollectCoverage>true</CollectCoverage>
|
||||||
|
<CoverletOutput>./TestResults/coverage/</CoverletOutput>
|
||||||
|
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
|
||||||
|
<Include>[Blog]*</Include>
|
||||||
|
<ExcludeByFile>**/Blog/Program.cs</ExcludeByFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
|
||||||
|
<Exec Command="reportgenerator -reports:./TestResults/coverage/*.xml -targetdir:./TestResults/coverage/report/ -reporttypes:Html_Dark" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="OpenTestReport" AfterTargets="GenerateHtmlCoverageReport" Condition="'$(DOTNET_ENVIRONMENT)' != 'CI'">
|
||||||
|
<Exec Command="start ./TestResults/coverage/report/index.html" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Blog\Blog.csproj" />
|
<ProjectReference Include="..\..\src\Blog\Blog.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include=".\EndToEnd\TestPosts\**">
|
||||||
|
<Link>TestPosts\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,15 +1,71 @@
|
|||||||
|
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;
|
namespace Blog.Tests.EndToEnd;
|
||||||
|
|
||||||
public class BlogHostFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
|
public class BlogHostFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
|
||||||
{
|
{
|
||||||
|
private IHost? _host;
|
||||||
|
|
||||||
|
private void EnsureServer()
|
||||||
|
{
|
||||||
|
if (_host is null)
|
||||||
|
{
|
||||||
|
using var _ = CreateDefaultClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
_host?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ServerAddress
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
EnsureServer();
|
||||||
|
return ClientOptions.BaseAddress.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected override IHost CreateHost(IHostBuilder builder)
|
protected override IHost CreateHost(IHostBuilder builder)
|
||||||
{
|
{
|
||||||
var testHost = base.CreateHost(builder);
|
var testHost = builder
|
||||||
builder.ConfigureWebHost(webHostBuilder => webHostBuilder.UseKestrel());
|
.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();
|
||||||
|
|
||||||
var kestrelHost = builder.Build();
|
builder.ConfigureWebHost(
|
||||||
kestrelHost.Start();
|
webHostBuilder => webHostBuilder.UseKestrel(
|
||||||
|
o => o.Listen(IPAddress.Loopback, 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return new CompositeHost(testHost, kestrelHost);
|
_host = builder.Build();
|
||||||
|
_host.Start();
|
||||||
|
|
||||||
|
var server = _host.Services.GetRequiredService<IServer>();
|
||||||
|
var addresses = server.Features.GetRequiredFeature<IServerAddressesFeature>();
|
||||||
|
|
||||||
|
ClientOptions.BaseAddress = addresses.Addresses
|
||||||
|
.Select(x => new Uri(x))
|
||||||
|
.Last();
|
||||||
|
|
||||||
|
testHost.Start();
|
||||||
|
return testHost;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
|
||||||
|
namespace Blog.Tests.EndToEnd;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class BlogTest : PageTest
|
||||||
|
{
|
||||||
|
private readonly BlogHostFactory<Program> _factory = new();
|
||||||
|
|
||||||
|
public override BrowserNewContextOptions ContextOptions()
|
||||||
|
{
|
||||||
|
var options = base.ContextOptions();
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace Blog.Tests.EndToEnd;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class HomeTests : BlogTest
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Home_WhenNavigatedTo_ItShouldDisplayCorrectPageTitle()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync("/");
|
||||||
|
var title = await Page.TitleAsync();
|
||||||
|
title.Should().Be("journal - A blog by Stevan Freeborn");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Home_WhenNavigatedTo_ItShouldCorrectSiteTitle()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync("/");
|
||||||
|
var message = Page.GetByRole(AriaRole.Heading, new() { Name = "journal" });
|
||||||
|
await Expect(message).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Home_WhenNavigatedTo_ItShouldDisplayWrittenByAttribution()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync("/");
|
||||||
|
var message = Page.GetByText("by");
|
||||||
|
var image = Page.GetByAltText("Stevan Freeborn");
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
global using NUnit.Framework;
|
global using System.IO.Abstractions;
|
||||||
global using Microsoft.AspNetCore.Mvc.Testing;
|
global using System.Net;
|
||||||
global using Microsoft.Extensions.Hosting;
|
|
||||||
|
global using Blog.Posts;
|
||||||
|
|
||||||
|
global using FluentAssertions;
|
||||||
|
|
||||||
global using Microsoft.AspNetCore.Hosting;
|
global using Microsoft.AspNetCore.Hosting;
|
||||||
global using Microsoft.Playwright.NUnit;
|
global using Microsoft.AspNetCore.Hosting.Server;
|
||||||
|
global using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
|
global using Microsoft.Extensions.Hosting;
|
||||||
|
global using Microsoft.Extensions.Options;
|
||||||
global using Microsoft.Playwright;
|
global using Microsoft.Playwright;
|
||||||
|
global using Microsoft.Playwright.NUnit;
|
||||||
|
global using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
global using Moq;
|
||||||
|
|
||||||
|
global using NUnit.Framework;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@inherits Bunit.TestContext
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Header_WhenRendered_ItShouldContainSiteTitle()
|
||||||
|
{
|
||||||
|
var cut = Render(@<Header />);
|
||||||
|
|
||||||
|
var heading = cut.Find("h1");
|
||||||
|
|
||||||
|
heading.MarkupMatches(@<h1>journal</h1>);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Header_WhenRendered_ItShouldContainAuthorInfo()
|
||||||
|
{
|
||||||
|
var cut = Render(@<Header />);
|
||||||
|
|
||||||
|
var author = cut.Find(".author-container > span");
|
||||||
|
|
||||||
|
author.MarkupMatches(@<span>by</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Header_WhenRendered_ItShouldContainAuthorImage()
|
||||||
|
{
|
||||||
|
var cut = Render(@<Header />);
|
||||||
|
var image = cut.Find("img[alt='Stevan Freeborn']");
|
||||||
|
image.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
namespace Blog.Tests;
|
|
||||||
|
|
||||||
public class Tests
|
|
||||||
{
|
|
||||||
[SetUp]
|
|
||||||
public void Setup()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
Assert.Pass();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@using Bunit
|
||||||
|
@using Blog.Components
|
||||||
|
@using Blog.Components.Layout
|
||||||
|
@using FluentAssertions
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user