using System.Reflection; using MySqlConnector; using Admin.NET.Plugin.AiDOP.MaterialWarehouse; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.MaterialWarehouse; public class InventoryMaterializationRetryTests { [Fact] public async Task Deadlock1213_RetriesUntilThirdAttempt() { var calls = 0; var ex = await Assert.ThrowsAsync(() => InventoryMaterializationRetry.ExecuteAsync(async () => { calls++; throw Mysql(1213, "deadlock"); })); Assert.Equal(3, calls); Assert.Equal(1213, ex.Number); } [Fact] public async Task LockWaitTimeout1205_IsRetried() { var calls = 0; await InventoryMaterializationRetry.ExecuteAsync(() => { calls++; if (calls < 3) throw Mysql(1205, "lock wait"); return Task.FromResult(calls); }); Assert.Equal(3, calls); } [Fact] public async Task RetryableInnerException_IsRetried() { var calls = 0; var result = await InventoryMaterializationRetry.ExecuteAsync(() => { calls++; if (calls == 1) throw new InvalidOperationException("wrap", Mysql(1213, "deadlock")); return Task.FromResult(7); }); Assert.Equal(7, result); Assert.Equal(2, calls); } [Fact] public async Task NonConcurrencySqlError_ThrowsImmediately() { var calls = 0; var ex = await Assert.ThrowsAsync(() => InventoryMaterializationRetry.ExecuteAsync(async () => { calls++; throw Mysql(1054, "unknown column"); })); Assert.Equal(1, calls); Assert.Equal(1054, ex.Number); } [Fact] public async Task CancelledToken_StopsBeforeAnotherAttempt() { using var cts = new CancellationTokenSource(); var calls = 0; await Assert.ThrowsAnyAsync(() => InventoryMaterializationRetry.ExecuteAsync(async () => { calls++; cts.Cancel(); throw Mysql(1213, "deadlock"); }, cts.Token)); Assert.Equal(1, calls); } [Fact] public void MaterializeCommand_IsTheOnlyRetriedStatement() { var src = File.ReadAllText(Path.Combine(RepoRoot(), "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "InventoryMdpSyncService.cs")); Assert.Contains("InventoryMaterializationRetry.ExecuteAsync(", src, StringComparison.Ordinal); Assert.Equal(1, Count(src, "InventoryMaterializationRetry.ExecuteAsync(")); var i = src.IndexOf("InventoryMaterializationRetry.ExecuteAsync(", StringComparison.Ordinal); var window = src[i..(i + 180)]; Assert.Contains("ExecuteCommandAsync(sql, ps)", window, StringComparison.Ordinal); } private static MySqlException Mysql(int number, string message) { var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; foreach (var ctor in typeof(MySqlException).GetConstructors(flags)) { var ps = ctor.GetParameters(); if (ps.Length == 2 && ps[1].ParameterType == typeof(string) && ps[0].ParameterType.IsEnum) return (MySqlException)ctor.Invoke([Enum.ToObject(ps[0].ParameterType, number), message]); } throw new InvalidOperationException("MySqlException has no (errorCode, message) constructor"); } private static string RepoRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AGENTS.md"))) dir = dir.Parent; return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); } private static int Count(string src, string token) { var n = 0; var i = 0; while ((i = src.IndexOf(token, i, StringComparison.Ordinal)) >= 0) { n++; i += token.Length; } return n; } }