Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up .NET (version from global.json)
uses: actions/setup-dotnet@v4
with:
global-json-file: global.json

# The smoke tests boot the API in-process and need a real SQL Server (Delta reads @@DBTS).
# The SA password is generated fresh for every run and never written to the repo or the log.
- name: Start SQL Server
run: |
set -euo pipefail
SA_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')Aa1!"
echo "::add-mask::$SA_PASSWORD"

docker run -d --name sqlserver \
-e ACCEPT_EULA=Y \
-e "MSSQL_SA_PASSWORD=$SA_PASSWORD" \
-p 1433:1433 \
mcr.microsoft.com/mssql/server:2022-latest

for i in $(seq 1 30); do
if docker exec sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" > /dev/null 2>&1; then
echo "SQL Server is ready after $((i * 2))s"
break
fi
if [ "$i" -eq 30 ]; then
echo "SQL Server did not become ready in time" >&2
docker logs sqlserver >&2
exit 1
fi
sleep 2
done

echo "HYPERCACHE_TEST_CONNECTION=Server=localhost,1433;Database=HyperCacheDB_Test;User ID=sa;Password=$SA_PASSWORD;TrustServerCertificate=True;MultipleActiveResultSets=true" >> "$GITHUB_ENV"

- name: Restore
run: dotnet restore HyperCache.slnx

- name: Build
run: dotnet build HyperCache.slnx --configuration Release --no-restore

- name: Test
run: dotnet test HyperCache.slnx --configuration Release --no-build --logger "trx;LogFileName=test-results.trx"

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: "**/TestResults/*.trx"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,6 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/HyperCache/appsettings.Development.json

# Claude Code guidance file (local only)
CLAUDE.md
93 changes: 93 additions & 0 deletions HyperCache.Api.Tests/CustomPropertiesEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Net;
using System.Net.Http.Json;

namespace HyperCache.Api.Tests;

public sealed class CustomPropertiesEndpointTests(HyperCacheFactory factory) : IClassFixture<HyperCacheFactory>
{
private const string PagedUrl = "/api/customproperties/paged?page=1&pageSize=5";

private sealed record PagedResponse(List<Item> Items, int CurrentPage, int TotalPages, bool HasPreviousPage, bool HasNextPage);
private sealed record Item(Guid Id, string Name, string Value, string ParentTable, string CreatedBy, string ModifiedBy);

[Fact]
public async Task Paged_returns_page_with_etag()
{
using var client = factory.CreateClient();

var response = await client.GetAsync(PagedUrl);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.ETag);

var body = await response.Content.ReadFromJsonAsync<PagedResponse>();
Assert.NotNull(body);
Assert.Equal(5, body.Items.Count);
Assert.Equal(1, body.CurrentPage);
Assert.Equal(HyperCacheFactory.SeedCount / 5, body.TotalPages);
Assert.False(body.HasPreviousPage);
Assert.True(body.HasNextPage);
}

[Fact]
public async Task Paged_with_matching_etag_returns_304()
{
using var client = factory.CreateClient();

var first = await client.GetAsync(PagedUrl);
var etag = first.Headers.ETag;
Assert.NotNull(etag);

using var request = new HttpRequestMessage(HttpMethod.Get, PagedUrl);
request.Headers.IfNoneMatch.Add(etag);
var second = await client.SendAsync(request);

Assert.Equal(HttpStatusCode.NotModified, second.StatusCode);
}

[Fact]
public async Task Search_returns_matches_and_rejects_empty_keyword()
{
using var client = factory.CreateClient();

var ok = await client.GetAsync("/api/customproperties/search?keyword=_Property_1");
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
var items = await ok.Content.ReadFromJsonAsync<List<Item>>();
Assert.NotNull(items);
Assert.NotEmpty(items);
Assert.All(items, i => Assert.Contains("_Property_1", i.Name));

var bad = await client.GetAsync("/api/customproperties/search?keyword=");
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
}

[Fact]
public async Task Details_validates_id_and_returns_404_for_unknown()
{
using var client = factory.CreateClient();

var bad = await client.GetAsync("/api/customproperties/not-a-guid");
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);

var missing = await client.GetAsync($"/api/customproperties/{Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);

var page = await client.GetFromJsonAsync<PagedResponse>(PagedUrl);
Assert.NotNull(page);
var found = await client.GetAsync($"/api/customproperties/{page.Items[0].Id}");
Assert.Equal(HttpStatusCode.OK, found.StatusCode);
}

[Fact]
public async Task OpenApi_document_is_served_in_development()
{
using var client = factory.CreateClient();

var response = await client.GetAsync("/openapi/v1.json");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var json = await response.Content.ReadAsStringAsync();
// Route templates keep the controller's casing in the document: /api/CustomProperties/paged
Assert.Contains("/api/customproperties/paged", json, StringComparison.OrdinalIgnoreCase);
}
}
25 changes: 25 additions & 0 deletions HyperCache.Api.Tests/HyperCache.Api.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\HyperCache\HyperCache.Api.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
91 changes: 91 additions & 0 deletions HyperCache.Api.Tests/HyperCacheFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using HyperCache.Api.Data;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;

namespace HyperCache.Api.Tests;

/// <summary>
/// Boots the real API in-process against a throw-away SQL Server database.
/// Delta reads @@DBTS, so these tests need a reachable SQL Server; there is no in-memory substitute.
///
/// Connection string resolution:
/// 1. HYPERCACHE_TEST_CONNECTION environment variable, if set.
/// 2. Otherwise the API's own ConnectionStrings:DefaultConnection (appsettings*.json) with the
/// database name replaced by <see cref="TestDatabaseName"/>.
/// The database is dropped and re-created from migrations on every run.
/// </summary>
public sealed class HyperCacheFactory : WebApplicationFactory<Program>
{
public const string TestDatabaseName = "HyperCacheDB_Test";
public const string ConnectionEnvVar = "HYPERCACHE_TEST_CONNECTION";
public const int SeedCount = 200;

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(Environments.Development);
builder.UseSetting("Seed:Count", SeedCount.ToString());

builder.ConfigureAppConfiguration((context, config) =>
{
var connectionString = ResolveConnectionString(context.Configuration);

// Runs while the host is being built, i.e. before Program's seeder executes.
RecreateDatabase(connectionString);

config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = connectionString
});
});
}

private static string ResolveConnectionString(IConfiguration appConfiguration)
{
var fromEnv = Environment.GetEnvironmentVariable(ConnectionEnvVar);
if (!string.IsNullOrWhiteSpace(fromEnv))
return fromEnv;

var fromApp = appConfiguration.GetConnectionString("DefaultConnection");
if (string.IsNullOrWhiteSpace(fromApp) || fromApp.Contains("<your-server-name>"))
{
throw new InvalidOperationException(
$"No usable SQL Server connection string. Set the {ConnectionEnvVar} environment variable " +
"or configure ConnectionStrings:DefaultConnection in HyperCache/appsettings.Development.json.");
}

return new SqlConnectionStringBuilder(fromApp) { InitialCatalog = TestDatabaseName }.ConnectionString;
}

private static void RecreateDatabase(string connectionString)
{
// This drops the database. Refuse anything that doesn't look like a test database so a
// mis-set HYPERCACHE_TEST_CONNECTION can never wipe HyperCacheDB.
var database = new SqlConnectionStringBuilder(connectionString).InitialCatalog;
if (!database.EndsWith("_Test", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Refusing to drop database '{database}': test connection strings must target a database whose name ends in '_Test'.");
}

var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(connectionString)
.Options;

using var db = new AppDbContext(options);
try
{
db.Database.EnsureDeleted();
db.Database.Migrate();
}
catch (SqlException ex)
{
throw new InvalidOperationException(
$"Could not reach SQL Server to create the test database '{TestDatabaseName}'. " +
$"Set {ConnectionEnvVar} to a reachable server. Underlying error: {ex.Message}", ex);
}
}
}
6 changes: 3 additions & 3 deletions HyperCache.Web/Constants/ApiConstant.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace HyperCache.Web.Constants;
namespace HyperCache.Web.Constants;

public class ApiConstant
public static class ApiConstant
{
public const string BaseUrl = "https://localhost:7148";
public const string CustomProperties = "api/customproperties";
public const string CustomPropertiesPaged = "api/customproperties/paged";
public const string CustomPropertiesSearch = "api/customproperties/search";
public const string CustomPropertiesAll = "api/customproperties/all";
}
}
7 changes: 4 additions & 3 deletions HyperCache.Web/HyperCache.Web.csproj
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="9.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion HyperCache.Web/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
<a href="https://github.com/biswajitpanday/HyperCache" target="_blank">GitHub</a>
</div>

<article class="content px-4">
Expand Down
22 changes: 18 additions & 4 deletions HyperCache.Web/Pages/Home.razor
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
@page "/"
@page "/"

<PageTitle>Home</PageTitle>
<PageTitle>HyperCache</PageTitle>

<h1>Hello, world!</h1>
<h1 class="text-primary">HyperCache</h1>

Welcome to your new app.
<p class="lead">
A demo of HTTP caching with the <a href="https://github.com/SimonCropp/Delta" target="_blank">Delta</a> middleware
on .NET 10, Entity Framework Core 10 and SQL Server.
</p>

<p>
The API behind this page holds one million rows. Every <code>GET</code> carries an <code>ETag</code> derived from
the database's change stamp, so repeating a request costs a <code>304 Not Modified</code> instead of a query.
Open the browser's network tab, page back and forth, and watch the repeat requests come back in milliseconds.
</p>

<div class="d-flex gap-2 mt-4">
<a class="btn btn-primary" href="custom-properties">Browse properties</a>
<a class="btn btn-outline-primary" href="search">Search</a>
</div>
8 changes: 3 additions & 5 deletions HyperCache.Web/wwwroot/index.html
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HyperCache</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="HyperCache.Web.styles.css" rel="stylesheet" />
<script type="importmap"></script>
</head>

<body>
<div id="app">
<svg class="loading-progress">
Expand All @@ -20,13 +20,11 @@
</svg>
<div class="loading-progress-text"></div>
</div>

<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
<script src="_framework/blazor.webassembly.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>

</html>
Loading
Loading