Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
42f3027
fix(tests): make the shared fixtures in `Shoko.TestData` loadable
Cazzar Sep 6, 2026
3efdc80
test: cover auto-grouping, the poco cache, episode lists and episode …
Cazzar Sep 6, 2026
c845b4c
repo(workflows): run the unit test suites in CI
Cazzar Sep 6, 2026
b208617
test: cover filter expression persistence and the sorting selectors
Cazzar Sep 6, 2026
17536c5
refactor(relocation): resolve file system access through `IFileSystem…
Cazzar Sep 6, 2026
26ba111
test: cover how video playback updates fold into stored user data
Cazzar Sep 6, 2026
5f335c7
fix(db): add the remaining missing primary keys on SQL Server
Cazzar Sep 6, 2026
e1a240b
test: compare the schema each database backend defines
Cazzar Sep 6, 2026
4c4eab5
test: cover automatic file deletion and the persistence converters
Cazzar Sep 6, 2026
e51702d
test: cover the missing-episode statistics written onto a series
Cazzar Sep 6, 2026
8c12de3
refactor(anidb): resolve the UDP socket through a factory
Cazzar Sep 6, 2026
119bd3f
test: cover the AniDB HTTP and UDP connection handlers
Cazzar Sep 6, 2026
a91818a
test: cover parsing of the AniDB anime XML
Cazzar Sep 6, 2026
8544e24
fix(db): compare list-valued columns by value when checking for changes
Cazzar Sep 6, 2026
29a9147
test: make the sorting selector and converter tests actually able to …
Cazzar Sep 6, 2026
25b935d
test: actually compare the schema across backends
Cazzar Sep 6, 2026
1c3806a
test: record the empty episode-input crash as a skipped test
Cazzar Sep 6, 2026
fc7fc89
test: reach the release-group and air-date branches in the series stats
Cazzar Sep 6, 2026
106ec0b
test: make the selector test data prove it can tell selectors apart
Cazzar Sep 6, 2026
38d0805
test: skip the latest-local-episode assertion pending a race investig…
Cazzar Sep 6, 2026
653b7ef
test: close the gaps a second review found in the new suites
Cazzar Sep 6, 2026
700dbbf
test: cover playlist DSL rejection, and record the broken extras syntax
Cazzar Sep 6, 2026
c0d4f20
test: close the gaps a third review found
Cazzar Sep 6, 2026
375e314
test: assert the documented playlist extras syntax, skipped pending a…
Cazzar Sep 6, 2026
0892236
test: drop the playlist extras assertion
Cazzar Sep 6, 2026
b26de14
test: serialise the stub settings provider install
Cazzar Sep 6, 2026
ec8ba98
fix(db): hash list-valued columns the same way they are compared
Cazzar Sep 6, 2026
84d7e0b
repo: build `Shoko.TestData` in the Release solution configuration
Cazzar Sep 6, 2026
cea6dfb
test(db): compare the three backend schemas column by column
Cazzar Sep 6, 2026
ddd4429
test(db): share one server bootstrap across the integration tests
Cazzar Sep 6, 2026
8fe5391
chore(db): trim the schema comparison comments
Cazzar Sep 6, 2026
2cdb99e
fix(db): hold every backend to the nullability its model declares
Cazzar Sep 6, 2026
18ec616
fix(db): repair the columns only damaged when the database is pre-cre…
Cazzar Sep 6, 2026
212e9a7
fix(db): skip runtime-emitted assemblies when resolving a filter's type
Cazzar Sep 6, 2026
4cbcb55
test: cover the binder's assembly scan, and stop the rate limiter tes…
Cazzar Sep 6, 2026
77b26ad
fix: skip runtime-emitted assemblies in every type scan
Cazzar Sep 6, 2026
9d64b5f
test: assert logic rather than elapsed time
Cazzar Sep 6, 2026
fab332d
test: stop the last two tests depending on how the machine schedules …
Cazzar Sep 6, 2026
bd95f39
repo(workflows): pin the artifact actions to the versions the repo al…
Cazzar Sep 6, 2026
2150d70
fix(db): keep the case-sensitive collation MySQL's text conversion un…
revam Sep 6, 2026
4166606
fix(db): give SQLite the column types the other two backends declare
revam Sep 6, 2026
248a028
fix(db): declare one width per column across the three backends
revam Sep 6, 2026
2d102cd
repo(scripts): create the comparison's database the way CI does
revam Sep 6, 2026
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
52 changes: 52 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ jobs:
- name: Run integration tests
env:
DB_TYPE: SQLite
SHOKO_SCHEMA_DIR: ${{ github.workspace }}/schema-dumps
run: dotnet test Shoko.IntegrationTests/Shoko.IntegrationTests.csproj -c Release --logger "console;verbosity=normal"

- name: Upload schema dump
uses: actions/upload-artifact@v7
with:
name: schema-SQLite
path: schema-dumps/schema-SQLite.json

test-mysql:
runs-on: ubuntu-latest
name: Integration Tests — MySQL (MariaDB)
Expand Down Expand Up @@ -67,8 +74,15 @@ jobs:
DB_USER: root
DB_PASS: root
DB_NAME: shoko
SHOKO_SCHEMA_DIR: ${{ github.workspace }}/schema-dumps
run: dotnet test Shoko.IntegrationTests/Shoko.IntegrationTests.csproj -c Release --logger "console;verbosity=normal"

- name: Upload schema dump
uses: actions/upload-artifact@v7
with:
name: schema-MySQL
path: schema-dumps/schema-MySQL.json

test-mssql:
runs-on: ubuntu-latest
name: Integration Tests — SQL Server
Expand Down Expand Up @@ -107,4 +121,42 @@ jobs:
DB_USER: sa
DB_PASS: "ShokoTest1!"
DB_NAME: shoko
SHOKO_SCHEMA_DIR: ${{ github.workspace }}/schema-dumps
run: dotnet test Shoko.IntegrationTests/Shoko.IntegrationTests.csproj -c Release --logger "console;verbosity=normal"

- name: Upload schema dump
uses: actions/upload-artifact@v7
with:
name: schema-SQLServer
path: schema-dumps/schema-SQLServer.json

schema-parity:
runs-on: ubuntu-latest
name: Schema Parity — SQLite vs MySQL vs SQL Server
needs: [test-sqlite, test-mysql, test-mssql]

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'

# Each backend job published the schema its own migration produced; the comparison needs all
# three side by side, and skips rather than passes if any is missing.
- name: Download schema dumps
uses: actions/download-artifact@v8
with:
pattern: schema-*
merge-multiple: true
path: schema-dumps

- name: Compare the three schemas
env:
SHOKO_SCHEMA_DIR: ${{ github.workspace }}/schema-dumps
run: >-
dotnet test Shoko.Tests/Shoko.Tests.csproj -c Release
--filter "FullyQualifiedName~SchemaTypeParityTests"
--logger "console;verbosity=normal"
31 changes: 31 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Unit Tests

on:
push:
branches:
- master
pull_request:
branches:
- master

jobs:
test:
runs-on: ubuntu-latest
name: Unit Tests

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.x'

# These suites are self-contained: no database, no network, and no native
# dependencies, so they need none of the setup the integration tests do.
- name: Run unit tests
run: dotnet test Shoko.Tests/Shoko.Tests.csproj -c Release --logger "console;verbosity=normal"

- name: Run queue processor tests
run: dotnet test Shoko.QueueProcessor.Tests/Shoko.QueueProcessor.Tests.csproj -c Release --logger "console;verbosity=normal"
41 changes: 36 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,42 @@ Plugin controllers are registered via `AddPluginControllers` during API setup.

### Testing

- **Framework**: xUnit 2.7.0 with `Xunit.DependencyInjection` 9.1.0 for DI in tests
- **Mocking**: Moq 4.20.70
- **Coverage**: coverlet 6.0.2
- **Test SDK**: Microsoft.NET.Test.Sdk 17.9.0
- Unit tests in `Shoko.Tests/`, integration tests in `Shoko.IntegrationTests/`
- **Framework**: xUnit v3 (`xunit.v3`) — there is no `Xunit.DependencyInjection`; test classes take
their dependencies through fixtures or build them directly
- **Mocking**: Moq
- **Coverage**: coverlet
- **Test SDK**: Microsoft.NET.Test.Sdk

**Where a test belongs**

| Project | Scope |
|---------|-------|
| `Shoko.Tests` | Unit tests. No database, no network, no DI container. |
| `Shoko.QueueProcessor.Tests` | The EF Core job queue, against in-memory SQLite. |
| `Shoko.IntegrationTests` | Full server bootstrap against a real database, run in CI over SQLite, MySQL and SQL Server (selected by `DB_TYPE`). |
| `Shoko.TestData` | Shared JSON fixtures consumed by tests and benchmarks. |

Prefer the cheapest option that can actually exercise the behaviour: plain unit tests first, then the
cache-backed repositories described below, and a real database only when persistence itself is the
subject.

**Testing code that reads `RepoFactory`**

Domain models resolve their navigation properties through the `RepoFactory` statics, which normally
forces a database. `Shoko.Tests/Infrastructure/` avoids that:

- `CachedRepo.Build<TRepo, TKey, TEntity>(keySelector, entities)` returns a **real** repository whose
rows live in an in-memory `PocoCache`. Read paths, including each repository's own indexes, run
exactly as in production. `Save`/`Delete` are not supported — mock those instead.
- `RepoFactoryScope` installs repositories into the `RepoFactory` statics and restores them on
dispose. Its `With<TRepo, TKey, TEntity>(...)` overload builds and installs in one step.

Those statics are process-global, so every test using `RepoFactoryScope` must be annotated
`[Collection(nameof(RepoFactoryCollection))]`, which serialises them while the rest of the suite
keeps running in parallel.

Note that `ISystemService.StaticServices` is **write-once per process** — it throws on a second
assignment. Nothing in `Shoko.Tests` sets it, and new tests should keep it that way.

### Database Migrations

Expand Down
2 changes: 1 addition & 1 deletion Shoko.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using BenchmarkDotNet.Running;
using Benchmarks;

BenchmarkRunner.Run<AniDB_AnimeBenchmarks>();
BenchmarkSwitcher.FromTypes([typeof(AniDB_AnimeBenchmarks), typeof(TagFilterBenchmarks)]).RunAll();
28 changes: 28 additions & 0 deletions Shoko.Benchmarks/TagFilterBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using BenchmarkDotNet.Attributes;
using Shoko.Server;

namespace Benchmarks;

/// <summary>
/// Tag filtering runs over every tag of every anime, so its throughput is worth watching.
/// </summary>
/// <remarks>
/// Was a <c>[Fact]</c> in <c>Shoko.Tests</c> asserting an average under 2000ms. That is a benchmark,
/// not a test, and on a shared CI runner it measured 2911ms and failed the suite.
/// </remarks>
[BenchmarkCategory("TagFilter")]
public class TagFilterBenchmarks
{
private const TagFilter.Filter Filters =
TagFilter.Filter.Genre | TagFilter.Filter.AnidbInternal | TagFilter.Filter.Programming | TagFilter.Filter.Misc;

private static readonly string[] _tags =
[
"comedy", "Comedy", "horror", "18 restricted", "large breasts", "japan", "violence", "action", "romance",
"school life", "seinen", "shounen", "asia", "contemporary fantasy", "earth", "afterlife", "alien",
"angst", "ecchi", "gore", "themes", "elements", "origin", "setting", "manga", "new", "ugly",
];

[Benchmark]
public List<string> ProcessTags() => TagFilter.String.ProcessTags(Filters, _tags);
}
17 changes: 17 additions & 0 deletions Shoko.IntegrationTests/DatabaseCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Xunit;

namespace Shoko.IntegrationTests;

/// <summary>
/// Shares one server bootstrap across every test class in the collection.
/// </summary>
/// <remarks>
/// <c>ISystemService.StaticServices</c> is write-once per process, so a second
/// <see cref="DatabaseMigrationFixture"/> throws. A class fixture is one instance per class; this is
/// one for the run.
/// </remarks>
[CollectionDefinition(Name)]
public class DatabaseCollection : ICollectionFixture<DatabaseMigrationFixture>
{
public const string Name = "Database";
}
19 changes: 19 additions & 0 deletions Shoko.IntegrationTests/DatabaseMigrationFixture.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System;
using System.Data;
using System.IO;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shoko.Server.Databases;
using Shoko.Server.Services;
using Shoko.Server.Settings;

Expand All @@ -25,6 +28,22 @@ public sealed class DatabaseMigrationFixture : IDisposable

public string? FailureMessage { get; private set; }

/// <summary>The backend this run migrated, as selected by <c>DB_TYPE</c>.</summary>
public string Backend { get; } = Environment.GetEnvironmentVariable("DB_TYPE") is { Length: > 0 } type ? type : "SQLite";

/// <summary>
/// An open connection to the migrated database, for reading its catalog. The caller owns it.
/// </summary>
public IDbConnection OpenConnection()
{
var session = _host!.Services.GetRequiredService<DatabaseFactory>().SessionFactory.OpenSession();
var connection = session.Connection;
if (connection.State is not ConnectionState.Open)
connection.Open();

return connection;
}

private readonly string _tempDir;

private readonly IHost? _host;
Expand Down
4 changes: 2 additions & 2 deletions Shoko.IntegrationTests/DatabaseMigrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ namespace Shoko.IntegrationTests;
/// Verifies that all database migrations run without error against the backend
/// configured via environment variables (see <see cref="DatabaseMigrationFixture"/>).
/// </summary>
[Collection("Database")]
public class DatabaseMigrationTests : IClassFixture<DatabaseMigrationFixture>
[Collection(DatabaseCollection.Name)]
public class DatabaseMigrationTests
{
private readonly DatabaseMigrationFixture _fixture;

Expand Down
39 changes: 39 additions & 0 deletions Shoko.IntegrationTests/SchemaSnapshotTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.IO;
using System.Text.Json;
using Shoko.TestData.Schema;
using Xunit;

namespace Shoko.IntegrationTests;

/// <summary>
/// Records the schema of the database this run migrated, for the cross-backend comparison in
/// <c>Shoko.Tests</c> to pick up.
/// </summary>
/// <remarks>
/// Written to the directory named by <see cref="SchemaDumps.DirectoryVariable"/>, which each CI job
/// publishes for a later job to compare. Unset, this only checks the schema can be read.
/// </remarks>
[Collection(DatabaseCollection.Name)]
public class SchemaSnapshotTests(DatabaseMigrationFixture fixture)
{
[Fact]
public void TheMigratedSchemaIsRecorded()
{
Assert.True(fixture.Success, fixture.FailureMessage);

using var connection = fixture.OpenConnection();
var schema = SchemaSnapshot.Read(connection, fixture.Backend);

// A near-empty dump would make every column look agreed downstream.
Assert.True(schema.Tables.Count > 60, $"{fixture.Backend}: only {schema.Tables.Count} tables.");

if (Environment.GetEnvironmentVariable(SchemaDumps.DirectoryVariable) is not { Length: > 0 } directory)
return;

Directory.CreateDirectory(directory);
File.WriteAllText(
Path.Combine(directory, SchemaDumps.FileNameFor(fixture.Backend)),
JsonSerializer.Serialize(schema.Tables, new JsonSerializerOptions { WriteIndented = true }));
}
}
1 change: 1 addition & 0 deletions Shoko.IntegrationTests/Shoko.IntegrationTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

<ItemGroup>
<ProjectReference Include="..\Shoko.Server\Shoko.Server.csproj" />
<ProjectReference Include="..\Shoko.TestData\Shoko.TestData.csproj" />
</ItemGroup>


Expand Down
12 changes: 9 additions & 3 deletions Shoko.QueueProcessor.Tests/PersistenceBufferTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,22 @@ public async Task OnEnqueue_MaxBatchReached_TriggersImmediateFlush()
var (buffer, repo) = Make(flushIntervalMs: 60_000, maxBatch: 3);

var insertedCount = 0;
// The flush is fire-and-forget, so the test waits to be told it happened rather than sleeping
// for a period a loaded machine can overrun.
var flushed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
repo.Setup(r => r.InsertBatchAsync(It.IsAny<IReadOnlyCollection<QueuedJob>>(), It.IsAny<CancellationToken>()))
.Callback<IReadOnlyCollection<QueuedJob>, CancellationToken>((jobs, _) => insertedCount += jobs.Count)
.Callback<IReadOnlyCollection<QueuedJob>, CancellationToken>((jobs, _) =>
{
insertedCount += jobs.Count;
flushed.TrySetResult();
})
.Returns(Task.CompletedTask);

buffer.OnEnqueue(FakeJob());
buffer.OnEnqueue(FakeJob());
buffer.OnEnqueue(FakeJob()); // this triggers the force flush

// Give the async flush a moment (it's fire-and-forget)
await Task.Delay(100, TestContext.Current.CancellationToken);
await flushed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);

Assert.Equal(3, insertedCount);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ public NetworkRequiredAcquisitionFilter(IConnectivityService connectivityService
_connectivityService.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
// Use OfType<NetworkRequiredAttribute>() rather than IsDefined so that subclasses
// of NetworkRequiredAttribute (e.g. AniDBHttpRateLimitedAttribute) are also matched.
// Skipping runtime-emitted assemblies: `GetTypes()` throws on one still being written to,
// and no job type is ever emitted at runtime.
_types = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic)
.SelectMany(a => a.GetTypes())
.Where(a => typeof(IQueueJob).IsAssignableFrom(a) && !a.IsAbstract &&
a.GetCustomAttributes(inherit: true).OfType<NetworkRequiredAttribute>().Any())
Expand Down
2 changes: 2 additions & 0 deletions Shoko.Server.sln
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ Global
{88A3A583-5844-4888-80F8-B5757EC7939E}.Debug|x86.ActiveCfg = Debug|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Debug|x86.Build.0 = Debug|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Release|Any CPU.Build.0 = Release|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Release|x64.ActiveCfg = Release|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Release|x64.Build.0 = Release|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Release|x86.ActiveCfg = Release|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.Release|x86.Build.0 = Release|Any CPU
{88A3A583-5844-4888-80F8-B5757EC7939E}.ApiLogging|Any CPU.ActiveCfg = Debug|Any CPU
Expand Down
3 changes: 2 additions & 1 deletion Shoko.Server/API/v3/Helpers/FilterFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Shoko.Server.Models.Shoko;
using Shoko.Server.Repositories;
using Shoko.Server.Server;
using Shoko.Server.Utilities;

namespace Shoko.Server.API.v3.Helpers;

Expand All @@ -25,7 +26,7 @@ public class FilterFactory

static FilterFactory()
{
var allTypes = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()).ToList();
var allTypes = ReflectionUtils.ScannableAssemblies().SelectMany(a => a.GetTypes()).ToList();
s_expressionTypes = allTypes
.Where(a => a != typeof(FilterExpression) && !a.IsGenericType && typeof(FilterExpression).IsAssignableFrom(a) &&
!typeof(SortingExpression).IsAssignableFrom(a))
Expand Down
Loading