Auto-seed dev PocketBase and share its schema with the tests

This commit is contained in:
tleininger committed 2026-09-23 15:59:15 +02:00
1 parent d5372963bb
commit c3da5eb6e1
6 files changed
+537 -65

No files matched your search

+35 -58
View File
@@ -2,6 +2,8 @@ using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Elternbeirat.PocketBase;
namespace Elternbeirat.Web.Tests;
@@ -16,9 +18,16 @@ namespace Elternbeirat.Web.Tests;
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
/// in one place -- the compose files -- and the tests always exercise the same
/// PocketBase the stack runs. The seed data is deliberately fixed here rather than
/// exported from the real instance, so tests assert against values this file
/// controls. Requires Docker with the Compose plugin.
/// PocketBase the stack runs.
/// </para>
/// <para>
/// The collection <b>schema</b> is not typed out here: it is imported from
/// <c>pb/pb_migrations/collections_schema.json</c>, the same file the dev seed
/// migration uses, so the schema is defined once and cannot drift between the two.
/// The seeded <b>records</b>, by contrast, are fixed in this file on purpose (not
/// the dev seed's data), so tests assert against values this file controls -- e.g.
/// a non-public draft page that must never appear, and an event at a known time.
/// Requires Docker with the Compose plugin.
/// </para>
/// </summary>
public sealed class PocketBaseFixture : IAsyncLifetime
@@ -145,15 +154,15 @@ public sealed class PocketBaseFixture : IAsyncLifetime
}
/// <summary>
/// Authenticates as superuser, then creates the collections and records. This
/// mirrors the shape the client reads, not the full production schema.
/// Authenticates as superuser, then imports the shared collection schema and
/// seeds the fixed test records the tests assert against.
/// </summary>
private async Task SeedAsync()
{
var token = await AuthenticateAsync(_http);
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
await CreateCollectionsAsync(_http);
await ImportCollectionsAsync(_http);
await SeedRecordsAsync(_http);
// Drop the superuser token so the tests read as an anonymous visitor would,
@@ -184,59 +193,27 @@ public sealed class PocketBaseFixture : IAsyncLifetime
}
}
private static async Task CreateCollectionsAsync(HttpClient http)
/// <summary>
/// Imports the four content collections from the shared schema file, the same
/// one the dev seed migration reads (<c>pb/pb_migrations/collections_schema.json</c>).
/// Uses PocketBase's <c>/api/collections/import</c> so the whole schema is
/// defined in one place instead of being typed out here.
/// </summary>
private static async Task ImportCollectionsAsync(HttpClient http)
{
// listRule/viewRule = "" means publicly readable; the client's
// filter=public=true does the visibility gating on top.
await CreateCollectionAsync(http, "pages", new object[]
{
new { name = "title", type = "text", required = true },
new { name = "body", type = "text" },
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
new { name = "order", type = "number" },
new { name = "slug", type = "text", required = true },
new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } },
new { name = "public", type = "bool" },
});
// The file maps name -> collection object; the import endpoint wants a
// flat list, so unwrap the values. deleteMissing=false leaves anything
// already present untouched (the import is idempotent).
var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json");
using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path));
var collections = schema.RootElement.EnumerateObject()
.Select(property => property.Value)
.ToArray();
await CreateCollectionAsync(http, "posts", new object[]
{
new { name = "date", type = "date" },
new { name = "title", type = "text", required = true },
new { name = "body", type = "text" },
new { name = "slug", type = "text", required = true },
new { name = "public", type = "bool" },
});
await CreateCollectionAsync(http, "events", new object[]
{
new { name = "start", type = "date", required = true },
new { name = "end", type = "date" },
new { name = "title", type = "text", required = true },
new { name = "location", type = "text" },
new { name = "note", type = "text" },
new { name = "public", type = "bool" },
});
await CreateCollectionAsync(http, "faqs", new object[]
{
new { name = "question", type = "text", required = true },
new { name = "answer", type = "text" },
new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } },
new { name = "public", type = "bool" },
});
}
private static async Task CreateCollectionAsync(HttpClient http, string name, object[] fields)
{
var response = await http.PostAsJsonAsync("/api/collections", new
{
name,
type = "base",
listRule = "",
viewRule = "",
fields,
});
// Serialize the JsonElement list back to JSON for the request body.
var body = JsonSerializer.Serialize(new { collections, deleteMissing = false });
using var content = new StringContent(body, Encoding.UTF8, "application/json");
var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content);
response.EnsureSuccessStatusCode();
}
@@ -277,7 +254,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime
await CreateRecordAsync(http, "pages", new
{
title = "FAQ Mensa", body = "# Mensa", location = "",
order = 0, slug = "faq-mensa", embed = Array.Empty<string>(), @public = true,
order = 0, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true,
});
await CreateRecordAsync(http, "pages", new
{
+1 -1
View File
@@ -49,7 +49,7 @@ public sealed class RouteSmokeTests : IDisposable
"/board",
"/patrons",
"/faqs",
"/faq-mensa",
"/faq-lunch",
"/downloads",
"/contact",
"/imprint",