Add PocketBase client with contracts and containerized tests
This commit is contained in:
1 parent
251f2d818b
commit
d78a13815a
5 files changed
+298
-73
No files matched your search
@@ -18,6 +18,7 @@
|
|||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
|
<PackageVersion Include="Testcontainers" Version="4.15.0" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -6,3 +6,14 @@
|
|||||||
# common, readable convention for tests. CA1707 (no underscores in member names)
|
# common, readable convention for tests. CA1707 (no underscores in member names)
|
||||||
# stays enforced in production code, but is turned off here.
|
# stays enforced in production code, but is turned off here.
|
||||||
dotnet_diagnostic.CA1707.severity = none
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
|
|
||||||
|
# CA1861 wants constant array arguments hoisted to static readonly fields to avoid
|
||||||
|
# re-allocation. In one-time test setup (collection seeding) that micro-optimization
|
||||||
|
# has no benefit and inline arrays keep the seed data readable.
|
||||||
|
dotnet_diagnostic.CA1861.severity = none
|
||||||
|
|
||||||
|
# CA1001 wants a type with a disposable field to implement IDisposable. The xunit
|
||||||
|
# fixture already owns and disposes its fields in IAsyncLifetime.DisposeAsync, which
|
||||||
|
# xunit calls; the analyzer just does not recognize that as the dispose contract.
|
||||||
|
# Adding IAsyncDisposable clashes with IAsyncLifetime's Task-returning DisposeAsync.
|
||||||
|
dotnet_diagnostic.CA1001.severity = none
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="Shouldly" />
|
<PackageReference Include="Shouldly" />
|
||||||
|
<PackageReference Include="Testcontainers" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,81 +1,35 @@
|
|||||||
using Elternbeirat.PocketBase;
|
|
||||||
|
|
||||||
namespace Elternbeirat.Web.Tests;
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Integration tests that hit a live PocketBase instance. They read the base URL
|
/// Tests the <see cref="PocketBaseClient"/> against a throwaway PocketBase container
|
||||||
/// from the PB_URL environment variable (falling back to the repo .env file) and
|
/// with a known seed (see <see cref="PocketBaseFixture"/>). Because the data is fixed,
|
||||||
/// skip themselves when no URL is configured, so CI without PocketBase stays green.
|
/// the tests assert on exact values, and they need no network to the live instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PocketBaseClientTests : IDisposable
|
public sealed class PocketBaseClientTests(PocketBaseFixture pocketBase) : IClassFixture<PocketBaseFixture>
|
||||||
{
|
{
|
||||||
// One HttpClient per test instance; xUnit creates a fresh instance per test,
|
|
||||||
// so this is disposed in Dispose() when the test finishes.
|
|
||||||
private readonly HttpClient _http = new();
|
|
||||||
|
|
||||||
public void Dispose() => _http.Dispose();
|
|
||||||
|
|
||||||
private static string? ResolvePocketBaseUrl()
|
|
||||||
{
|
|
||||||
var fromEnv = Environment.GetEnvironmentVariable("PB_URL");
|
|
||||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
|
||||||
return fromEnv;
|
|
||||||
|
|
||||||
// Fall back to the repo .env (gitignored, holds PB_URL locally).
|
|
||||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
||||||
while (dir is not null)
|
|
||||||
{
|
|
||||||
var envPath = Path.Combine(dir.FullName, ".env");
|
|
||||||
if (File.Exists(envPath))
|
|
||||||
{
|
|
||||||
foreach (var line in File.ReadAllLines(envPath))
|
|
||||||
{
|
|
||||||
var trimmed = line.Trim();
|
|
||||||
if (trimmed.StartsWith("PB_URL=", StringComparison.Ordinal))
|
|
||||||
return trimmed["PB_URL=".Length..].Trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dir = dir.Parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private PocketBaseClient? CreateClient()
|
|
||||||
{
|
|
||||||
var url = ResolvePocketBaseUrl();
|
|
||||||
if (string.IsNullOrWhiteSpace(url))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
_http.BaseAddress = new Uri(url);
|
|
||||||
return new PocketBaseClient(_http);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Events_load_from_live_instance()
|
public async Task Events_load_sorted_by_start()
|
||||||
{
|
{
|
||||||
var client = CreateClient();
|
var client = pocketBase.CreateClient();
|
||||||
if (client is null) return; // PB_URL not configured; skip live test.
|
|
||||||
|
|
||||||
var events = await client.GetEventsAsync();
|
var events = await client.GetEventsAsync();
|
||||||
|
|
||||||
events.ShouldNotBeEmpty();
|
// Two seeded events, earliest start first.
|
||||||
// Every event must at least have a title and a start.
|
events.Select(e => e.Title).ShouldBe(["Elternbeiratssitzung", "Herbstbasar"]);
|
||||||
events.ShouldAllBe(e => !string.IsNullOrWhiteSpace(e.Title) && e.Start != default);
|
events.ShouldAllBe(e => e.Public);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Event_time_is_read_as_wall_clock_not_shifted()
|
public async Task Event_time_is_read_as_wall_clock_not_shifted()
|
||||||
{
|
{
|
||||||
var client = CreateClient();
|
var client = pocketBase.CreateClient();
|
||||||
if (client is null) return; // PB_URL not configured; skip live test.
|
|
||||||
|
|
||||||
var events = await client.GetEventsAsync();
|
var events = await client.GetEventsAsync();
|
||||||
|
|
||||||
// The Elternbeiratssitzung is stored as 19:30; by the timezone convention
|
// The meeting is seeded as 19:30; by the timezone convention the number is
|
||||||
// the number is taken at face value, so no shift to 20:30/21:30 happens.
|
// taken at face value, so no shift to 20:30/21:30 happens.
|
||||||
var meeting = events.FirstOrDefault(@event =>
|
var meeting = events.FirstOrDefault(e =>
|
||||||
@event.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
|
e.Title.Contains("Elternbeiratssitzung", StringComparison.Ordinal));
|
||||||
meeting.ShouldNotBeNull();
|
meeting.ShouldNotBeNull();
|
||||||
meeting.Start.Hour.ShouldBe(19);
|
meeting.Start.Hour.ShouldBe(19);
|
||||||
meeting.Start.Minute.ShouldBe(30);
|
meeting.Start.Minute.ShouldBe(30);
|
||||||
@@ -83,29 +37,61 @@ public sealed class PocketBaseClientTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Pages_load_with_navigation_fields()
|
public async Task Event_without_end_maps_to_null()
|
||||||
{
|
{
|
||||||
var client = CreateClient();
|
var client = pocketBase.CreateClient();
|
||||||
if (client is null) return; // PB_URL not configured; skip live test.
|
|
||||||
|
|
||||||
var pages = await client.GetPagesAsync();
|
var events = await client.GetEventsAsync();
|
||||||
|
|
||||||
pages.ShouldNotBeEmpty();
|
var meeting = events.Single(e => e.Title == "Elternbeiratssitzung");
|
||||||
// Navigation depends on location + slug being present on every page.
|
var basar = events.Single(e => e.Title == "Herbstbasar");
|
||||||
pages.ShouldAllBe(p =>
|
meeting.End.ShouldBeNull(); // no end seeded
|
||||||
!string.IsNullOrWhiteSpace(p.Slug) && (p.Location == "header" || p.Location == "footer"));
|
basar.End.ShouldNotBeNull(); // end seeded
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Posts_and_faqs_load_from_live_instance()
|
public async Task Posts_load_newest_first()
|
||||||
{
|
{
|
||||||
var client = CreateClient();
|
var client = pocketBase.CreateClient();
|
||||||
if (client is null) return; // PB_URL not configured; skip live test.
|
|
||||||
|
|
||||||
var posts = await client.GetPostsAsync();
|
var posts = await client.GetPostsAsync();
|
||||||
|
|
||||||
|
// Sorted by -date: March before January.
|
||||||
|
posts.Select(p => p.Title).ShouldBe(["Neuer Vorstand", "Neue Sporthalle"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Pages_exclude_non_public_records()
|
||||||
|
{
|
||||||
|
var client = pocketBase.CreateClient();
|
||||||
|
|
||||||
|
var pages = await client.GetPagesAsync();
|
||||||
|
|
||||||
|
// Three pages seeded, one with public=false; the draft must be filtered out.
|
||||||
|
pages.Select(p => p.Slug).ShouldBe(["home", "contact"]);
|
||||||
|
pages.ShouldNotContain(p => p.Slug == "draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Page_embed_is_read_as_list()
|
||||||
|
{
|
||||||
|
var client = pocketBase.CreateClient();
|
||||||
|
|
||||||
|
var pages = await client.GetPagesAsync();
|
||||||
|
|
||||||
|
var home = pages.Single(p => p.Slug == "home");
|
||||||
|
home.Embed.ShouldBe(["posts", "events"], ignoreOrder: true);
|
||||||
|
home.Location.ShouldBe("header");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Faqs_load_from_instance()
|
||||||
|
{
|
||||||
|
var client = pocketBase.CreateClient();
|
||||||
|
|
||||||
var faqs = await client.GetFaqsAsync();
|
var faqs = await client.GetFaqsAsync();
|
||||||
|
|
||||||
posts.ShouldNotBeEmpty();
|
faqs.Select(f => f.Topic).ShouldBe(["mensa", "schliessfach"], ignoreOrder: true);
|
||||||
faqs.ShouldNotBeEmpty();
|
faqs.ShouldAllBe(f => !string.IsNullOrWhiteSpace(f.Question));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using DotNet.Testcontainers.Builders;
|
||||||
|
using DotNet.Testcontainers.Containers;
|
||||||
|
using Elternbeirat.PocketBase;
|
||||||
|
|
||||||
|
namespace Elternbeirat.Web.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a throwaway PocketBase container once per test run, creates the four
|
||||||
|
/// content collections and seeds them with a small, known data set. The tests run
|
||||||
|
/// against this instance instead of the live one, so they are hermetic (no network
|
||||||
|
/// to Unraid), reproducible (fixed data) and safe (isolated from production).
|
||||||
|
/// <para>
|
||||||
|
/// The seed data is deliberately fixed here rather than exported from the real
|
||||||
|
/// instance, so tests assert against values this file controls. Requires Docker.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
// Same image tag as production, so the tests exercise the real PocketBase
|
||||||
|
// version. Superuser credentials are only used to set up the container.
|
||||||
|
private const string Image = "ghcr.io/muchobien/pocketbase:0.40.4";
|
||||||
|
private const int PocketBasePort = 8090;
|
||||||
|
private const string AdminEmail = "test@example.com";
|
||||||
|
private const string AdminPassword = "test-password"; // >= 8 chars (PB rule)
|
||||||
|
|
||||||
|
private readonly IContainer _container = new ContainerBuilder(Image)
|
||||||
|
// PB_ADMIN_EMAIL/PASSWORD make the entrypoint upsert a superuser on start.
|
||||||
|
// The command must stay empty, or the entrypoint skips that step.
|
||||||
|
.WithEnvironment("PB_ADMIN_EMAIL", AdminEmail)
|
||||||
|
.WithEnvironment("PB_ADMIN_PASSWORD", AdminPassword)
|
||||||
|
.WithPortBinding(PocketBasePort, assignRandomHostPort: true)
|
||||||
|
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||||
|
.UntilHttpRequestIsSucceeded(r => r.ForPath("/api/health").ForPort(PocketBasePort)))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// One HttpClient shared by all tests through the client; the fixture owns it and
|
||||||
|
// disposes it in DisposeAsync. Its BaseAddress is set once the container is up.
|
||||||
|
private readonly HttpClient _http = new();
|
||||||
|
|
||||||
|
/// <summary>Base URL of the running container, e.g. http://localhost:49153.</summary>
|
||||||
|
public Uri BaseUrl =>
|
||||||
|
new($"http://{_container.Hostname}:{_container.GetMappedPublicPort(PocketBasePort)}");
|
||||||
|
|
||||||
|
/// <summary>Creates a <see cref="PocketBaseClient"/> pointed at this container.</summary>
|
||||||
|
public PocketBaseClient CreateClient() => new(_http);
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _container.StartAsync();
|
||||||
|
_http.BaseAddress = BaseUrl;
|
||||||
|
await SeedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_http.Dispose();
|
||||||
|
await _container.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates as superuser, then creates the collections and records. This
|
||||||
|
/// mirrors the shape the client reads, not the full production schema.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SeedAsync()
|
||||||
|
{
|
||||||
|
var token = await AuthenticateAsync(_http);
|
||||||
|
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
||||||
|
|
||||||
|
await CreateCollectionsAsync(_http);
|
||||||
|
await SeedRecordsAsync(_http);
|
||||||
|
|
||||||
|
// Drop the superuser token so the tests read as an anonymous visitor would,
|
||||||
|
// exercising the public list/view rules rather than a privileged bypass.
|
||||||
|
_http.DefaultRequestHeaders.Authorization = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> AuthenticateAsync(HttpClient http)
|
||||||
|
{
|
||||||
|
// The superuser upsert runs before serve, so once /api/health answers the
|
||||||
|
// account exists; a couple of retries still guard against a race.
|
||||||
|
for (var attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
"/api/collections/_superusers/auth-with-password",
|
||||||
|
new { identity = AdminEmail, password = AdminPassword });
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<AuthResponse>();
|
||||||
|
return payload?.Token ?? throw new InvalidOperationException("No auth token returned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt >= 5)
|
||||||
|
response.EnsureSuccessStatusCode(); // give up: throw with the status.
|
||||||
|
|
||||||
|
await Task.Delay(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CreateCollectionsAsync(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 = "editor" },
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await CreateCollectionAsync(http, "posts", new object[]
|
||||||
|
{
|
||||||
|
new { name = "date", type = "date" },
|
||||||
|
new { name = "title", type = "text", required = true },
|
||||||
|
new { name = "body", type = "editor" },
|
||||||
|
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 = "editor" },
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedRecordsAsync(HttpClient http)
|
||||||
|
{
|
||||||
|
// Pages: one header page, one footer page. Both public.
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Startseite", body = "# Willkommen", location = "header",
|
||||||
|
order = 1, slug = "home", embed = new[] { "posts", "events" }, @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Kontakt", body = "Mail an uns", location = "footer",
|
||||||
|
order = 1, slug = "contact", embed = Array.Empty<string>(), @public = true,
|
||||||
|
});
|
||||||
|
// A draft page that must never appear (public=false).
|
||||||
|
await CreateRecordAsync(http, "pages", new
|
||||||
|
{
|
||||||
|
title = "Entwurf", body = "geheim", location = "header",
|
||||||
|
order = 9, slug = "draft", embed = Array.Empty<string>(), @public = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Posts: newest first once sorted by -date.
|
||||||
|
await CreateRecordAsync(http, "posts", new
|
||||||
|
{
|
||||||
|
date = "2026-03-01 00:00:00.000Z", title = "Neuer Vorstand",
|
||||||
|
body = "Text", slug = "new-board", @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "posts", new
|
||||||
|
{
|
||||||
|
date = "2026-01-15 00:00:00.000Z", title = "Neue Sporthalle",
|
||||||
|
body = "Text", slug = "new-hall", @public = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events: the meeting carries the wall-clock time the timezone test checks.
|
||||||
|
await CreateRecordAsync(http, "events", new
|
||||||
|
{
|
||||||
|
start = "2026-10-08 19:30:00.000Z", title = "Elternbeiratssitzung",
|
||||||
|
location = "Aula", note = "", @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "events", new
|
||||||
|
{
|
||||||
|
start = "2026-11-22 09:00:00.000Z", end = "2026-11-22 13:00:00.000Z",
|
||||||
|
title = "Herbstbasar", location = "Schulhof", note = "", @public = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Faqs: two topics.
|
||||||
|
await CreateRecordAsync(http, "faqs", new
|
||||||
|
{
|
||||||
|
question = "Wann gibt es Mittagessen?", answer = "Um 12 Uhr.",
|
||||||
|
topic = "mensa", @public = true,
|
||||||
|
});
|
||||||
|
await CreateRecordAsync(http, "faqs", new
|
||||||
|
{
|
||||||
|
question = "Wie viel kostet ein Schließfach?", answer = "20 Euro.",
|
||||||
|
topic = "schliessfach", @public = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CreateRecordAsync(HttpClient http, string collection, object record)
|
||||||
|
{
|
||||||
|
var response = await http.PostAsJsonAsync($"/api/collections/{collection}/records", record);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AuthResponse
|
||||||
|
{
|
||||||
|
[System.Text.Json.Serialization.JsonPropertyName("token")]
|
||||||
|
public string Token { get; init; } = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user