using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FrameworkDAL.CustomCode.MenuGroup; using FrameworkDAL.DTO.MenuGroup; using FrameworkDAL.Query.MenuGroup; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using Moq; using Xunit; namespace FrameworkTests; /// /// MenuGroupDAL.SaveMenuGroupDetail ports SkillProcessStageMapDAL.Save's diff-save shape /// (delete-not-in-surviving-ids + per-row insert/update, one ExecuteInTransactionAsync call) /// for MMENUGROUPDETAIL. IQueryExecutor is mocked (Moq, already a project dependency) so /// these assert the exact statement list built, without a real DB — the SQL text itself /// (MenuGroupQB constants) is plain parameterized Dapper SQL with no dialect-specific syntax, /// so there's nothing dialect-sensitive left to verify against a real connection here. /// public class MenuGroupDetailDiffSaveTests { private static LoginDTO Login() => new() { ClientId = -1, UserId = -1 }; [Fact] public async Task SaveMenuGroupDetail_MixOfNewUpdateAndOmitted_BuildsDeleteNotInPlusUpsertStatements() { List<(string sql, object param)>? captured = null; var qe = new Mock(); qe.Setup(x => x.ExecuteInTransactionAsync(It.IsAny(), It.IsAny>())) .Callback>((_, statements) => captured = statements.ToList()) .Returns(Task.CompletedTask); var dal = new MenuGroupDAL(qe.Object); // MenuGroupDetailId 20 survives (update), 10 is omitted (must be deleted), 0 is new (insert). var leaves = new List { new() { MenuGroupDetailId = 20, MenuId = 501, SortOrder = 1 }, new() { MenuGroupDetailId = 0, MenuId = 502, SortOrder = 2 }, }; await dal.SaveMenuGroupDetail(menuGroupId: 7, leaves, Login(), CancellationToken.None); Assert.NotNull(captured); var deleteStatement = Assert.Single(captured!, s => s.sql == MenuGroupQB.DELETE_MENUGROUPDETAIL_NOT_IN); var survivingIds = Assert.IsAssignableFrom( deleteStatement.param.GetType().GetProperty("SurvivingIds")!.GetValue(deleteStatement.param)) .Cast().ToList(); Assert.Equal(new[] { 20 }, survivingIds); Assert.Contains(captured!, s => s.sql == MenuGroupQB.UPDATE_MENUGROUPDETAIL); Assert.Contains(captured!, s => s.sql == MenuGroupQB.INSERT_MENUGROUPDETAIL); Assert.Equal(2, captured!.Count(s => s.sql == MenuGroupQB.UPDATE_MENUGROUPDETAIL || s.sql == MenuGroupQB.INSERT_MENUGROUPDETAIL)); } [Fact] public async Task SaveMenuGroupDetail_EmptyList_DeletesAllExistingLeaves() { // Intentional deviation from SkillProcessStageMapDAL.Save (which no-ops on an empty // list) — see MenuGroupDAL's own comment: "this group now has zero leaves" is a real // admin action, not something to silently skip. List<(string sql, object param)>? captured = null; var qe = new Mock(); qe.Setup(x => x.ExecuteInTransactionAsync(It.IsAny(), It.IsAny>())) .Callback>((_, statements) => captured = statements.ToList()) .Returns(Task.CompletedTask); var dal = new MenuGroupDAL(qe.Object); await dal.SaveMenuGroupDetail(menuGroupId: 7, new List(), Login(), CancellationToken.None); Assert.NotNull(captured); var only = Assert.Single(captured!); Assert.Equal(MenuGroupQB.DELETE_MENUGROUPDETAIL_BY_GROUP, only.sql); } [Fact] public async Task SaveMenuGroupDetail_AllNew_NoDeleteNotInStatement_UsesDeleteAllGuard() { // No surviving ids (every incoming row is new) must use the delete-all-by-group form, // not a NOT IN with an empty list (which is invalid/always-true SQL depending on dialect). List<(string sql, object param)>? captured = null; var qe = new Mock(); qe.Setup(x => x.ExecuteInTransactionAsync(It.IsAny(), It.IsAny>())) .Callback>((_, statements) => captured = statements.ToList()) .Returns(Task.CompletedTask); var dal = new MenuGroupDAL(qe.Object); await dal.SaveMenuGroupDetail(menuGroupId: 7, new List { new() { MenuGroupDetailId = 0, MenuId = 900 } }, Login(), CancellationToken.None); Assert.NotNull(captured); Assert.Contains(captured!, s => s.sql == MenuGroupQB.DELETE_MENUGROUPDETAIL_BY_GROUP); Assert.DoesNotContain(captured!, s => s.sql == MenuGroupQB.DELETE_MENUGROUPDETAIL_NOT_IN); } }