Skip to content

Add a bunch of unit tests & run on CI all the time. - #1423

Merged
da3dsoul merged 43 commits into
masterfrom
automated-testing
Sep 13, 2026
Merged

Add a bunch of unit tests & run on CI all the time.#1423
da3dsoul merged 43 commits into
masterfrom
automated-testing

Conversation

@Cazzar

@Cazzar Cazzar commented Sep 6, 2026

Copy link
Copy Markdown
Member

No description provided.

Neither accessor on `TestData` worked, and nothing consumed them closely
enough to notice.

- `CrossRef_File_Episode` read the `AniDB_Anime.json` resource, so it
  deserialised anime rows into cross-references and handed back records
  whose every field was left at its default
- `AniDB_Anime` threw outright, because `AniDB_Anime.AirDate` is a
  `PartialDateOnly` and the stored `"2024-07-03 00:00:00"` has no default
  Newtonsoft conversion. `Shoko.Benchmarks` is the only consumer, which is
  why this stayed hidden

Moved `PartialDateOnlyConverter` into `Shoko.TestData` so the fixtures and
`FilterTests` share one copy, and added tests over both accessors.
…input

Added 102 tests over logic that had none, all of them free of a database,
a DI container and the network.

- `AutoAnimeGroupCalculator` — the relation-graph walk and fuzzy title
  metric that decide how a collection is carved into groups. Covers both
  main-anime strategies, every `AutoGroupExclude` flag, transitive and
  cyclic graphs, and the title normalisation, including that `the movie`
  and `the animation` are stripped before comparison and that `-` splits
  words where other punctuation does not
- `PocoCache`/`PocoIndex` — the substrate behind every cached repository.
  Covers index maintenance across add, update, remove and clear, and the
  many-valued index
- `AnimeSeriesService.EpisodeList` — the part matching that decides when a
  split OVA or movie counts as held
- `ModelHelper.GetEpisodeNumberAndTypeFromInput` — the `S3`/`C1` prefixes
  the v3 API accepts in range parameters
- `AnimeEpisode.DefaultTitle` — the English fallback and its placeholder

Added `Shoko.Tests/Infrastructure/` to reach code that reads `RepoFactory`
without standing up a database. `CachedRepo` seeds a real repository's
`PocoCache` and runs its own `PopulateIndexes`, so reads execute exactly as
in production, and `RepoFactoryScope` installs those repositories into the
`RepoFactory` statics and restores them afterwards. Those statics are
process-global, so such tests join a non-parallel collection. Nothing here
touches the write-once `ISystemService.StaticServices`.

Also dropped `Startup` and `TestServerSettings`, both unreferenced —
`Startup` is the `Xunit.DependencyInjection` convention class and that
package is long gone.
`integration-tests.yml` was the only workflow invoking `dotnet test`, and it
runs `Shoko.IntegrationTests` alone. `Shoko.Tests` and
`Shoko.QueueProcessor.Tests` had never gated a merge, so either could be
broken without CI noticing.

Both suites are self-contained — no database, no network and no native
dependencies — so the job needs none of the setup the integration tests do
and finishes in seconds.
Two areas that had no coverage and fail quietly when they break.

**Stored filter expressions.** `FilterPreset.Expression` and
`SortingExpression` are persisted as JSON that records each node by its
*simple* class name. `SimpleNameSerializationBinder.BindToType` resolves
that name by scanning loaded assemblies and taking the first match, and
returns `null` when nothing matches — which `FilterExpressionConverter`
swallows through its error handler. So renaming or moving an expression
turns every filter using it into a broken one, with nothing raised at the
call site, and two types sharing a simple name would bind to whichever the
scan happened to find first.

Covers all 284 concrete expression types: each binds back to its own type,
no two share a simple name, and every constructible one survives a
round-trip. Also pins that a name outside the `FilterExpression` hierarchy
does not bind, which is what stops stored JSON naming arbitrary types.

**Sorting selectors.** 76 of them, previously untested. Beyond evaluating
to a non-null comparable, each is checked against the flags the filtering
engine caches on: a selector that reads user info must declare
`UserDependent`, and one whose value moves with the clock must declare
`TimeDependent`.

`FilterableFactory` populates the filterable doubles by reflection rather
than by hand, so a member added to the interface later cannot silently
arrive as null in tests that look like they cover it. `TestFilterable`'s
two image-type properties gained `init` so they can be populated like every
sibling.
…Helpers`

`FileSystemHelpers` was a concrete class, so nothing that moves or deletes a
user's files could be exercised without a real disk. Extracted the interface
it already implicitly had and switched every consumer to it. The concrete
type stays registered and the interface resolves to the same singleton, so
behaviour is unchanged.

Added tests over the guards in `DirectlyRelocateFile`, which had none. They
run against a mocked file system, and a rejected request is proven by the
mock never being asked to move, delete or create anything:

- requests with no managed folder, no relative path, or already cancelled
- relative paths climbing out of the managed folder
- files in an excluded folder, or in a drop destination when relocating
  inside destinations is disabled
- a source file that is missing on disk

One of these pins a subtle protection: containment is a prefix comparison,
and it is only correct because `ShokoManagedFolder.Path` always ends in a
directory separator. Without it a relative path could reach a sibling folder
sharing a prefix — `/media/animeX` against `/media/anime` — and write
outside the managed folder. Both that and the separator itself are now
covered.
`UserDataService` decides whether a file counts as watched, how far through
it the user is, and how many times they have seen it — the state the whole
watched/unwatched view of a collection is built on — and none of it was
tested.

Covers marking watched and unwatched, the playback counter, progress
handling including the 97.5% "near enough finished" threshold, the
save/no-save decision, and the saved event.

Two of these pin behaviour that is easy to regress and invisible when it
goes wrong:

- A watched date handed in as UTC is stored as local. The comparison against
  the stored date is by value, so a UTC date would otherwise read as a change
  on every write. Asserted on the `DateTimeKind` as well as the value,
  because the value alone proves nothing when the tests run on a machine set
  to UTC — which is what happens in CI.
- An update carrying no changes must not write. Playback progress reports
  arrive continuously, so a needless save there is a write per second per
  client.

`CachedRepo.BuildWritable` extends the harness to writes: a partial mock
keeps every real read path and replaces only the virtual `Save`/`Delete`,
landing them in the same cache the reads come from, so a saved entity is
visible to the next lookup as it would be in production. It also installs a
`VideoLocal` in `RepoFactory` because `VideoLocal_User.ToString()` resolves
through it, and Moq calls that when rendering a failed verification.
Seven tables have no primary key on SQL Server while both SQLite and MySQL
declare one:

- `AniDB_Anime_PreferredImage`
- `AniDB_Episode_PreferredImage`
- `AniDB_FileUpdate`
- `AuthTokens`
- `ShokoImage_Entity`
- `TMDB_Image`
- `TMDB_Image_Entity`

All seven were created before the version 180 sweep that added the other 38
but were missed by it. Each keys off an `IDENTITY` column, so the values are
already unique and non-null and the constraint cannot fail against existing
data.
Every backend carries its own copy of the schema as an ordered list of raw
SQL, so the three can drift apart with nothing failing until a user on that
backend hits it. Both `add missing primary keys on SQL Server for 38 tables`
and `widen TMDB_Show/TMDB_Movie Genres column on MySQL and SQL Server` were
that, and neither is reachable from a test that only exercises SQLite.

Replays each backend's statements into a logical schema — tracking creates,
drops and renames — and asserts every surviving table declares a primary
key. This is what found the seven tables fixed in the previous commit.

Deliberately narrow. A table-set comparison across backends was tried and
dropped: distinguishing a genuine divergence from a rename this crude parser
mishandles needs a real SQL parser, and a check that cries wolf is worse
than no check. The primary-key assertion needs only the table name and
whether the statement declares a key, which is reliable across all three
dialects.

`StubSettingsProvider` exists because the MySQL backend reads the settings
singleton while initialising its DDL fields, so one has to be installed
before it can be constructed at all.
**Automatic deletion.** `ReleaseAutoManagementService.ComputeRedundantPlaces`
decides which of a user's files get removed when a better release of the
same episodes is present. It is the most destructive decision the server
makes unattended and it had no tests. It returns the list rather than acting
on it, so every rule is checkable without deleting anything, and the real
`ReleaseComparisonService` is used rather than a stub so the actual ranking
runs.

The two that matter most:

- A file belonging to the primary is never deleted, even when the same
  physical file also appears in a secondary gap-fill candidate. Removing the
  guard that enforces this makes both of those tests fail.
- The eligibility gate holds. A primary that is mixed, corrupted, or has no
  release info cannot cause deletions, and the bypass is reserved for a
  primary the user picked by hand.

Also covers per-file versus whole-candidate mode for airing series, and that
a file whose episode coverage cannot be resolved is always kept.

**Persistence converters.** The `IUserType` implementations sit between
entity properties and columns, so one that loses information corrupts data
silently. Checks the contract every converter owes NHibernate across all
twelve — column types, returned type, and that two nulls compare equal,
which dirty-checking runs on every flush — plus round-trips for MessagePack,
string lists, partial and whole dates, types and JSON.

One pins a limitation rather than a guarantee: `StringListConverter` joins
on `"|||"` with no escaping, so an entry containing that separator comes
back as two entries. Better recorded here than discovered in someone's data.
`AnimeSeriesService.UpdateStats` computes the counts behind the
missing-episode filters, the dashboard and the calendar, and nothing else
recomputes them — a series carries whatever this last wrote.

Covers episodes with and without files, that un-aired episodes are not
counted as missing, that hidden episodes are counted separately, that only
regular episodes count (not specials or credits), that the counts are
recomputed rather than accumulated across runs, and the derived
`LatestLocalEpisodeNumber` and `LatestEpisodeAirDate`.

Marked `AnimeSeriesRepository.Save(AnimeSeries, bool, bool)` and
`AniDB_GroupStatusRepository.GetByAnimeID` as `virtual` so they can be stood
in for. `virtual` alone changes no behaviour, and it follows what was
already done ad hoc for the handful of methods an earlier test needed.
`AniDBUDPConnectionHandler` constructed its `AniDBSocketHandler` inline, so
none of the protocol handling around the socket could be reached without
binding a port. `IAniDBSocketHandler` already existed; this adds the factory
to go with it and switches the field to the interface. The real factory
returns the same socket handler as before, so behaviour is unchanged.
Neither connection handler was tested, and both carry the logic that keeps
the server out of an AniDB ban — which costs a user a day of metadata when
it goes wrong.

**HTTP.** A successful call returns the body and status; a response
containing the `>banned<` marker sets the ban and throws; a further call
while banned never reaches the network, because talking to AniDB while
banned is what extends the ban; `force` still gets through for the caller
that needs it; and a server error or transport failure is surfaced rather
than mistaken for a ban.

**UDP.** `Init` builds the socket and records whether it connected, and
refuses incomplete credentials. Commands go out as UTF-16 or ASCII as asked,
replies are decoded by their byte order mark and the mark is stripped. An
all-zero reply is treated as a ban — a silent socket cannot be told apart
from one, and assuming the worse is what stops the server digging deeper —
after which `Send` stops talking.

**No test opens a connection of any kind.** HTTP goes through a stub
`HttpMessageHandler` that answers from a queue, and UDP through a stub
`IAniDBSocketHandler` that replays canned payloads. Both are pointed at
`anidb.invalid` — reserved by RFC 2606 and guaranteed never to resolve — so
anything that did try to reach the network would fail loudly rather than
quietly succeed.
`HttpAnimeParser` turns AniDB's XML into the anime, title and episode
records the rest of the server is built on. It is pure — XML in, objects out
— and had no tests, in one of the most frequently fixed areas of the
codebase.

Covers the documents it must reject (no anime id, no main title), the anime
type mapping, episode counts, the restricted flag, titles with their type
and language, and the episode fields including type prefixes, double
episodes, and length in minutes becoming seconds.

Two pin AniDB quirks that are invisible until they break:

- `1970-01-01` is AniDB's "unknown date" sentinel. Taking it literally would
  file shows under 1970 and break every year filter and season grouping.
- Apostrophes arrive as backticks throughout the API and are translated back
  in titles and descriptions.
`StringListConverter`, `TmdbContentRatingConverter` and
`TmdbProductionCountryConverter` all return a `List<T>`, and all compared
with `x.Equals(y)`, which for a list is reference equality.

`NullSafeGet` builds a fresh list on every load, so the loaded value never
matched the stored one and NHibernate treated every mapped list column as
changed on every flush, rewriting rows that nobody had touched. The columns
affected are the AniDB title/tag lists and the TMDB content ratings and
production countries.

They now compare the string each side would be written as, so two lists are
the same exactly when they would produce the same column value.
…fail

An adversarial review of the new suites found several that passed regardless
of what the production code did. Verified by mutation, before and after.

**Sorting selectors.** 74 of the 75 are a single property read, so "produces
a value", "is comparable", "is deterministic" and "ignores the time" could
never be false; and the two value assertions could not tell one property
from another, because `FilterableFactory` gave every property of the same
type the same sample. Changing a selector to return a sibling field kept all
381 cases green.

The factory now seeds each value from the property name, and the selectors
are pinned by a generated table of selector to the field it reads. Four
selectors mutated to read a sibling — plain, nested, user-scoped, and the
date-converting one — now each fail.

**Converters.** Both equality theories passed `x` as both arguments or two
nulls, so only the `ReferenceEquals` short-circuit ran and the comparison
that decides whether a row is rewritten was never reached. Replaced with
distinct-but-equal values per converter, which caught the list-comparison
bug fixed in the previous commit. Dropped the two that asserted a hardcoded
`typeof()` was not null and one that asserted nothing at all, and added
`MessagePackConverter<MediaContainer>` explicitly, since reflection never
yields an open generic and it had been excluded from every theory.

**AniDB HTTP.** `TheRequestGoesToTheConfiguredServer` only checked that the
relative URL it passed in came back, so the handler could ignore the
configured server entirely and stay green. Asserts the whole URI now.

Also: assert the actual date rather than not-null in the series stats,
tighten an over-broad `ThrowsAny`, drop a strictly weaker duplicate in the
release management tests, and correct a class comment that claimed ranking
was exercised when only redundancy is.
The file was named for a cross-backend comparison but never made one —
every assertion ran against a single backend, so deleting a table from
MySQL alone kept it green.

Two replay bugs had to be fixed first, or real drift would have been
indistinguishable from parser noise:

- A table rename now requires `RENAME TO`. The old pattern also matched
  `ALTER TABLE x RENAME <col> TO <col>`, a column rename, which renamed the
  table to the column name and left a phantom table behind.
- Command strings holding several `;`-separated statements are now split.
  Only the first was ever examined, so a create or drop following a rename
  in the same migration was invisible.

With those fixed the backends agree on every table but `Language`, which all
three drop — SQLite from a coded migration that a static replay cannot
observe, so it is excluded and the reason recorded.

Removing one `CREATE TABLE` from MySQL alone now fails.
`GetEpisodeNumberAndTypeFromInput` reads `input[0]` with no length check, so
an empty string throws `IndexOutOfRangeException` instead of returning the
error tuple every other bad input gets. It is reachable from the v3 range
parameters, where it surfaces as a 500 rather than a validation message.

Written against the intended behaviour and skipped with "Possible bug -
Needs investigation", so it documents what should happen and turns green
when the guard is added, rather than asserting the current behaviour and
enshrining it. Verified that adding the guard makes it pass.
The review found three paths in `UpdateMissingEpisodeStats` that no test
could reach, two of them because the harness could not express the input.

- Un-aired episodes were arranged with `AirDate = 0`, which AniDB means as
  *no date*, not *the future*. That took the fallback to whether the series
  had finished, and passed only because the harness left the anime's end
  date unset. A spec can now carry a real air date, so the future-dated
  comparison is exercised, and the unknown-date fallback is covered
  separately in both directions.
- The group-status list was always stubbed empty, which forces every aired
  episode to count as released and leaves `MissingEpisodeCountGroups`
  structurally zero. A spec can now attach a release group to a file, so the
  "released by a group I collect" total is covered — along with an episode
  no group has released yet, which should not count as missing at all.

Setting `epReleased` to true, or `epReleasedGroup` to false, now each fail a
test.

Two things this shook out about the setup rather than the code: a release
only exposes its group once all four group fields are set, and two harnesses
must not be alive at once, since both install into the same `RepoFactory`
statics.
Seeding each value from its property name left one hole: every collection
was built with exactly one element, so every `.Count` read returned 1 and
the four selectors reading a collection count — audio languages, subtitle
languages, custom tags, user tags — were indistinguishable from each other.
Swapping any of them to a sibling collection kept the suite green.

Collections are now sized from the name as well as filled from it, and a
guard asserts that two selectors reading different fields never resolve to
the same value on the double. That is the property the whole table depends
on, so it is checked rather than assumed: returning every set to a single
element now fails the guard, and the two count selectors above now fail when
swapped.

Selectors that genuinely read the same field are allowed to agree — there
are two, both reading `SortName`.
…ation

The test asserts the right result but fails roughly one full run in ten, and
never when its class runs alone — so it would have flaked CI and nowhere
else.

The cause is not the test. `UpdateMissingEpisodeStats` updates
`latestLocalEpNumber` and `lastEpAirDate` from inside an `AsParallel` body
without synchronising either, while every neighbouring accumulator in the
same lambda is explicitly locked. A lost update leaves a stale value on the
series, which drives continue-watching ordering and the calendar.

Skipped rather than fixed in place, and recorded for investigation. Guarding
one of the two accumulators was tried and did not settle it, so the extent
is not yet established.
**Converter equality had no negative direction.** Every assertion was
`Assert.True`, so replacing any converter's `Equals` with `=> true` passed
the whole suite — the mirror of the bug fixed in `999db949d`, and worse,
since NHibernate would then never write a changed column back at all. Added
the unequal-value direction, which now fails for both converters when their
comparison is stubbed out.

The table also skipped the converters that inherit whatever equality their
entity type happens to have, including the two heaviest columns in the
schema. `MessagePackConverter<MediaContainer>` had been added to discovery
with a comment about having been missed, then left out of the one test the
comment was about. Covered now, and the TMDB rows use non-empty lists, which
the empty ones could never have distinguished.

**The `Language` exclusion was unconditional**, so it also hid MySQL or SQL
Server failing to drop the table. Only SQLite migrates it from code, so only
SQLite is excluded; stubbing out MySQL's `DROP TABLE` now fails.

**Nothing checked the selector table covered every selector** — one was
silently absent, and anything added later would have been covered only by
"returns something comparable".

Also: scalars and collection sizes now occupy disjoint ranges, after the
collision guard caught a `.Count` matching a scalar and then two scalars
matching each other; and the schema remarks no longer claim to catch a
column-width regression, which nothing there models.
The playlist DSL is user-supplied text from the v3 API, so how it turns bad
input away matters as much as what it accepts. Covers unknown and
non-positive group IDs, non-positive release group IDs, and entries carrying
more than a group and a release group.

Writing these turned up that the documented `g<id>+<extra>` suffix cannot be
parsed at all. Entries are split on `+` before the extras are looked for, so
the suffix is never found and survives as a separate sub-item, which is then
rejected. `recursive` fares worst: it begins with `r`, so it is taken for a
release group ID. None of the seven documented flags can be used.

Recorded rather than fixed, with the four documented forms as a skipped
theory. Only the rejection paths are asserted here — once an entry parses
the service builds the playlist, which needs the full service graph.
**An empty playlist entry was not really covered.** The test asserted only
that no error was raised, which stays true with the guard it was named for
deleted — an entry producing nothing is discarded further down anyway. The
skip is not observable from outside; what is observable is that the entry
still consumes a position, so the assertion is now on the error being
attributed to the right index.

**Four converters looked covered for equality but were not.** They appeared
only among the equal values, where `Assert.True(Equals(a, b))` is satisfied
by a stuck-true implementation — the very failure the pair of theories
exists to catch. Covered in both directions now, and a check asserts the two
tables stay paired, so a converter cannot be added to one and forgotten in
the other.

Also corrected the diagnosis recorded for the playlist extras bug. The split
consuming the `+` is only half of it: `IndexOf(['+', ' '])` binds to the
span overload and searches for the two-character sequence `"+ "`, which
never occurs. Either correction alone still fails; with both, the parser
accepts all four documented forms, which is now measured rather than
assumed.
… fix

Replaces the earlier placeholder with the six examples taken from
`ParsePlaylist`'s own remarks, covering the group and series branches, and
written against the intended behaviour so it turns green when the parsing is
corrected.

The entry is split on `+` before the extras suffix is looked for, so the
suffix survives as a separate sub-item and is rejected; `recursive` is taken
for a release group ID because it begins with `r`. This has never worked —
the first version split on `+` and then searched the result for `+`, which
could never match — so nothing regressed, and two years without a report
suggests the flags are unused.

Correcting the split alone is not enough: `IndexOf(['+', ' '])` binds to the
span overload and looks for the literal sequence `"+ "`, so `IndexOfAny` is
needed too. Both were measured.
The documented `g<id>+<extra>` syntax cannot be demonstrated as a defect.
`items` arrives through `[FromQuery]`, and a query string decodes `+` to a
space, so a client following the documentation sends `g5+recursive` and the
service receives `g5 recursive`. The literal `+` only survives if the client
percent-encodes it.

That makes the suffix indistinguishable from a second sub-item once decoded,
which is why the parser accepts either delimiter, and means the syntax is
not expressible over this transport rather than being mis-parsed. The
remaining rejection tests are unaffected.
`Install()` was an unsynchronised check-then-act against the process-global
`ISettingsProvider.Instance`. `AniDBHttpConnectionTests` writes `HTTPServerUrl` onto
whatever provider is installed after calling it, and `SchemaParityTests` installs from a
different, parallelisable collection — so both could see the static unset, both install,
and the loser's settings be discarded.

Also tidied stray whitespace in `PocoCacheTests` and a missing trailing newline in
`PlaylistParsingTests`.
`999db949d` made `IUserType.Equals` value-based on `StringListConverter`,
`TmdbContentRatingConverter` and `TmdbProductionCountryConverter`, but left
`GetHashCode` hashing the `List<T>` reference. Two values NHibernate now considers
equal therefore hashed differently, which breaks the contract its user-type caching
relies on.

Both sides now key off the serialized form, so equal values hash equally.
It had a `Release|Any CPU.ActiveCfg` but no matching `Build.0`, so a solution build in
Release skipped it while still resolving references to it. Debug built fine, which is
why nothing had noticed; a Release build of anything referencing it failed with
`CS0234`.
The three backends each keep their own hand-written DDL, and nothing forced them to
agree. `SchemaParityTests` compared tables and primary keys only, so a column added,
widened or made nullable on one backend and missed on another went unnoticed —
`add missing primary keys on SQL Server for 38 tables` and
`widen TMDB_Show/TMDB_Movie Genres column on MySQL and SQL Server` were both that.

The new comparison reads the catalog of a real database of each backend rather than
replaying the DDL, because a replay cannot see the whole migration: MySQL performs some
of its through `PREPARE stmt FROM @sqlstmt`, and every backend has migrations written
in C#. Nothing is committed — a recorded schema is a copy that falls behind the
migrations it claims to describe — so the dumps are produced at runtime and compared
side by side.

- `SchemaSnapshot` reduces a backend's catalog to a type family, declared width and
  nullability per column, so the dialects can be compared at all
- `SchemaSnapshotTests` writes the dump for the backend it just migrated
- `SchemaTypeParityTests` compares the three, and skips rather than passes when they
  are not all present
- CI publishes each job's dump and adds a `schema-parity` job over all three
- `scripts/compare_schemas.sh` runs the whole thing locally against Docker

SQLite takes no part in the width comparison, having no declared widths, and its
`INTEGER` is treated as `BIGINT`, which it is.

The comparison currently fails: 6 columns differ in type, 15 in width between MySQL and
SQL Server, and 110 in nullability. It reports them in full so the list can be worked
through.
Both test classes took `DatabaseMigrationFixture` as a class fixture, which is one
instance per class, so the second bootstrap hit the write-once
`ISystemService.StaticServices` and failed with `The service provider has already been
set`. Running either class alone passed, which is why it was not caught when
`SchemaSnapshotTests` was added.

Moved to a collection fixture, so the whole run shares one.
Cazzar and others added 13 commits September 7, 2026 00:19
`SchemaTypeParityTests` found 110 columns whose nullability differed between the three
backends. Every one of them backs a non-nullable model property — `string MainTitle`,
`DateTime LastUpdatedAt` — so a null could never have been read back into one, and
SQLite already agreed with the model on 93 of the 95 it could be checked against.

Most of the MySQL half was one cause: `MySQLFixUTF8` and `MySQLFixUTF8MB4` rebuild every
text column with `MODIFY`, which replaces the whole column definition and silently drops
any attribute left unstated. 83 columns lost `NOT NULL` that way, `AniDB_Anime.MainTitle`
among them, despite its `CREATE TABLE` declaring it. Those two are versioned migrations
that have already run, so they are left alone and the columns are repaired instead.

- MySQL v185 tightens 90 columns
- SQL Server v183 tightens 25, mostly TMDB `CreatedAt`/`LastUpdatedAt`
- SQLite v164 tightens `AniDB_Anime_Title.Title` and `VideoLocal.DateTimeCreated`

Each fills its nulls before altering, since a stored null would fail the alter.

SQLite cannot tighten a column in place, so `MakeColumnNotNull` rebuilds the table around
a `CREATE TABLE` patched from the one the database reports. It has to come from the
database: `MoveAnidbFileDataToReleaseInfoFormat` is a `PostDatabaseFix`, which runs after
every other command, so a database migrating in one pass still has the `CRC32`, `MD5` and
`SHA1` columns that one migrating from an older version dropped long ago. Both shapes are
covered by `SqliteNotNullVariantTests`.

Verified against all three backends, migrating from empty and, for MySQL and SQL Server,
upgrading a database already at the previous version.
…ated

The v185 list was derived from a database Shoko had created itself, which uses
utf8mb4_unicode_ci — so `MySQLFixUTF8MB4` had nothing to convert for six columns and they
kept their `NOT NULL`. A database created outside Shoko keeps the server's default
collation instead, and the conversion then reaches those too.

CI creates the schema through `MARIADB_DATABASE`, so it hit exactly that and reported six
divergences the local run could not: `AniDB_Episode.Rating`, `AniDB_Episode.Votes`,
`AnimeEpisode_User.UserTags`, `AnimeSeries_User.UserTags`, `Versions.VersionType` and
`Versions.VersionValue`.

Since every text column is converted when none of them match, this is the whole of the
damage rather than another instalment of it. Verified against a database created the way
CI creates it, both migrating from empty and upgrading one already at v185.
`BindToType` scans every loaded assembly, and `GetTypes()` throws
`ReflectionTypeLoadException` on one that is still being written to. The throw reaches
`FilterExpressionConverter.ConvertFrom`, whose error handler sets `Handled` and hands back
`null`, so the filter comes back blank rather than failing.

CI hit it on six of the 287 `EveryConstructibleExpressionSurvivesARoundTrip` cases, spread
across unrelated namespaces — whichever happened to be deserializing while a Castle proxy
was being emitted for a mock elsewhere in the run.

Nothing bound here is ever emitted at runtime, so skipping dynamic assemblies costs
nothing. Reproduced by mocking distinct interfaces while scanning, which threw 32, 52 and
40 times across three runs, and threw none once the assemblies were skipped.
…t racing timers

`SimpleNameSerializationBinderTests` reproduces the scan failing while assemblies are
emitted alongside it; it fails on all three runs without the fix in the previous commit.

`SlotsExpireAfterWindow_AllowsNewCalls` asserted that two calls finish within 200ms after
a 200ms window expires. `SegmentsPerWindow` is 10, so slots came back in 20ms slices —
finer than a loaded runner services its timers, and it measured 373ms in CI. Widened to a
1000ms window so a slice is 100ms, with proportionally more slack.
`212e9a7d3` fixed one of these. `TypeStringConverter` had the same scan and failed the
same way — `ReflectionTypeLoadException: Could not load type 'Castle.Proxies.ITextStreamProxy'
from assembly 'DynamicProxyGenAssembly2'` — so the fix belongs in one place rather than
wherever the next failure happens to land.

Thirteen scans looked up job types, subtitle providers, filter expressions, AniDB request
types and mapped entities through `AppDomain.CurrentDomain.GetAssemblies()`. None of those
are ever emitted at runtime, so none of them need the assemblies that are, and
`Assembly.GetTypes()` throws on one that is still being written to.

`ReflectionUtils.ScannableAssemblies` now states that once. `Shoko.QueueProcessor` cannot
reference it, so its one scan says the same thing locally.
The suite measured how long things took in seven places, which fails on a runner under
load and says nothing about the code. Two did fail: the rate limiter measured 1933ms
against a 500ms bound, and a `Speed Test` averaged 2911ms against a 600ms target.

- `TmdbRateLimiterTests` now asserts what the limiter reports — `RemainingInWindow`,
  `CallsInWindow` and `BackoffUntilTicks`. That a caller with no permit left is made to wait
  is `SlidingWindowRateLimiter`'s job; ours is to hand it the right window and record the
  backoff, which is what those show. Mutation-checked: reporting full capacity, and never
  recording a deadline, each fail two of them.
- `TagFilterTest.TestSpeed` asserted an average under 2000ms and nothing else. That is a
  benchmark, so it moved to `Shoko.Benchmarks` as `TagFilterBenchmarks`.
- `ReflectionUtilsTests` covers the shared assembly scan; removing the guard fails it every
  time, where the flake it replaces showed up about once in ten full runs.

No assertion on elapsed time is left in any of the three test projects.
…them

`TheScanSurvivesAssembliesBeingEmittedAlongsideIt` passed its cancellation token to
`Task.Run`. A task that has not been scheduled by the time the token fires is returned
cancelled rather than run, so `Task.WhenAll` threw `TaskCanceledException` on a runner with
fewer cores than the eight tasks it starts. The token now only ends the loops. Confirmed by
firing it immediately: cancelled with the token passed, clean without it.

`OnEnqueue_MaxBatchReached_TriggersImmediateFlush` slept 100ms and asserted a
fire-and-forget flush had happened. It now waits to be told, with a timeout well clear of
any scheduling delay. Still fails, as a timeout, when the force flush is disabled.
…ready uses

`upload-artifact@v4` and `download-artifact@v5` still run on Node 20, which GitHub now
warns about. Every other workflow here is already on `upload-artifact@v7` and
`download-artifact@v8`.
…does

v170 made twelve columns `utf8mb4_bin` so that hashes, paths and tokens
compare case-sensitively. v185 rebuilt ten of them with `MODIFY`, which
replaces the whole definition, and every one of those named
`utf8mb4_unicode_ci` — quietly making `VideoLocal.Hash`,
`VideoLocal_Place.FilePath` and `ImportFolder.ImportFolderLocation`
case-insensitive again on any database upgrading through it. Each of the
ten now restates the collation the column already has.

`MySQLFixUTF8` undid the same thing, and had been doing so since v170. It
is a `PostDatabaseFix`, so on a database migrating in one pass it runs
after every patch, and it converted anything not already
`utf8mb4_unicode_ci` — v170's columns included, dropping their `NOT NULL`
along the way. A fresh install therefore ended up case-insensitive where
one that upgraded step by step, having recorded the fix long ago, stayed
case-sensitive. It now leaves `utf8mb4_bin` alone, which is already
utf8mb4 and never what it was written to convert.

`SchemaTypeParityTests` cannot see a collation, so neither showed up
there. Verified on MariaDB migrating from empty: all twelve keep
`utf8mb4_bin`, and no column is left on any other collation.
`SchemaTypeParityTests` found six columns SQLite stores under a different
type family than MySQL and SQL Server do:

- `AniDB_Anime.AirDate` and `EndDate` became `varchar(10)` on MySQL at
  v171 and on SQL Server at v167, when they became a `PartialDateOnly`.
  SQLite never got that migration and kept the `DATETIME` it was created
  as, so rows written before the change still carry a time of day the
  other two dropped
- `AnimeEpisode_User.UserTags` and `AnimeSeries_User.UserTags` were added
  by an `ALTER TABLE ... ADD COLUMN` that named no type at all, leaving
  them with BLOB affinity where the model maps a `StringListConverter`
- `TMDB_Episode.Runtime` and `TMDB_Movie.Runtime` map `RuntimeMinutes`,
  an `int?`, but were created `TEXT`

SQLite cannot retype a column in place, so v165 rebuilds each table the
way v164 does for nullability. `RetypedVariantOf` patches the type into
the table's own `CREATE TABLE`, read from the database rather than
written out here, and replaces only the type: the constraints that follow
carry the nullability, the default and the primary key. It has to cope
with a type that is several words, brackets a comma, or is not there at
all, all of which `SqliteRetypedVariantTests` covers.

Verified against all three backends migrating from empty.
`SchemaTypeParityTests` found fifteen columns MySQL and SQL Server
declare at different widths. In every case one of the two already bounds
the column and the other leaves it unbounded, so the bound is what the
data already is — nothing longer can ever have been written on the
backend that enforces it. Each takes the bound.

- MySQL v186 narrows eight `text` columns SQL Server bounds:
  `AniDB_Anime_Relation.RelationType` to 100, `ScanFile.Hash` and
  `HashResult` to 100, `AnimeSeries.AirsOn` to 10, `FilterPreset.Name` to
  250, and three user-entered titles to 500
- SQL Server v184 narrows seven `MAX` columns MySQL bounds:
  `AniDB_Episode.Rating` and `Votes` to 200, `AnimeSeries`'s two default
  languages to 50, both `ImportFolder` columns to 500, and
  `TMDB_Person.PlaceOfBirth` to 128

Each trims its values to the width first, since anything longer would
fail the alter. `FilterPreset.Name` drops its index before the alter and
recreates it after: MySQL indexed it by a 255-character prefix, which no
longer fits a 250-character column, and a bounded column does not need
one.

Verified against all three backends, migrating from empty and, for MySQL
and SQL Server, upgrading a database seeded with values too long for the
new width — every one is trimmed rather than failing the migration.
`compare_schemas.sh` created the MariaDB database with an explicit
`utf8mb4_unicode_ci`, which is what Shoko's own `CREATE DATABASE` uses.
CI creates it through `MARIADB_DATABASE`, which keeps the server default,
and so does anyone who creates one outside Shoko — and only then does
`MySQLFixUTF8MB4` reach the columns it leaves alone otherwise. The local
run therefore could not reproduce what CI reported. It now names no
collation either.
@sonarqubecloud

sonarqubecloud Bot commented Sep 7, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@da3dsoul
da3dsoul merged commit 3186c5d into master Sep 13, 2026
5 of 6 checks passed
@revam
revam deleted the automated-testing branch September 14, 2026 01:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants