From ea4aa0133ae5f5fb888df5574fbe25ee4a5de0b5 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:42:56 -0500 Subject: [PATCH 1/3] Create MySQL evidence notes as text so a world database builds from empty SeedWorldContent writes today's Bootcamp evidence, and some of its notes are longer than the varchar(256) ConsolidatedWorldSchema created, so `dotnet ef database update --context MySqlWorldContext` on an empty server stopped with "Data too long for column 'reconstruction_note'". Sqlite doesn't enforce the width, and the existing boundary test only sees InsertDataOperation rows, not the seed's raw SQL inserts. Databases already past the seed were widened by WildernessAliaBranches and WildernessEvidenceCapacity; for them this changes nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .../MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs b/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs index 2665eef7..1661a15c 100644 --- a/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs +++ b/src/Rasa.DBL/Migrations/MySqlWorld/20260926190344_ConsolidatedWorldSchema.cs @@ -393,7 +393,12 @@ protected override void Up(MigrationBuilder migrationBuilder) local_client_path = table.Column(type: "varchar(256)", nullable: true) .Annotation("MySql:CharSet", "utf8mb4"), confidence = table.Column(type: "double unsigned", nullable: false), - reconstruction_note = table.Column(type: "varchar(256)", nullable: false) + // text, not the varchar(256) this first shipped with: SeedWorldContent, next, + // writes today's Bootcamp evidence, some of whose notes are longer, and MySQL + // refused them ("Data too long"), so no MySQL world database could be built from + // empty. Databases already past this point were widened by + // WildernessAliaBranches and WildernessEvidenceCapacity. + reconstruction_note = table.Column(type: "text", nullable: false) .Annotation("MySql:CharSet", "utf8mb4") }, constraints: table => From 5a61173865248ef3a780fae144dc3582c74f30d6 Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:42:56 -0500 Subject: [PATCH 2/3] CI: test the MySQL contexts against real MySQL 8.0 and 8.4 (#132: CI-6) A `mysql` job, matrixed over mysql:8.0 and mysql:8.4 service containers, applies all three MySQL contexts' migrations to an empty server with `dotnet ef database update`, then runs the new `MySql` test category against it. It fails unless every test in the category ran and passed, and `tests complete` now requires it. The MySql tests (MySqlLiveDatabaseTests) check that every migration is applied with none pending, that every table holds the same rows as after Sqlite's migrations, and that accounts, characters and missions round-trip through the repositories with MySQL's collation and foreign keys. They find the server through RASA_TEST_MYSQL and report inconclusive without it. Config stays file-only: the job writes a databasesettings.env.json for the design-time factories. TestShards.cs leaves the category out of the plan and every lane's filter excludes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AxABTHjs6nJrsXVCTnmMo7 --- .github/scripts/TestShards.cs | 34 +++- .github/workflows/dotnet.yml | 86 +++++++- docs/setup.md | 23 +++ src/Rasa.Test/Database/LiveMySql.cs | 69 +++++++ .../Database/MySqlLiveDatabaseTests.cs | 190 ++++++++++++++++++ 5 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 src/Rasa.Test/Database/LiveMySql.cs create mode 100644 src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs diff --git a/.github/scripts/TestShards.cs b/.github/scripts/TestShards.cs index 33e5c8b6..9d038419 100644 --- a/.github/scripts/TestShards.cs +++ b/.github/scripts/TestShards.cs @@ -14,6 +14,9 @@ // // Every lane but the last lists what it runs; the last runs everything the others don't list. So a // test this script fails to find, or one added since, still runs exactly once, in the last lane. +// +// Tests in an excluded category (ExcludedCategories below: the live-MySQL tests, which run in their +// own job against a MySQL server) are left out of the plan, and every lane's filter excludes them. using System.Reflection; using System.Reflection.Metadata; @@ -26,6 +29,8 @@ return 2; } +string[] ExcludedCategories = ["MySql"]; + var assemblyPath = args[0]; var laneCount = int.Parse(args[1]); var outputDir = args[2]; @@ -37,7 +42,7 @@ return 2; } -var classes = FindTests(assemblyPath); +var classes = FindTests(assemblyPath, ExcludedCategories); if (classes.Count == 0) { Console.Error.WriteLine($"no [TestClass] types found in {assemblyPath}"); @@ -85,6 +90,8 @@ double ClassWeight(string cls) => filter = listed.Count > 0 ? string.Join("&", listed.Select(t => t.Replace("=", "!="))) : "FullyQualifiedName!=__run_everything__"; + // & binds tighter than |, so a lane's list of alternatives is grouped before the exclusion. + filter = $"({filter})" + string.Concat(ExcludedCategories.Select(c => $"&TestCategory!={c}")); File.WriteAllText(Path.Combine(outputDir, $"lane-{i}.filter"), filter); } @@ -111,8 +118,9 @@ double ClassWeight(string cls) => File.AppendAllLines(summary, report.Prepend("### Test lanes").Append("")); return 0; -// Test class full name -> test method name -> number of cases (each [DataRow] is one). -static Dictionary> FindTests(string path) +// Test class full name -> test method name -> number of cases (each [DataRow] is one). A method +// with, or in a class with, a [TestCategory] in excluded isn't listed. +static Dictionary> FindTests(string path, string[] excluded) { using var stream = File.OpenRead(path); using var pe = new PEReader(stream); @@ -122,7 +130,8 @@ static Dictionary> FindTests(string path) foreach (var handle in md.TypeDefinitions) { var type = md.GetTypeDefinition(handle); - if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute")) + if ((type.Attributes & TypeAttributes.Abstract) != 0 || !HasAttribute(md, type.GetCustomAttributes(), "TestClassAttribute") + || Categories(md, type.GetCustomAttributes()).Intersect(excluded).Any()) continue; var methods = new Dictionary(StringComparer.Ordinal); @@ -130,7 +139,8 @@ static Dictionary> FindTests(string path) { var method = md.GetMethodDefinition(methodHandle); var attributes = method.GetCustomAttributes(); - if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute")) + if (!HasAttribute(md, attributes, "TestMethodAttribute") && !HasAttribute(md, attributes, "DataTestMethodAttribute") + || Categories(md, attributes).Intersect(excluded).Any()) continue; methods[md.GetString(method.Name)] = Math.Max(1, attributes.Count(a => AttributeName(md, md.GetCustomAttribute(a)) == "DataRowAttribute")); @@ -146,6 +156,20 @@ static Dictionary> FindTests(string path) static bool HasAttribute(MetadataReader md, CustomAttributeHandleCollection attributes, string name) => attributes.Any(a => AttributeName(md, md.GetCustomAttribute(a)) == name); +// The names in [TestCategory("...")] attributes: a blob of the 0x0001 prolog and one string argument. +static IEnumerable Categories(MetadataReader md, CustomAttributeHandleCollection attributes) +{ + foreach (var handle in attributes) + { + var attribute = md.GetCustomAttribute(handle); + if (AttributeName(md, attribute) != "TestCategoryAttribute") + continue; + var blob = md.GetBlobReader(attribute.Value); + if (blob.ReadUInt16() == 1 && blob.ReadSerializedString() is { } category) + yield return category; + } +} + static string? AttributeName(MetadataReader md, CustomAttribute attribute) { switch (attribute.Constructor.Kind) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index c4bb33f8..9ff375f4 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -205,7 +205,7 @@ jobs: status=0 for context in SqliteAuthContext MySqlAuthContext SqliteCharContext MySqlCharContext SqliteWorldContext MySqlWorldContext; do echo "::group::$context" - if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then + if ! dotnet ef migrations has-pending-model-changes --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context"; then echo "::error::$context has model changes without a migration (or could not be checked)." status=1 fi @@ -213,10 +213,87 @@ jobs: done exit $status + # The MySql test category against a real server, after every MySQL migration is applied to it + # from empty, the way a MySQL deployment gets its schema. The other lanes leave this category + # out (TestShards.cs) and only build the MySQL model offline. + mysql: + name: mysql (${{ matrix.mysql }}) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + mysql: [ '8.0', '8.4' ] + services: + mysql: + image: mysql:${{ matrix.mysql }} + env: + MYSQL_ROOT_PASSWORD: rasa-ci + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -prasa-ci" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + env: + RASA_TEST_MYSQL: Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + - name: Restore NuGet cache + uses: actions/cache/restore@v6 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json', '.config/dotnet-tools.json') }} + restore-keys: nuget-${{ runner.os }}- + # The design-time factories read databasesettings.json and then databasesettings.env.json from + # the build output, the same files the servers read; the override points them at the service. + # The timeout is also the command timeout, and the seed migrations insert thousands of rows. + - name: Point the database settings at the service + run: | + jq '.Databases |= ((.Auth, .Char, .World) |= (.Host = "127.0.0.1" | .Port = 3306 | .User = "root" | .Password = "rasa-ci" + | .TimeoutInMilliseconds = 600000))' \ + src/Rasa.DBL/databasesettings.json > src/Rasa.DBL/databasesettings.env.json + - run: dotnet tool restore + - name: Build + run: dotnet build src/Rasa.Test + - name: Apply the MySQL migrations + run: | + for context in MySqlAuthContext MySqlCharContext MySqlWorldContext; do + dotnet ef database update --no-build --project src/Rasa.DBL --startup-project src/Rasa.Game --context "$context" + done + - name: Test + run: | + dotnet test src/Rasa.Test --no-build \ + --filter TestCategory=MySql \ + --blame-hang-timeout 10m \ + --logger "trx;LogFileName=mysql.trx" \ + --results-directory TestResults + ran=$(grep -o ']*outcome="Passed"' TestResults/mysql.trx | wc -l) + echo "MySQL ${{ matrix.mysql }}: $ran results, $passed passed." | tee -a "$GITHUB_STEP_SUMMARY" + if [ "$passed" -eq 0 ] || [ "$passed" -ne "$ran" ]; then + echo "::error::Every MySql test must run and pass against the server ($passed of $ran passed)." + exit 1 + fi + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: mysql-results-${{ matrix.mysql }} + path: TestResults/ + retention-days: 14 + # The one check to require: it passes when every lane passed, or when the PR changed only docs. tests-complete: name: tests complete - needs: [ changes, build, test, drift ] + needs: [ changes, build, test, drift, mysql ] if: always() runs-on: ubuntu-24.04 timeout-minutes: 5 @@ -232,6 +309,7 @@ jobs: BUILD: ${{ needs.build.result }} TEST: ${{ needs.test.result }} DRIFT: ${{ needs.drift.result }} + MYSQL: ${{ needs.mysql.result }} run: | if [ "$CHANGES" = success ] && [ "$CODE" = false ]; then echo "Docs-only change; tests skipped." @@ -244,7 +322,7 @@ jobs: failed=$((failed + $(grep -o ']*outcome="Failed"' "$trx" | wc -l))) done echo "Across all lanes: $total results, $passed passed, $failed failed." | tee -a "$GITHUB_STEP_SUMMARY" - if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ]; then - echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT" + if [ "$BUILD" != success ] || [ "$TEST" != success ] || [ "$DRIFT" != success ] || [ "$MYSQL" != success ]; then + echo "::error::build: $BUILD, test runners: $TEST, migration drift: $DRIFT, mysql: $MYSQL" exit 1 fi diff --git a/docs/setup.md b/docs/setup.md index 9d67b088..96209e5a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -425,6 +425,29 @@ The compatibility tests verify that the SDK, Docker images and package versions dotnet test src\Rasa.Test\Rasa.Test.csproj --filter "FullyQualifiedName~Compatibility" ``` +### Live MySQL tests + +The tests in the `MySql` category run against a real MySQL server: every MySQL migration applied from empty, the same row counts as Sqlite in every table afterwards, and accounts, characters and missions written and read back through the repositories. CI runs them against MySQL 8.0 and 8.4 in the `mysql` job and leaves them out of the other test lanes. Without a server, `dotnet test` reports them as skipped. + +To run them locally, start a throwaway server (`--tmpfs` keeps its data in memory, which makes the seed migrations much faster): + +```powershell +docker run -d --name rasa-mysql -p 3306:3306 --tmpfs /var/lib/mysql -e MYSQL_ROOT_PASSWORD=rasa-ci mysql:8.4 +``` + +Point the design-time factories at it with a `src\Rasa.DBL\databasesettings.env.json` (see [Database configuration](#database-configuration)) that sets `Host` `127.0.0.1`, `User` `root`, `Password` `rasa-ci` and a `TimeoutInMilliseconds` of a few minutes for each of `Auth`, `Char` and `World`; the timeout is also the command timeout, and the World seed is large. Then build, apply the migrations, and run the category with the server in `RASA_TEST_MYSQL`: + +```powershell +dotnet build src\Rasa.Test +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlAuthContext +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlCharContext +dotnet ef database update --no-build --project src\Rasa.DBL --startup-project src\Rasa.Game --context MySqlWorldContext +$env:RASA_TEST_MYSQL = "Server=127.0.0.1;Port=3306;User ID=root;Password=rasa-ci" +dotnet test src\Rasa.Test --no-build --filter TestCategory=MySql +``` + +The tests use the database names from `databasesettings.json` and remove the rows they add. Delete `databasesettings.env.json` afterwards, or the servers you run from that build will read it too. + ### Create a game user The authentication server can be used to create a user by running a command in the terminal. The usage is: `create `. Running this command will create a new user in the database that you can use to login with the game client. diff --git a/src/Rasa.Test/Database/LiveMySql.cs b/src/Rasa.Test/Database/LiveMySql.cs new file mode 100644 index 00000000..5eedf890 --- /dev/null +++ b/src/Rasa.Test/Database/LiveMySql.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MySqlConnector; + +namespace Rasa.Test.Database +{ + using Rasa.Configuration; + using Rasa.Configuration.ConnectionStrings; + using Rasa.Configuration.ContextSetup; + using Rasa.Context; + using Rasa.Services.DbContext; + + /// + /// Contexts on a live MySQL server, for the tests in the "MySql" category. The server comes from + /// RASA_TEST_MYSQL, a MySqlConnector connection string without a database + /// ("Server=127.0.0.1;Port=3306;User ID=root;Password=..."); the database names are the ones + /// databasesettings.json gives, which is where `dotnet ef database update` applied the + /// migrations. Contexts are configured the way the servers configure them (server version + /// detection, the bounded migration lock), not with the offline setup the model tests use. + /// + /// CI runs this category in its own job against mysql:8.0 and mysql:8.4 and leaves it out of + /// the other lanes; without RASA_TEST_MYSQL the tests report inconclusive. docs/setup.md + /// has the commands to run them locally. + /// + internal static class LiveMySql + { + internal const string Category = "MySql"; + internal const string ServerVariable = "RASA_TEST_MYSQL"; + + internal static RasaDbContextBase CreateContext(Type contextType) + { + var server = Environment.GetEnvironmentVariable(ServerVariable); + if (string.IsNullOrWhiteSpace(server)) + Assert.Inconclusive( + $"{ServerVariable} is not set. These tests need a MySQL server with the migrations applied; see docs/setup.md."); + + var builder = new MySqlConnectionStringBuilder(server); + using var settings = JsonDocument.Parse(File.ReadAllText( + Path.Combine(AppContext.BaseDirectory, "databasesettings.json"))); + var databases = settings.RootElement.GetProperty("Databases"); + + DatabaseConnectionConfiguration Connection(string name) => new() + { + Host = builder.Server, + Port = builder.Port, + User = builder.UserID, + Password = builder.Password, + Database = databases.GetProperty(name).GetProperty("Database").GetString(), + TimeoutInMilliseconds = 30000 + }; + + var options = Options.Create(new DatabaseConfiguration + { + Provider = "MySql", + Auth = Connection("Auth"), + Char = Connection("Char"), + World = Connection("World") + }); + return (RasaDbContextBase)Activator.CreateInstance( + contextType, + options, + new MySqlDbContextConfigurationService(new MySqlConnectionStringFactory()), + new MySqlDbContextPropertyModifier()); + } + } +} diff --git a/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs b/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs new file mode 100644 index 00000000..1618c896 --- /dev/null +++ b/src/Rasa.Test/Database/MySqlLiveDatabaseTests.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Rasa.Test.Database +{ + using Rasa.Context; + using Rasa.Context.Auth; + using Rasa.Context.Char; + using Rasa.Context.World; + using Rasa.Repositories.Auth.Account; + using Rasa.Repositories.Char.Character; + using Rasa.Repositories.Char.CharacterMission; + using Rasa.Repositories.Char.GameAccount; + using Rasa.Services.Passwords; + using Rasa.Services.Random; + using Rasa.Structures.Char; + + /// + /// The MySQL contexts against a real server with every migration applied by + /// `dotnet ef database update`, the way a MySQL deployment gets its schema. The other database + /// tests build the MySQL model offline against a faked server version; these are the ones that + /// find a migration MySQL rejects, seed data that differs from Sqlite's, or a constraint only + /// one provider enforces. See for how they find the server. + /// + [TestClass] + [TestCategory(LiveMySql.Category)] + [DoNotParallelize] + public class MySqlLiveDatabaseTests + { + [TestMethod] + [DataRow(typeof(MySqlAuthContext))] + [DataRow(typeof(MySqlCharContext))] + [DataRow(typeof(MySqlWorldContext))] + public void EveryMigrationIsAppliedAndNoneIsPending(Type contextType) + { + using var context = LiveMySql.CreateContext(contextType); + + CollectionAssert.AreEqual( + context.Database.GetMigrations().ToArray(), + context.Database.GetAppliedMigrations().ToArray()); + Assert.IsFalse(context.Database.GetPendingMigrations().Any()); + } + + /// + /// Every table holds as many rows after MySQL's migrations as after Sqlite's: the seed data + /// and the data migrations are written per provider, and this is what notices when one of + /// them drifts. The tests here that write rows remove them again. + /// + [TestMethod] + [DataRow(typeof(MySqlAuthContext), typeof(SqliteAuthContext))] + [DataRow(typeof(MySqlCharContext), typeof(SqliteCharContext))] + [DataRow(typeof(MySqlWorldContext), typeof(SqliteWorldContext))] + public void MigratedTablesHoldTheSameRowsAsSqlite(Type mySqlType, Type sqliteType) + { + using var mySql = LiveMySql.CreateContext(mySqlType); + var path = Path.Combine(AppContext.BaseDirectory, "TestDatabases", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + try + { + using var sqlite = PersistenceIntegrationTests.CreateContext(sqliteType, Path.Combine(path, "database")); + sqlite.Database.Migrate(); + + var tables = mySql.Model.GetEntityTypes() + .Select(entity => entity.GetTableName()) + .Where(table => table != null) + .Distinct() + .OrderBy(table => table, StringComparer.Ordinal) + .ToArray(); + Assert.IsTrue(tables.Length > 0); + + var differences = new List(); + foreach (var table in tables) + { + var expected = CountRows(sqlite, table); + var actual = CountRows(mySql, table); + if (expected != actual) + differences.Add($"{table}: Sqlite {expected}, MySQL {actual}"); + } + + Assert.AreEqual(0, differences.Count, string.Join(Environment.NewLine, differences)); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(path, true); + } + } + + [TestMethod] + public void AuthAccountsRoundTripAndUsernamesAreUniqueWithoutCase() + { + var name = "Live" + Guid.NewGuid().ToString("N")[..12]; + using var context = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + using var random = new RandomNumberService(); + var accounts = new AuthAccountRepository(context, random, new HashSettings()); + try + { + accounts.Create(name + "@example.invalid", name, "password123"); + + using var reopened = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + var found = new AuthAccountRepository(reopened, random, new HashSettings()) + .FindByUserNameOrEmail(name.ToUpperInvariant(), null); + Assert.IsNotNull(found); + Assert.AreEqual(name, found.Username); + Assert.AreEqual("0.0.0.0", found.LastIp); + Assert.IsFalse(found.Locked); + Assert.IsTrue(accounts.CheckPassword(found, "password123")); + Assert.IsFalse(accounts.CheckPassword(found, "password124")); + + // MySQL's default collation compares without case, so the unique index already + // refuses "Bob" beside "bob"; Sqlite's BINARY collation would store both. + using var duplicate = (AuthContext)LiveMySql.CreateContext(typeof(MySqlAuthContext)); + Assert.ThrowsExactly(() => + new AuthAccountRepository(duplicate, random, new HashSettings()) + .Create("other-" + name + "@example.invalid", name.ToUpperInvariant(), "password123")); + } + finally + { + using var cleanup = LiveMySql.CreateContext(typeof(MySqlAuthContext)); + cleanup.Database.ExecuteSql($"DELETE FROM `account` WHERE username = {name}"); + } + } + + [TestMethod] + public void CharactersAndMissionsRoundTripAndForeignKeysHold() + { + var accountId = 4_000_000_000u + (uint)Random.Shared.Next(1, 100_000_000); + var name = "Live" + accountId; + try + { + uint characterId; + using (var context = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + var accounts = new GameAccountRepository(context); + accounts.CreateOrUpdate(accountId, name, name + "@example.invalid"); + var character = new CharacterRepository(context) + .Create(accounts.Get(accountId), 1, name, 1, 1.0, 0); + Assert.IsNotNull(character, "The character was not created; the error is in the log above."); + characterId = character.Id; + new CharacterMissionRepository(context).Add(new CharacterMissionEntry(characterId, 429, 2)); + } + + using (var reopened = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + var character = new CharacterRepository(reopened).GetByAccountId(accountId, 1); + Assert.IsNotNull(character); + Assert.AreEqual(characterId, character.Id); + Assert.AreEqual(name, character.GameAccount.Name); + Assert.AreEqual(string.Empty, character.GameAccount.FamilyName); + var mission = new CharacterMissionRepository(reopened).GetByCharacterAndMission(characterId, 429); + Assert.IsNotNull(mission); + Assert.AreEqual(2u, mission.MissionState); + Assert.AreEqual(32, mission.AssignmentId.Length); + + // InnoDB enforces both keys: an account can't go while it has a character + // (Restrict), and a character takes its missions with it (Cascade). + reopened.Remove(reopened.GameAccountEntries.Single(e => e.Id == accountId)); + Assert.ThrowsExactly(() => reopened.SaveChanges()); + } + + using (var context = (CharContext)LiveMySql.CreateContext(typeof(MySqlCharContext))) + { + context.Database.ExecuteSql($"DELETE FROM `character` WHERE id = {characterId}"); + Assert.AreEqual(0, context.CharacterMissionEntries.Count(e => e.CharacterId == characterId)); + } + } + finally + { + using var cleanup = LiveMySql.CreateContext(typeof(MySqlCharContext)); + cleanup.Database.ExecuteSql($"DELETE FROM `character` WHERE account_id = {accountId}"); + cleanup.Database.ExecuteSql($"DELETE FROM `account` WHERE id = {accountId}"); + } + } + + // Backticks quote an identifier in both MySQL and Sqlite. An identifier can't be a + // parameter; the table names come from the model. + private static int CountRows(RasaDbContextBase context, string table) => + context.Database.SqlQueryRaw("SELECT COUNT(*) AS Value FROM `" + table + "`").Single(); + + private sealed class HashSettings : IPasswordHashSettings + { + public string Pepper => string.Empty; + public int Iterations => PasswordHasher.MinimumIterations; + } + } +} From 1bb12d56a8196acfa183ad0e6ee888804288fecd Mon Sep 17 00:00:00 2001 From: Steven Cady Date: Mon, 5 Oct 2026 08:44:26 -0500 Subject: [PATCH 3/3] [fork test] Move the auth port in config but not in compose --- src/Rasa.Auth/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rasa.Auth/appsettings.json b/src/Rasa.Auth/appsettings.json index fcafd0bf..6e725270 100644 --- a/src/Rasa.Auth/appsettings.json +++ b/src/Rasa.Auth/appsettings.json @@ -11,7 +11,7 @@ }, "AuthListType": "Online", "AuthConfig": { - "Port": 2106, + "Port": 2116, "Backlog": 100, "ClientTimeout": 300 },