using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using FrameworkDAL.DTO.MenuGroup;
using FrameworkDAL.Query.MenuGroup;
using Microsoft.Data.Sqlite;
using Xunit;
namespace FrameworkTests;
///
/// Covers MenuGroupQB.GET_RESOLVED_LEAVES_BY_GROUPIDS's override/fallback resolution order
/// (gb5_menu_presentation_layer.sql's documented rule: override row found -> use it; not
/// found -> MMENU's own display name + the bundle's own SORTORDER + ISVISIBLE = 1) against a
/// real in-memory SQLite database, matching ConflictResolverTests' "real behavioral proof,
/// not a syntax-compatible fiction" approach. MenuGroupQB's SQL uses only ANSI COALESCE/CASE,
/// so it runs unmodified here exactly as it would against SQL Server or Postgres.
///
public class MenuGroupResolutionTests : IAsyncLifetime
{
private SqliteConnection _conn = null!;
public async Task InitializeAsync()
{
_conn = new SqliteConnection("Data Source=:memory:");
await _conn.OpenAsync();
// SQLite has substr(), not SUBSTRING() — register it as a real function so the query
// runs completely unmodified here, same as it would against SQL Server or Postgres
// (both of which support the 3-arg SUBSTRING(string, start, length) form natively).
_conn.CreateFunction("SUBSTRING", (s, start, length) =>
{
if (string.IsNullOrEmpty(s)) return s ?? string.Empty;
int zeroStart = (int)Math.Max(0, start - 1);
if (zeroStart >= s.Length) return string.Empty;
int len = (int)Math.Min(length, s.Length - zeroStart);
return s.Substring(zeroStart, len);
});
await _conn.ExecuteAsync(@"
CREATE TABLE MMENUGROUPDETAIL (MENUGROUPDETAILID INTEGER PRIMARY KEY, MENUGROUPID INTEGER, MENUID INTEGER, SORTORDER INTEGER);
CREATE TABLE MMENU (MENUID INTEGER PRIMARY KEY, MENUCODE TEXT, MENUDISPLAYNAME TEXT);
CREATE TABLE MMENUSETDETAIL (MENUSETDETAILID INTEGER PRIMARY KEY, MENUSETID INTEGER, MENUID INTEGER, MENUDISPLAYNAME TEXT, SORTORDER INTEGER, ISVISIBLE INTEGER, MENUGROUPID INTEGER);
CREATE TABLE MROLEVSMENU (ROLEID INTEGER, MENUID INTEGER, ALLOW TEXT);");
}
public async Task DisposeAsync() => await _conn.DisposeAsync();
private Task> ResolveAsync(int menuSetId, int? roleId = null, params int[] groupIds) =>
_conn.QueryAsync(
MenuGroupQB.GET_RESOLVED_LEAVES_BY_GROUPIDS,
new { GroupIds = groupIds, MenuSetId = menuSetId, RoleId = roleId })
.ContinueWith(t => t.Result.ToList());
[Fact]
public async Task NoOverrideRow_FallsBackToMenuDisplayNameAndBundleSortOrderAndVisibleTrue()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
// No MMENUSETDETAIL row for (MenuSetId=100, MenuId=501) — the "not found" branch.
var leaves = await ResolveAsync(menuSetId: 100, groupIds: 10);
var leaf = Assert.Single(leaves);
Assert.Equal(10, leaf.MenuGroupId);
Assert.Equal("Sales Order", leaf.DisplayName);
Assert.Equal(3, leaf.SortOrder);
Assert.Equal(1, leaf.IsVisible);
}
[Fact]
public async Task OverrideRowFound_UsesOverrideDisplayNameSortOrderAndVisibility()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
await _conn.ExecuteAsync(@"
INSERT INTO MMENUSETDETAIL (MENUSETID, MENUID, MENUDISPLAYNAME, SORTORDER, ISVISIBLE)
VALUES (100, 501, 'My Sales Order', 9, 0)");
var leaves = await ResolveAsync(menuSetId: 100, groupIds: 10);
var leaf = Assert.Single(leaves);
Assert.Equal("My Sales Order", leaf.DisplayName);
Assert.Equal(9, leaf.SortOrder);
Assert.Equal(0, leaf.IsVisible);
}
[Fact]
public async Task OverrideRowWithBlankDisplayName_StillFallsBackToMenuDisplayName()
{
// The override exists (so SortOrder/ISVISIBLE come from it) but MENUDISPLAYNAME is
// blank — the CASE WHEN COALESCE(...,'')='' branch must still fall back to MMENU's name.
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
await _conn.ExecuteAsync(@"
INSERT INTO MMENUSETDETAIL (MENUSETID, MENUID, MENUDISPLAYNAME, SORTORDER, ISVISIBLE)
VALUES (100, 501, '', 9, 2)");
var leaf = Assert.Single(await ResolveAsync(menuSetId: 100, groupIds: 10));
Assert.Equal("Sales Order", leaf.DisplayName);
Assert.Equal(9, leaf.SortOrder);
Assert.Equal(2, leaf.IsVisible);
}
[Fact]
public async Task OverrideMenuGroupId_RefilesLeafUnderADifferentGroupThanTheBundleDefault()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
// Bundle default places this menu under group 10 ...
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
// ... but this client re-filed it under group 20 via MMENUSETDETAIL.MENUGROUPID.
await _conn.ExecuteAsync(@"
INSERT INTO MMENUSETDETAIL (MENUSETID, MENUID, MENUGROUPID)
VALUES (100, 501, 20)");
var leaf = Assert.Single(await ResolveAsync(menuSetId: 100, groupIds: 10));
Assert.Equal(20, leaf.MenuGroupId);
}
[Fact]
public async Task OtherMenuSetsOverride_IsIgnored_OnlyTheRequestedSetsOverrideApplies()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
// Override belongs to a DIFFERENT MenuSetId (200) — must not leak into MenuSetId 100's resolution.
await _conn.ExecuteAsync(@"
INSERT INTO MMENUSETDETAIL (MENUSETID, MENUID, MENUDISPLAYNAME, SORTORDER, ISVISIBLE)
VALUES (200, 501, 'Someone Else''s Override', 1, 0)");
var leaf = Assert.Single(await ResolveAsync(menuSetId: 100, groupIds: 10));
Assert.Equal("Sales Order", leaf.DisplayName);
Assert.Equal(3, leaf.SortOrder);
Assert.Equal(1, leaf.IsVisible);
}
[Fact]
public async Task NullRoleId_SkipsRoleFilterEntirely_AdminAndContentManagementCallersSeeFullMembership()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
// No MROLEVSMENU row at all for this menu — would deny every role, but roleId=null must
// bypass the filter entirely (admin/content-management path).
var leaf = Assert.Single(await ResolveAsync(menuSetId: 100, roleId: null, groupIds: 10));
Assert.Equal(501, leaf.MenuId);
}
[Fact]
public async Task RoleIdProvided_WithNoMatchingRoleVsMenuRow_LeafIsFilteredOut()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
// Role 7 has no MROLEVSMENU grant at all for menu 501 — deny-by-default, same as the
// legacy GetMenuForUserModule query's inner-join-only-grants-are-visible semantics.
var leaves = await ResolveAsync(menuSetId: 100, roleId: 7, groupIds: 10);
Assert.Empty(leaves);
}
[Fact]
public async Task RoleIdProvided_WithDeniedAllowFlag_LeafIsFilteredOut()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
// Allow position 1 = '1' means view NOT granted (legacy convention: '0' = allowed).
await _conn.ExecuteAsync("INSERT INTO MROLEVSMENU (ROLEID, MENUID, ALLOW) VALUES (7, 501, '10000')");
var leaves = await ResolveAsync(menuSetId: 100, roleId: 7, groupIds: 10);
Assert.Empty(leaves);
}
[Fact]
public async Task RoleIdProvided_WithGrantedAllowFlag_LeafIsIncluded()
{
await _conn.ExecuteAsync("INSERT INTO MMENU (MENUID, MENUCODE, MENUDISPLAYNAME) VALUES (501, 'SALESORD', 'Sales Order')");
await _conn.ExecuteAsync("INSERT INTO MMENUGROUPDETAIL (MENUGROUPID, MENUID, SORTORDER) VALUES (10, 501, 3)");
await _conn.ExecuteAsync("INSERT INTO MROLEVSMENU (ROLEID, MENUID, ALLOW) VALUES (7, 501, '00000')");
// A different role (8) is denied — must not leak this role's grant into role 7's view.
await _conn.ExecuteAsync("INSERT INTO MROLEVSMENU (ROLEID, MENUID, ALLOW) VALUES (8, 501, '10000')");
var leaf = Assert.Single(await ResolveAsync(menuSetId: 100, roleId: 7, groupIds: 10));
Assert.Equal(501, leaf.MenuId);
}
}