diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..0091a1b3 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,46 @@ +# Runs the ClientApp's unit and component tests (vitest) on every pull request that touches +# the UI. +# +# The deploy pipeline only runs the .NET test projects, and only after a merge, so until +# this existed nothing ever ran the frontend tests outside someone's machine. These need +# no backend or database — the component tests render pages against a mock network — so +# they take seconds. The Playwright e2e suite is not run here: it needs a running backend +# with seeded data. +name: Frontend tests + +on: + pull_request: + branches: [releases/**] + paths: + - 'SW.Bitween.Web/ClientApp/**' + # Read by the tests: transformArgs.test.ts checks the editor names arguments as the engine + # does, and the component tests sign in holding every permission the catalog defines. + - 'SW.Bitween.NativeAdapters/Mapper/Transforms.cs' + - 'SW.Bitween.Sdk/Model/Permissions.cs' + - '.github/workflows/frontend-tests.yml' + +permissions: + contents: read + +jobs: + vitest: + runs-on: ubuntu-latest + defaults: + run: + working-directory: SW.Bitween.Web/ClientApp + steps: + # Pinned to commits, since a tag can be moved; and the checkout keeps no token, since the + # install below runs dependencies' own scripts. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' # matches the Dockerfile's ui-build stage + cache: yarn + cache-dependency-path: SW.Bitween.Web/ClientApp/yarn.lock + - run: yarn install --frozen-lockfile + # The same type-check the Docker build runs, which covers the tests too — so a test file + # that breaks it fails here rather than in the deploy after the merge. + - run: npx tsc -b + - run: yarn test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 900616d8..d1b7e245 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -266,8 +266,25 @@ dotnet test SW.Bitween.UnitTests # Run tests with coverage dotnet test --collect:"XPlat Code Coverage" + +# Frontend, from SW.Bitween.Web/ClientApp +yarn test # unit + component tests, no backend needed +yarn test:e2e # end-to-end, needs the backend running on the Local profile ``` +### Where a New Test Goes + +Four layers, fastest first. Put a test in the lowest layer that can see the bug it guards against. + +| Layer | Where | Use it for | +|---|---|---| +| Unit | `SW.Bitween.UnitTests` (MSTest); `ClientApp/src/**/__tests__/*.test.ts` (vitest) | Logic with no database, bus or screen: the mapping engine, transforms, schedules, parsing, the editor's reducer. | +| Integration | `SW.Bitween.IntegrationTests` (xUnit, real Postgres and RabbitMQ in Docker) | Anything that crosses into the database, the bus, or a permission guard. | +| Component | `ClientApp/src/**/__tests__/*.test.tsx` (vitest in jsdom, against a mock network — see `src/__tests__/support`) | What one page shows and does: which controls appear, what a click sends, how an error reads. | +| End-to-end | `ClientApp/e2e` (Playwright, against the running backend) | A whole user journey through the real app, and what only a real browser can check: layout widths, drag and drop, two tabs. | + +An end-to-end test that asserts what the engine returns, or what one page renders, belongs a layer down: it runs far slower, and fails for reasons that have nothing to do with what it tests. + ### Writing Tests #### Unit Tests diff --git a/SW.Bitween.IntegrationTests/Tests/AuditTrailTests.cs b/SW.Bitween.IntegrationTests/Tests/AuditTrailTests.cs index 4451cf78..23429bb3 100644 --- a/SW.Bitween.IntegrationTests/Tests/AuditTrailTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/AuditTrailTests.cs @@ -179,7 +179,20 @@ static async Task SingleEntryFor(BitweenDbContext db, string entityN await EntriesFor(db, entityName, key).SingleAsync(); static Dictionary Changes(AuditEntry entry) => - Newtonsoft.Json.JsonConvert.DeserializeObject>(entry.Changes); + Newtonsoft.Json.Linq.JObject.Parse(entry.Changes).Properties().ToDictionary( + p => p.Name, + p => new Diff { Old = Text(p.Value["Old"]), New = Text(p.Value["New"]) }); + + /// + /// A value as the test compares it. Most columns are recorded as text, but a collection — the + /// names of a partner's secret properties, say — is recorded as the list it is. + /// + static string? Text(Newtonsoft.Json.Linq.JToken? value) => value?.Type switch + { + null or Newtonsoft.Json.Linq.JTokenType.Null => null, + Newtonsoft.Json.Linq.JTokenType.String => (string?)value, + _ => value.ToString(Newtonsoft.Json.Formatting.None), + }; private class Diff { diff --git a/SW.Bitween.IntegrationTests/Tests/MappingPreviewTests.cs b/SW.Bitween.IntegrationTests/Tests/MappingPreviewTests.cs index 38c4196e..b5221739 100644 --- a/SW.Bitween.IntegrationTests/Tests/MappingPreviewTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/MappingPreviewTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; @@ -146,6 +147,52 @@ public async Task An_unsupported_format_names_what_is_supported() response.Error); } + /// Rules that write a delimited file, with the target side's options left to the caller. + private static object CsvTargetRules(object targetCsv) => new + { + version = 1, + sourceFormat = "json", + targetFormat = "csv", + targetCsv, + root = new + { + over = "", + fields = new object[] + { + new { target = new[] { "Tracking" }, from = new { kind = "Path", path = "t" } }, + new { target = new[] { "Status" }, from = new { kind = "Path", path = "s" } }, + }, + }, + }; + + private const string Scans = """[ { "t": "1309981172", "s": "OK" }, { "t": "1309981174", "s": "CC" } ]"""; + + /// + /// The writer's own tests prove it honours a delimiter and a header. This pins that the ones on + /// the rules are the ones it is handed — the preview builds its target format from them. + /// + [Fact] + public async Task A_delimited_file_is_written_with_the_delimiter_and_header_the_rules_ask_for() + { + var response = await PreviewAsync(CsvTargetRules(new { delimiter = ";", hasHeader = true }), Scans); + + Assert.Null(response.Error); + var lines = response.OutputDocument!.Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(new[] { "Tracking;Status", "1309981172;OK", "1309981174;CC" }, + lines.Select(l => l.TrimEnd('\r')).ToArray()); + } + + /// The mark is invisible, so what is checked is the first character itself. + [Fact] + public async Task A_delimited_file_starts_with_a_byte_order_mark_only_when_asked() + { + var marked = await PreviewAsync(CsvTargetRules(new { byteOrderMark = true }), Scans); + var plain = await PreviewAsync(CsvTargetRules(new { byteOrderMark = false }), Scans); + + Assert.Equal('\uFEFF', marked.OutputDocument![0]); + Assert.NotEqual('\uFEFF', plain.OutputDocument![0]); + } + [Fact] public async Task Rules_from_a_newer_version_are_refused() { diff --git a/SW.Bitween.IntegrationTests/Tests/ReadGuardTests.cs b/SW.Bitween.IntegrationTests/Tests/ReadGuardTests.cs new file mode 100644 index 00000000..50a6aadb --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/ReadGuardTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// Reads are guarded as well as writes, and in both directions: a narrow role must not read the +/// lists of areas it doesn't hold, yet every picker in the app needs id/name pairs from areas the +/// person may never browse. Lookup mode is what serves the pickers, so it stays open. +/// +[Collection("Bitween")] +public class ReadGuardTests(BitweenFixture fixture) +{ + /// Signs in as a fresh account whose only grant is reading information types. + private static async Task SignInAsInformationTypeReader(AsyncServiceScope scope) + { + var db = scope.ServiceProvider.GetRequiredService(); + + var role = new Role($"docs-reader-{Guid.NewGuid():N}", "Reads information types only", + [Permissions.Documents.View]); + db.Set().Add(role); + var account = new Account("Docs Reader", $"docs-reader-{Guid.NewGuid():N}@test.local", "hash", + AccountRole.Member); + db.Set().Add(account); + await db.SaveChangesAsync(); + + db.Set().Add(new AccountRoleLink(account.Id, role.Id)); + await db.SaveChangesAsync(); + + scope.As(account.Id); + } + + private static Func> Search(AsyncServiceScope scope, bool lookup = false) + where THandler : ISearchyHandler => + () => ActivatorUtilities.CreateInstance(scope.ServiceProvider) + .Handle(new SearchyRequest(), lookup); + + [Fact] + public async Task One_view_permission_reads_its_own_list_and_no_other() + { + await using var scope = fixture.CreateScope(); + await SignInAsInformationTypeReader(scope); + + // The one area held is readable. + await Search(scope)(); + + var refused = new Dictionary>> + { + ["partners"] = Search(scope), + ["exchanges"] = Search(scope), + ["subscriptions"] = Search(scope), + ["notifiers"] = Search(scope), + ["API gateways"] = Search(scope), + ["bus gateways"] = Search(scope), + ["retry policies"] = Search(scope), + ["global values"] = Search(scope), + ["scheduled retries"] = Search(scope), + ["work groups"] = () => ActivatorUtilities.CreateInstance( + scope.ServiceProvider).Handle(new SearchWorkGroupModel()), + ["queue health"] = () => ActivatorUtilities.CreateInstance( + scope.ServiceProvider).Handle(), + }; + + foreach (var (area, read) in refused) + { + var denied = await Record.ExceptionAsync(read); + Assert.True(denied is SWUnauthorizedException, $"{area} should be refused, got {denied?.GetType().Name ?? "a result"}"); + } + } + + [Fact] + public async Task Lookup_mode_stays_readable_so_pickers_work_for_any_role() + { + await using var scope = fixture.CreateScope(); + await SignInAsInformationTypeReader(scope); + + await Search(scope, lookup: true)(); + await Search(scope, lookup: true)(); + await Search(scope, lookup: true)(); + await ActivatorUtilities.CreateInstance(scope.ServiceProvider) + .Handle(new SearchMembersModel { Lookup = true }); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs b/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs index 0105ee35..5d551f3c 100644 --- a/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs +++ b/SW.Bitween.IntegrationTests/Tests/SettingsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -27,6 +28,7 @@ public class SettingsTests(BitweenFixture fixture) : IAsyncLifetime private const string SecretKey = "Bitween.RebexLicenseKey"; private const string EditableKey = "Bitween.JwtExpiryMinutes"; private const string EnvironmentOwnedKey = "Bitween.DocumentPrefix"; + private const string CronKey = "Bitween.RetryJobCron"; private readonly Dictionary _originals = new(); @@ -166,4 +168,37 @@ public async Task Clearing_an_optional_setting_is_a_real_change_not_a_no_op() // do" would make a setting impossible to unset once set. Assert.Equal(string.Empty, await LiveValue(SecretKey)); } + + /// + /// What the settings page is sent for a secret: that one is set, and nothing more. Storing it + /// encrypted protects a database dump; this is the other half, the page itself. + /// + [Fact] + public async Task A_secret_is_read_back_as_set_but_never_with_its_value() + { + await Store(SecretKey, "REBEX-5678-SECRET-VALUE"); + + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var handler = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + var row = ((IEnumerable)await handler.Handle()).Single(r => r.Key == SecretKey); + + Assert.True(row.Secret); + Assert.True(row.HasValue); + Assert.Null(row.Value); + Assert.Equal(string.Empty, row.DefaultValue); + } + + /// + /// Checked before it is stored, because the value is handed straight to the job scheduler: a + /// bad expression saved here would surface as the retry job never running. + /// + [Fact] + public async Task A_schedule_that_is_not_a_cron_expression_is_refused() + { + var ex = await Assert.ThrowsAsync(() => Store(CronKey, "not a cron")); + + Assert.StartsWith("SETTING_INVALID_VALUE", ex.Message); + Assert.Contains("not a valid cron expression", ex.Message); + } } diff --git a/SW.Bitween.IntegrationTests/Tests/TeamGuardTests.cs b/SW.Bitween.IntegrationTests/Tests/TeamGuardTests.cs new file mode 100644 index 00000000..3b2688cc --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/TeamGuardTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using SW.PrimitiveTypes; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// The rules that keep the team manageable: someone must always hold Administrator, a role that +/// people still hold can't vanish from under them, and two roles can't be told apart by name alone +/// if they share one. +/// +[Collection("Bitween")] +public class TeamGuardTests(BitweenFixture fixture) +{ + private static string Unique(string label) => $"{label}-{Guid.NewGuid():N}"; + + private static async Task CreateAccount(BitweenDbContext db, string label, params int[] roleIds) + { + var account = new Account("Test User", $"{Unique(label)}@test.local", "irrelevant-hash", AccountRole.Member); + db.Set().Add(account); + await db.SaveChangesAsync(); + + foreach (var roleId in roleIds) + db.Set().Add(new AccountRoleLink(account.Id, roleId)); + await db.SaveChangesAsync(); + + return account; + } + + /// + /// The collection shares one database, and other tests leave administrators in it. So the + /// "only administrator" state is made inside a transaction — everyone else disabled, which the + /// guard doesn't count — and rolled back afterwards, so no other test ever sees it. + /// + [Fact] + public async Task The_last_administrator_cannot_lose_the_role_be_disabled_or_be_removed() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await db.Database.BeginTransactionAsync(); + + var admin = await CreateAccount(db, "last-admin", Role.AdministratorId); + await db.Set().Where(a => a.Id != admin.Id) + .ExecuteUpdateAsync(s => s.SetProperty(a => a.Disabled, true)); + + var setRoles = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + var setDisabled = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + var remove = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var demoted = await Assert.ThrowsAsync(() => + setRoles.Handle(admin.Id, new SetAccountRolesModel { RoleIds = [Role.MemberId] })); + var disabled = await Assert.ThrowsAsync(() => + setDisabled.Handle(admin.Id, new SetAccountDisabledModel { Disabled = true })); + var removed = await Assert.ThrowsAsync(() => + remove.Handle(admin.Id, null)); + + foreach (var refusal in new[] { demoted, disabled, removed }) + Assert.StartsWith("LAST_ADMINISTRATOR", refusal.Message); + } + + [Fact] + public async Task A_role_someone_still_holds_cannot_be_deleted() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var db = scope.ServiceProvider.GetRequiredService(); + + var role = new Role(Unique("in-use"), "Held by one member", [Permissions.Exchanges.View]); + db.Set().Add(role); + await db.SaveChangesAsync(); + var holder = await CreateAccount(db, "role-holder", role.Id); + + var delete = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var refusal = await Assert.ThrowsAsync(() => delete.Handle(role.Id)); + Assert.StartsWith("ROLE_IN_USE", refusal.Message); + Assert.Contains("still assigned to 1 member", refusal.Message); + + // Once nobody holds it, the same delete goes through. + db.Set().RemoveRange(db.Set().Where(l => l.AccountId == holder.Id)); + await db.SaveChangesAsync(); + await delete.Handle(role.Id); + + Assert.False(await db.Set().AnyAsync(r => r.Id == role.Id)); + } + + [Fact] + public async Task A_role_cannot_take_a_name_another_role_already_has() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + + var create = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + + var refusal = await Assert.ThrowsAsync(() => create.Handle(new RoleCreate + { + Name = "Administrator", + Description = "Should be refused.", + Permissions = [Permissions.Exchanges.View], + })); + Assert.StartsWith("ROLE_EXISTS", refusal.Message); + } +} diff --git a/SW.Bitween.IntegrationTests/Tests/TeamMemberTests.cs b/SW.Bitween.IntegrationTests/Tests/TeamMemberTests.cs new file mode 100644 index 00000000..975bc4c4 --- /dev/null +++ b/SW.Bitween.IntegrationTests/Tests/TeamMemberTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SW.Bitween.Domain.Accounts; +using SW.Bitween.IntegrationTests.Fixtures; +using SW.Bitween.Model; +using Xunit; + +namespace SW.Bitween.IntegrationTests.Tests; + +/// +/// What an administrator changes about a member from the member drawer is really kept. The drawer +/// re-reads the member list after every save rather than trusting what it sent; these hold that +/// there's something there to read. +/// +[Collection("Bitween")] +public class TeamMemberTests(BitweenFixture fixture) +{ + private static async Task CreateViewer(BitweenDbContext db, string label) + { + var account = new Account("Test User", $"{label}-{Guid.NewGuid():N}@test.local", "irrelevant-hash", + AccountRole.Member); + db.Set().Add(account); + await db.SaveChangesAsync(); + + db.Set().Add(new AccountRoleLink(account.Id, Role.ViewerId)); + await db.SaveChangesAsync(); + + return account; + } + + [Fact] + public async Task Setting_a_members_roles_replaces_the_ones_they_held() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = await CreateViewer(db, "role-swap"); + + var setRoles = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + await setRoles.Handle(account.Id, new SetAccountRolesModel { RoleIds = [Role.MemberId] }); + + var held = await db.Set().AsNoTracking() + .Where(l => l.AccountId == account.Id) + .Select(l => l.RoleId) + .ToListAsync(); + Assert.Equal([Role.MemberId], held); + } + + [Fact] + public async Task A_disabled_member_stays_disabled_until_re_enabled() + { + await using var scope = fixture.CreateScope(); + scope.Superuser(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = await CreateViewer(db, "on-leave"); + + var setDisabled = ActivatorUtilities.CreateInstance(scope.ServiceProvider); + Task Stored() => db.Set().AsNoTracking() + .Where(a => a.Id == account.Id) + .Select(a => a.Disabled) + .SingleAsync(); + + await setDisabled.Handle(account.Id, new SetAccountDisabledModel { Disabled = true }); + Assert.True(await Stored()); + + await setDisabled.Handle(account.Id, new SetAccountDisabledModel { Disabled = false }); + Assert.False(await Stored()); + } +} diff --git a/SW.Bitween.UnitTests/NativeMapper/DocumentMapperTests.cs b/SW.Bitween.UnitTests/NativeMapper/DocumentMapperTests.cs index 7830bc11..b43dde0c 100644 --- a/SW.Bitween.UnitTests/NativeMapper/DocumentMapperTests.cs +++ b/SW.Bitween.UnitTests/NativeMapper/DocumentMapperTests.cs @@ -266,6 +266,48 @@ public void Lookup_MissWithNoFallback_IsNull() Assert.IsNull(Scalar(Map(rules, Order), "state")); } + /// + /// The table is asked about the value after the transform, so a table keyed on "JO" matches a + /// document that says "jo" once it has been uppercased. The other order would miss. + /// + [TestMethod] + public void Lookup_IsAskedAboutTheTransformedValue() + { + var rules = new MappingRules + { + Fields = + [ + Field("countryName", Path("country"), transform: Transform("upper"), + lookup: new LookupRule { Table = new Dictionary { ["JO"] = "Jordan" } }), + ], + }; + + Assert.AreEqual("Jordan", Scalar(Map(rules, """{ "country": "jo" }"""), "countryName")); + } + + /// + /// How an ambiguous date is read is a setting on the mapping, not on the rule — a partner writes + /// dates one way throughout. The transforms have their own tests; this pins that the mapping's + /// setting is what reaches them. + /// + [TestMethod] + public void SourceDateOrder_DecidesHowTheTransformsReadADate() + { + const string document = """{ "shippingDate": "04.09.2026" }"""; + MappingRules Rules(DateOrder order) => new() + { + SourceDateOrder = order, + Fields = [Field("shipDate", Path("shippingDate"), transform: Transform("formatDate", ("format", "yyyy-MM-dd")))], + }; + + Assert.AreEqual("2026-09-04", Scalar(Map(Rules(DateOrder.DayFirst), document), "shipDate")); + Assert.AreEqual("2026-04-09", Scalar(Map(Rules(DateOrder.MonthFirst), document), "shipDate")); + + // Left at year-first, it is refused rather than guessed. + var refused = Assert.ThrowsException(() => Map(Rules(DateOrder.YearFirst), document)); + StringAssert.Contains(refused.Errors[0].Reason, "04.09.2026"); + } + // ── loops ─────────────────────────────────────────────────────────────────── [TestMethod] @@ -412,6 +454,32 @@ public void NestedLoops_WalkTheInnerList() Assert.AreEqual(1, ((ListNode)Values.Resolve(orders.Items[1], "items")!).Items.Count); } + /// + /// A list of plain values produces one value per entry, and that value is a whole rule: it is + /// transformed and typed exactly like a named field would be. + /// + [TestMethod] + public void AListOfPlainValues_IsTransformedAndTypedLikeAnyField() + { + var rules = new MappingRules + { + Lists = + [ + new ListRule + { + Over = "price", Target = ["totals"], + Item = Field("", Path(""), ValueType.Number, Transform("multiply", ("by", 2))), + }, + ], + }; + + var totals = (ListNode)Values.Resolve(Map(rules, """{ "price": [10, 20] }"""), "totals")!; + + CollectionAssert.AreEqual( + new object?[] { 20m, 40m }, + totals.Items.Select(i => ((ScalarNode)i).Value).ToArray()); + } + // ── failure ───────────────────────────────────────────────────────────────── /// @@ -692,6 +760,36 @@ public void AFixedEntry_ReadsTheScopeTheListSitsIn() Assert.AreEqual("Ali", Scalar(first, "who")); } + /// A fixed entry can carry a partner value too, like the header line a partner expects. + [TestMethod] + public void AFixedEntry_ReadsThePartner() + { + var context = new MappingContext + { + Partner = new Dictionary { ["WarehouseCode"] = "WH-7" }, + }; + var rules = new MappingRules + { + Lists = + [ + new ListRule + { + Over = "order.line", Target = ["lines"], + Fixed = + [ + Entry(Field("sku", Fixed("HEADER")), + Field("warehouse", new ValueSource { Kind = ValueSourceKind.Partner, Key = "WarehouseCode" })), + ], + Fields = [Field("sku", Path("sku"))], + }, + ], + }; + + var first = (ObjectNode)((ListNode)Values.Resolve(Map(rules, Order, context), "lines")!).Items[0]; + + Assert.AreEqual("WH-7", Scalar(first, "warehouse")); + } + /// /// No source list to walk: the list is exactly its fixed entries. This is what the previous /// mapper called a primitive array — one slot per rule rather than one per source entry. diff --git a/SW.Bitween.UnitTests/SettingsCatalogTests.cs b/SW.Bitween.UnitTests/SettingsCatalogTests.cs new file mode 100644 index 00000000..d79973f7 --- /dev/null +++ b/SW.Bitween.UnitTests/SettingsCatalogTests.cs @@ -0,0 +1,94 @@ +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SW.Bitween.Services; + +namespace SW.Bitween.UnitTests; + +/// +/// The settings page renders whatever the catalog says, so what an administrator is offered — which +/// sections, which rows are edits and which are only shown — is decided here rather than in the UI. +/// +[TestClass] +public class SettingsCatalogTests +{ + [TestMethod] + public void Sections_are_the_ones_the_page_lists_in_the_order_it_lists_them() + { + // The page takes its section links from the rows, first appearance first. + CollectionAssert.AreEqual( + new[] + { + "Documents & storage", + "API behavior", + "Single sign-on (Microsoft)", + "Adapters", + "Reliability & jobs", + "Messaging", + "Database", + "Security", + "Brand & theme", + }, + SettingsCatalog.All.Select(d => d.Section).Distinct().ToArray()); + } + + /// + /// There is no restart-required row: a setting that couldn't take effect immediately is shown as + /// an environment value instead of being offered as an edit that needs a restart to land. + /// + [TestMethod] + public void Every_setting_is_either_applied_live_or_environment_owned() + { + foreach (var definition in SettingsCatalog.All) + { + // An editable setting takes effect by being written to the live options; an environment + // one has nowhere to be written that would matter, since whoever reads it did so at boot. + Assert.AreEqual(definition.Stored, definition.Write is not null, + $"{definition.Key} is {definition.Access} but {(definition.Write is null ? "can't" : "can")} be written."); + Assert.AreEqual(definition.Access == SettingAccess.Editable, definition.Stored, definition.Key); + } + } + + [TestMethod] + public void Database_settings_are_shown_but_never_stored() + { + var database = SettingsCatalog.All.Where(d => d.Section == "Database").ToDictionary(d => d.Key); + + // The provider and its credentials are fixed when the connection is built at startup. + Assert.AreEqual(SettingAccess.ReadOnly, database["Bitween.UseAzureManagedIdentity"].Access); + Assert.AreEqual(SettingKind.Boolean, database["Bitween.UseAzureManagedIdentity"].Kind); + // …and a client ID is reported only as set or not set, never by its content. + Assert.AreEqual(SettingAccess.Presence, database["Bitween.AzureManagedIdentityClientId"].Access); + Assert.IsFalse(database.Values.Any(d => d.Stored), "A database setting is offered as an edit."); + } + + [TestMethod] + public void Microsoft_only_sign_in_is_an_editable_setting_not_an_environment_value() + { + var definition = SettingsCatalog.Find("Bitween.DisableEmailPasswordLogin"); + + // It applies per request — the Login handler and the config endpoint both read it live — so + // it belongs in the catalog as an edit rather than a read-only environment row. + Assert.AreEqual("Single sign-on (Microsoft)", definition.Section); + Assert.AreEqual("Microsoft sign-in only", definition.Label); + Assert.AreEqual(SettingKind.Boolean, definition.Kind); + Assert.AreEqual(SettingAccess.Editable, definition.Access); + Assert.IsFalse(definition.Secret); + Assert.AreEqual("false", SettingsService.DefaultOf(definition)); + } + + /// + /// Whether an invalid expression is refused is SettingsTests' + /// A_schedule_that_is_not_a_cron_expression_is_refused; this is that it can be edited at all. + /// + [TestMethod] + public void The_retry_schedule_is_editable_and_polls_every_minute_by_default() + { + var definition = SettingsCatalog.Find("Bitween.RetryJobCron"); + + Assert.AreEqual("Reliability & jobs", definition.Section); + Assert.AreEqual("Retry poll schedule", definition.Label); + Assert.AreEqual(SettingKind.String, definition.Kind); + Assert.AreEqual(SettingAccess.Editable, definition.Access); + Assert.AreEqual("0 * * * * ?", SettingsService.DefaultOf(definition)); + } +} diff --git a/SW.Bitween.Web/ClientApp/e2e/audit-trail.spec.ts b/SW.Bitween.Web/ClientApp/e2e/audit-trail.spec.ts index 75483af1..a20302c0 100644 --- a/SW.Bitween.Web/ClientApp/e2e/audit-trail.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/audit-trail.spec.ts @@ -41,24 +41,6 @@ async function audit(page: Page, query: string) { }; } -/** Creates a partner through the API so a test can set fields the form doesn't expose. */ -async function createPartnerViaApi(page: Page, name: string, adapterProperties: Record) { - const token = await page.evaluate(() => localStorage.getItem("access_token")); - const res = await page.request.post(`${API}/partners`, { - headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, - data: { name, adapterProperties }, - }); - expect(res.ok()).toBeTruthy(); - return (await res.json()) as number; -} - -async function deletePartnerViaApi(page: Page, id: number) { - const token = await page.evaluate(() => localStorage.getItem("access_token")); - await page.request.delete(`${API}/partners/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }); -} - /** The History panel on an entity page, and the rows inside it. */ const historyPanel = (page: Page) => page.locator("section").filter({ has: page.getByRole("heading", { name: "History", level: 2 }) }); @@ -119,166 +101,6 @@ test.describe("audit trail", () => { expect(deleted!.changes.Name.new).toBeNull(); }); - test("adapter properties never reach the trail", async ({ page }) => { - const name = `Playwright Secret ${Date.now()}`; - const secret = `do-not-store-${Date.now()}`; - - await page.goto("partners"); - const id = await createPartnerViaApi(page, name, { Host: "smtp.example.test", Password: secret }); - - const rows = await audit(page, `entityName=Partner&entityKey=${id}`); - expect(rows.totalCount).toBeGreaterThan(0); - - const asText = JSON.stringify(rows.result); - expect(asText, "the secret value must not be stored").not.toContain(secret); - expect(asText, "the property bag holding it must not be stored").not.toContain("AdapterProperties"); - // The rest of the entity is still recorded — redaction is a scalpel, not a blanket. - expect(rows.result.some((r) => r.changes.Name?.new === name)).toBeTruthy(); - - await deletePartnerViaApi(page, id); - }); - - test("an account's password never reaches the trail", async ({ page }) => { - const email = await addMember(page, { name: "Playwright Audited", roles: ["Viewer"] }); - - const rows = await audit(page, "entityName=Account&limit=50"); - const asText = JSON.stringify(rows.result); - expect(asText, "the password hash must not be stored").not.toContain(FIRST_PASSWORD); - expect(asText).not.toContain('"Password"'); - // The member was recorded, just without the credential. - expect(rows.result.some((r) => r.changes.Email?.new === email)).toBeTruthy(); - - await removeMember(page, email); - }); - - test("runtime traffic is not audited", async ({ page }) => { - await page.goto("audit"); - // Everything that flows through Bitween at runtime. One row each would bury the - // configuration changes the trail exists to show, so the policy excludes them. - for (const entityName of ["Xchange", "XchangeResult", "ReceiveAttempt", "RefreshToken"]) { - const rows = await audit(page, `entityName=${entityName}`); - expect(rows.totalCount, `${entityName} must not be audited`).toBe(0); - } - }); - - test("the trail page filters, groups one save, and clears", async ({ page }) => { - const name = `Playwright Filter ${Date.now()}`; - await page.goto("partners"); - const id = await createPartnerViaApi(page, name, {}); - - await page.goto("audit"); - await expect(page.getByRole("heading", { name: "Audit trail" })).toBeVisible(); - - // Narrowing to this one row proves the entity filters reach the query, not just the URL. - await page.goto(`audit?entityName=Partner&entityKey=${id}`); - await expect(page.getByRole("row").filter({ hasText: "Partner" }).first()).toBeVisible(); - await expect(page.getByRole("link", { name: String(id), exact: true }).first()).toBeVisible(); - - // "Same save" pivots to the correlation id — every row one SaveChanges wrote. - await page.getByRole("button", { name: "Same save" }).first().click(); - await expect(page).toHaveURL(/correlationId=/); - await expect(page.getByText("Showing one save only")).toBeVisible(); - - await page.getByRole("button", { name: "Clear filters" }).click(); - await expect(page).toHaveURL(/\/audit$/); - - await deletePartnerViaApi(page, id); - }); - - test("the history card is on every entity page that has one", async ({ page }) => { - test.setTimeout(120_000); - - // Reached by URL rather than by clicking a list row: rows carry links of their own — a stray - // click on the subscriptions list lands on an information type — so the id comes from the API - // and the page is opened directly. - const areas: { label: string; list: string; path: (id: string) => string }[] = [ - { label: "partner", list: "/partners?limit=1", path: (id) => `partners/${id}` }, - { label: "information type", list: "/documents?limit=1", path: (id) => `information-types/${id}` }, - { label: "work group", list: "/workgroups?limit=1", path: (id) => `work-groups/${id}` }, - { label: "global value set", list: "/globaladaptervaluessets", path: (id) => `global-values/${id}` }, - { label: "retry policy", list: "/retrypolicies?limit=1", path: (id) => `retry-policies/${id}` }, - { label: "notifier", list: "/notifiers?limit=1", path: (id) => `notifiers/${id}` }, - { label: "API gateway", list: "/apigateways?limit=1", path: (id) => `api-gateways/${id}` }, - { label: "subscription", list: "/subscriptions?limit=1", path: (id) => `subscriptions/${id}` }, - ]; - - const token = await page.evaluate(() => localStorage.getItem("access_token")); - const skipped: string[] = []; - let checked = 0; - - for (const area of areas) { - const res = await page.request.get(`${API}${area.list}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(res.ok(), `could not list ${area.label}s`).toBeTruthy(); - const body = await res.json(); - const first = (Array.isArray(body) ? body : (body.result ?? []))[0]; - if (!first) { - skipped.push(area.label); - continue; // nothing seeded in this area on this database - } - - await page.goto(area.path(String(first.id))); - await expect(historyPanel(page), `no History card on a ${area.label}`).toBeVisible({ - timeout: 20_000, - }); - checked++; - } - - // An area with nothing in it is skipped rather than failed — a database need not hold one - // of everything. But a nearly empty one would sail through the loop having tested nothing, - // so most areas must actually have been reached. - expect( - checked, - `only ${checked} of ${areas.length} areas were checked (no rows for: ${skipped.join(", ")})`, - ).toBeGreaterThanOrEqual(areas.length - 3); - - // Settings has no per-row page, so its card covers the whole area. - await page.goto("settings"); - await expect(historyPanel(page)).toBeVisible(); - }); - - test("a custom role's page carries its history", async ({ page }) => { - // Built-in roles are deliberately excluded — their grants are computed rather than stored, - // so nothing ever edits one — which makes a custom role the case worth covering. - const role = await createRole(page, { - name: `PW Audit Role ${Date.now()}`, - permissions: [{ area: "Partners", action: "View" }], - }); - - await page.goto("team/roles"); - await page.getByRole("link", { name: new RegExp(role) }).click(); - await expect(historyPanel(page)).toBeVisible({ timeout: 15000 }); - await expect(historyPanel(page).getByRole("row").filter({ hasText: "Added" })).toBeVisible(); - - await deleteRole(page, role); - }); - - test("a junk offset in the URL doesn't break the page", async ({ page }) => { - // Number("bad") is NaN, which used to go out on the wire as offset=NaN. - for (const offset of ["bad", "-5"]) { - await page.goto(`audit?offset=${offset}`); - await expect(page.getByRole("heading", { name: "Audit trail" })).toBeVisible(); - await expect(page.getByText(/Showing 1[–-]/)).toBeVisible(); - } - }); - - test("a member drawer shows that member's history", async ({ page }) => { - const email = await addMember(page, { name: "Playwright Drawer", roles: ["Viewer"] }); - - await page.goto("team/members"); - await page.getByRole("row", { name: new RegExp(email) }).click(); - const drawer = page.getByRole("dialog", { name: "Member details" }); - await drawer.waitFor(); - - await expect(drawer.getByRole("heading", { name: "History" })).toBeVisible(); - await expect(drawer.getByRole("row").filter({ hasText: "Added" }).first()).toBeVisible(); - await expect(drawer.getByRole("link", { name: "What this member changed" })).toBeVisible(); - - await page.keyboard.press("Escape"); - await removeMember(page, email); - }); - test("without audit.view there is no nav item, no page, and no card", async ({ page }) => { // Everything the role needs to reach the pages the card sits on — but not the trail. const role = await createRole(page, { diff --git a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts index 375789b9..55696b85 100644 --- a/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/dashboard.spec.ts @@ -41,41 +41,3 @@ test("dashboard loads with real aggregated data", async ({ page }) => { await expect(page).toHaveURL(/\/exchanges\?ids=/); } }); - -test("subscription health pages its rows instead of growing without bound", async ({ page }) => { - // Fourteen unhealthy subscriptions, built from a real row so the rest of the page still resolves: - // eleven failing, then three paused. - await page.route("**/api/subscriptions", async (route) => { - const res = await route.fetch(); - const body = await res.json(); - const template = body.result[0]; - body.result = Array.from({ length: 14 }, (_, i) => ({ - ...template, - id: 900000 + i, - name: `Health page ${i + 1}`, - consecutiveFailures: i < 11 ? i + 1 : 0, - pausedOn: i < 11 ? null : new Date().toISOString(), - })); - body.totalCount = body.result.length; - await route.fulfill({ response: res, json: body }); - }); - - await page.goto("login"); - await page.fill("#login-email", ADMIN_EMAIL); - await page.fill("#login-password", ADMIN_PASSWORD); - await page.getByRole("button", { name: "Sign in" }).click(); - await page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15000 }); - - await page.goto("dashboard"); - const panel = page.locator("section").filter({ has: page.getByRole("heading", { name: "Subscription health" }) }); - await expect(panel.getByText("1–10 of 14")).toBeVisible({ timeout: 15000 }); - await expect(panel.getByRole("listitem")).toHaveCount(10); - await expect(panel.getByText("Health page 1", { exact: true })).toBeVisible(); - - await panel.getByRole("button", { name: "Next →" }).click(); - await expect(panel.getByText("11–14 of 14")).toBeVisible(); - await expect(panel.getByRole("listitem")).toHaveCount(4); - await expect(panel.getByText("Health page 11", { exact: true })).toBeVisible(); - await expect(panel.getByText("Paused")).toHaveCount(3); - await expect(panel.getByRole("button", { name: "Next →" })).toBeDisabled(); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts index b5cd3c9d..c91aed3f 100644 --- a/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts @@ -134,76 +134,3 @@ test("a retried exchange shows its chain and sends you to the newest attempt", a await expect(page.getByText(/Retry chain · \d+ attempts/)).toBeVisible(); await expect(page.getByText("You are here")).toBeVisible(); }); - -/** - * A selection has to be able to mean "everything this filter matches", or a 200-exchange - * recovery is 8 pages of ticking boxes. Stops at the confirm — what it says is the point, and - * running it would retry the whole filter. - */ -test("select all matching covers the whole filter, and the confirm says what will run", async ({ page }) => { - await page.goto("exchanges?status=failed"); - await expect(page.getByRole("row").nth(1)).toBeVisible({ timeout: 15000 }); - await page.getByLabel("Refresh interval").selectOption("0"); - - await page.getByRole("checkbox", { name: "Select all on this page" }).check(); - const offer = page.getByRole("button", { name: /Select all [\d,]+\+? matching this filter/ }); - await expect(offer).toBeVisible(); - await offer.click(); - - await expect(page.getByText(/everything this filter matches/)).toBeVisible(); - - // Unticking a row in this mode records an exclusion rather than dropping out of it. - const before = await page.locator("text=/^[\\d,]+\\+? selected/").first().innerText(); - await page.getByRole("checkbox", { name: /^Select (?!all\b)/ }).first().uncheck(); - await expect(page.getByText(/1 unticked/)).toBeVisible(); - expect(await page.locator("text=/^[\\d,]+\\+? selected/").first().innerText()).not.toBe(before); - - // The confirm describes the selection the server resolved, not the rows on screen. - await page.getByRole("button", { name: "Retry selected…" }).click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - await expect(dialog.getByText(/Retry [\d,]+ exchanges\?/)).toBeVisible({ timeout: 15000 }); - // Either it is within the cap and says how many will run, or it is past it and refuses. - await expect(dialog.getByText(/will run again|more than the [\d,]+ a single retry|Nothing here can be retried/)).toBeVisible({ timeout: 15000 }); - - // By text, not accessible name: the dialog's own × is also called "Close". - await dialog.locator("button", { hasText: /^(Cancel|Close)$/ }).click(); - await expect(dialog).toHaveCount(0); -}); - -/** - * A chain is one piece of work however many attempts it took, so the list needs to be able to - * show the newest attempt of each — otherwise a chain retried nine times fills nine rows, none - * of which is the current state of anything. - */ -test("the list can show only the newest attempt of each chain", async ({ page }) => { - await page.goto("exchanges?status=failed"); - await expect(page.getByRole("row").nth(1)).toBeVisible({ timeout: 15000 }); - await page.getByLabel("Refresh interval").selectOption("0"); - - const total = async () => - Number( - (await page.locator("text=/Showing .* of [\\d,]+/").first().innerText()) - .match(/of ([\d,]+)/)![1] - .replace(/,/g, ""), - ); - - const attempts = await total(); - const pill = page.getByRole("button", { name: "Latest attempt only" }); - await expect(pill).toHaveAttribute("aria-pressed", "false"); - - await pill.click(); - await expect(page).toHaveURL(/latest=1/); - await expect(pill).toHaveAttribute("aria-pressed", "true"); - - // Never more than the attempts, and fewer as soon as anything has been retried. - const problems = await total(); - expect(problems).toBeLessThanOrEqual(attempts); - - // And it survives a reload, since it lives in the URL like every other filter. - await page.reload(); - await expect(page.getByRole("button", { name: "Latest attempt only" })).toHaveAttribute( - "aria-pressed", - "true", - ); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts index 1f0e3218..8637afd2 100644 --- a/SW.Bitween.Web/ClientApp/e2e/global-setup.ts +++ b/SW.Bitween.Web/ClientApp/e2e/global-setup.ts @@ -18,24 +18,6 @@ const TEST_EMAIL = /^pw-.*@example\.test$/; const TEST_ROLE = /^PW /; /** Everything the suite creates is named this way, so it can be found again and removed. */ const TEST_NAME = /^Playwright /; -/** - * The partner and values set the mapping tests preview against. - * - * Seeded here rather than by the spec because the mapper's Partner and Global rows - * cannot be seen working without them: both subscription types the editor opens - * from are required to have no partner of their own, so the preview has nothing to - * resolve against unless a partner is chosen explicitly. - */ -export const MAPPER_PARTNER = "Playwright Mapper Partner"; -export const MAPPER_PARTNER_PROPS: Record = { - WarehouseCode: "WH-7", - SenderId: "BITWEEN-JO", -}; -export const MAPPER_VALUES_SET = { id: "pw-mapper", name: "Playwright Mapper Values" }; -export const MAPPER_VALUES: Record = { - channel: "EDI", - region: "AMMAN", -}; /** The only settings the suite writes to — see the reset below for why this is a list, not "all". */ const TEST_SETTINGS = ["Theme.PrimaryColor", "Theme.TabTitle", "Theme.CompanyName"]; @@ -151,25 +133,5 @@ export default async function purgeTestData() { data: {}, }); - // ── Then put back the two the mapping tests need ─────────────────────────── - // Checked, unlike a purge: a failed delete leaves the old row and the re-create then - // collides by name. Ignoring that would surface much later as the test partner simply - // missing from the preview picker, which says nothing about what actually went wrong. - const partner = await api.post(`${API}/partners`, { - headers: auth(), - data: { name: MAPPER_PARTNER, adapterProperties: MAPPER_PARTNER_PROPS }, - }); - if (!partner.ok()) - throw new Error(`could not seed ${MAPPER_PARTNER}: ${partner.status()} ${await partner.text()}`); - - const values = await api.post(`${API}/globaladaptervaluessets`, { - headers: auth(), - data: { ...MAPPER_VALUES_SET, values: MAPPER_VALUES }, - }); - if (!values.ok()) - throw new Error( - `could not seed the ${MAPPER_VALUES_SET.id} values set: ${values.status()} ${await values.text()}`, - ); - await api.dispose(); } diff --git a/SW.Bitween.Web/ClientApp/e2e/login.spec.ts b/SW.Bitween.Web/ClientApp/e2e/login.spec.ts index da909f0a..083cfb15 100644 --- a/SW.Bitween.Web/ClientApp/e2e/login.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/login.spec.ts @@ -1,53 +1,6 @@ -import { test, expect, type Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; import { ADMIN_EMAIL, ADMIN_PASSWORD, signInAsAdmin } from "./helpers"; -/** - * What the sign-in page offers is driven by the anonymous config endpoint. These tests rewrite - * that response rather than saving the real setting: `Bitween.DisableEmailPasswordLogin` lives in - * the database, and a test that flipped it on and then failed would leave this instance reachable - * only through Microsoft — which the local profile has no MSAL app for. That's a locked door with - * no key, so the flag is exercised at the boundary instead. - */ -async function withConfig(page: Page, overrides: Record) { - await page.route("**/api/settings/config", async (route) => { - const real = await route.fetch(); - const body = await real.json(); - await route.fulfill({ json: { ...body, ...overrides } }); - }); -} - -const passwordField = (page: Page) => page.locator("#login-password"); -const microsoftButton = (page: Page) => page.getByRole("button", { name: "Continue with Microsoft" }); - -test("by default the sign-in page asks for an email and password", async ({ page }) => { - await page.goto("login"); - - await expect(passwordField(page)).toBeVisible(); - await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible(); -}); - -test("Microsoft-only hides the password form instead of letting it fail", async ({ page }) => { - await withConfig(page, { disableEmailPasswordLogin: true, msalClientId: "00000000-0000-0000-0000-000000000000" }); - await page.goto("login"); - - // The backend rejects email/password outright in this mode, so the form must not be offered. - await expect(microsoftButton(page)).toBeVisible(); - await expect(passwordField(page)).toHaveCount(0); - await expect(page.getByRole("button", { name: "Sign in" })).toHaveCount(0); - // With nothing above it, the divider has nothing to divide. - await expect(page.getByText("or", { exact: true })).toHaveCount(0); -}); - -test("Microsoft-only with no Microsoft app configured explains itself", async ({ page }) => { - await withConfig(page, { disableEmailPasswordLogin: true, msalClientId: null }); - await page.goto("login"); - - // Both doors are shut. Saying so beats an empty card that looks like a failed page load. - await expect(page.getByText(/Microsoft sign-in isn't configured/)).toBeVisible(); - await expect(passwordField(page)).toHaveCount(0); - await expect(microsoftButton(page)).toHaveCount(0); -}); - /** * Where signing in leaves you. Two different answers, which is the point of testing both: an * operator opening the app wants to see how the system is doing, but somebody who followed a diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-cases.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-cases.spec.ts deleted file mode 100644 index ea800f98..00000000 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-cases.spec.ts +++ /dev/null @@ -1,1061 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { signInAsAdmin } from "./helpers"; -import { MAPPER_PARTNER, MAPPER_VALUES_SET } from "./global-setup"; -import { - SAMPLE, - addFixedRule, - addList, - addListField, - addListValue, - addPathRule, - buildFromSample, - createSubscription, - expectPreview, - openDetail, - openWithSample, - preview, - saveAndReload, - setSourcePath, - suggestionsFor, - writeMapperProperties, -} from "./mapperHelpers"; - -/** - * Every shape a JSON-to-JSON mapping can take, built in the editor and run. - * - * The mapping engine itself is covered exhaustively by the C# unit tests — every - * transform argument, every coercion, every filter operator. What those cannot see - * is whether the editor can *express* each of those shapes, and whether what it - * saves reads back as the same mapping. That is what these are for: one pass per - * shape, through the real UI, against the real server. - */ - -test.beforeEach(async ({ page }) => { - await signInAsAdmin(page); -}); - -/** Chooses whose partner values the preview resolves against. */ -async function previewAsTestPartner(page: import("@playwright/test").Page) { - await page - .getByRole("combobox", { name: "Preview as partner" }) - .selectOption({ label: `${MAPPER_PARTNER} · 2 properties` }); -} - -/** Adds a field at the top level, leaving it selected and unassigned. */ -async function addNamedRule(page: import("@playwright/test").Page, name: string) { - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await page.getByRole("textbox", { name: "Output field name" }).last().fill(name); -} - -/** Sets a rule's output type, which is behind its detail chevron. */ -async function setType( - page: import("@playwright/test").Page, - name: string, - type: "string" | "number" | "boolean", -) { - await openDetail(page, name); - await page.getByRole("combobox", { name: "Value type" }).selectOption(type); - await openDetail(page, name); -} - -// ─── Where a value can come from ────────────────────────────────────────────── - -test("every kind of value reaches the output, and comes back as itself", async ({ page }) => { - await openWithSample(page, { order: { customer: "Ali" } }); - - // A scheduled job is required to have no partner of its own, so without choosing - // one here the partner and global rows could never be seen working. - await previewAsTestPartner(page); - - await addPathRule(page, "customer", "order.customer"); - await addFixedRule(page, "channel", "WEB"); - - // A literal on a number target arrives as a number, not as the text "42". - await addFixedRule(page, "copies", "42"); - await setType(page, "copies", "number"); - - // A yes/no target offers two choices rather than free text, so "yes" can't be typed. - await addNamedRule(page, "urgent"); - await setType(page, "urgent", "boolean"); - await page.getByRole("radio", { name: "Fixed" }).last().click(); - await page.getByRole("combobox", { name: "Fixed value" }).last().selectOption("true"); - - await addNamedRule(page, "warehouse"); - await page.getByRole("radio", { name: "Partner" }).last().click(); - await page.getByRole("combobox", { name: "Partner property key" }).last().fill("WarehouseCode"); - - await addNamedRule(page, "network"); - await page.getByRole("radio", { name: "Global" }).last().click(); - await page - .getByRole("combobox", { name: "Global values set" }) - .last() - .selectOption(MAPPER_VALUES_SET.id); - // The set's keys are a list to choose from, so a typo cannot reach the server. - await page.getByRole("combobox", { name: "Global value key" }).last().selectOption("channel"); - - await expectPreview(page, '"customer": "Ali"'); - await expect(preview(page)).toContainText('"channel": "WEB"'); - await expect(preview(page)).toContainText('"copies": 42'); - await expect(preview(page)).toContainText('"urgent": true'); - await expect(preview(page)).toContainText('"warehouse": "WH-7"'); - await expect(preview(page)).toContainText('"network": "EDI"'); - - // ── Each source comes back as the kind it was, not as a path ──────────────── - await saveAndReload(page); - - await expect(page.getByRole("combobox", { name: "Partner property key" })).toHaveValue( - "WarehouseCode", - ); - await expect(page.getByRole("combobox", { name: "Global values set" })).toHaveValue( - MAPPER_VALUES_SET.id, - ); - await expect(page.getByRole("combobox", { name: "Global value key" })).toHaveValue("channel"); - - // A saved mapping does not carry the partner it was previewed against, so the - // reloaded editor has to be told again — and until it is, the partner rows are - // empty rather than stale. - await previewAsTestPartner(page); - await expectPreview(page, '"warehouse": "WH-7"'); -}); - -test("a partner key or a global key that is not there leaves the field empty", async ({ page }) => { - await openWithSample(page, { order: { customer: "Ali" } }); - await previewAsTestPartner(page); - - await addNamedRule(page, "missingProp"); - await page.getByRole("radio", { name: "Partner" }).last().click(); - await page.getByRole("combobox", { name: "Partner property key" }).last().fill("NotAProperty"); - - await addNamedRule(page, "missingGlobal"); - await page.getByRole("radio", { name: "Global" }).last().click(); - await page - .getByRole("combobox", { name: "Global values set" }) - .last() - .selectOption(MAPPER_VALUES_SET.id); - await page.getByRole("combobox", { name: "Global value key" }).last().selectOption("region"); - - await expectPreview(page, '"missingGlobal": "AMMAN"'); - // Empty rather than a failure: a partner not having a property is a normal state, - // unlike a value that cannot be converted. - await expect(preview(page)).toContainText('"missingProp": null'); - await expect(page.getByText(/could not be applied/)).toHaveCount(0); -}); - -test("a rule with no partner chosen resolves nothing, and says nothing is wrong", async ({ - page, -}) => { - await openWithSample(page, { order: { customer: "Ali" } }); - - await addNamedRule(page, "warehouse"); - await page.getByRole("radio", { name: "Partner" }).last().click(); - await page.getByRole("combobox", { name: "Partner property key" }).last().fill("WarehouseCode"); - - // The default: no partner, so the value is absent — which is exactly what a - // scheduled job's own mapping would produce, and why the picker exists. - await expectPreview(page, '"warehouse": null'); - - await previewAsTestPartner(page); - await expectPreview(page, '"warehouse": "WH-7"'); -}); - -// ─── What a value is written as ─────────────────────────────────────────────── - -test("values are written as the partner asked for, or the rule is named", async ({ page }) => { - await openWithSample(page, { qty: "12", flag: "true", net: 100, name: "Ali" }); - - await addPathRule(page, "qty", "qty"); - await setType(page, "qty", "number"); - - await addPathRule(page, "flag", "flag"); - await setType(page, "flag", "boolean"); - - await addPathRule(page, "net", "net"); - await setType(page, "net", "string"); - - await addPathRule(page, "asItComes", "net"); - - await expectPreview(page, '"qty": 12'); - await expect(preview(page)).toContainText('"flag": true'); - await expect(preview(page)).toContainText('"net": "100"'); - // No type at all leaves the number a number. - await expect(preview(page)).toContainText('"asItComes": 100'); - - // ── And a conversion that cannot work fails loudly ───────────────────────── - await addPathRule(page, "broken", "name"); - await setType(page, "broken", "number"); - - await expect(page.getByText(/cannot convert 'Ali' to number/).first()).toBeVisible({ - timeout: 15000, - }); -}); - -// ─── Transforms ─────────────────────────────────────────────────────────────── - -/** Every transform, its arguments, and what it makes of the sample below. */ -const TRANSFORM_CASES: { - field: string; - path: string; - fn: string; - args?: [string, string][]; - expect: string; -}[] = [ - { field: "up", path: "text", fn: "upper", expect: '"up": "HELLO"' }, - { field: "down", path: "shout", fn: "lower", expect: '"down": "loud"' }, - { field: "tidy", path: "padded", fn: "trim", expect: '"tidy": "pad"' }, - { - field: "part", - path: "text", - fn: "substring", - args: [ - ["Take part of the text — Start at", "1"], - ["Take part of the text — Length", "3"], - ], - expect: '"part": "ell"', - }, - { - field: "swapped", - path: "text", - fn: "replace", - args: [ - ["Replace text — Find", "l"], - ["Replace text — Replace with", "L"], - ], - expect: '"swapped": "heLLo"', - }, - { - field: "joined", - path: "text", - fn: "concat", - args: [["Append text — Append", "!"]], - expect: '"joined": "hello!"', - }, - { - field: "rounded", - path: "n", - fn: "round", - args: [["Round — Decimals", "2"]], - expect: '"rounded": 10.57', - }, - { - field: "times", - path: "n", - fn: "multiply", - args: [["Multiply — By", "2"]], - expect: '"times": 21.134', - }, - { - field: "plus", - path: "n", - fn: "add", - args: [["Add — Amount", "1"]], - expect: '"plus": 11.567', - }, - { - field: "when", - path: "date", - fn: "formatDate", - args: [["Format a date — Format", "dd MMM yyyy"]], - expect: '"when": "04 Mar 2026"', - }, - { - field: "filled", - path: "blank", - fn: "defaultIfEmpty", - args: [["Use a default when empty — Default", "NONE"]], - expect: '"filled": "NONE"', - }, -]; - -test("every transform has the argument boxes it needs, and produces its value", async ({ - page, -}) => { - test.slow(); - - await openWithSample(page, { - text: "hello", - shout: "LOUD", - padded: " pad ", - n: 10.567, - date: "2026-03-04", - blank: "", - }); - - for (const testCase of TRANSFORM_CASES) { - await addPathRule(page, testCase.field, testCase.path); - await openDetail(page, testCase.field); - await page.getByRole("combobox", { name: "Transform" }).selectOption(testCase.fn); - - // The boxes are named from the function, so a function whose arguments the - // editor spells differently to the server would show up right here. - // - // By label rather than by role: an argument box may be a plain input, or one - // carrying a suggestion list — and an `` reports as a combobox, not - // a textbox. The name is what identifies it either way. - for (const [label, value] of testCase.args ?? []) { - const box = page.getByLabel(label, { exact: true }); - // Some arguments are a closed list now, so the gesture depends on the control. - if ((await box.evaluate((el) => el.tagName)) === "SELECT") await box.selectOption(value); - else await box.fill(value); - } - - await openDetail(page, testCase.field); - } - - for (const testCase of TRANSFORM_CASES) - await expect(preview(page)).toContainText(testCase.expect, { timeout: 20000 }); -}); - -test("a transform leaves an absent value absent, unless it is there to replace one", async ({ - page, -}) => { - await openWithSample(page, { there: "yes" }); - - // `missing` names nothing in the document. Uppercasing nothing is nothing — not - // "" — so the field is absent rather than becoming an empty string. - await addPathRule(page, "shouted", "there"); - await openDetail(page, "shouted"); - await page.getByRole("combobox", { name: "Transform" }).selectOption("upper"); - await openDetail(page, "shouted"); - - await addNamedRule(page, "quiet"); - await setSourcePath(page, ""); - await openDetail(page, "quiet"); - await page.getByRole("combobox", { name: "Transform" }).selectOption("upper"); - await openDetail(page, "quiet"); - - await addNamedRule(page, "defaulted"); - await openDetail(page, "defaulted"); - await page.getByRole("combobox", { name: "Transform" }).selectOption("defaultIfEmpty"); - await page - .getByRole("textbox", { name: "Use a default when empty — Default", exact: true }) - .fill("NONE"); - await openDetail(page, "defaulted"); - - await expectPreview(page, '"shouted": "YES"'); - await expect(preview(page)).toContainText('"quiet": null'); - await expect(preview(page)).toContainText('"defaulted": "NONE"'); -}); - -// ─── Lookup tables ──────────────────────────────────────────────────────────── - -test("a lookup runs after the transform, not before it", async ({ page }) => { - await openWithSample(page, { country: "jo" }); - - await addPathRule(page, "countryName", "country"); - await openDetail(page, "countryName"); - - await page.getByRole("combobox", { name: "Transform" }).selectOption("upper"); - await page.getByRole("checkbox", { name: "Substitute values from a table" }).check(); - await page.getByRole("button", { name: "Add incoming value" }).click(); - // Keyed on the *transformed* value: "jo" uppercased is "JO", and it is "JO" the - // table is asked about. A table keyed on "jo" would miss. - await page.getByRole("textbox", { name: "Incoming value 1" }).fill("JO"); - await page.getByRole("textbox", { name: "Becomes 1" }).fill("Jordan"); - - await expectPreview(page, '"countryName": "Jordan"'); -}); - -// ─── Lists ──────────────────────────────────────────────────────────────────── - -const OPERATOR_CASES: { field: string; operator: string; expect: RegExp }[] = [ - { field: "eq", operator: "equal", expect: /"eq":\s*\[\s*2\s*\]/ }, - { field: "ne", operator: "notEqual", expect: /"ne":\s*\[\s*1,\s*3\s*\]/ }, - { field: "gt", operator: "greaterThan", expect: /"gt":\s*\[\s*3\s*\]/ }, - { field: "ge", operator: "greaterThanOrEqual", expect: /"ge":\s*\[\s*2,\s*3\s*\]/ }, - { field: "lt", operator: "lessThan", expect: /"lt":\s*\[\s*1\s*\]/ }, - { field: "le", operator: "lessThanOrEqual", expect: /"le":\s*\[\s*1,\s*2\s*\]/ }, -]; - -test("every filter comparison keeps the entries it should", async ({ page }) => { - test.slow(); - - await openWithSample(page, { line: [{ qty: 1 }, { qty: 2 }, { qty: 3 }] }); - - for (const { field, operator } of OPERATOR_CASES) { - const list = await addList(page, field, "line"); - await page.getByRole("button", { name: `Settings for the list ${field}` }).click(); - - await page.getByRole("checkbox", { name: "Only some entries" }).last().check(); - await page.getByRole("textbox", { name: "Filter field" }).last().fill("qty"); - await page.getByRole("combobox", { name: "Filter comparison" }).last().selectOption(operator); - await page.getByRole("textbox", { name: "Filter value" }).last().fill("2"); - - await page.getByRole("button", { name: `Settings for the list ${field}` }).click(); - - // A list of plain values, so what survived the filter reads straight off the - // preview rather than through a wrapper object. - await addListValue(list, field, "qty"); - } - - for (const { expect: shape } of OPERATOR_CASES) - await expect(preview(page)).toHaveText(shape, { timeout: 20000 }); -}); - -test("a list can walk the incoming document itself", async ({ page }) => { - // A partner that sends a bare array, which is not reachable by any path. - await openWithSample(page, [ - { sku: "A1", qty: 2 }, - { sku: "B7", qty: 5 }, - ]); - - await page.getByRole("button", { name: "Add a list", exact: true }).click(); - await page.getByRole("textbox", { name: "Output list name" }).fill("lines"); - await page.getByRole("combobox", { name: "Source list" }).selectOption({ label: "(the document)" }); - - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await addListField(lines, "lines", "code", "sku"); - - await expectPreview(page, '"code": "A1"'); - await expect(preview(page)).toContainText('"code": "B7"'); - - // "The document is the list" and "nothing is walked" are different states, and - // the row says which by the word in front of the dropdown. - await expect(page.getByText("for each of")).toBeVisible(); - await saveAndReload(page); - await expect(page.getByRole("combobox", { name: "Source list" })).toHaveValue("p:"); -}); - -test("an empty source list gives an empty list, and so does one that is not there", async ({ - page, -}) => { - await openWithSample(page, { line: [], other: { deep: 1 } }); - - await addList(page, "lines", "line"); - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await addListField(lines, "lines", "code", "sku"); - - // The sample has no entry to take field names from, so there is nothing to - // suggest — and the mapping is still expressible, because the path was typed. - // While this box was a dropdown, a sample like this one could not be mapped at all. - const field = lines.getByRole("combobox", { name: "Source field" }); - expect(await suggestionsFor(page, field)).toEqual([]); - await expect(field).toHaveValue("sku"); - await expect(lines.getByText("⚠")).toBeVisible(); - - // An empty array is not an error, and neither is a path naming nothing: a partner - // sending no lines today is a normal document. - await expectPreview(page, '"lines": []'); - await expect(page.getByText(/could not be applied/)).toHaveCount(0); - - // And it is a real mapping, not an accident of the sample: the same rules against - // a document that does have lines produce them. - await page - .getByRole("textbox", { name: "Sample source document" }) - .fill(JSON.stringify({ line: [{ sku: "A1" }, { sku: "B7" }] }, null, 2)); - - await expectPreview(page, '"code": "A1"'); - await expect(preview(page)).toContainText('"code": "B7"'); - await expect(lines.getByText("⚠")).toHaveCount(0); -}); - -test("the whole output can be a list, of records or of plain values", async ({ page }) => { - await openWithSample(page); - - await page.getByRole("checkbox", { name: /The whole output is a list/ }).check(); - await page.getByRole("combobox", { name: "Source list" }).selectOption("p:order.line"); - - const root = page.getByRole("group", { name: "Rules for the list at the root" }); - await addListField(root, "the root list", "code", "sku"); - - // A bare array, with no object wrapped round it — what the old mapper needed a - // separate output mode for. - await expectPreview(page, '"code": "A1"'); - await expect(preview(page)).toHaveText(/^\[[\s\S]*\]$/); - - // ── And the same thing as plain values ───────────────────────────────────── - // A list holds one or the other, and says so by what it will let you add: the - // record's field has to go before the value can be put in its place. - await root.getByRole("button", { name: "Remove the rule for code" }).click(); - await addListValue(root, "the root list", "sku"); - - await expect(preview(page)).toHaveText(/^\[\s*"A1",\s*"B7"\s*\]$/, { timeout: 15000 }); -}); - -test("an entry written into a list may read the document, the partner, and hold a list", async ({ - page, -}) => { - await openWithSample(page, { - order: { ref: "ORD-9", line: [{ sku: "A1", tag: [{ code: "cold" }] }] }, - }); - await previewAsTestPartner(page); - - await addList(page, "lines", "order.line"); - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await addListField(lines, "lines", "sku", "sku"); - - // A header entry the partner expects. It is built from ordinary rules, so unlike - // the old mapper's literal JSON it can read anything a walked entry can. - await lines.getByRole("button", { name: "Add an entry to lines" }).click(); - const entry = page.getByRole("group", { name: "Rules for entry 1" }); - - await entry.getByRole("button", { name: "Add a field to entry 1" }).click(); - await entry.getByRole("textbox", { name: "Output field name" }).last().fill("sku"); - await entry.getByRole("radio", { name: "Fixed" }).last().click(); - await entry.getByRole("textbox", { name: "Fixed value" }).last().fill("HEADER"); - - await entry.getByRole("button", { name: "Add a field to entry 1" }).click(); - await entry.getByRole("textbox", { name: "Output field name" }).last().fill("ref"); - await setSourcePath(entry, "order.ref"); - - await entry.getByRole("button", { name: "Add a field to entry 1" }).click(); - await entry.getByRole("textbox", { name: "Output field name" }).last().fill("warehouse"); - await entry.getByRole("radio", { name: "Partner" }).last().click(); - await entry.getByRole("combobox", { name: "Partner property key" }).last().fill("WarehouseCode"); - - await expectPreview(page, '"sku": "HEADER"'); - await expect(preview(page)).toContainText('"ref": "ORD-9"'); - await expect(preview(page)).toContainText('"warehouse": "WH-7"'); - // Written first, then one per walked entry. - await expect(preview(page)).toHaveText(/HEADER[\s\S]*"sku": "A1"/); - - // A written entry reads from where the list sits, not from an entry that was - // never walked — so `order.ref` resolves rather than being relative to a line. - await saveAndReload(page); - await expect(page.getByRole("group", { name: "Rules for entry 1" })).toBeVisible(); -}); - -test("a list nested three deep still reads the entry it sits in", async ({ page }) => { - await openWithSample(page, { - order: { - line: [ - { - sku: "A1", - box: [{ id: "B1", item: [{ serial: "S1" }, { serial: "S2" }] }], - }, - ], - }, - }); - - await buildFromSample(page, { - line: [{ sku: "", box: [{ id: "", item: [{ serial: "" }] }] }], - }); - - await expectPreview(page, '"serial": "S1"'); - await expect(preview(page)).toContainText('"serial": "S2"'); - await expect(preview(page)).toContainText('"id": "B1"'); - await expect(preview(page)).toContainText('"sku": "A1"'); -}); - -// ─── The shape of the output ────────────────────────────────────────────────── - -test("dotted names build objects, and two rules share one", async ({ page }) => { - await openWithSample(page, { c: "Amman", k: "JO", n: "Ali" }); - - await addPathRule(page, "billing.city", "c"); - await addPathRule(page, "billing.country", "k"); - await addPathRule(page, "name", "n"); - - await expectPreview(page, '"city": "Amman"'); - await expect(preview(page)).toHaveText(/"billing":\s*\{[\s\S]*"country": "JO"[\s\S]*\}/); - - // One branch in the tree rather than two rows that happen to share a prefix, and - // the rows inside it show only their last segment. - const branch = page.getByRole("group", { name: "Fields inside billing" }); - await expect(branch.getByRole("textbox", { name: "Output field name" })).toHaveCount(2); - await expect(branch.getByRole("textbox", { name: "Output field name" }).first()).toHaveValue( - "city", - ); -}); - -test("the rules are written in the order they are listed", async ({ page }) => { - await openWithSample(page, { a: 1, b: 2 }); - - await addPathRule(page, "second", "b"); - await addPathRule(page, "first", "a"); - - // The order the rules sit in, not alphabetical and not the source document's — - // some partners read positionally. - await expectPreview(page, /"second"[\s\S]*"first"/); -}); - -// ─── The editor itself ──────────────────────────────────────────────────────── - -test("removing a field, a list, and a written entry", async ({ page }) => { - await openWithSample(page); - - await addPathRule(page, "customer", "order.customer"); - await addList(page, "lines", "order.line"); - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await addListField(lines, "lines", "code", "sku"); - await lines.getByRole("button", { name: "Add an entry to lines" }).click(); - - await expectPreview(page, '"code": "A1"'); - - await page.getByRole("button", { name: "Remove entry 1" }).click(); - await expect(page.getByRole("group", { name: "Entry 1" })).toHaveCount(0); - - await page.getByRole("button", { name: "Remove the rule for code" }).click(); - await page.getByRole("button", { name: "Remove the list lines" }).click(); - await page.getByRole("button", { name: "Remove the rule for customer" }).click(); - - await expect(page.getByText("No rules yet.")).toBeVisible(); - // Removing every rule is a mapping that produces an empty document, not a failure. - await expectPreview(page, "{}"); -}); - -test("undo puts back a rule that was removed", async ({ page }) => { - await openWithSample(page); - - await addPathRule(page, "customer", "order.customer"); - await expectPreview(page, '"customer": "Ali"'); - - await page.getByRole("button", { name: "Remove the rule for customer" }).click(); - await expect(page.getByRole("textbox", { name: "Output field name" })).toHaveCount(0); - - await page.getByRole("button", { name: "Undo" }).click(); - await expect(page.getByRole("textbox", { name: "Output field name" })).toHaveValue("customer"); - - await page.getByRole("button", { name: "Redo" }).click(); - await expect(page.getByRole("textbox", { name: "Output field name" })).toHaveCount(0); -}); - -test("searching the output keeps the branches above a match", async ({ page }) => { - await openWithSample(page, { c: "Amman", k: "JO", n: "Ali" }); - - await addPathRule(page, "billing.city", "c"); - await addPathRule(page, "billing.country", "k"); - await addPathRule(page, "name", "n"); - - await page.getByRole("textbox", { name: "Search output fields" }).fill("city"); - - // The match is reachable, which means the object above it survives too. - await expect(page.getByRole("group", { name: "Fields inside billing" })).toBeVisible(); - const names = page.getByRole("textbox", { name: "Output field name" }); - await expect(names).toHaveCount(1); - await expect(names).toHaveValue("city"); - - // Searching does not change the mapping — only what is shown of it. - await expect(preview(page)).toContainText('"name": "Ali"'); - - await page.getByRole("textbox", { name: "Search output fields" }).fill(""); - await expect(page.getByRole("textbox", { name: "Output field name" })).toHaveCount(3); -}); - -test("a list folds away without losing what is inside it", async ({ page }) => { - await openWithSample(page); - - await addList(page, "lines", "order.line"); - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await addListField(lines, "lines", "code", "sku"); - await expectPreview(page, '"code": "A1"'); - - await page.getByRole("button", { name: "Collapse the list lines" }).click(); - await expect(page.getByRole("group", { name: "Rules for the list lines" })).toHaveCount(0); - // Folded, not removed: the mapping still produces the same document. - await expect(preview(page)).toContainText('"code": "A1"'); - - await page.getByRole("button", { name: "Expand the list lines" }).click(); - await expect( - page.getByRole("group", { name: "Rules for the list lines" }).getByRole("textbox", { - name: "Output field name", - }), - ).toHaveValue("code"); -}); - -test("a rule with no name is reported rather than dropped", async ({ page }) => { - await openWithSample(page); - - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await setSourcePath(page, "order.customer"); - - // A value with nowhere to go is a mistake worth naming — the old mapper wrote it - // to an empty key and moved on. - await expect(page.getByText(/no target/).first()).toBeVisible({ timeout: 15000 }); -}); - -test("how many rules there are, and how many have a value", async ({ page }) => { - await openWithSample(page); - - await addPathRule(page, "customer", "order.customer"); - await expect(page.getByText("1 rule · 1 assigned")).toBeVisible(); - - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await page.getByRole("textbox", { name: "Output field name" }).last().fill("pending"); - - // Counted but not assigned, which is the difference between a mapping being - // incomplete and being wrong. - await expect(page.getByText("2 rules · 1 assigned")).toBeVisible(); -}); - -// ─── Stored rules the editor must not open ──────────────────────────────────── - -const REFUSED = [ - { - what: "are not readable at all", - rules: "{ this is not json", - says: /could not be read/, - }, - { - what: "come from a newer version of Bitween", - rules: JSON.stringify({ version: 99, fields: [], lists: [] }), - says: /version 99/, - }, - { - what: "were saved before lists were renamed", - rules: JSON.stringify({ version: 1, fields: [], loops: [{ over: "x", target: ["y"] }] }), - says: /before lists were renamed/, - }, -]; - -test("rules the editor cannot read refuse to open rather than starting blank", async ({ page }) => { - const subscriptionId = await createSubscription(page); - - for (const { what, rules, says } of REFUSED) { - await writeMapperProperties(subscriptionId, "NativeMapper", { MappingRules: rules }); - - await page.goto(`subscriptions/${subscriptionId}/mapper`); - - // Opening blank and letting someone press Save would replace a working mapping - // with nothing, which is worse than refusing to open. - await expect(page.getByText(says), what).toBeVisible({ timeout: 15000 }); - await expect(page.getByRole("button", { name: "Save" })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Back to the subscription" })).toBeVisible(); - } -}); - -test("a stored date format the dropdown never offered still shows what is saved", async ({ - page, -}) => { - const subscriptionId = await createSubscription(page); - - // The engine formats with any .NET pattern, so a saved mapping can hold one this - // closed list does not offer — set through the API, or offered here under a label - // that has since changed. A select with no matching option shows nothing selected, - // which reads as "no format chosen". - await writeMapperProperties(subscriptionId, "NativeMapper", { - MappingRules: JSON.stringify({ - version: 1, - sourceFormat: "json", - targetFormat: "json", - fields: [ - { - target: ["shipped"], - from: { kind: "path", path: "order.date" }, - transform: { fn: "formatDate", format: "d MMMM" }, - }, - ], - lists: [], - }), - }); - - await page.goto(`subscriptions/${subscriptionId}/mapper`); - await expect(page.getByRole("button", { name: "Save" })).toBeVisible({ timeout: 15000 }); - await openDetail(page, "shipped"); - - await expect(page.getByLabel("Format a date — Format")).toHaveValue("d MMMM"); - - // And saving the mapping for some unrelated reason must not quietly replace it. - await addFixedRule(page, "channel", "web"); - await saveAndReload(page); - await openDetail(page, "shipped"); - await expect(page.getByLabel("Format a date — Format")).toHaveValue("d MMMM"); -}); - -test("the sample document is stored with the mapping, so it is there next time", async ({ - page, -}) => { - await openWithSample(page); - await addPathRule(page, "customer", "order.customer"); - await saveAndReload(page); - - // Reopening a mapping months later with no sample to hand meant it could not be - // previewed at all, so the sample is part of what is saved. - await expect(page.getByRole("textbox", { name: "Sample source document" })).toHaveValue(SAMPLE); - await expectPreview(page, '"customer": "Ali"'); -}); - -test("a source document that is not JSON says so instead of previewing nothing", async ({ - page, -}) => { - await openWithSample(page, "{ not json at all"); - await addNamedRule(page, "customer"); - - await expect(page.getByText(/could not be read|not valid/i).first()).toBeVisible({ - timeout: 15000, - }); -}); - -// ─── The toolbar, and reading a big mapping ─────────────────────────────────── - -test("clicking a row shows what is behind its chevron", async ({ page }) => { - await openWithSample(page); - - await addPathRule(page, "total", "order.net"); - - // The transform and the type live behind the chevron, and finding the chevron was - // the whole complaint: the row itself is the obvious thing to click. - await expect(page.getByRole("combobox", { name: "Transform" })).toHaveCount(0); - - await page.getByRole("textbox", { name: "Output field name" }).click(); - await expect(page.getByRole("combobox", { name: "Transform" })).toHaveCount(0); - - // Clicking the row's own space, rather than a control in it. - await page.getByText("←", { exact: true }).first().click(); - await expect(page.getByRole("combobox", { name: "Transform" })).toBeVisible(); - - await page.getByText("←", { exact: true }).first().click(); - await expect(page.getByRole("combobox", { name: "Transform" })).toHaveCount(0); -}); - -test("matching the source fields fills in the rules already there", async ({ page }) => { - await openWithSample(page, { - customer: "Ali", - net: 100, - line: [{ sku: "A1" }], - }); - - // Rules with names but no source — a mapping typed out from a partner's spec - // before anyone had a sample document to point it at. - await addNamedRule(page, "customer"); - await addNamedRule(page, "net"); - await addNamedRule(page, "somethingElse"); - - await addList(page, "lines", "line"); - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await lines.getByRole("button", { name: "Add a field to lines" }).click(); - await lines.getByRole("textbox", { name: "Output field name" }).last().fill("sku"); - - await page.getByRole("button", { name: "Match the source fields" }).click(); - - await expect(page.getByText("3 matched · 1 left empty")).toBeVisible(); - // A rule inside a list is matched against one entry, so `sku` and not `line.sku`. - await expect(lines.getByRole("combobox", { name: "Source field" })).toHaveValue("sku"); - - await expectPreview(page, '"customer": "Ali"'); - await expect(preview(page)).toContainText('"sku": "A1"'); - - // The one it could not place is empty rather than guessed at. - const names = page.getByRole("combobox", { name: "Source field" }); - await expect(names.nth(2)).toHaveValue(""); -}); - -test("clearing every rule, and undoing it", async ({ page }) => { - await openWithSample(page); - - await addPathRule(page, "customer", "order.customer"); - await addList(page, "lines", "order.line"); - await expectPreview(page, '"customer": "Ali"'); - - await page.getByRole("button", { name: "Clear all the rules" }).click(); - await expect(page.getByText("No rules yet.")).toBeVisible(); - - // One press, one undo — not one per rule it removed. - await page.getByRole("button", { name: "Undo" }).click(); - await expect(page.getByRole("textbox", { name: "Output field name" })).toHaveValue("customer"); - await expect(page.getByRole("textbox", { name: "Output list name" })).toHaveValue("lines"); - // The sample survives, because it was never a rule. - await expect(page.getByRole("textbox", { name: "Sample source document" })).toHaveValue(SAMPLE); -}); - -test("hiding the preview gives the rules the whole width", async ({ page }) => { - await openWithSample(page); - await addPathRule(page, "customer", "order.customer"); - await expectPreview(page, '"customer": "Ali"'); - - await page.getByRole("button", { name: "Hide the preview" }).click(); - await expect(page.getByText("— what a partner would receive")).toHaveCount(0); - - // Hidden, not switched off: the rules are untouched and it comes back as it was. - await page.getByRole("button", { name: "Show the preview" }).click(); - await expectPreview(page, '"customer": "Ali"'); -}); - -test("the partner key box offers the keys the previewed partner actually has", async ({ page }) => { - await openWithSample(page, { order: { customer: "Ali" } }); - - await addNamedRule(page, "warehouse"); - await page.getByRole("radio", { name: "Partner" }).last().click(); - - const key = page.getByRole("combobox", { name: "Partner property key" }); - - // With no partner chosen there is nothing to suggest, and the box is still a box: - // the mapping runs against whichever partner the exchange belongs to, not this one. - expect(await suggestionsFor(page, key)).toEqual([]); - - await previewAsTestPartner(page); - // Fetched for the chosen partner, so the box fills in a moment rather than at once. - await expect - .poll(() => suggestionsFor(page, key)) - .toEqual(expect.arrayContaining(["WarehouseCode", "SenderId"])); - - await key.fill("WarehouseCode"); - await expectPreview(page, '"warehouse": "WH-7"'); - await expect(page.getByText("⚠")).toHaveCount(0); - - // A key that partner does not have is flagged rather than refused. - await key.fill("NotAProperty"); - await expect(page.getByText("⚠")).toBeVisible(); -}); - -// ─── The source side of a big document ──────────────────────────────────────── - -test("the source tree shows the fields inside a list, named as a rule names them", async ({ - page, -}) => { - await openWithSample(page); - - // `sku` sits inside `order.line`, and a rule in a list over that list reads it as - // `sku`. Hiding these meant the only way to see what was in a list was to read the - // sample somewhere else. - await expect(page.getByRole("button", { name: "sku", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "qty", exact: true })).toBeVisible(); - - await page.getByRole("button", { name: "Collapse order.line" }).click(); - await expect(page.getByRole("button", { name: "sku", exact: true })).toHaveCount(0); - - await page.getByRole("button", { name: "Expand order.line" }).click(); - await expect(page.getByRole("button", { name: "sku", exact: true })).toBeVisible(); -}); - -test("each object says how much of it is already read", async ({ page }) => { - await openWithSample(page); - - // `order` holds customer, net, and the line's sku and qty. - const order = page.getByRole("button", { name: "Collapse order", exact: true }); - await expect(order).toContainText("0/4"); - - await addPathRule(page, "customerName", "order.customer"); - await expect(order).toContainText("1/4"); - - // A rule inside a list counts too: it reads one of the fields in there. - const lines = await addList(page, "lines", "order.line"); - await addListField(lines, "lines", "code", "sku"); - await expect(order).toContainText("2/4"); -}); - -test("dragging a field out of a list onto a rule in that list wires it up", async ({ page }) => { - await openWithSample(page); - - const lines = await addList(page, "lines", "order.line"); - await lines.getByRole("button", { name: "Add a field to lines" }).click(); - await lines.getByRole("textbox", { name: "Output field name" }).last().fill("code"); - - await page - .getByRole("button", { name: "sku", exact: true }) - .dragTo(lines.getByRole("textbox", { name: "Output field name" })); - - await expect(lines.getByRole("combobox", { name: "Source field" })).toHaveValue("sku"); - await expectPreview(page, '"code": "A1"'); -}); - -test("undo, redo and save from the keyboard", async ({ page }) => { - await openWithSample(page); - - await addPathRule(page, "customer", "order.customer"); - const source = page.getByRole("combobox", { name: "Source field" }); - await expect(source).toHaveValue("order.customer"); - - // Focus is in a box after typing, and Ctrl+Z there belongs to the box. Clicking - // the panel's own space takes it back. - await page.getByText("Output", { exact: true }).click(); - - // One step is one change, so this undoes pointing the rule somewhere — not the - // whole rule, which was three changes ago. - await page.keyboard.press("ControlOrMeta+z"); - await expect(source).toHaveValue(""); - - await page.keyboard.press("ControlOrMeta+y"); - await expect(source).toHaveValue("order.customer"); - - await page.keyboard.press("ControlOrMeta+z"); - await page.keyboard.press("ControlOrMeta+Shift+z"); - await expect(source).toHaveValue("order.customer"); - - await page.keyboard.press("ControlOrMeta+s"); - await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); -}); - -test("Ctrl+Z inside a box undoes the typing, not the mapping", async ({ page }) => { - await openWithSample(page); - await addPathRule(page, "customer", "order.customer"); - - const name = page.getByRole("textbox", { name: "Output field name" }); - await name.click(); - await name.press("ControlOrMeta+z"); - - // The rule is still there. The old editor took this key in both cases, so fixing a - // mistyped name meant undoing a change somewhere else entirely. - await expect(name).toHaveCount(1); -}); - -test("a checkbox in a settings panel can be ticked by its text", async ({ page }) => { - await openWithSample(page); - - const lines = await addList(page, "lines", "order.line"); - await addListField(lines, "lines", "qty", "qty"); - await page.getByRole("button", { name: "Settings for the list lines" }).click(); - - // Clicking the words, not the box — which is what anyone does, and what a test - // using .check() never exercises, because that clicks the input directly. The row - // click that opens these settings used to swallow it: the box ticked and the panel - // folded away in the same tick, so it looked like the click did nothing. - await page.getByText("Only some entries").click(); - - await expect(page.getByRole("textbox", { name: "Filter field" })).toBeVisible(); - await page.getByRole("textbox", { name: "Filter field" }).fill("qty"); - await page.getByRole("combobox", { name: "Filter comparison" }).selectOption("greaterThan"); - await page.getByRole("textbox", { name: "Filter value" }).fill("0"); - - // The entry with qty 0 is gone, which is the whole point of the checkbox. - await expectPreview(page, '"qty": 2'); - await expect(preview(page)).not.toContainText('"qty": 0'); -}); - -test("a checkbox in a rule's detail can be ticked by its text", async ({ page }) => { - await openWithSample(page, { country: "JO" }); - - await addPathRule(page, "countryName", "country"); - await openDetail(page, "countryName"); - - await page.getByText("Substitute values from a table").click(); - await expect(page.getByRole("button", { name: "Add incoming value" })).toBeVisible(); - - await page.getByRole("button", { name: "Add incoming value" }).click(); - await page.getByRole("textbox", { name: "Incoming value 1" }).fill("JO"); - await page.getByRole("textbox", { name: "Becomes 1" }).fill("Jordan"); - - await expectPreview(page, '"countryName": "Jordan"'); -}); - -test("a date that could be read two ways has to say which", async ({ page }) => { - // A real CargoNet shipping date: the 4th of September, French style. - await openWithSample(page, { order: { shippingdate: "04.09.2026" } }); - - await addPathRule(page, "shipDate", "order.shippingdate"); - await openDetail(page, "shipDate"); - await page.getByRole("combobox", { name: "Transform" }).selectOption("formatDate"); - - // One control on the row, and it is a closed list: what the date should look like on - // the way out, shown as the date itself rather than as yyyy-MM-dd letters. - const format = page.getByRole("combobox", { name: "Format a date — Format" }); - await expect(format.locator("option")).toContainText(["Format…", "2026-09-04", "04/09/2026"]); - await format.selectOption("yyyy-MM-dd"); - - // Refused rather than guessed. The invariant parser reads this as the 9th of April - // perfectly happily, which would date a shipment five months out with nothing said. - await expect(page.getByText(/could not read '04\.09\.2026'/).first()).toBeVisible({ - timeout: 15000, - }); - - // Answered once for the document, under the sample it describes — a partner writes - // dates one way throughout, so this is not a per-rule question. - const dates = page.getByRole("combobox", { name: "Dates in the incoming document" }); - await expect(dates.locator("option")).toHaveText([ - "Year first — 2026-09-04", - "Day first — 04.09.2026", - "Month first — 09.04.2026", - ]); - - await dates.selectOption("dayFirst"); - await expectPreview(page, '"shipDate": "2026-09-04"'); - - // And the other way round, from the same characters. - await dates.selectOption("monthFirst"); - await expectPreview(page, '"shipDate": "2026-04-09"'); - - // It is part of the mapping, so it comes back with it. - await saveAndReload(page); - await expect(page.getByRole("combobox", { name: "Dates in the incoming document" })).toHaveValue( - "monthFirst", - ); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts deleted file mode 100644 index 36376897..00000000 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { signInAsAdmin } from "./helpers"; -import { - addListField, - createSubscription, - expectPreview, - openMapper, - preview, - withFormats, -} from "./mapperHelpers"; - -/** - * Delimited text, through the real editor against the real backend. - * - * The point of running these end to end rather than in a unit test: the tree the editor - * draws comes from d3-dsv in the browser, and the document the mapping actually reads - * comes from CsvHelper on the server. Two parsers, and nothing but a test like this - * notices when they stop agreeing — a column offered here that resolves to nothing there - * is a mapping that looks complete and quietly writes an empty field. - * - * The files are the three a single client really sends, unaltered. - */ - -/** Pipe, no header, three record types told apart by the first field. */ -const TRACKING = - "H|FFSTAT|1|0||||||||||202609141313|1309981|N\n" + - "D|1309981172|OK|DELIVERY|0.100|KGM|1|||20260908FRACPKT03831|3800351262|202609141307|20260911|NTE|CDG|NTE||BRIAN MATIAS CASTRO PENA|GLOBAL LOGTICS NETWORK|||||||222998693|Clementine Sandri|\n" + - "D|1309981174|CC|AWAITING CONSIGNEE COLLECTION|0.100|KGM|1|||E824836443|4472825486|202609141305|20260911|MRS|CDG|MRS||CHRISTOPHER GERGES|GLOBAL LOGISTIC NETWORK|||||||222998693||\n" + - "T|9|1309981|\n"; - -/** Comma, with a header naming its columns. */ -const MOVEMENTS = - "ShipmentNumber,Reference,TrackingCode,Date,Time,Comment1,Comment2\n" + - "6G61965126082,202493482,SHOR020,2026-09-14,08:29:49,,\n" + - "8G49824171336,202340914,SHOR020,2026-09-14,08:34:34,,\n"; - -test.beforeEach(async ({ page }) => { - await signInAsAdmin(page); -}); - -/** Opens the editor with the source side reading delimited text. */ -async function openWithCsv(page: import("@playwright/test").Page, sample: string, delimiter: string, header: boolean) { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await withFormats(page, async () => { - await page.getByLabel("From format").selectOption("csv"); - await page.getByLabel("source delimiter").selectOption(delimiter); - const box = page.getByRole("checkbox", { name: "source header row" }); - if (header) await box.check(); - else await box.uncheck(); - }); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(sample); - return subscriptionId; -} - -/** Makes the whole output a list walking the document, which is what every row-per-row mapping is. */ -async function rootListOverTheDocument(page: import("@playwright/test").Page) { - await page.getByRole("checkbox", { name: /The whole output is a list/ }).check(); - await page.getByRole("combobox", { name: "Source list" }).selectOption("p:"); - return page.getByRole("group", { name: "Rules for the list at the root" }); -} - -test("a header names the columns, and the server reads the same names", async ({ page }) => { - await openWithCsv(page, MOVEMENTS, ",", true); - - const root = await rootListOverTheDocument(page); - await addListField(root, "the root list", "shipment", "ShipmentNumber"); - await addListField(root, "the root list", "at", "Time"); - - // Produced by the server from its own reading of the file. If the two parsers disagreed - // about where the columns are, this is where it would show. - await expectPreview(page, '"shipment": "6G61965126082"'); - await expect(preview(page)).toContainText('"at": "08:34:34"'); -}); - -test("the columns the editor offers are the ones the server can read", async ({ page }) => { - await openWithCsv(page, MOVEMENTS, ",", true); - - const root = await rootListOverTheDocument(page); - await root.getByRole("button", { name: "Add a field to the root list" }).click(); - await root.getByRole("textbox", { name: "Output field name" }).last().fill("checked"); - - // Every column the source panel offers, tried against the server one at a time — and each - // asserted on the value it should carry. A "not null" check would pass off the previous - // column's preview before the next one arrived, which is exactly the mismatch being hunted. - const field = root.getByRole("combobox", { name: "Source field" }).last(); - const firstRow: [string, string][] = [ - ["ShipmentNumber", "6G61965126082"], - ["Reference", "202493482"], - ["TrackingCode", "SHOR020"], - ["Date", "2026-09-14"], - ["Time", "08:29:49"], - ]; - - for (const [column, value] of firstRow) { - await field.fill(column); - await expect(preview(page)).toContainText(`"checked": "${value}"`, { timeout: 15000 }); - } -}); - -test("three record types in one file are separated by the list's own filter", async ({ page }) => { - await openWithCsv(page, TRACKING, "|", false); - - const root = await rootListOverTheDocument(page); - - // The claim the whole plan rests on: a file holding an H record, D records and a T record - // needs no feature of its own. Field 1 says which kind of line this is. - await page.getByRole("button", { name: "Settings for the list at the root" }).click(); - await page.getByRole("checkbox", { name: "Only some entries" }).check(); - await page.getByRole("textbox", { name: "Filter field" }).fill("1"); - await page.getByRole("combobox", { name: "Filter comparison" }).selectOption("equal"); - await page.getByRole("textbox", { name: "Filter value" }).fill("D"); - await page.getByRole("button", { name: "Settings for the list at the root" }).click(); - - await addListField(root, "the root list", "tracking", "2"); - await addListField(root, "the root list", "status", "3"); - - await expectPreview(page, '"tracking": "1309981172"'); - await expect(preview(page)).toContainText('"status": "CC"'); - - // The header and the trailer are gone, and nothing had to be told they existed. - await expect(preview(page)).not.toContainText("FFSTAT"); - await expect(preview(page)).not.toContainText('"tracking": "9"'); -}); - -test("a leading zero and a trailing scale survive the round trip to the server", async ({ - page, -}) => { - await openWithCsv(page, TRACKING, "|", false); - - const root = await rootListOverTheDocument(page); - await addListField(root, "the root list", "weight", "5"); - await addListField(root, "the root list", "account", "26"); - - // 0.100 as 0.1, or an account reference losing a character, is a file the partner - // rejects with nothing anywhere to say why. - await expectPreview(page, '"weight": "0.100"'); - await expect(preview(page)).toContainText('"account": "222998693"'); -}); - -test("writing a delimited file takes its delimiter and header from the target side", async ({ - page, -}) => { - await openWithCsv(page, TRACKING, "|", false); - - await withFormats(page, async () => { - await page.getByLabel("To format").selectOption("csv"); - await page.getByLabel("target delimiter").selectOption(";"); - await page.getByRole("checkbox", { name: "target header row" }).check(); - }); - - const root = await rootListOverTheDocument(page); - await page.getByRole("button", { name: "Settings for the list at the root" }).click(); - await page.getByRole("checkbox", { name: "Only some entries" }).check(); - await page.getByRole("textbox", { name: "Filter field" }).fill("1"); - await page.getByRole("textbox", { name: "Filter value" }).fill("D"); - await page.getByRole("button", { name: "Settings for the list at the root" }).click(); - - await addListField(root, "the root list", "Tracking", "2"); - await addListField(root, "the root list", "Status", "3"); - - await expectPreview(page, "Tracking;Status"); - await expect(preview(page)).toContainText("1309981172;OK"); - await expect(preview(page)).toContainText("1309981174;CC"); -}); - -test("a nested rule becomes a dotted column", async ({ page }) => { - await openWithCsv(page, MOVEMENTS, ",", true); - await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); - - const root = await rootListOverTheDocument(page); - // A row is flat, so the nesting has to land somewhere. The name box splits on dots, so - // this is the only way a column of this name can exist at all. - await addListField(root, "the root list", "shipment.number", "ShipmentNumber"); - - await expectPreview(page, "shipment.number"); - await expect(preview(page)).toContainText("6G61965126082"); -}); - -test("a shape a row cannot hold is refused with a reason", async ({ page }) => { - await openWithCsv(page, MOVEMENTS, ",", true); - await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); - - // A list inside a row. There is no cell that holds one, and inventing a way to fit it — - // joining the entries, taking the first — would lose data without a word. - const root = await rootListOverTheDocument(page); - await addListField(root, "the root list", "shipment", "ShipmentNumber"); - await root.getByRole("button", { name: "Add a list to the root list" }).click(); - await root.getByRole("textbox", { name: "Output list name" }).last().fill("tags"); - - await expect(page.getByText(/cannot hold one|cannot itself be a list/)).toBeVisible({ - timeout: 15000, - }); -}); - -/** Adds a field inside one of a list's written entries, pointed at a fixed value. */ -async function addEntryField( - scope: import("@playwright/test").Locator, - entry: string, - name: string, - value: string, -) { - await scope.getByRole("button", { name: `Add a field to ${entry}` }).click(); - await scope.getByRole("textbox", { name: "Output field name" }).last().fill(name); - await scope.getByRole("radio", { name: "Fixed" }).last().click(); - await scope.getByRole("textbox", { name: "Fixed value" }).last().fill(value); -} - -test("the carrier's whole file can be produced, trailer count and all", async ({ page }) => { - // The other direction, and the one that needed two features of its own: a file like the - // client's ends with a record carrying how many records came before it. - await openWithCsv(page, TRACKING, "|", false); - - await withFormats(page, async () => { - await page.getByLabel("To format").selectOption("csv"); - await page.getByLabel("target delimiter").selectOption("|"); - await page.getByRole("checkbox", { name: "target header row" }).uncheck(); - }); - - const root = await rootListOverTheDocument(page); - await page.getByRole("button", { name: "Settings for the list at the root" }).click(); - await page.getByRole("checkbox", { name: "Only some entries" }).check(); - await page.getByRole("textbox", { name: "Filter field" }).fill("1"); - await page.getByRole("textbox", { name: "Filter value" }).fill("D"); - await page.getByRole("button", { name: "Settings for the list at the root" }).click(); - - // The header line, written before anything is walked. - await root.getByRole("button", { name: "Add an entry to the root list" }).click(); - const first = page.getByRole("group", { name: "Rules for entry 1" }); - await addEntryField(first, "entry 1", "1", "H"); - await addEntryField(first, "entry 1", "2", "FFSTAT"); - - // One line per shipment. - await addListField(root, "the root list", "1", "1"); - await addListField(root, "the root list", "2", "2"); - await addListField(root, "the root list", "3", "3"); - - // And the trailer, which is the only place a rule can see what the list ended up holding. - await root.getByRole("button", { name: "Add a closing entry to the root list" }).click(); - const closing = page.getByRole("group", { name: "Rules for closing entry 1" }); - await addEntryField(closing, "closing entry 1", "1", "T"); - await closing.getByRole("button", { name: "Add a field to closing entry 1" }).click(); - await closing.getByRole("textbox", { name: "Output field name" }).last().fill("2"); - await closing.getByRole("radio", { name: "Count" }).last().click(); - - await expectPreview(page, "H|FFSTAT"); - await expect(preview(page)).toContainText("D|1309981172|OK"); - await expect(preview(page)).toContainText("D|1309981174|CC"); - - // Two shipments, so the trailer says 2 — the header line above it is not a record, and - // adding one later cannot move the number. - await expect(preview(page)).toContainText("T|2"); -}); - -test("counting is offered inside a list and nowhere else", async ({ page }) => { - // Outside a list there is nothing to count, so the segment is not there to be chosen. - await openWithCsv(page, MOVEMENTS, ",", true); - - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await expect(page.getByRole("radio", { name: "Count" })).toHaveCount(0); - - const root = await rootListOverTheDocument(page); - await root.getByRole("button", { name: "Add a field to the root list" }).click(); - await expect(root.getByRole("radio", { name: "Count" })).toHaveCount(1); -}); - -test("a file can be marked so Excel opens accented names correctly", async ({ page }) => { - await openWithCsv(page, MOVEMENTS, ",", true); - await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); - - const root = await rootListOverTheDocument(page); - await addListField(root, "the root list", "shipment", "ShipmentNumber"); - await expectPreview(page, "shipment"); - - // The mark itself is invisible, so what is checked is that asking for it changes the - // document the server produced rather than that anything looks different. - const before = await preview(page).textContent(); - await withFormats(page, () => - page.getByRole("checkbox", { name: "write a byte-order mark" }).check(), - ); - await expect - .poll(async () => (await preview(page).textContent())?.charCodeAt(0), { timeout: 15000 }) - .toBe(0xfeff); - expect(before?.charCodeAt(0)).not.toBe(0xfeff); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts index 5051677e..a95fe104 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts @@ -1,15 +1,9 @@ import { test, expect } from "@playwright/test"; import { signInAsAdmin } from "./helpers"; import { - addFixedRule, - addList, - addListField, - addPathRule, - buildFromSample, createSubscription, expectPreview, openMapper, - saveAndReload, suggestionsFor, withFormats, } from "./mapperHelpers"; @@ -84,139 +78,3 @@ test("the paths the source tree offers are the ones the server can read", async // Read by the server, not by the editor — which is what makes this a real check. await expectPreview(page, '"account": "55480501"'); }); - -test("an empty element reads as empty rather than as missing", async ({ page }) => { - await openWithXml(page); - - await addPathRule(page, "address2", "Envelope.Body.shipping.shipperValue.shipperAdress2"); - await addPathRule(page, "city", "Envelope.Body.shipping.shipperValue.shipperCity"); - - // `` is all over a real request. The element is there and its text is - // empty, and the mapping has to be able to tell that from an element nobody sent. - await expectPreview(page, '"address2": ""'); - await expectPreview(page, '"city": "LYON"'); -}); - -test("an order with a single line still maps that line", async ({ page }) => { - // The quietest bug in XML mapping: one is the same document as no list at all, - // so the mapping reports success and the only line is gone. It surfaces in production, - // on the one order that happened to have a single item. - const one = `A1ONLY`; - const three = `A1 - XYZ`; - - await openWithXml(page, three); - - const list = await addList(page, "lines", "order.line"); - await addListField(list, "lines", "code", "sku"); - await expectPreview(page, /"code": "X"[\s\S]*"code": "Y"[\s\S]*"code": "Z"/); - - // The same mapping, against a document with one of them. - await page.getByRole("textbox", { name: "Sample source document" }).fill(one); - await expectPreview(page, '"code": "ONLY"'); -}); - -test("a mapping that writes XML takes its namespaces from the sample of the output", async ({ - page, -}) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await withFormats(page, async () => { - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); - }); - await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); - - await buildFromSample( - page, - ` - 0 - `, - ); - - // Nothing about a namespace was typed: the sample carried it, the scaffold made it a - // fixed value, and the writer put the prefix back where the sample had it. - await page - .getByLabel("Source field", { exact: true }) - .last() - .fill("Envelope.Body.shipping.headerValue.accountNumber"); - - await expectPreview(page, 'xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"'); - await expectPreview(page, "55480501"); - await expectPreview(page, ""); - - // And it is all still there after a round trip through the database. - await saveAndReload(page); - await expectPreview(page, 'xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"'); -}); - -test("a shape XML cannot hold is refused with a reason, not a broken document", async ({ - page, -}) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await withFormats(page, async () => { - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); - }); - await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); - - // JSON writes as many top-level keys as it likes; XML has exactly one root element. - await addFixedRule(page, "first", "1"); - await addFixedRule(page, "second", "2"); - - await expect(page.getByText(/exactly one root element/)).toBeVisible({ timeout: 15000 }); -}); - -test("a source sample that is not XML says so instead of showing an empty tree", async ({ - page, -}) => { - await openWithXml(page, "A1"); - - await expect(page.getByText(/not valid XML/i).first()).toBeVisible({ timeout: 15000 }); -}); - -test("an element that carries both an attribute and a value maps as two rules", async ({ - page, -}) => { - // `0.940` is one element holding two separate things, so it - // is two rules: `@unit` for the attribute and `#text` for the element's own value. The - // same convention as reading, in reverse. - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await withFormats(page, async () => { - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); - }); - await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); - - // The sample needs a value between the tags, not just the attribute: an element with - // no text has no text node to make a rule for. - await buildFromSample(page, `0`); - - const names = page.getByRole("textbox", { name: "Output field name" }); - await expect(names).toHaveCount(2); - - const sourceOf = (n: number) => page.getByLabel("Source field", { exact: true }).nth(n); - await sourceOf(0).fill("Envelope.Body.shipping.weight.@unit"); - await sourceOf(1).fill("Envelope.Body.shipping.weight.#text"); - - await expectPreview(page, '0.940'); -}); - -test("an attribute can be added to an element by hand, without a sample", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await withFormats(page, async () => { - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); - }); - await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); - - // Dots separate the levels, so `order.weight.@unit` puts the attribute on `weight` - // two levels down. Nothing about attributes needs its own control. - await addFixedRule(page, "order.weight.@unit", "kg"); - await addPathRule(page, "order.weight.#text", "Envelope.Body.shipping.weight.#text"); - - await expectPreview(page, '0.940'); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts b/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts index c83464e4..86e7a2e6 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts @@ -53,16 +53,6 @@ export async function openMapper(page: Page, subscriptionId: string) { await expect(page.getByRole("button", { name: "Save" })).toBeVisible({ timeout: 15000 }); } -/** Creates a subscription, opens its editor, and pastes a source sample. */ -export async function openWithSample(page: Page, sample: unknown = SAMPLE): Promise { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await page - .getByRole("textbox", { name: "Sample source document" }) - .fill(typeof sample === "string" ? sample : JSON.stringify(sample, null, 2)); - return subscriptionId; -} - /** * Runs `set` with the format panel open, and closes it afterwards. * @@ -83,16 +73,6 @@ export async function withFormats(page: Page, set: () => Promise) { await expect(panel).toBeHidden({ timeout: 15000 }); } -/** Pastes an output sample into the toolbar panel and builds the rules from it. */ -export async function buildFromSample(page: Page, target: unknown) { - await page.getByRole("button", { name: "Build from a sample of the output" }).click(); - await page - .getByRole("textbox", { name: "Sample output document" }) - .fill(typeof target === "string" ? target : JSON.stringify(target, null, 2)); - await page.getByRole("button", { name: "Build the rules" }).click(); - await page.keyboard.press("Escape"); -} - /** * Points the last-added rule within `scope` at a source path. * @@ -132,66 +112,11 @@ export async function addPathRule(page: Page, name: string, path: string) { await setSourcePath(page, path); } -/** Adds a field at the top level whose value is a literal. */ -export async function addFixedRule(page: Page, name: string, value: string) { - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await page.getByRole("textbox", { name: "Output field name" }).last().fill(name); - await page.getByRole("radio", { name: "Fixed" }).last().click(); - await page.getByRole("textbox", { name: "Fixed value" }).last().fill(value); -} - /** Opens a row's detail panel, which is where the transform, type and lookup live. */ export async function openDetail(page: Page, name: string) { await page.getByRole("button", { name: `Details for ${name}` }).click(); } -/** Adds a list at the top level over a source path, and returns its rules group. */ -export async function addList(page: Page, name: string, over: string | null): Promise { - await page.getByRole("button", { name: "Add a list", exact: true }).click(); - await page.getByRole("textbox", { name: "Output list name" }).last().fill(name); - await page - .getByRole("combobox", { name: "Source list" }) - .last() - .selectOption(over === null ? "none" : `p:${over}`); - return page.getByRole("group", { name: `Rules for the list ${name}` }); -} - -/** - * Adds a field inside a list, pointed at a path on the entry. - * - * `addTo` is the list as its own add button names it — its output name, or "the - * root list". Passed rather than read off the row, because the root list has no - * name box to read: it says "the whole output" instead. - */ -export async function addListField( - list: Locator, - addTo: string, - name: string, - path: string, - from: "entry" | "document" = "entry", -) { - await list.getByRole("button", { name: `Add a field to ${addTo}` }).click(); - await list.getByRole("textbox", { name: "Output field name" }).last().fill(name); - await setSourcePath(list, path, from); -} - -/** - * Makes a list hold plain values, and points its one value at a path. - * - * The counterpart of `addListField`. What a list holds is decided by what is put into - * it, so this is a click that adds a row rather than a setting that changes a mode — - * and it is only offered while the list is still empty. - */ -export async function addListValue( - list: Locator, - addTo: string, - path: string, - from: "entry" | "document" = "entry", -) { - await list.getByRole("button", { name: `Add a value to ${addTo}` }).click(); - await setSourcePath(list, path, from); -} - /** The mapped document, which the server produces. */ export const preview = (page: Page): Locator => page.locator("pre").first(); @@ -200,13 +125,6 @@ export async function expectPreview(page: Page, text: string | RegExp) { await expect(preview(page)).toContainText(text, { timeout: 15000 }); } -export async function saveAndReload(page: Page) { - await page.getByRole("button", { name: "Save" }).click(); - await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); - await page.reload(); - await expect(page.getByRole("button", { name: "Save" })).toBeVisible({ timeout: 15000 }); -} - // ─── Reaching past the editor ───────────────────────────────────────────────── let api: APIRequestContext | null = null; diff --git a/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts b/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts index 9d320765..16f71486 100644 --- a/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts @@ -2,15 +2,11 @@ import { test, expect } from "@playwright/test"; import { pickOption, signInAsAdmin } from "./helpers"; import { SAMPLE, - addList, - addListValue, addPathRule, - buildFromSample, createSubscription, openDetail, openMapper, setSourcePath, - suggestionsFor, writeMapperProperties, } from "./mapperHelpers"; @@ -23,7 +19,9 @@ import { * changed and nothing said so. * * The mapping shapes themselves — every source, every transform, every kind of - * list — are in mapper-cases.spec.ts. + * list — are tested below the browser: what the engine makes of them in C# + * (SW.Bitween.UnitTests/NativeMapper), and the editor's handling of them in + * src/components/nativeMapper/__tests__. */ test.beforeEach(async ({ page }) => { @@ -123,46 +121,6 @@ test("builds a mapping, previews it, saves it, and reloads exactly what was buil await expect(page.locator("pre").first()).toContainText('"total": 116,', { timeout: 15000 }); }); -test("a rule that cannot be applied is named rather than producing an empty field", async ({ - page, -}) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(SAMPLE); - - await addPathRule(page, "total", "order.customer"); - await openDetail(page, "total"); - await page.getByRole("combobox", { name: "Value type" }).last().selectOption("number"); - - // "Ali" is not a number. The old mapper wrote null into the field and said nothing; - // this fails the mapping and names the rule. - await expect(page.getByText(/could not be applied/)).toBeVisible({ timeout: 15000 }); - - // Reported twice on purpose — once on the rule row that is wrong, and once in the - // preview panel's summary of everything that failed. - await expect(page.getByText(/cannot convert 'Ali' to number/)).toHaveCount(2); - await expect(page.getByRole("alert").filter({ hasText: /cannot convert 'Ali'/ })).toBeVisible(); -}); - -test("stored rules survive a switch to a list-shaped output and back", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(SAMPLE); - await addPathRule(page, "customerName", "order.customer"); - - await page.getByRole("checkbox", { name: /The whole output is a list/ }).check(); - await expect(page.getByRole("button", { name: "Collapse the list at the root" })).toBeVisible(); - - await page.getByRole("checkbox", { name: /The whole output is a list/ }).uncheck(); - - // The field rules were put aside, not thrown away. - await expect(page.getByRole("textbox", { name: "Output field name" })).toHaveValue( - "customerName", - ); -}); - test("choosing the new mapper offers its editor, and the old mapper keeps its own", async ({ page, }) => { @@ -262,147 +220,6 @@ test("saving over the mapping the other mapper already has asks first", async ({ await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); }); -test("builds the whole output from a sample of it, and matches the source fields", async ({ - page, -}) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(SAMPLE); - - // What the partner expects. Adding this by hand is five rules; on a real document - // it is hundreds, which is the whole point of building it from the sample. - await buildFromSample(page, { customer: "", net: 0, line: [{ sku: "", qty: 0 }] }); - - await page.getByRole("button", { name: "Build from a sample of the output" }).click(); - await expect(page.getByText(/Added 5 rules · 5 matched to a source field/)).toBeVisible(); - await page.keyboard.press("Escape"); - - const names = page.getByRole("textbox", { name: "Output field name" }); - await expect(names.nth(0)).toHaveValue("customer"); - await expect(names.nth(1)).toHaveValue("net"); - - await expect(page.getByRole("textbox", { name: "Output list name" })).toHaveValue("line"); - await expect(page.getByRole("combobox", { name: "Source list" })).toHaveValue("p:order.line"); - - // A rule inside the list reads one entry, so its path is `sku`, not `order.line.sku`. - const lines = page.getByRole("group", { name: "Rules for the list line" }); - await expect(lines.getByRole("combobox", { name: "Source field" }).first()).toHaveValue("sku"); - - // The number came from `0` in the sample, so the output keeps the partner's type. - await openDetail(page, "net"); - await expect(page.getByRole("combobox", { name: "Value type" })).toHaveValue("number"); - await openDetail(page, "net"); - - // ── The mapping actually runs ────────────────────────────────────────────── - const preview = page.locator("pre").first(); - await expect(preview).toContainText('"customer": "Ali"', { timeout: 15000 }); - await expect(preview).toContainText('"net": 100'); - await expect(preview).toContainText('"sku": "A1"'); - await expect(preview).toContainText('"sku": "B7"'); -}); - -test("a list of plain values built from a sample is wired up and says so", async ({ - page, -}) => { - // The shape that sent this round: both sides hold `[1,2,3]`, and the scaffolder - // wires each entry to the entry itself — the right answer, which used to be shown - // as an empty box behind a checkbox and read as nothing configured at all. - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page - .getByRole("textbox", { name: "Sample source document" }) - .fill(JSON.stringify({ city: "errr", test: [1, 2, 3] })); - await buildFromSample(page, { city: "", test: [1, 2, 3] }); - await page.getByRole("button", { name: "Build from a sample of the output" }).click(); - await page.keyboard.press("Escape"); - - const list = page.getByRole("group", { name: "Rules for the list test" }); - - // A row in the tree, not a setting behind a chevron — and it reads as an answer - // rather than as a box waiting to be filled in. - const value = list.getByRole("combobox", { name: "Source field" }); - await expect(value).toHaveAttribute("placeholder", "the entry itself"); - await expect(value).toHaveValue(""); - await expect(list.getByText("each entry")).toBeVisible(); - - // Nothing is left unassigned, which is what the count above the tree has to agree - // with: an empty path here is the answer, not a blank. - await expect(page.getByText("2 rules · 2 assigned")).toBeVisible(); - - // And it runs: the source values come straight through. - await expect(page.locator("pre").first()).toHaveText(/"test":\s*\[\s*1,\s*2,\s*3\s*\]/, { - timeout: 15000, - }); -}); - -test("a list's value takes a type and a transform like any other rule", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await page - .getByRole("textbox", { name: "Sample source document" }) - .fill(JSON.stringify({ price: [10, 20] })); - - const list = await addList(page, "totals", "price"); - await addListValue(list, "totals", ""); - - // The row carries the whole rule, which is the point of it being a row: the value - // each entry produces can be multiplied and typed exactly like a named field. - await list.getByRole("button", { name: "Details for each entry" }).click(); - await list.getByRole("combobox", { name: "Transform" }).selectOption("multiply"); - await list.getByRole("textbox", { name: /Multiply.*By/ }).fill("2"); - await list.getByRole("combobox", { name: "Value type" }).selectOption("number"); - - await expect(page.locator("pre").first()).toHaveText(/"totals":\s*\[\s*20,\s*40\s*\]/, { - timeout: 15000, - }); -}); - -test("a list inside a list offers the entry's own lists, not the document's", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - // `tags` sits inside an entry of `order.line`, and there is a decoy `tags` at the - // top of the document that the inner list must not reach for. - await page.getByRole("textbox", { name: "Sample source document" }).fill( - JSON.stringify( - { - tags: [{ code: "DECOY" }], - order: { - line: [ - { sku: "A1", tags: [{ code: "fragile" }, { code: "boxed" }] }, - { sku: "B7", tags: [{ code: "cold" }] }, - ], - }, - }, - null, - 2, - ), - ); - - await buildFromSample(page, { line: [{ sku: "", tags: [{ code: "" }] }] }); - - // The outer list is named from the document; the inner one from one entry of it. - // Offering `order.line.tags` here was a real bug: the mapper resolves a nested - // list against the entry, so that path names nothing at all. - const lists = page.getByRole("combobox", { name: "Source list" }); - await expect(lists.nth(0)).toHaveValue("p:order.line"); - await expect(lists.nth(1)).toHaveValue("p:tags"); - // Only the entry's own lists, plus the choice to walk nothing at all. - await expect(lists.nth(1).locator("option")).toHaveText([ - "— just the entries below —", - "tags", - ]); - - // And it runs: two entries, each with its own tags, and no sign of the decoy. - const preview = page.locator("pre").first(); - await expect(preview).toContainText('"code": "fragile"', { timeout: 15000 }); - await expect(preview).toContainText('"code": "boxed"'); - await expect(preview).toContainText('"code": "cold"'); - await expect(preview).not.toContainText("DECOY"); -}); - test("dragging a source field onto a rule wires it up", async ({ page }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); @@ -424,155 +241,3 @@ test("dragging a source field onto a rule wires it up", async ({ page }) => { timeout: 15000, }); }); - -test("a lookup table substitutes values, and says what happens to a miss", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill( - JSON.stringify({ country: "JO", other: "XX" }, null, 2), - ); - - await addPathRule(page, "countryName", "country"); - await addPathRule(page, "otherName", "other"); - - // ── The table applies ────────────────────────────────────────────────────── - await openDetail(page, "countryName"); - await page.getByRole("checkbox", { name: "Substitute values from a table" }).check(); - await page.getByRole("button", { name: "Add incoming value" }).click(); - await page.getByRole("textbox", { name: "Incoming value 1" }).fill("JO"); - await page.getByRole("textbox", { name: "Becomes 1" }).fill("Jordan"); - - await expect(page.locator("pre").first()).toContainText('"countryName": "Jordan"', { - timeout: 15000, - }); - - // ── A miss is empty unless the rule says otherwise ───────────────────────── - await expect(page.getByText("Otherwise the field is left empty.")).toBeVisible(); - await page.getByRole("checkbox", { name: /Use a fallback/ }).check(); - await page.getByRole("textbox", { name: "Lookup fallback" }).fill("Unknown"); - - await expect(page.locator("pre").first()).toContainText('"otherName": "XX"'); - await expect(page.locator("pre").first()).toContainText('"countryName": "Jordan"'); -}); - -test("a rule inside a list can read a value from the top of the document", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(SAMPLE); - - await page.getByRole("button", { name: "Add a list", exact: true }).click(); - await page.getByRole("textbox", { name: "Output list name" }).fill("lines"); - await page.getByRole("combobox", { name: "Source list" }).selectOption("p:order.line"); - - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await lines.getByRole("button", { name: "Add a field to lines" }).click(); - await lines.getByRole("textbox", { name: "Output field name" }).fill("code"); - - // Inside a list, "sku" alone is ambiguous — it could be the line's or the - // document's — so the scope is its own control, and the suggestions follow it. - const field = lines.getByRole("combobox", { name: "Source field" }); - const scope = lines.getByRole("combobox", { name: "Read from" }); - - await expect(scope).toHaveValue("entry"); - expect(await suggestionsFor(page, field)).toContain("sku"); - - await scope.selectOption("doc"); - expect(await suggestionsFor(page, field)).toContain("order.customer"); - await scope.selectOption("entry"); - - await setSourcePath(lines, "sku"); - - await lines.getByRole("button", { name: "Add a field to lines" }).click(); - await lines.getByRole("textbox", { name: "Output field name" }).last().fill("customer"); - await setSourcePath(lines, "order.customer", "document"); - - // Every line carries the order's customer, which a path read on the entry cannot do. - const preview = page.locator("pre").first(); - await expect(preview).toContainText('"code": "A1"', { timeout: 15000 }); - await expect(preview).toContainText('"customer": "Ali"'); - - // And it survives the round trip as a document-scoped read, not an entry one. - await page.getByRole("button", { name: "Save" }).click(); - await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); - await page.reload(); - await expect(page.getByRole("button", { name: "Save" })).toBeVisible({ timeout: 15000 }); - - const reloaded = page.getByRole("group", { name: "Rules for the list lines" }); - await expect(reloaded.getByRole("combobox", { name: "Source field" }).nth(0)).toHaveValue("sku"); - await expect(reloaded.getByRole("combobox", { name: "Read from" }).nth(0)).toHaveValue("entry"); - await expect(reloaded.getByRole("combobox", { name: "Source field" }).nth(1)).toHaveValue( - "order.customer", - ); - await expect(reloaded.getByRole("combobox", { name: "Read from" }).nth(1)).toHaveValue("doc"); -}); - -test("a list can carry entries written into it, before the ones it walks", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(SAMPLE); - - await page.getByRole("button", { name: "Add a list", exact: true }).click(); - await page.getByRole("textbox", { name: "Output list name" }).fill("lines"); - await page.getByRole("combobox", { name: "Source list" }).selectOption("p:order.line"); - - const lines = page.getByRole("group", { name: "Rules for the list lines" }); - await lines.getByRole("button", { name: "Add a field to lines" }).click(); - await lines.getByRole("textbox", { name: "Output field name" }).fill("sku"); - await setSourcePath(lines, "sku"); - - // ── A header line the partner expects ────────────────────────────────────── - await lines.getByRole("button", { name: "Add an entry to lines" }).click(); - - const entry = page.getByRole("group", { name: "Rules for entry 1" }); - await entry.getByRole("button", { name: "Add a field to entry 1" }).click(); - await entry.getByRole("textbox", { name: "Output field name" }).fill("sku"); - await entry.getByRole("radio", { name: "Fixed" }).click(); - await entry.getByRole("textbox", { name: "Fixed value" }).fill("HEADER"); - - // Written entries come first, then one per entry of the source list — the order - // the previous mapper produced for the same configuration. - const preview = page.locator("pre").first(); - await expect(preview).toContainText('"sku": "HEADER"', { timeout: 15000 }); - await expect(preview).toHaveText(/HEADER[\s\S]*A1[\s\S]*B7/); - - // ── And it survives the round trip ───────────────────────────────────────── - await page.getByRole("button", { name: "Save" }).click(); - await expect(page.getByText("Saved")).toBeVisible({ timeout: 15000 }); - await page.reload(); - await expect(page.getByRole("button", { name: "Save" })).toBeVisible({ timeout: 15000 }); - - await expect(page.getByRole("group", { name: "Rules for entry 1" })).toBeVisible(); - await expect(page.locator("pre").first()).toHaveText(/HEADER[\s\S]*A1/, { timeout: 15000 }); -}); - -test("a list of values with a slot per rule, walking nothing", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("textbox", { name: "Sample source document" }).fill(SAMPLE); - - await page.getByRole("button", { name: "Add a list", exact: true }).click(); - await page.getByRole("textbox", { name: "Output list name" }).fill("codes"); - - // Nothing to walk, so the list is exactly what is written into it. This is what - // the old mapper called a primitive array. - await page.getByRole("combobox", { name: "Source list" }).selectOption("none"); - - // What the list holds is decided by what is put in it, not by a setting: the first - // slot says these are plain values, and every entry after it follows. - await page.getByRole("button", { name: "Add a value to codes" }).click(); - await page.getByRole("button", { name: "Add an entry to codes" }).click(); - - const first = page.getByRole("group", { name: "Entry 1" }); - const second = page.getByRole("group", { name: "Entry 2" }); - - await setSourcePath(first, "order.customer"); - await second.getByRole("radio", { name: "Fixed" }).click(); - await second.getByRole("textbox", { name: "Fixed value" }).fill("WEB"); - - await expect(page.locator("pre").first()).toContainText('"codes"', { timeout: 15000 }); - await expect(page.locator("pre").first()).toHaveText(/"codes":\s*\[\s*"Ali",\s*"WEB"\s*\]/); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts index d44ca129..2519aea2 100644 --- a/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/permissions-enforcement.spec.ts @@ -6,10 +6,8 @@ import { deleteRole, removeMember, signIn, - openMember, signInAsAdmin, signOut, - startsWith, } from "./helpers"; /** @@ -111,28 +109,6 @@ test("Viewer can read but not write", async ({ page }) => { await removeMember(page, email); }); -test("Member can configure subscriptions but not manage the team", async ({ page }) => { - await signInAsAdmin(page); - const email = await addMember(page, { name: "Regular Member", roles: ["Member"] }); - - await signOut(page); - await signIn(page, email, FIRST_PASSWORD); - - await page.goto("partners"); - await expect(page.getByRole("button", { name: "New partner" })).toBeVisible(); - - // The whole Administration group is absent for a Member. - await expect(sidebarLinks(page).filter({ hasText: "Team" })).toHaveCount(0); - await expect(sidebarLinks(page).filter({ hasText: "Settings" })).toHaveCount(0); - - expect(await apiStatus(page, "GET", "/accounts?limit=5")).toBe(401); - expect(await apiStatus(page, "POST", "/roles", { name: "x", description: "", permissions: [] })).toBe(401); - - await signOut(page); - await signInAsAdmin(page); - await removeMember(page, email); -}); - test("editing a role changes what its members can do, without them signing in again", async ({ page, }) => { @@ -167,29 +143,3 @@ test("editing a role changes what its members can do, without them signing in ag await removeMember(page, email); await deleteRole(page, roleName); }); - -test("a member with no roles at all sees nothing and can do nothing", async ({ page }) => { - await signInAsAdmin(page); - // A member cannot be created without a role — the server refuses an empty one — so the - // roleless state is reached the only way it can be: by taking their one role away after. - const email = await addMember(page, { name: "No Roles", roles: ["Viewer"] }); - await openMember(page, email); - const drawer = page.getByRole("dialog", { name: "Member details" }); - await drawer.getByRole("checkbox", { name: startsWith("Viewer") }).uncheck(); - await drawer.getByRole("button", { name: "Save roles" }).click(); - await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); - - await signOut(page); - await signIn(page, email, FIRST_PASSWORD); - - await expect(sidebarLinks(page)).toHaveCount(0); - for (const path of ["exchanges", "partners", "team/members", "settings"]) { - await page.goto(path); - await expect(page.getByText("You don't have access to this page")).toBeVisible(); - } - expect(await apiStatus(page, "POST", "/partners", { name: "nope" })).toBe(401); - - await signOut(page); - await signInAsAdmin(page); - await removeMember(page, email); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts b/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts deleted file mode 100644 index 340bf0e4..00000000 --- a/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { signInAsAdmin } from "./helpers"; -import { - addPathRule, - createSubscription, - expectPreview, - openMapper, - preview, - withFormats, -} from "./mapperHelpers"; - -/** - * Making a document readable: laid out over lines, and coloured. - * - * Two halves of one job, split by whether anyone types into the box. A box you type - * into keeps a real textarea and gets a Format button; a pane you only read gets - * colour. Nothing gets both, because colouring text under a caret means either a - * contenteditable or an overlay that has to track the caret exactly. - * - * ── Laying out a document in a box someone types into ────────────────────── - * - * A button rather than the exchange drawer's Raw/Formatted toggle: that shows a - * document nobody can edit, whereas these boxes hold text belonging to whoever typed - * it, so reflowing it is an action they take and undo reverses. The button is absent - * when there is nothing to gain, which is `formatDocument`'s own answer and the thing - * most worth pinning — it is what stops it offering to mangle a half-typed document. - */ - -/** How a partner actually hands over a sample: one line, no spaces. */ -const MINIFIED_XML = - `` + - `55480501` + - ``; - -const MINIFIED_JSON = `{"order":{"customer":"Ali","line":[{"sku":"A1"}]}}`; - -test.beforeEach(async ({ page }) => { - await signInAsAdmin(page); -}); - -test("lays out a one-line XML sample, and the tree still reads it", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await withFormats(page, () => page.getByLabel("From format").selectOption("xml")); - - const sample = page.getByRole("textbox", { name: "Sample source document" }); - await sample.fill(MINIFIED_XML); - - await page.getByRole("button", { name: "Format" }).click(); - - // Laid out over lines, with the data untouched. - await expect(sample).toHaveValue(/\n {2}/); - await expect(sample).toHaveValue(/55480501<\/accountNumber>/); - - // And it is still the same document as far as the mapping is concerned. - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await page.getByRole("textbox", { name: "Output field name" }).last().fill("account"); - await page - .getByLabel("Source field", { exact: true }) - .last() - .fill("Envelope.Body.shipping.headerValue.accountNumber"); - await expectPreview(page, '"account": "55480501"'); -}); - -test("offers nothing while there is nothing to lay out", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - const sample = page.getByRole("textbox", { name: "Sample source document" }); - const button = page.getByRole("button", { name: "Format" }); - - // Empty, and half-typed: offering to reflow either one could only mangle it. - await expect(button).toHaveCount(0); - await sample.fill(`{"order":{"customer":`); - await expect(button).toHaveCount(0); - - await sample.fill(MINIFIED_JSON); - await expect(button).toBeVisible(); - - // Gone again once the document is already laid out — there is no second press. - await button.click(); - await expect(sample).toHaveValue(/\n {2}"order": \{/); - await expect(button).toHaveCount(0); -}); - -test("formatting changes the layout and nothing else", async ({ page }) => { - // The invariant that makes the button safe, and the reason it not being undoable is - // tolerable: it inserts whitespace between tokens and touches nothing else, so the - // document afterwards maps to exactly what it mapped to before. - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - const sample = page.getByRole("textbox", { name: "Sample source document" }); - await sample.fill(MINIFIED_JSON); - - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await page.getByRole("textbox", { name: "Output field name" }).last().fill("who"); - await page.getByLabel("Source field", { exact: true }).last().fill("order.customer"); - await expectPreview(page, '"who": "Ali"'); - const before = await preview(page).textContent(); - - await page.getByRole("button", { name: "Format" }).click(); - await expect(sample).toHaveValue(/\n {2}"order": \{/); - - // Same output, from a document that now reads as several lines instead of one. - await expectPreview(page, '"who": "Ali"'); - expect(await preview(page).textContent()).toBe(before); -}); - -test("lays out the sample of the output too", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - - await page.getByRole("button", { name: "Build from a sample of the output" }).click(); - const target = page.getByRole("textbox", { name: "Sample output document" }); - await target.fill(MINIFIED_JSON); - - await page.getByRole("button", { name: "Format" }).click(); - await expect(target).toHaveValue(/\n {2}"order": \{/); -}); - -test("the mapped document is coloured, in whichever format it is written", async ({ page }) => { - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await page.getByRole("textbox", { name: "Sample source document" }).fill(MINIFIED_JSON); - await addPathRule(page, "who", "order.customer"); - - await expectPreview(page, '"who": "Ali"'); - - // The key and the value are separate things, and the pane says so. - const preview = page.locator(".doc-hl"); - await expect(preview.locator(".hljs-attr").first()).toBeVisible(); - await expect(preview.locator(".hljs-string").first()).toBeVisible(); - - // Switching the output to XML colours it as XML, because the mapping declares the - // format rather than the pane guessing from the text. - await withFormats(page, () => page.getByLabel("To format").selectOption("xml")); - await page.getByRole("textbox", { name: "Output field name" }).first().fill("order"); - await expect(preview.locator(".hljs-name").first()).toBeVisible({ timeout: 15000 }); -}); - -test("markup inside a document is shown, never run", async ({ page }) => { - // The one place in the app that turns a document into HTML. A partner controls the - // bytes, so the guarantee is worth checking through the real page and not only in - // the unit test that pins the escaping. - const subscriptionId = await createSubscription(page); - await openMapper(page, subscriptionId); - await page.getByRole("textbox", { name: "Sample source document" }).fill(MINIFIED_JSON); - - await page.getByRole("button", { name: "Add a field", exact: true }).click(); - await page.getByRole("textbox", { name: "Output field name" }).last().fill("note"); - await page.getByRole("radio", { name: "Fixed" }).last().click(); - await page - .getByRole("textbox", { name: "Fixed value" }) - .last() - .fill(""); - - // Visible as characters… - await expectPreview(page, ""); - - // …and inert: nothing was added to the document, and nothing ran. - await expect(page.locator(".doc-hl script")).toHaveCount(0); - expect(await page.evaluate(() => (window as unknown as { __ran?: number }).__ran)).toBeUndefined(); -}); - -test("Raw shows the bytes as they arrived, uncoloured", async ({ page }) => { - // The Raw toggle's whole promise is that nothing has been done to the document. - // Colour is a claim about its structure, and the pane was making that claim on both - // sides of the toggle — including for a payload that never parsed, where the parts a - // grammar still recognises would come out looking fine. - await page.goto("exchanges/new"); - await page.getByRole("combobox", { name: "Pick a subscription…" }).click(); - await page.getByRole("option").first().click(); - await page.getByRole("heading", { name: "New exchange" }).click(); - await page.locator("textarea").fill(MINIFIED_JSON); - await page.getByRole("button", { name: "Create exchange" }).click(); - await expect(page).toHaveURL(/\/exchanges\?ids=/); - - const row = page.getByRole("row").nth(1); - await expect(row).toBeVisible({ timeout: 15000 }); - await row.locator("td").last().click(); - // The drawer opens on the furthest stage with a document, and whether mapping has - // finished by now is a race. Raw is a promise about what arrived, so ask for that. - await page.getByTitle("Show the Input document").click(); - - // Formatted is the default, and it parsed, so it is coloured. - const pane = page.locator(".doc-hl-dark"); - await expect(pane).toContainText('"customer"', { timeout: 15000 }); - await expect(pane.locator(".hljs-attr").first()).toBeVisible(); - - await page.getByRole("button", { name: "Raw" }).click(); - - // The same document, one line again, and no token spans anywhere in it. - await expect(pane).toContainText(MINIFIED_JSON); - await expect(pane.locator("span")).toHaveCount(0); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts b/SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts deleted file mode 100644 index 34c75e6f..00000000 --- a/SW.Bitween.Web/ClientApp/e2e/session-outage.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { signInAsAdmin } from "./helpers"; - -/** - * A server that cannot answer is not a server saying "signed out". - * - * `getSession` used to swallow every failure and return null, which the guard reads as - * "not signed in" — so a rate-limited or briefly unreachable backend threw people to - * the sign-in page mid-task, with a perfectly good token still in localStorage. That is - * also what made a whole afternoon of rate-limited test runs look like an auth problem. - */ -test.beforeEach(async ({ page }) => { - await signInAsAdmin(page); -}); - -for (const status of [429, 500, 503]) { - test(`a ${status} from the profile call is an outage, not a sign-out`, async ({ page }) => { - await page.route("**/api/accounts/profile", (route) => - route.fulfill({ status, contentType: "application/json", body: "{}" }), - ); - - await page.goto("subscriptions"); - - await expect(page.getByRole("heading", { name: "Can't reach Bitween" })).toBeVisible({ - timeout: 15000, - }); - // The distinction that matters: still signed in, so nothing asks for a password - // and the token is left where it is. - await expect(page).not.toHaveURL(/\/login/); - expect(await page.evaluate(() => localStorage.getItem("access_token"))).toBeTruthy(); - }); -} - -test("a 401 still signs you out, because that one is the server's answer", async ({ page }) => { - // Both the profile read and the silent refresh behind it, so there is nothing left - // to restore the session with — which is a real, unrecoverable sign-out. - await page.route("**/api/accounts/profile", (route) => route.fulfill({ status: 401, body: "" })); - await page.route("**/api/accounts/login", (route) => route.fulfill({ status: 401, body: "" })); - - await page.goto("subscriptions"); - - await expect(page).toHaveURL(/\/login/, { timeout: 15000 }); - await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible(); -}); - -test("the outage screen recovers when the server comes back", async ({ page }) => { - let failing = true; - await page.route("**/api/accounts/profile", (route) => - failing ? route.fulfill({ status: 503, contentType: "application/json", body: "{}" }) : route.fallback(), - ); - - await page.goto("subscriptions"); - await expect(page.getByRole("heading", { name: "Can't reach Bitween" })).toBeVisible({ - timeout: 15000, - }); - - failing = false; - await page.getByRole("button", { name: "Try again" }).click(); - - await expect(page.getByRole("button", { name: "Account menu" })).toBeVisible({ timeout: 15000 }); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts index 9179cd43..96dc1780 100644 --- a/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/settings.spec.ts @@ -1,11 +1,10 @@ import { test, expect, type Page } from "@playwright/test"; -import { signInAsAdmin, signOut, startsWith } from "./helpers"; +import { signInAsAdmin, signOut } from "./helpers"; const TEAL = "#0f766e"; /** A second colour, so the sign-in test stands on its own if the one above left residue. */ const INDIGO = "#4338ca"; const DEFAULT_COLOR = "#e3311d"; -const DEFAULT_CRON = "0 * * * * ?"; const brandColorVar = (page: Page) => page.evaluate(() => document.documentElement.style.getPropertyValue("--color-crimson-600").trim()); @@ -25,80 +24,6 @@ test.beforeEach(async ({ page }) => { await signInAsAdmin(page); }); -test("sections come from the backend catalog, with no restart-required rows", async ({ page }) => { - await page.goto("settings"); - - for (const section of [ - "Documents & storage", - "API behavior", - "Single sign-on (Microsoft)", - "Adapters", - "Reliability & jobs", - "Messaging", - "Database", - "Security", - "Brand & theme", - ]) - await expect(sectionLink(page, section)).toBeVisible(); - - // Nothing carries a restart badge: a setting that couldn't take effect immediately is shown - // as an environment value instead of being offered as an edit that needs a restart to land. - await expect(page.getByText("Restart", { exact: true })).toHaveCount(0); -}); - -test("environment settings are shown but not offered as edits", async ({ page }) => { - await page.goto("settings"); - await sectionLink(page, "Database").click(); - - // A read-only row renders its value as text — there's no control carrying its label… - await expect(page.getByText("Use Azure managed identity")).toBeVisible(); - await expect(page.getByText("Off", { exact: true })).toBeVisible(); - await expect( - page.getByRole("textbox", { name: "Use Azure managed identity", exact: true }), - ).toHaveCount(0); - await expect(page.getByRole("checkbox")).toHaveCount(0); - - // …and a presence row reports only whether a value is set, never the value itself. - await expect(page.getByText("Not set", { exact: true })).toBeVisible(); - await expect( - page.getByRole("textbox", { name: "Managed identity client ID", exact: true }), - ).toHaveCount(0); - - // Neither kind can be reset, because neither is stored. - await expect(page.getByRole("button", { name: "Reset to default" })).toHaveCount(0); - await expect(page.getByText("Environment").first()).toBeVisible(); -}); - -test("Microsoft-only sign-in is an editable setting, not an environment value", async ({ page }) => { - await page.goto("settings"); - await sectionLink(page, "Single sign-on (Microsoft)").click(); - - // It applies per request — the Login handler and the config endpoint both read it live — so it - // belongs in the catalog as an edit rather than a read-only environment row. - const toggle = page.getByRole("checkbox", { name: startsWith("Off") }); - await expect(toggle).toBeVisible(); - await expect(toggle).not.toBeChecked(); - await expect(page.getByText("Microsoft sign-in only")).toBeVisible(); -}); - -test("the retry schedule is editable and rejects an invalid cron", async ({ page }) => { - await page.goto("settings"); - await sectionLink(page, "Reliability & jobs").click(); - - const cron = page.getByRole("textbox", { name: "Retry poll schedule", exact: true }); - await expect(cron).toHaveValue(DEFAULT_CRON); - - // The backend validates the expression before storing it, because a bad one would break the - // startup job seeding — so a rejected save leaves the draft dirty rather than silently passing. - await cron.fill("not a cron"); - await cron.blur(); - await page.getByRole("button", { name: "Save changes" }).click(); - await expect(page.getByText(/not a valid cron expression/)).toBeVisible(); - - await page.getByRole("button", { name: "Discard" }).click(); - await expect(cron).toHaveValue(DEFAULT_CRON); -}); - test("brand colour: staged draft previews app-wide, saves, and resets", async ({ page }) => { await openBrandSection(page); const hex = hexInput(page); @@ -133,33 +58,6 @@ test("brand colour: staged draft previews app-wide, saves, and resets", async ({ await expect(hexInput(page)).toHaveValue(DEFAULT_COLOR); }); -test("a secret's value never reaches the browser", async ({ page }) => { - const payloads: string[] = []; - page.on("response", async (res) => { - if (res.url().endsWith("/api/settings")) payloads.push(await res.text()); - }); - - await page.goto("settings"); - await sectionLink(page, "Adapters").click(); - - // The local backend configures a Rebex key, so the row shows as set — masked, with the - // adapter-config "Replace" affordance rather than the value itself. - await expect(page.getByText("••••••••")).toBeVisible(); - await expect(page.getByRole("button", { name: "Replace" })).toBeVisible(); - - expect(payloads.length).toBeGreaterThan(0); - const rebex = JSON.parse(payloads[0]).find( - (r: { key: string }) => r.key === "Bitween.RebexLicenseKey", - ); - expect(rebex.secret).toBe(true); - expect(rebex.value).toBeNull(); - expect(rebex.defaultValue).toBe(""); - expect(rebex.hasValue).toBe(true); - // Editable because this instance has an encryption key configured; without one the row comes - // back read-only instead. - expect(rebex.editable).toBe(true); -}); - test("the sign-in page brands itself before anyone has signed in", async ({ page }) => { await openBrandSection(page); await hexInput(page).fill(INDIGO); diff --git a/SW.Bitween.Web/ClientApp/e2e/sign-out.spec.ts b/SW.Bitween.Web/ClientApp/e2e/sign-out.spec.ts index 60b11b84..76385b4e 100644 --- a/SW.Bitween.Web/ClientApp/e2e/sign-out.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/sign-out.spec.ts @@ -2,14 +2,14 @@ import { expect, test, type Page } from "@playwright/test"; import { ADMIN_EMAIL, ADMIN_PASSWORD } from "./helpers"; /** - * Signing out, and the four ways it used to go wrong. + * Signing out, in the one way that needs a real browser: two tabs. * * The session lives in four places — a row in the database, the HttpOnly refresh * cookie, the Jwt in localStorage, and React's own copy. Only the last one decides - * what you see, and it used to be the one thing a sign-out could fail to clear: - * `signOut` awaited the server first, so any failure threw before the session was - * ended and left the whole app on screen with the Jwt already deleted. Every test - * here is a way that could happen. + * what you see, and each tab has its own. The other ways a sign-out used to leave + * the app on screen are in src/auth/__tests__/SignOut.test.tsx; this one depends on + * the browser delivering a `storage` event from one tab to another, which jsdom + * cannot do. */ const submitCredentials = async (page: Page) => { @@ -32,34 +32,6 @@ const signOut = async (page: Page) => { /** The signed-in shell: present only while React holds a session. */ const shell = (page: Page) => page.getByRole("button", { name: "Account menu" }); -test("the login page appears without waiting for the server", async ({ page }) => { - await signIn(page); - await page.route("**/api/accounts/logout", async (r) => { - await new Promise((res) => setTimeout(res, 3000)); - await r.continue(); - }); - const started = Date.now(); - await signOut(page); - // Held for 3s on purpose: the session ends locally first, so nothing waits on it. - await page.waitForURL(/\/login/, { timeout: 2500 }); - expect(Date.now() - started).toBeLessThan(2500); -}); - -test("a refused sign-out still signs you out", async ({ page }) => { - await signIn(page); - await page.route("**/api/accounts/logout", (r) => r.fulfill({ status: 500, body: "boom" })); - await signOut(page); - await page.waitForURL(/\/login/, { timeout: 5000 }); - expect(await page.evaluate(() => localStorage.getItem("access_token"))).toBeNull(); -}); - -test("an unreachable backend still signs you out", async ({ page }) => { - await signIn(page); - await page.route("**/api/accounts/logout", (r) => r.abort("connectionrefused")); - await signOut(page); - await page.waitForURL(/\/login/, { timeout: 5000 }); -}); - test("signing out in one tab ends the session in the other", async ({ page, context }) => { await signIn(page); const other = await context.newPage(); @@ -73,79 +45,3 @@ test("signing out in one tab ends the session in the other", async ({ page, cont // too — this tab just never used to find out until someone pressed refresh. await other.waitForURL(/\/login/, { timeout: 8000 }); }); - -test("a sign-out that never answers does not block signing back in", async ({ page }) => { - await signIn(page); - await page.route("**/api/accounts/logout", () => { - /* never fulfilled: the request hangs rather than failing */ - }); - await signOut(page); - await page.waitForURL(/\/login/, { timeout: 5000 }); - - // A sign-in cancels the pending sign-out rather than waiting for it. Waiting was - // the obvious guard against the race in the next test, and would have deadlocked - // here for as long as the request hung. - await submitCredentials(page); - await page.waitForURL((u) => !u.pathname.endsWith("/login"), { timeout: 8000 }); - expect(await shell(page).count()).toBe(1); -}); - -test("a slow sign-out response cannot wipe the session that replaced it", async ({ page }) => { - await signIn(page); - // The logout response carries `Clear-Site-Data: "cookies", "storage"`, which the - // browser applies to the whole origin whenever it lands — including over a newer - // sign-in. Cancelling the request means the response never arrives. - await page.route("**/api/accounts/logout", async (r) => { - await new Promise((res) => setTimeout(res, 4000)); - await r.continue(); - }); - await signOut(page); - await page.waitForURL(/\/login/, { timeout: 5000 }); - await submitCredentials(page); - await page.waitForURL((u) => !u.pathname.endsWith("/login"), { timeout: 10000 }); - - await page.waitForTimeout(5000); // outlast the delayed response - expect(page.url()).not.toContain("/login"); - expect(await page.evaluate(() => localStorage.getItem("access_token"))).not.toBeNull(); - await page.goto("partners"); - await page.waitForTimeout(1500); - expect(await shell(page).count()).toBe(1); -}); - -test("a slow session read cannot flash the app back after a sign-out", async ({ page, context }) => { - await signIn(page); - - // Held open so it lands after the sign-out below. It returns 200 — the Jwt went - // out before the sign-out and is still valid — so its result must be discarded on - // arrival rather than trusted, or the app appears again over a dead session. - await page.route("**/api/accounts/profile", async (r) => { - await new Promise((res) => setTimeout(res, 4000)); - await r.continue(); - }); - const reloading = page.reload(); - await page.waitForTimeout(800); - - const other = await context.newPage(); - await other.goto("https://localhost:7155/partners"); - await other.waitForTimeout(1500); - await signOut(other); - await other.waitForURL(/\/login/, { timeout: 8000 }); - - let everSignedIn = false; - for (let i = 0; i < 60; i++) { - if (await shell(page).count()) everSignedIn = true; - await page.waitForTimeout(100); - } - await reloading.catch(() => {}); - expect(everSignedIn).toBe(false); - expect(page.url()).toContain("/login"); -}); - -test("a session that ended server-side does not need a refresh", async ({ page }) => { - await signIn(page); - // Exactly what a sign-out elsewhere leaves behind: no cookie, a useless Jwt. - await page.context().clearCookies(); - await page.evaluate(() => localStorage.setItem("access_token", "dead")); - await page.getByRole("link", { name: /^Partners$/ }).click(); - await page.waitForURL(/\/login/, { timeout: 10000 }); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts b/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts index c7bcfcff..9f8f0120 100644 --- a/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/table-layout.spec.ts @@ -5,11 +5,10 @@ import { signInAsAdmin } from "./helpers"; * The layout contracts the tables have to keep, whatever is in them. * * These are all data-shape problems: a customer with 60-character subscription - * names, ten promoted properties, a 4KB minified payload and 45 subscriptions on - * one information type. None of that exists in a dev database, so every test - * here rewrites the API response on the way past rather than seeding rows — - * nothing is written, and the assertions don't drift with whatever the local - * data happens to be. + * names, a 4KB minified payload and 45 subscriptions on one information type. + * None of that exists in a dev database, so every test here rewrites the API + * response on the way past rather than seeding rows — nothing is written, and + * the assertions don't drift with whatever the local data happens to be. */ const LONG_NAMES = [ @@ -102,64 +101,6 @@ test("long names wrap rather than collapsing into a row of ellipses", async ({ p } }); -test("promoted properties open in a panel, not just a tooltip", async ({ page, context }) => { - await context.grantPermissions(["clipboard-read", "clipboard-write"]); - - await page.route("**/xchanges?**", async (route) => { - const res = await route.fetch(); - let body: any; - try { body = await res.json(); } catch { return route.fulfill({ response: res }); } - // Ten properties, one value too long for a chip, and a null — the value - // shape that used to take the page down on paging. - for (const row of body.result ?? []) - row.promotedProperties = { - "Trace Code": "SHOR020", "Agent Code": null, "First Time": "True", - CreatedBy: "madebydaily.shopify.com", "Order Ref": "SO-2026-0088341-RETURN-LINE-2", - Weight: "2.4kg", Destination: "FR-75011", Service: "EXPRESS", Attempt: "3", Manifest: "M-88214", - }; - await route.fulfill({ response: res, json: body }); - }); - - await page.goto("exchanges"); - const trigger = page.getByRole("button", { name: "Show all 10 promoted properties" }).first(); - await trigger.click(); - - // Every property, in full — including the one too long to have fitted a chip. - await expect(page.getByText("10 promoted properties")).toBeVisible(); - await expect(page.getByText("SO-2026-0088341-RETURN-LINE-2")).toBeVisible(); - - // Opening the panel is not a request to expand the row underneath it. - await expect(page.getByText("EXCHANGE ID")).toHaveCount(0); - - await page.getByRole("button", { name: "Copy all" }).click(); - const copied = await page.evaluate(() => navigator.clipboard.readText()); - expect(copied).toContain("Order Ref=SO-2026-0088341-RETURN-LINE-2"); - expect(copied.split("\n")).toHaveLength(10); -}); - -test("paging the exchanges list survives a null promoted value", async ({ page }) => { - await page.route("**/xchanges?**", async (route) => { - const res = await route.fetch(); - let body: any; - try { body = await res.json(); } catch { return route.fulfill({ response: res }); } - // A promoted path that resolved to nothing arrives as null, not "". - for (const row of body.result ?? []) - row.promotedProperties = { "Agent Code": null, "Trace Code": null, "First Time": "True" }; - await route.fulfill({ response: res, json: body }); - }); - - const crashes: string[] = []; - page.on("pageerror", (e) => crashes.push(e.message)); - - await page.goto("exchanges"); - const next = page.getByRole("button", { name: "Next" }).first(); - if (!(await next.isDisabled())) { - await next.click(); - await expect(page.getByText("Unexpected Application Error")).toHaveCount(0); - } - expect(crashes).toEqual([]); -}); - test("a long payload doesn't stretch the exchanges table", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); const payload = JSON.stringify({ @@ -191,7 +132,7 @@ test("a long payload doesn't stretch the exchanges table", async ({ page }) => { expect(await page.evaluate(() => document.querySelector("table")!.scrollWidth)).toBe(before); }); -test("a panel list pages and filters once it runs long", async ({ page }) => { +test("a long panel list keeps its Type column inside the card", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.route("**/subscriptions?filter=DocumentId*", async (route) => { const res = await route.fetch(); @@ -214,14 +155,8 @@ test("a panel list pages and filters once it runs long", async ({ page }) => { await page.locator("tbody tr").filter({ has: usedBy }).first().locator("td").nth(1).click(); await expect(page).toHaveURL(/\/information-types\/\d+$/); - // Long names in a ~360px panel used to push Type off the right-hand edge. + // Long names in a ~360px panel used to push Type off the right-hand edge. Only a real browser + // lays the panel out; paging and filtering this list are in UsedByPanel.test.tsx. await expect(page.getByRole("columnheader", { name: "Type" }).first()).toBeVisible(); expect(await overflowing(page)).toEqual([]); - - await expect(page.getByText("1–10 of 45")).toBeVisible(); - const box = page.getByPlaceholder("Search 45 subscriptions"); - await box.fill(LONG_NAMES[0].slice(0, 20)); - // Filtering to one page takes the pager away but leaves the box that got you there. - await expect(page.getByText(/of 45$/)).toHaveCount(0); - await expect(box).toBeVisible(); }); diff --git a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts index 7206c6c4..cfd96d0f 100644 --- a/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/team-members.spec.ts @@ -1,7 +1,5 @@ import { test, expect } from "@playwright/test"; import { - ADMIN_EMAIL, - startsWith, FIRST_PASSWORD, ROTATED_PASSWORD, addMember, @@ -35,25 +33,6 @@ test("add a member, they can sign in, then remove them", async ({ page }) => { await expect(page.getByText(email)).toHaveCount(0); }); -test("change which roles a member holds", async ({ page }) => { - const email = await addMember(page, { name: "Role Swap", roles: ["Viewer"] }); - - await openMember(page, email); - const drawer = page.getByRole("dialog", { name: "Member details" }); - await drawer.getByRole("checkbox", { name: startsWith("Viewer") }).uncheck(); - await drawer.getByRole("checkbox", { name: startsWith("Member") }).check(); - await drawer.getByRole("button", { name: "Save roles" }).click(); - await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); - - // Reload rather than trust the optimistic UI — proves the write reached the database. - await page.reload(); - const row = page.getByRole("row", { name: new RegExp(email) }); - await expect(row).toContainText("Member"); - await expect(row).not.toContainText("Viewer"); - - await removeMember(page, email); -}); - test("an administrator resets a member's password", async ({ page }) => { const email = await addMember(page, { name: "Forgot Pass", roles: ["Viewer"] }); @@ -94,90 +73,3 @@ test("the old password stops working after a reset", async ({ page }) => { await signInAsAdmin(page); await removeMember(page, email); }); - -test("disable a member, then re-enable them", async ({ page }) => { - const email = await addMember(page, { name: "On Leave", roles: ["Viewer"] }); - - await openMember(page, email); - const drawer = page.getByRole("dialog", { name: "Member details" }); - await drawer.getByRole("button", { name: "Disable account" }).click(); - await expect(drawer.getByRole("button", { name: "Re-enable account" })).toBeVisible(); - - await page.reload(); - await expect(page.getByRole("row", { name: new RegExp(email) })).toContainText("Disabled"); - - // A disabled account keeps its roles and history but must not be able to sign in. - await signOut(page); - await page.fill("#login-email", email); - await page.fill("#login-password", FIRST_PASSWORD); - await page.getByRole("button", { name: "Sign in" }).click(); - await expect(page).toHaveURL(/\/login$/); - - await signInAsAdmin(page); - await openMember(page, email); - await drawer.getByRole("button", { name: "Re-enable account" }).click(); - await expect(drawer.getByRole("button", { name: "Disable account" })).toBeVisible(); - - await removeMember(page, email); -}); - -test("the last administrator can't be removed or disabled", async ({ page }) => { - // This test only means anything while the seeded admin is the *only* administrator, and it's - // the one test that could strip its own role if the guard didn't fire. So assert the - // precondition rather than assume it, and put the role back if the save somehow goes through. - await page.goto("team/members"); - const admins = page.getByRole("row", { name: /Administrator/ }); - await expect( - admins, - "another account holds Administrator — the guard under test can't fire", - ).toHaveCount(1); - - await openMember(page, ADMIN_EMAIL); - const drawer = page.getByRole("dialog", { name: "Member details" }); - const role = drawer.getByRole("checkbox", { name: startsWith("Administrator") }); - - // Nothing destructive is even offered on your own account. - await expect(drawer.getByRole("button", { name: "Remove from team" })).toHaveCount(0); - await expect(drawer.getByRole("button", { name: "Disable account" })).toHaveCount(0); - - // Dropping the role is offered, but the server refuses it. - await role.uncheck(); - await drawer.getByRole("button", { name: "Save roles" }).click(); - - try { - await expect(drawer.getByText(/only member with the Administrator role/i)).toBeVisible(); - await page.reload(); - await expect(page.getByRole("row", { name: new RegExp(ADMIN_EMAIL) })).toContainText( - "Administrator", - ); - } finally { - // Belt and braces: if the guard let it through, put the role back before failing, so the - // rest of the suite doesn't run against an instance nobody can administer. Read the list - // fresh — the unchecked box in the drawer is a rejected draft, not what the server holds. - await page.goto("team/members"); - const adminRow = page.getByRole("row", { name: new RegExp(ADMIN_EMAIL) }); - if (!((await adminRow.textContent()) ?? "").includes("Administrator")) { - await adminRow.click(); - await drawer.getByRole("checkbox", { name: startsWith("Administrator") }).check(); - await drawer.getByRole("button", { name: "Save roles" }).click(); - await expect(drawer.getByRole("button", { name: "Save roles" })).toHaveCount(0); - } - } -}); - -test("filter and search the member list", async ({ page }) => { - const email = await addMember(page, { name: "Findable Person", roles: ["Viewer"] }); - - await page.getByLabel("Search members").fill("Findable"); - await expect(page.getByRole("row", { name: new RegExp(email) })).toBeVisible(); - await expect(page.getByRole("row", { name: new RegExp(ADMIN_EMAIL) })).toHaveCount(0); - - await page.getByLabel("Search members").fill(""); - await page.getByRole("button", { name: "Disabled", exact: true }).click(); - await expect(page.getByRole("row", { name: new RegExp(email) })).toHaveCount(0); - - await page.getByRole("button", { name: "Active", exact: true }).click(); - await expect(page.getByRole("row", { name: new RegExp(email) })).toBeVisible(); - - await removeMember(page, email); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts b/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts index 75845a52..d9fc5fa5 100644 --- a/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/team-roles.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test"; -import { addMember, createRole, deleteRole, removeMember, signInAsAdmin } from "./helpers"; +import { createRole, deleteRole, signInAsAdmin } from "./helpers"; test.beforeEach(async ({ page }) => { await signInAsAdmin(page); @@ -33,111 +33,3 @@ test("create a custom role, then delete it", async ({ page }) => { await deleteRole(page, name); await expect(page.getByText(name)).toHaveCount(0); }); - -test("granting an action implies View, and clearing View clears the row", async ({ page }) => { - await page.goto("team/roles/new"); - - // An action you can't view is an action you can't reach, so View comes along. - const view = page.getByRole("checkbox", { name: "Partners: View", exact: true }); - const edit = page.getByRole("checkbox", { name: "Partners: Edit", exact: true }); - const del = page.getByRole("checkbox", { name: "Partners: Delete", exact: true }); - - // Only the count granted is asserted, not the catalog size — that changes whenever a - // permission is added or dropped, and it isn't what this test is about. - const granted = (n: number) => new RegExp(`\\b${n}/\\d+ permissions granted`); - - await edit.check(); - await expect(view).toBeChecked(); - await expect(page.getByText(granted(2))).toBeVisible(); - - await del.check(); - await expect(page.getByText(granted(3))).toBeVisible(); - - // Removing View takes the whole area with it. - await view.uncheck(); - await expect(edit).not.toBeChecked(); - await expect(del).not.toBeChecked(); - await expect(page.getByText(granted(0))).toBeVisible(); -}); - -test("the access preview shows what the role would see", async ({ page }) => { - await page.goto("team/roles/new"); - - await expect(page.getByText("No pages yet — grant a View permission.")).toBeVisible(); - - await page.getByRole("checkbox", { name: "Partners: View", exact: true }).check(); - const preview = page.locator("section, div").filter({ hasText: "What members with this role see" }).last(); - await expect(preview.getByText("Partners")).toBeVisible(); - await expect(preview.getByText("Exchanges")).toHaveCount(0); - - await page.getByRole("checkbox", { name: "Exchanges: View", exact: true }).check(); - await expect(preview.getByText("Exchanges")).toBeVisible(); -}); - -test("built-in roles are read-only", async ({ page }) => { - await page.goto("team/roles"); - await page.getByRole("link", { name: /Administrator/ }).click(); - - await expect(page.getByText("This role is built in")).toBeVisible(); - await expect(page.getByRole("checkbox", { name: "Partners: View", exact: true })).toBeDisabled(); - await expect(page.getByRole("button", { name: "Delete role" })).toHaveCount(0); - // Name and description aren't even rendered for a built-in. - await expect(page.locator("#role-name")).toHaveCount(0); -}); - -test("a role in use can't be deleted", async ({ page }) => { - const roleName = `PW InUse ${Date.now()}`; - await createRole(page, { name: roleName, permissions: [{ area: "Exchanges", action: "View" }] }); - const email = await addMember(page, { name: "Role Holder", roles: [roleName] }); - - await page.goto("team/roles"); - await expect(page.getByRole("link", { name: new RegExp(roleName) })).toContainText("1 member"); - - await page.getByRole("link", { name: new RegExp(roleName) }).click(); - await page.getByRole("button", { name: "Delete role" }).click(); - await page.getByRole("button", { name: "Delete role" }).last().click(); - await expect(page.getByText(/is still assigned to 1 member/i)).toBeVisible(); - - // Free the role up, and the delete goes through. - await removeMember(page, email); - await deleteRole(page, roleName); - await expect(page.getByText(roleName)).toHaveCount(0); -}); - -test("two roles can't share a name", async ({ page }) => { - await page.goto("team/roles/new"); - await page.fill("#role-name", "Administrator"); - await page.fill("#role-desc", "Should be refused."); - await page.getByRole("checkbox", { name: "Exchanges: View", exact: true }).check(); - await page.getByRole("button", { name: "Create role" }).click(); - - await expect(page.getByText(/already exists/i)).toBeVisible(); - await expect(page).toHaveURL(/\/team\/roles\/new$/); -}); - -test("duplicate a role", async ({ page }) => { - const original = `PW Source ${Date.now()}`; - await createRole(page, { - name: original, - permissions: [ - { area: "Partners", action: "View" }, - { area: "Partners", action: "Edit" }, - ], - }); - - await page.getByRole("link", { name: new RegExp(original) }).click(); - await page.getByRole("button", { name: "Duplicate" }).click(); - - await expect(page.locator("#role-name")).toHaveValue(`Copy of ${original}`); - await expect(page.getByRole("checkbox", { name: "Partners: Edit", exact: true })).toBeChecked(); - - const copy = `PW Copy ${Date.now()}`; - await page.fill("#role-name", copy); - await page.getByRole("button", { name: "Create role" }).click(); - await page.waitForURL(/\/team\/roles$/); - - await expect(page.getByRole("link", { name: new RegExp(copy) })).toBeVisible(); - - await deleteRole(page, copy); - await deleteRole(page, original); -}); diff --git a/SW.Bitween.Web/ClientApp/e2e/view-guards.spec.ts b/SW.Bitween.Web/ClientApp/e2e/view-guards.spec.ts deleted file mode 100644 index 7d339a18..00000000 --- a/SW.Bitween.Web/ClientApp/e2e/view-guards.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { - FIRST_PASSWORD, - addMember, - createRole, - deleteRole, - removeMember, - signIn, - signInAsAdmin, - signOut, -} from "./helpers"; - -/** - * Reads are permission-guarded too, which is easy to get wrong in the other direction: a page can - * legitimately need data from an area the viewer has no business browsing. These cover both sides — - * what a narrow role can't read, and the pages it can still open in full. - */ - -async function apiStatus(page: import("@playwright/test").Page, path: string): Promise { - const token = await page.evaluate(() => localStorage.getItem("access_token")); - const res = await page.request.fetch(`https://localhost:7155/api${path}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - return res.status(); -} - -test("a role with one view permission can't read any other area's list", async ({ page }) => { - const roleName = `PW Docs Reader ${Date.now()}`; - await signInAsAdmin(page); - await createRole(page, { - name: roleName, - permissions: [{ area: "Information types", action: "View" }], - }); - const email = await addMember(page, { name: "Docs Reader", roles: [roleName] }); - - await signOut(page); - await signIn(page, email, FIRST_PASSWORD); - - // The one area they hold is readable. - expect(await apiStatus(page, "/documents")).toBe(200); - - // Every other list is refused, not merely hidden in the nav. - for (const path of [ - "/partners", - "/xchanges", - "/subscriptions", - "/notifiers", - "/apigateways", - "/busgateways", - "/retrypolicies", - "/globaladaptervaluessets", - "/workgroups", - "/delayedretries", - "/ops/summary", - ]) - expect(await apiStatus(page, path), `${path} should be refused`).toBe(401); - - await signOut(page); - await signInAsAdmin(page); - await removeMember(page, email); - await deleteRole(page, roleName); -}); - -test("lookup mode stays readable, because pickers across the app depend on it", async ({ page }) => { - const roleName = `PW Lookup Only ${Date.now()}`; - await signInAsAdmin(page); - await createRole(page, { - name: roleName, - permissions: [{ area: "Information types", action: "View" }], - }); - const email = await addMember(page, { name: "Lookup User", roles: [roleName] }); - - await signOut(page); - await signIn(page, email, FIRST_PASSWORD); - - // id/name pairs only — what a picker needs, and not the data the guard protects. - for (const path of [ - "/partners?lookup=true", - "/subscriptions?lookup=true", - "/retrypolicies?lookup=true", - "/accounts?lookup=true", - ]) - expect([200, 206], `${path} should be allowed in lookup mode`).toContain( - await apiStatus(page, path), - ); - - await signOut(page); - await signInAsAdmin(page); - await removeMember(page, email); - await deleteRole(page, roleName); -}); - -test("a page still loads when the area behind its Used by count is refused", async ({ page }) => { - const roleName = `PW No Subscriptions ${Date.now()}`; - await signInAsAdmin(page); - await createRole(page, { - name: roleName, - permissions: [{ area: "Information types", action: "View" }], - }); - const email = await addMember(page, { name: "No Subscriptions", roles: [roleName] }); - - await signOut(page); - await signIn(page, email, FIRST_PASSWORD); - - // The information types list counts how many subscriptions use each type, which needs the - // subscriptions list this role can't read. The count is what's expendable, not the page. - await page.goto("information-types"); - await expect(page.getByText("You don't have access to this page")).toHaveCount(0); - await expect(page.getByRole("table")).toBeVisible(); - await expect(page.getByText(/failed|error/i)).toHaveCount(0); - - await signOut(page); - await signInAsAdmin(page); - await removeMember(page, email); - await deleteRole(page, roleName); -}); diff --git a/SW.Bitween.Web/ClientApp/package.json b/SW.Bitween.Web/ClientApp/package.json index 2b102b97..8a229700 100644 --- a/SW.Bitween.Web/ClientApp/package.json +++ b/SW.Bitween.Web/ClientApp/package.json @@ -35,12 +35,17 @@ }, "devDependencies": { "@playwright/test": "^1.61.1", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@types/d3-dsv": "^3.0.7", "@types/node": "^24.13.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^30.0.1", + "msw": "^2.15.0", "oxlint": "^1.71.0", "typescript": "~6.0.2", "vite": "^8.1.1", diff --git a/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx b/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx new file mode 100644 index 00000000..ad02d2d4 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/__tests__/support/renderApp.tsx @@ -0,0 +1,87 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse, type RequestHandler } from "msw"; +import { RouterProvider, createMemoryRouter } from "react-router"; +import { TOKEN_KEY } from "../../api"; +import type { AppConfig } from "../../api/http/appConfig"; +import { SessionProvider } from "../../auth/SessionContext"; +import { routes } from "../../router"; +import { server } from "./server"; + +/** + * Every permission key there is, read from the C# catalog rather than copied, so an administrator + * in these tests holds exactly what a real one would. + */ +export const ALL_PERMISSIONS = [ + // From the project root: under jsdom, import.meta.url is a page URL rather than a file. + ...readFileSync(resolve(process.cwd(), "../../SW.Bitween.Sdk/Model/Permissions.cs"), "utf8") + .matchAll(/public const string \w+ = "([a-z-]+\.[a-z-]+)"/g), +].map((m) => m[1]); + +/** A path on the API, matched whatever origin the page runs under. */ +export const apiPath = (path: string) => `*/api${path}`; + +export const ADMIN = { id: 9999, email: "admin@test.local", name: "Test Admin" }; + +export interface Signed { + id?: number; + email?: string; + name?: string; + permissions?: string[]; + roles?: { id: number; name: string }[]; +} + +/** The profile the app loads its session from. */ +export const profile = (who: Signed = {}) => + http.get(apiPath("/accounts/profile"), () => + HttpResponse.json({ + id: who.id ?? ADMIN.id, + email: who.email ?? ADMIN.email, + name: who.name ?? ADMIN.name, + role: "Admin", + disabled: false, + createdOn: "2026-01-01T00:00:00Z", + roles: who.roles ?? [{ id: 1, name: "Administrator" }], + permissions: who.permissions ?? ALL_PERMISSIONS, + }), + ); + +/** The pre-sign-in configuration: branding and which sign-in methods exist. */ +export const appConfig = (config: AppConfig = {}) => + http.get(apiPath("/settings/config"), () => HttpResponse.json(config)); + +export interface RenderAppOptions { + /** Who is signed in, or `null` for nobody. Defaults to an administrator. */ + as?: Signed | null; + config?: AppConfig; + /** Whatever else the page asks the API for. */ + handlers?: RequestHandler[]; +} + +/** + * Mounts the whole app — session, router, data layer — at one URL, against the mock network. + * + * The real routes and the real API client run; only the server is fake. So a test here reads like + * the Playwright spec it replaced, minus the backend and the database that made those slow and + * dependent on whatever data happened to be lying around. + */ +export function renderApp(path: string, { as = {}, config = {}, handlers = [] }: RenderAppOptions = {}) { + if (as) localStorage.setItem(TOKEN_KEY, "test-token"); + server.use(...handlers, appConfig(config), ...(as ? [profile(as)] : [])); + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const router = createMemoryRouter(routes, { initialEntries: [path] }); + + render( + + + + + , + ); + + return { user: userEvent.setup(), router, queryClient }; +} diff --git a/SW.Bitween.Web/ClientApp/src/__tests__/support/server.ts b/SW.Bitween.Web/ClientApp/src/__tests__/support/server.ts new file mode 100644 index 00000000..02ff4101 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/__tests__/support/server.ts @@ -0,0 +1,8 @@ +import { setupServer } from "msw/node"; + +/** + * The network every component test runs against. It starts empty and refuses anything it wasn't + * told about, so a page asking for more than its test expected fails loudly rather than rendering + * an error state that happens to satisfy an assertion. + */ +export const server = setupServer(); diff --git a/SW.Bitween.Web/ClientApp/src/__tests__/support/setup.ts b/SW.Bitween.Web/ClientApp/src/__tests__/support/setup.ts new file mode 100644 index 00000000..320a5238 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/__tests__/support/setup.ts @@ -0,0 +1,46 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { resetAppConfig } from "../../api/http/appConfig"; +import { server } from "./server"; + +/** Requests the page made that no handler answered, collected so the test can fail on them. */ +const unanswered: string[] = []; + +beforeAll(() => { + // Printing alone isn't enough: vitest hides a passing test's output, and the page just renders + // its error state — which can satisfy an assertion as easily as the real thing would. + server.listen({ + onUnhandledRequest: (request, print) => { + const url = new URL(request.url); + unanswered.push(`${request.method} ${url.pathname}${url.search}`); + print.error(); + }, + }); + + // The API client asks for "/api/…", which a browser resolves against the page. Node's fetch has + // no page, so do what the browser would. Installed after listen() so it wraps the fetch the mock + // server intercepts, rather than the one underneath it. + const intercepted = globalThis.fetch; + globalThis.fetch = (input, init) => + intercepted( + typeof input === "string" && input.startsWith("/") ? new URL(input, window.location.origin) : input, + init, + ); +}); + +afterEach(() => { + cleanup(); + server.resetHandlers(); + localStorage.clear(); + // Unsaved drafts (the settings page's, for one) live here, and would leak into the next test. + sessionStorage.clear(); + // Fetched once per page load and cached for the life of the module; each test is a new page. + resetAppConfig(); + + // Last, so a failure here still leaves the next test a clean page. + const leaked = unanswered.splice(0); + if (leaked.length) throw new Error(`The page asked for something no handler answers: ${leaked.join(", ")}`); +}); + +afterAll(() => server.close()); diff --git a/SW.Bitween.Web/ClientApp/src/auth/__tests__/SessionOutage.test.tsx b/SW.Bitween.Web/ClientApp/src/auth/__tests__/SessionOutage.test.tsx new file mode 100644 index 00000000..2c24b136 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/__tests__/SessionOutage.test.tsx @@ -0,0 +1,66 @@ +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { TOKEN_KEY } from "../../api"; +import { apiPath, renderApp } from "../../__tests__/support/renderApp"; + +/** + * A server that cannot answer is not a server saying "signed out". + * + * `getSession` used to swallow every failure and return null, which the guard reads as + * "not signed in" — so a rate-limited or briefly unreachable backend threw people to + * the sign-in page mid-task, with a perfectly good token still in localStorage. That is + * also what made a whole afternoon of rate-limited test runs look like an auth problem. + */ +const outage = () => screen.findByRole("heading", { name: "Can't reach Bitween" }); + +/** What the subscriptions page reads once it is let in: nothing configured yet. */ +const empty = () => HttpResponse.json({ result: [], totalCount: 0 }); +const subscriptionsPage = ["/subscriptions", "/documents", "/partners", "/apigateways", "/busgateways"].map( + (path) => http.get(apiPath(path), empty), +); + +describe("a session read the server could not answer", () => { + it.each([429, 500, 503])("treats a %i from the profile call as an outage, not a sign-out", async (status) => { + const { router } = renderApp("/subscriptions", { + handlers: [http.get(apiPath("/accounts/profile"), () => HttpResponse.json({}, { status }))], + }); + + expect(await outage()).toBeVisible(); + // The distinction that matters: still signed in, so nothing asks for a password + // and the token is left where it is. + expect(router.state.location.pathname).toBe("/subscriptions"); + expect(localStorage.getItem(TOKEN_KEY)).toBeTruthy(); + }); + + it("still signs you out on a 401, because that one is the server's answer", async () => { + // Both the profile read and the silent refresh behind it, so there is nothing left + // to restore the session with — which is a real, unrecoverable sign-out. + const { router } = renderApp("/subscriptions", { + handlers: [ + http.get(apiPath("/accounts/profile"), () => new HttpResponse(null, { status: 401 })), + http.post(apiPath("/accounts/login"), () => new HttpResponse(null, { status: 401 })), + ], + }); + + await waitFor(() => expect(router.state.location.pathname).toBe("/login")); + expect(await screen.findByRole("heading", { name: "Sign in" })).toBeVisible(); + }); + + it("recovers from the outage screen when the server comes back", async () => { + let failing = true; + const { user } = renderApp("/subscriptions", { + handlers: [ + // Once it stops failing it falls through to the ordinary profile. + http.get(apiPath("/accounts/profile"), () => (failing ? HttpResponse.json({}, { status: 503 }) : undefined)), + ...subscriptionsPage, + ], + }); + expect(await outage()).toBeVisible(); + + failing = false; + await user.click(screen.getByRole("button", { name: "Try again" })); + + expect(await screen.findByRole("button", { name: "Account menu" })).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/auth/__tests__/SignOut.test.tsx b/SW.Bitween.Web/ClientApp/src/auth/__tests__/SignOut.test.tsx new file mode 100644 index 00000000..2b6337ee --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/auth/__tests__/SignOut.test.tsx @@ -0,0 +1,219 @@ +import { screen, waitFor } from "@testing-library/react"; +import type { UserEvent } from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { TOKEN_KEY } from "../../api"; +import { apiPath, renderApp } from "../../__tests__/support/renderApp"; +import { server } from "../../__tests__/support/server"; + +/** + * Signing out, and the ways it used to go wrong. + * + * The session lives in four places — a row in the database, the HttpOnly refresh + * cookie, the Jwt in localStorage, and React's own copy. Only the last one decides + * what you see, and it used to be the one thing a sign-out could fail to clear: + * `signOut` awaited the server first, so any failure threw before the session was + * ended and left the whole app on screen with the Jwt already deleted. Every test + * here is a way that could happen. + * + * The one that needs two real tabs — a sign-out in one ending the session in the + * other — stays in e2e/sign-out.spec.ts. + */ + +/** Somewhere every account can be, which asks the API for nothing but the permission catalog. */ +const signedIn = () => + renderApp("/profile", { handlers: [http.get(apiPath("/permissions"), () => HttpResponse.json([]))] }); + +const signOut = async (user: UserEvent) => { + await user.click(await screen.findByRole("button", { name: "Account menu" })); + await user.click(screen.getByRole("button", { name: /Sign out/ })); +}; + +const submitCredentials = async (user: UserEvent) => { + await user.type(screen.getByLabelText("Email"), "admin@test.local"); + await user.type(document.querySelector("#login-password")!, "correct-horse"); + await user.click(screen.getByRole("button", { name: "Sign in" })); +}; + +const loginPage = () => screen.findByRole("heading", { name: "Sign in" }); + +/** The signed-in shell: present only while React holds a session. */ +const shell = () => screen.queryByRole("button", { name: "Account menu" }); +const ACCOUNT_MENU = '[aria-label="Account menu"]'; + +/** A sign-in the server accepts. The same endpoint also serves the silent refresh. */ +const acceptSignIn = () => + server.use(http.post(apiPath("/accounts/login"), () => HttpResponse.json({ jwt: "fresh-token" }))); + +/** + * A logout the test answers when it chooses — or never. Records the request, so a test can + * see it went out, and whether it was cancelled while waiting. + */ +function heldLogout(answer: (request: Request) => Response = () => HttpResponse.json({})) { + let release!: () => void; + const released = new Promise((r) => (release = r)); + const state = { request: null as Request | null, landed: false, release }; + server.use( + http.post(apiPath("/accounts/logout"), async ({ request }) => { + state.request = request; + await released; + state.landed = true; + return answer(request); + }), + ); + return state; +} + +describe("signing out", () => { + it("shows the login page without waiting for the server", async () => { + const logout = heldLogout(); + const { user, router } = signedIn(); + + await signOut(user); + + // Held on purpose: the session ends locally first, so nothing waits on it. + expect(await loginPage()).toBeVisible(); + expect(router.state.location.pathname).toBe("/login"); + await waitFor(() => expect(logout.request).not.toBeNull()); + expect(logout.landed).toBe(false); + }); + + it("still signs you out when the server refuses", async () => { + let answered = false; + server.use( + http.post(apiPath("/accounts/logout"), () => { + answered = true; + return new HttpResponse("boom", { status: 500 }); + }), + ); + const { user, router } = signedIn(); + + await signOut(user); + + expect(await loginPage()).toBeVisible(); + await waitFor(() => expect(answered).toBe(true)); + expect(router.state.location.pathname).toBe("/login"); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + }); + + it("still signs you out when the backend is unreachable", async () => { + let attempted = false; + server.use( + http.post(apiPath("/accounts/logout"), () => { + attempted = true; + return HttpResponse.error(); + }), + ); + const { user, router } = signedIn(); + + await signOut(user); + + expect(await loginPage()).toBeVisible(); + await waitFor(() => expect(attempted).toBe(true)); + expect(router.state.location.pathname).toBe("/login"); + }); + + it("does not block signing back in when the sign-out never answers", async () => { + // Never released: the request hangs rather than failing. + const logout = heldLogout(); + const { user } = signedIn(); + await signOut(user); + await loginPage(); + + // A sign-in cancels the pending sign-out rather than waiting for it. Waiting was + // the obvious guard against the race in the next test, and would have deadlocked + // here for as long as the request hung. + acceptSignIn(); + await submitCredentials(user); + + expect(await screen.findByRole("button", { name: "Account menu" })).toBeVisible(); + expect(logout.request?.signal.aborted).toBe(true); + }); + + it("cannot let a slow sign-out response wipe the session that replaced it", async () => { + // The logout response carries `Clear-Site-Data: "cache", "cookies", "storage"`, which + // the browser applies to the whole origin whenever it lands — including over a newer + // sign-in. Cancelling the request means the response never arrives. jsdom ignores the + // header, so the mock does what the browser would, and only if the response reaches it. + const logout = heldLogout((request) => { + if (!request.signal.aborted) localStorage.clear(); + return HttpResponse.json({}, { headers: { "Clear-Site-Data": '"cache", "cookies", "storage"' } }); + }); + const { user, router } = signedIn(); + await signOut(user); + await loginPage(); + acceptSignIn(); + await submitCredentials(user); + await screen.findByRole("button", { name: "Account menu" }); + + logout.release(); // the slow response finally comes back + await waitFor(() => expect(logout.landed).toBe(true)); + + expect(router.state.location.pathname).not.toBe("/login"); + expect(localStorage.getItem(TOKEN_KEY)).toBe("fresh-token"); + expect(shell()).toBeInTheDocument(); + }); + + it("cannot let a slow session read flash the app back after a sign-out", async () => { + // Held open so it lands after the sign-out below. It then falls through to the ordinary + // 200 — the Jwt went out before the sign-out and is still valid — so its result must be + // discarded on arrival rather than trusted, or the app appears again over a dead session. + let profileRequested = false; + let release!: () => void; + const released = new Promise((r) => (release = r)); + const { router } = renderApp("/partners", { + handlers: [ + http.get(apiPath("/accounts/profile"), async () => { + profileRequested = true; + await released; + }), + ], + }); + + // Anything that ever mounts the shell counts, however briefly it stays. + let everSignedIn = false; + const watcher = new MutationObserver((records) => { + for (const node of records.flatMap((r) => [...r.addedNodes])) + if (node instanceof Element && (node.matches(ACCOUNT_MENU) || node.querySelector(ACCOUNT_MENU))) + everSignedIn = true; + }); + watcher.observe(document.body, { childList: true, subtree: true }); + + await waitFor(() => expect(profileRequested).toBe(true)); + // A sign-out in another tab, as this one hears it: the key gone, and a `storage` event. + // Real delivery between two tabs is what the e2e spec's two-tab test covers. + localStorage.removeItem(TOKEN_KEY); + window.dispatchEvent( + new StorageEvent("storage", { key: TOKEN_KEY, oldValue: "test-token", newValue: null, storageArea: localStorage }), + ); + release(); + + // Settled one way or the other, now that the read has landed. + await waitFor(() => expect(shell() ?? screen.queryByRole("heading", { name: "Sign in" })).not.toBeNull()); + watcher.disconnect(); + expect(everSignedIn).toBe(false); + expect(router.state.location.pathname).toBe("/login"); + expect(screen.getByRole("heading", { name: "Sign in" })).toBeVisible(); + }); + + it("does not need a refresh when the session ended server-side", async () => { + const { user } = signedIn(); + await screen.findByRole("button", { name: "Account menu" }); + + // Exactly what a sign-out elsewhere leaves behind: no cookie, a useless Jwt. + localStorage.setItem(TOKEN_KEY, "dead"); + const refused = () => new HttpResponse(null, { status: 401 }); + server.use( + // Everything the partners page reads. + http.get(apiPath("/partners"), refused), + http.get(apiPath("/subscriptions"), refused), + http.get(apiPath("/apigateways"), refused), + http.get(apiPath("/busgateways"), refused), + // No cookie, so the silent refresh behind those 401s is refused too. + http.post(apiPath("/accounts/login"), refused), + ); + await user.click(screen.getByRole("link", { name: /^Partners$/ })); + + expect(await loginPage()).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/csvCounting.test.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/csvCounting.test.tsx new file mode 100644 index 00000000..23d3a522 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/csvCounting.test.tsx @@ -0,0 +1,42 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { fill, mapperBackend, openEditor, withFormats } from "./editorHarness"; + +/** + * Delimited text in the editor. + * + * Whether the tree drawn here and the document the server reads agree is pinned on each side + * rather than end to end: the browser's reading in src/lib/nativeMapper/__tests__/ + * csvSampleTree.test.ts, the server's in C# CsvFormatTests and CsvMappingTests. + */ + +/** Comma, with a header naming its columns. From a real client, unaltered. */ +const MOVEMENTS = + "ShipmentNumber,Reference,TrackingCode,Date,Time,Comment1,Comment2\n" + + "6G61965126082,202493482,SHOR020,2026-09-14,08:29:49,,\n" + + "8G49824171336,202340914,SHOR020,2026-09-14,08:34:34,,\n"; + +describe("delimited text", () => { + it("offers counting inside a list and nowhere else", async () => { + const { user } = await openEditor(mapperBackend()); + await withFormats(user, async () => { + await user.selectOptions(screen.getByLabelText("From format"), "csv"); + await user.selectOptions(screen.getByLabelText("source delimiter"), ","); + const header = screen.getByRole("checkbox", { name: "source header row" }); + if (!(header as HTMLInputElement).checked) await user.click(header); + }); + await fill(user, screen.getByRole("textbox", { name: "Sample source document" }), MOVEMENTS); + + // Outside a list there is nothing to count, so the segment is not there to be chosen. + await user.click(screen.getByRole("button", { name: "Add a field" })); + expect(screen.queryByRole("radio", { name: "Count" })).not.toBeInTheDocument(); + + // Made a list walking the document, which is what every row-per-row mapping is. + await user.click(screen.getByRole("checkbox", { name: /The whole output is a list/ })); + await user.selectOptions(screen.getByRole("combobox", { name: "Source list" }), "p:"); + const root = screen.getByRole("group", { name: "Rules for the list at the root" }); + + await user.click(within(root).getByRole("button", { name: "Add a field to the root list" })); + expect(within(root).getAllByRole("radio", { name: "Count" })).toHaveLength(1); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/editingRules.test.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/editingRules.test.tsx new file mode 100644 index 00000000..fc283165 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/editingRules.test.tsx @@ -0,0 +1,173 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + addList, + addListField, + addPathRule, + expectPreview, + expectSent, + listGroup, + mapperBackend, + openWithSample, + preview, +} from "./editorHarness"; + +/** + * Building a mapping up and taking it apart again in the rendered editor. + * + * The reducer underneath has its own tests (src/lib/nativeMapper/__tests__), and the engine the + * preview runs is the C# suite's. What neither can see is whether the rows on screen do what they + * say — a remove button that removes, a fold that only folds — and whether what the editor then + * asks the server to map is still the mapping on screen. + */ + +const nameBoxes = () => screen.queryAllByRole("textbox", { name: "Output field name" }); + +describe("editing the rules", { timeout: 20000 }, () => { + it("removes a field, a list, and a written entry", async () => { + const backend = mapperBackend({ + preview: ({ rules }) => ({ + outputDocument: + rules.fields.length + rules.lists.length === 0 + ? "{}" + : JSON.stringify({ customer: "Ali", lines: [{ code: "A1" }, { code: "B7" }] }, null, 2), + }), + }); + const { user } = await openWithSample(backend); + + await addPathRule(user, "customer", "order.customer"); + const lines = await addList(user, "lines", "order.line"); + await addListField(user, lines, "lines", "code", "sku"); + await user.click(screen.getByRole("button", { name: "Add an entry to lines" })); + + await expectSent(backend, { + fields: [{ target: ["customer"], from: { kind: "path", path: "order.customer" } }], + lists: [ + { + target: ["lines"], + over: "order.line", + fields: [{ target: ["code"], from: { kind: "path", path: "sku" } }], + fixed: [{ fields: [], lists: [] }], + }, + ], + }); + await expectPreview('"code": "A1"'); + + await user.click(screen.getByRole("button", { name: "Remove entry 1" })); + expect(screen.queryByRole("group", { name: "entry 1" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Remove the rule for code" })); + await user.click(screen.getByRole("button", { name: "Remove the list lines" })); + await user.click(screen.getByRole("button", { name: "Remove the rule for customer" })); + + expect(screen.getByText(/No rules yet\./)).toBeVisible(); + // Removing every rule is a mapping that produces an empty document, not a failure — so it is + // still sent to be mapped, and what comes back is shown. That the engine answers `{}` is + // DocumentMapperTests.EmptyRules_ProduceAnEmptyDocument. + await expectSent(backend, { fields: [], lists: [] }); + await expectPreview("{}"); + expect(preview()).toHaveTextContent(/^\{\}$/); + }); + + it("puts back a removed rule on undo", async () => { + const backend = mapperBackend({ + preview: ({ rules }) => ({ + outputDocument: rules.fields.length ? '{\n "customer": "Ali"\n}' : "{}", + }), + }); + const { user } = await openWithSample(backend); + + await addPathRule(user, "customer", "order.customer"); + await expectSent(backend, { + fields: [{ target: ["customer"], from: { kind: "path", path: "order.customer" } }], + }); + await expectPreview('"customer": "Ali"'); + + await user.click(screen.getByRole("button", { name: "Remove the rule for customer" })); + expect(nameBoxes()).toHaveLength(0); + + await user.click(screen.getByRole("button", { name: "Undo" })); + expect(screen.getByRole("textbox", { name: "Output field name" })).toHaveValue("customer"); + + await user.click(screen.getByRole("button", { name: "Redo" })); + expect(nameBoxes()).toHaveLength(0); + }); + + it("keeps the branches above a match when the output is searched", async () => { + const backend = mapperBackend({ + preview: () => ({ + outputDocument: JSON.stringify( + { billing: { city: "Amman", country: "JO" }, name: "Ali" }, + null, + 2, + ), + }), + }); + const { user } = await openWithSample(backend, { c: "Amman", k: "JO", n: "Ali" }); + + await addPathRule(user, "billing.city", "c"); + await addPathRule(user, "billing.country", "k"); + await addPathRule(user, "name", "n"); + const mapping = { + fields: [ + { target: ["billing", "city"], from: { kind: "path", path: "c" } }, + { target: ["billing", "country"], from: { kind: "path", path: "k" } }, + { target: ["name"], from: { kind: "path", path: "n" } }, + ], + }; + await expectSent(backend, mapping); + const sent = backend.previews.length; + + await user.type(screen.getByRole("textbox", { name: "Search output fields" }), "city"); + + // The match is reachable, which means the object above it survives too. + expect(screen.getByRole("group", { name: "Fields inside billing" })).toBeVisible(); + expect(nameBoxes()).toHaveLength(1); + expect(nameBoxes()[0]).toHaveValue("city"); + + // Searching does not change the mapping — only what is shown of it. Nothing new is sent to + // be mapped, and the whole document, `name` included, is still what the preview shows. + await expectPreview('"name": "Ali"'); + expect(backend.previews).toHaveLength(sent); + expect(backend.lastPreview().rules).toMatchObject(mapping); + + await user.clear(screen.getByRole("textbox", { name: "Search output fields" })); + expect(nameBoxes()).toHaveLength(3); + }); + + it("folds a list away without losing what is inside it", async () => { + const backend = mapperBackend({ + preview: () => ({ + outputDocument: JSON.stringify({ lines: [{ code: "A1" }, { code: "B7" }] }, null, 2), + }), + }); + const { user } = await openWithSample(backend); + + const lines = await addList(user, "lines", "order.line"); + await addListField(user, lines, "lines", "code", "sku"); + const mapping = { + lists: [ + { + target: ["lines"], + over: "order.line", + fields: [{ target: ["code"], from: { kind: "path", path: "sku" } }], + }, + ], + }; + await expectSent(backend, mapping); + await expectPreview('"code": "A1"'); + const sent = backend.previews.length; + + await user.click(screen.getByRole("button", { name: "Collapse the list lines" })); + expect(screen.queryByRole("group", { name: "Rules for the list lines" })).not.toBeInTheDocument(); + // Folded, not removed: the mapping is the one already sent, and it still produces the same + // document. + expect(preview()).toHaveTextContent('"code": "A1"'); + expect(backend.previews).toHaveLength(sent); + + await user.click(screen.getByRole("button", { name: "Expand the list lines" })); + expect( + within(listGroup("lines")).getByRole("textbox", { name: "Output field name" }), + ).toHaveValue("code"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/editorHarness.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/editorHarness.tsx new file mode 100644 index 00000000..db0fe809 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/editorHarness.tsx @@ -0,0 +1,351 @@ +import { cleanup, screen, waitFor, within } from "@testing-library/react"; +import type { UserEvent } from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { expect } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +import type { MappingRules } from "../../../lib/nativeMapper/types"; + +/** + * The mapping editor, opened from a subscription against a mock of everything it asks the API for. + * + * The page reads the subscription (and, through it, the gateways, the recent exchanges and the + * information type), the partners for the "Preview as" picker, and posts the rules to the preview + * endpoint whenever they change. The preview here answers whatever the test tells it to, and keeps + * every request, so a test can say both what the editor asked the server to map and that it shows + * what came back. What the engine does with the rules is the C# suite's business + * (SW.Bitween.UnitTests/NativeMapper), not this one's. + */ + +// The connection lines between the panels watch their own size, which jsdom cannot measure. +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +export const SUBSCRIPTION_ID = 12; + +/** A source document with a value, a number, and a list with one entry to filter out. */ +export const SAMPLE = { + order: { + customer: "Ali", + net: 100, + line: [ + { sku: "A1", qty: 2 }, + { sku: "B7", qty: 0 }, + ], + }, +}; + +/** + * The partner the preview can run as. + * + * Needed for the same reason the e2e suite seeded one: a scheduled job has no partner of its own, + * so a Partner rule has nothing to resolve against unless one is chosen explicitly. + */ +export const PARTNER = { + id: 7, + name: "Mapper Partner", + properties: { WarehouseCode: "WH-7", SenderId: "BITWEEN-JO" }, +}; + +/** What the editor posted to the preview endpoint, with the rules read back out of their string. */ +export interface SentPreview { + rules: MappingRules; + sourceDocument: string; + partnerId: number | null; +} + +/** The preview endpoint's answer, as `RawMappingPreviewResponse` in src/api/http/mappers.ts has it. */ +export interface PreviewAnswer { + outputDocument?: string | null; + ruleErrors?: { target: string; reason: string }[]; + error?: string | null; +} + +const noRows = { result: [], totalCount: 0 }; + +/** What the server labels each target format's output with. */ +const CONTENT_TYPES: Record = { + json: "application/json", + xml: "application/xml", + csv: "text/csv", +}; + +/** + * A mock backend holding one subscription. + * + * Saving writes to it, so a remount afterwards reads back what was saved — the same round trip a + * page reload made against the real server. + */ +export function mapperBackend({ + mapperProperties = {}, + preview = () => ({ outputDocument: "{}" }), +}: { + /** What the subscription is stored with, as the editor's `MappingRules`/`SourceSample` keys. */ + mapperProperties?: Record; + preview?: (sent: SentPreview) => PreviewAnswer; +} = {}) { + const stored = { mapperId: null as string | null, mapperProperties: { ...mapperProperties } }; + const previews: SentPreview[] = []; + const saves: { mapperId: string | null; mapperProperties: Record }[] = []; + + // RawSubscription in src/api/http/subscriptions.ts: a scheduled job with no partner and, until + // something is saved, no mapper — which is what opens the new editor. + const subscription = () => ({ + id: SUBSCRIPTION_ID, + name: "Mapper cases", + documentId: 3, + partnerId: null, + aggregationForId: null, + type: "Receiving", + handlerId: "NativeHttpHandler", + mapperId: stored.mapperId, + receiverId: "NativeHttpReceiver", + dataSourceId: null, + validatorId: null, + inactive: false, + temporary: false, + categoryId: null, + handlerProperties: [{ key: "Url", value: "https://example.com/post" }], + mapperProperties: Object.entries(stored.mapperProperties).map(([key, value]) => ({ key, value })), + receiverProperties: [{ key: "Url", value: "https://example.com/feed" }], + validatorProperties: [], + documentFilter: [], + matchExpression: null, + workGroupId: null, + retryPolicyId: null, + customRetryPolicy: null, + schedules: [], + responseSubscriptionId: null, + responseMessageTypeName: null, + receiveOn: null, + aggregateOn: null, + pausedOn: null, + isRunning: false, + consecutiveFailures: 0, + lastException: null, + }); + + const handlers = [ + http.get(apiPath(`/subscriptions/${SUBSCRIPTION_ID}`), () => HttpResponse.json(subscription())), + http.post(apiPath(`/subscriptions/${SUBSCRIPTION_ID}`), async ({ request }) => { + const body = (await request.json()) as { + mapperId: string | null; + mapperProperties: { key: string; value: string }[]; + }; + const saved = { + mapperId: body.mapperId, + mapperProperties: Object.fromEntries(body.mapperProperties.map((kv) => [kv.key, kv.value])), + }; + saves.push(saved); + stored.mapperId = saved.mapperId; + stored.mapperProperties = saved.mapperProperties; + return new HttpResponse(null, { status: 204 }); + }), + + // What getSubscription gathers alongside the row itself. + http.get(apiPath("/apigateways"), () => HttpResponse.json(noRows)), + http.get(apiPath("/busgateways"), () => HttpResponse.json(noRows)), + http.get(apiPath("/xchanges"), () => HttpResponse.json(noRows)), + http.get(apiPath("/subscriptions"), () => HttpResponse.json(noRows)), + http.get(apiPath("/documents/3"), () => + HttpResponse.json({ + id: 3, + code: "SHIPMENT_ORDER", + name: "Shipment order", + documentFormat: "Json", + busEnabled: false, + busMessageTypeName: null, + duplicateInterval: 0, + disregardsUnfilteredMessages: false, + promotedProperties: [], + usedByCount: 1, + retiredOn: null, + }), + ), + + // The list sends property names only; the values need the partner itself. + http.get(apiPath("/partners"), () => + HttpResponse.json({ + result: [ + { + id: PARTNER.id, + name: PARTNER.name, + subscriptionsCount: 0, + keys: 0, + propertyKeys: Object.keys(PARTNER.properties), + }, + ], + totalCount: 1, + }), + ), + http.get(apiPath(`/partners/${PARTNER.id}`), () => + HttpResponse.json({ + name: PARTNER.name, + apiCredentials: [], + adapterProperties: PARTNER.properties, + secretProperties: [], + }), + ), + + http.post(apiPath("/mappingpreviews"), async ({ request }) => { + const body = (await request.json()) as { + mappingRules: string; + sourceDocument: string; + partnerId: number | null; + }; + const sent: SentPreview = { + rules: JSON.parse(body.mappingRules) as MappingRules, + sourceDocument: body.sourceDocument, + partnerId: body.partnerId ?? null, + }; + previews.push(sent); + const reply = preview(sent); + return HttpResponse.json({ + outputDocument: reply.outputDocument ?? null, + // The server names the type of what it wrote, which follows the format the rules ask for. + contentType: reply.outputDocument ? (CONTENT_TYPES[sent.rules.targetFormat] ?? null) : null, + ruleErrors: reply.ruleErrors ?? [], + error: reply.error ?? null, + }); + }), + ]; + + return { + handlers, + saves, + previews, + /** What the editor last asked to have mapped. */ + lastPreview: () => previews[previews.length - 1], + }; +} + +export type MapperBackend = ReturnType; + +/** Mounts the subscription's mapping editor, without waiting for anything. */ +export const mountEditor = (backend: MapperBackend) => + renderApp(`/subscriptions/${SUBSCRIPTION_ID}/mapper`, { handlers: backend.handlers }); + +/** Opens the subscription's mapping editor and waits for it to finish loading. */ +export async function openEditor(backend: MapperBackend) { + const app = mountEditor(backend); + await screen.findByRole("button", { name: "Save" }, { timeout: 5000 }); + return app; +} + +/** Opens the editor and pastes a source sample into it. */ +export async function openWithSample(backend: MapperBackend, sample: unknown = SAMPLE) { + const app = await openEditor(backend); + await fill( + app.user, + screen.getByRole("textbox", { name: "Sample source document" }), + typeof sample === "string" ? sample : JSON.stringify(sample, null, 2), + ); + return app; +} + +/** + * Replaces a box's text in one change, the way Playwright's fill() did. + * + * Typed a key at a time, every keystroke would be its own step on the undo stack — and the + * keyboard test is precisely about one undo taking back one change. + */ +export async function fill(user: UserEvent, box: HTMLElement, text: string) { + await user.clear(box); + if (text) await user.paste(text); +} + +/** Unmounts the editor and opens it again from what the mock backend now holds, like a reload. */ +export async function reopen(backend: MapperBackend) { + cleanup(); + return openEditor(backend); +} + +const last = (items: T[]): T => items[items.length - 1]; + +/** Adds a field at the top level, leaving it selected and unassigned. */ +export async function addNamedRule(user: UserEvent, name: string) { + await user.click(screen.getByRole("button", { name: "Add a field" })); + await fill(user, last(screen.getAllByRole("textbox", { name: "Output field name" })), name); +} + +/** Adds a field at the top level and points it at a path. */ +export async function addPathRule(user: UserEvent, name: string, path: string) { + await addNamedRule(user, name); + await fill(user, last(screen.getAllByRole("combobox", { name: "Source field" })), path); +} + +/** Adds a field at the top level whose value is a literal. */ +export async function addFixedRule(user: UserEvent, name: string, value: string) { + await addNamedRule(user, name); + await user.click(last(screen.getAllByRole("radio", { name: "Fixed" }))); + await fill(user, last(screen.getAllByRole("textbox", { name: "Fixed value" })), value); +} + +/** Adds a list at the top level over a source path, and returns its rules group. */ +export async function addList(user: UserEvent, name: string, over: string) { + await user.click(screen.getByRole("button", { name: "Add a list" })); + await fill(user, last(screen.getAllByRole("textbox", { name: "Output list name" })), name); + await user.selectOptions(last(screen.getAllByRole("combobox", { name: "Source list" })), `p:${over}`); + return listGroup(name); +} + +/** The rows inside a list, which is where its own add buttons live. */ +export const listGroup = (name: string) => + screen.getByRole("group", { name: `Rules for the list ${name}` }); + +/** Adds a field inside a list, pointed at a path on the entry. */ +export async function addListField(user: UserEvent, list: HTMLElement, addTo: string, name: string, path: string) { + await user.click(within(list).getByRole("button", { name: `Add a field to ${addTo}` })); + await fill(user, last(within(list).getAllByRole("textbox", { name: "Output field name" })), name); + await fill(user, last(within(list).getAllByRole("combobox", { name: "Source field" })), path); +} + +/** Opens a row's detail panel, which is where the transform, type and lookup live. */ +export async function openDetail(user: UserEvent, name: string) { + await user.click(screen.getByRole("button", { name: `Details for ${name}` })); +} + +/** + * Runs `set` with the format panel open, and closes it afterwards. + * + * What the mapping reads and writes sits behind a summary chip rather than in the toolbar row, so + * changing a format means opening the panel first. + */ +export async function withFormats(user: UserEvent, set: () => Promise) { + await user.click(screen.getByRole("button", { name: "What this mapping reads and writes" })); + await screen.findByLabelText("From format"); + await set(); + await user.keyboard("{Escape}"); +} + +/** + * What a suggest box offers, which is a hint and not a limit. + * + * Focused first: only the box being typed in carries a suggestion list, so that a big mapping does + * not put every row's copy of every path into the page at once. + */ +export async function suggestionsFor(user: UserEvent, box: HTMLElement): Promise { + if (document.activeElement !== box) await user.click(box); + const list = document.getElementById(box.getAttribute("list") ?? ""); + return [...(list?.querySelectorAll("option") ?? [])].map((o) => o.value); +} + +/** The mapped document as the preview panel shows it. */ +export const preview = () => document.querySelector("pre"); + +/** + * Waits for the editor to have asked the server to map rules shaped like `rules`. + * + * Matched as `toMatchObject` does, so `rules` names only what the test is about — which is why it + * is not typed as the rules themselves, whose required keys it deliberately leaves out. + */ +export async function expectSent(backend: MapperBackend, rules: object) { + await waitFor(() => expect(backend.lastPreview()?.rules).toMatchObject(rules), { timeout: 3000 }); +} + +/** Waits for the preview panel to show `text` from the server's answer. */ +export async function expectPreview(text: string) { + await waitFor(() => expect(preview()).toHaveTextContent(text), { timeout: 3000 }); +} diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/readableDocuments.test.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/readableDocuments.test.tsx new file mode 100644 index 00000000..2b49c9e0 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/readableDocuments.test.tsx @@ -0,0 +1,193 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + addPathRule, + fill, + mapperBackend, + openEditor, + preview, + withFormats, + type SentPreview, +} from "./editorHarness"; + +/** + * Making a document readable in the mapping editor: laid out over lines, and coloured. + * + * Two halves of one job, split by whether anyone types into the box. A box you type into keeps + * a real textarea and gets a Format button; a pane you only read gets colour. Nothing gets + * both, because colouring text under a caret means either a contenteditable or an overlay that + * has to track the caret exactly. + * + * What `formatDocument` and `colourDocument` produce is pinned in + * src/lib/__tests__/documentPreview.test.ts and documentHighlight.test.ts. These are about the + * editor offering them in the right places. The mapped document is the server's, so it is mocked + * here; reading a laid-out document is the engine's job and is pinned in C#, in + * XmlFormatReadTests (A_prefix_is_not_part_of_the_path, + * A_subtree_put_back_into_no_namespace_reads_like_any_other) and MappingPreviewTests. + * + * ── Laying out a document in a box someone types into ────────────────────── + * + * A button rather than the exchange drawer's Raw/Formatted toggle: that shows a document nobody + * can edit, whereas these boxes hold text belonging to whoever typed it, so reflowing it is an + * action they take. The button is absent when there is nothing to gain, which is + * `formatDocument`'s own answer and the thing most worth pinning — it is what stops it offering + * to mangle a half-typed document. + */ + +/** How a partner actually hands over a sample: one line, no spaces. */ +const MINIFIED_XML = + `` + + `55480501` + + ``; + +const MINIFIED_JSON = `{"order":{"customer":"Ali","line":[{"sku":"A1"}]}}`; + +const sampleBox = () => screen.getByRole("textbox", { name: "Sample source document" }); +const formatButton = () => screen.queryByRole("button", { name: "Format" }); +const textOf = (box: HTMLElement) => (box as HTMLTextAreaElement).value; + +/** Waits for the preview pane to show `text`, which arrives after the editor's debounce. */ +const expectPreview = (text: string) => + waitFor(() => expect(preview()).toHaveTextContent(text, { normalizeWhitespace: false }), { + timeout: 3000, + }); + +describe("laying out a sample", () => { + it("lays out a one-line XML sample, and the tree still reads it", async () => { + const backend = mapperBackend(); + const { user } = await openEditor(backend); + await withFormats(user, () => user.selectOptions(screen.getByLabelText("From format"), "xml")); + + await fill(user, sampleBox(), MINIFIED_XML); + await user.click(formatButton()!); + + // Laid out over lines, with the data untouched. + expect(textOf(sampleBox())).toMatch(/\n {2}/); + expect(textOf(sampleBox())).toContain("55480501"); + + // And it is still the same document as far as the mapping is concerned: the tree offers the + // same path, and a rule reading it sends the laid-out document to be mapped. + expect( + screen.getByRole("button", { name: "Envelope.Body.shipping.headerValue.accountNumber" }), + ).toBeInTheDocument(); + await addPathRule(user, "account", "Envelope.Body.shipping.headerValue.accountNumber"); + + const laidOut = textOf(sampleBox()); + await waitFor(() => expect(backend.lastPreview()?.sourceDocument).toBe(laidOut), { timeout: 3000 }); + expect(backend.lastPreview()!.rules.sourceFormat).toBe("xml"); + }); + + it("offers nothing while there is nothing to lay out", async () => { + const { user } = await openEditor(mapperBackend()); + + // Empty, and half-typed: offering to reflow either one could only mangle it. + expect(formatButton()).not.toBeInTheDocument(); + await fill(user, sampleBox(), `{"order":{"customer":`); + expect(formatButton()).not.toBeInTheDocument(); + + await fill(user, sampleBox(), MINIFIED_JSON); + expect(formatButton()).toBeVisible(); + + // Gone again once the document is already laid out — there is no second press. + await user.click(formatButton()!); + expect(textOf(sampleBox())).toMatch(/\n {2}"order": \{/); + expect(formatButton()).not.toBeInTheDocument(); + }); + + it("formatting changes the layout and nothing else", async () => { + // The invariant that makes the button safe, and the reason it not being undoable is + // tolerable: it inserts whitespace between tokens and touches nothing else, so the document + // afterwards maps to exactly what it mapped to before. The server maps what it is sent, so + // what is checked here is that it is sent the same rules and the same data. + const backend = mapperBackend({ preview: () => ({ outputDocument: `{\n "who": "Ali"\n}` }) }); + const { user } = await openEditor(backend); + + await fill(user, sampleBox(), MINIFIED_JSON); + await addPathRule(user, "who", "order.customer"); + await expectPreview(`"who": "Ali"`); + const before = preview()!.textContent; + const asked = backend.lastPreview()!; + expect(asked.sourceDocument).toBe(MINIFIED_JSON); + + await user.click(formatButton()!); + expect(textOf(sampleBox())).toMatch(/\n {2}"order": \{/); + + // Asked again, for a document that now reads as several lines instead of one… + await waitFor(() => expect(backend.lastPreview()!.sourceDocument).not.toBe(MINIFIED_JSON), { + timeout: 3000, + }); + const after = backend.lastPreview()!; + expect(after.sourceDocument).toMatch(/\n {2}"order": \{/); + + // …with the same rules, and the same data. + expect(after.rules).toEqual(asked.rules); + expect(JSON.parse(after.sourceDocument)).toEqual(JSON.parse(MINIFIED_JSON)); + + // Same output. + await expectPreview(`"who": "Ali"`); + expect(preview()!.textContent).toBe(before); + }); + + it("lays out the sample of the output too", async () => { + const { user } = await openEditor(mapperBackend()); + + await user.click(screen.getByRole("button", { name: "Build from a sample of the output" })); + const target = screen.getByRole("textbox", { name: "Sample output document" }); + await fill(user, target, MINIFIED_JSON); + + await user.click(formatButton()!); + expect(textOf(target)).toMatch(/\n {2}"order": \{/); + }); +}); + +describe("colouring the mapped document", () => { + it("colours it in whichever format it is written", async () => { + // Answers in the format the rules ask for, as the server does. + const answer = (sent: SentPreview) => + sent.rules.targetFormat === "xml" ? "Ali" : `{\n "who": "Ali"\n}`; + const { user } = await openEditor(mapperBackend({ preview: (sent) => ({ outputDocument: answer(sent) }) })); + await fill(user, sampleBox(), MINIFIED_JSON); + await addPathRule(user, "who", "order.customer"); + + await expectPreview(`"who": "Ali"`); + + // The key and the value are separate things, and the pane says so. + expect(preview()!.querySelector(".hljs-attr")).toBeInTheDocument(); + expect(preview()!.querySelector(".hljs-string")).toBeInTheDocument(); + + // Switching the output to XML colours it as XML, because the mapping declares the format + // rather than the pane guessing from the text. (Declared beating sniffed is pinned in + // documentHighlight.test.ts, "prefers a declared format over how the text looks".) + await withFormats(user, () => user.selectOptions(screen.getByLabelText("To format"), "xml")); + const name = screen.getAllByRole("textbox", { name: "Output field name" })[0]; + await user.clear(name); + await user.type(name, "order"); + + await waitFor(() => expect(preview()!.querySelector(".hljs-name")).toBeInTheDocument(), { + timeout: 3000, + }); + }); + + it("shows markup inside a document, and never runs it", async () => { + // The one place in the app that turns a document into HTML. A partner controls the bytes, + // so the guarantee is worth checking through the real page and not only in the unit test + // that pins the escaping. + const markup = ""; + const { user } = await openEditor( + mapperBackend({ preview: () => ({ outputDocument: `{\n "note": "${markup}"\n}` }) }), + ); + await fill(user, sampleBox(), MINIFIED_JSON); + + await user.click(screen.getByRole("button", { name: "Add a field" })); + await user.type(screen.getAllByRole("textbox", { name: "Output field name" }).at(-1)!, "note"); + await user.click(screen.getAllByRole("radio", { name: "Fixed" }).at(-1)!); + await user.type(screen.getAllByRole("textbox", { name: "Fixed value" }).at(-1)!, markup); + + // Visible as characters… + await expectPreview(markup); + + // …and inert: nothing was added to the document, and nothing ran. + expect(preview()!.querySelector("script")).not.toBeInTheDocument(); + expect((window as unknown as { __ran?: number }).__ran).toBeUndefined(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/rowsAndPanels.test.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/rowsAndPanels.test.tsx new file mode 100644 index 00000000..1bb7dced --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/rowsAndPanels.test.tsx @@ -0,0 +1,142 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + addList, + addListField, + addPathRule, + expectPreview, + expectSent, + mapperBackend, + openDetail, + openWithSample, + preview, +} from "./editorHarness"; + +/** + * Reading and reaching into a big mapping: what a click on a row opens, what the source tree + * shows, and the checkboxes that sit inside a clickable row. + */ + +const transform = () => screen.queryByRole("combobox", { name: "Transform" }); + +describe("the rows and the panels", { timeout: 20000 }, () => { + it("shows what is behind a row's chevron when the row is clicked", async () => { + const { user } = await openWithSample(mapperBackend()); + + await addPathRule(user, "total", "order.net"); + + // The transform and the type live behind the chevron, and finding the chevron was the whole + // complaint: the row itself is the obvious thing to click. + expect(transform()).not.toBeInTheDocument(); + + await user.click(screen.getByRole("textbox", { name: "Output field name" })); + expect(transform()).not.toBeInTheDocument(); + + // Clicking the row's own space, rather than a control in it. + await user.click(screen.getAllByText("←")[0]); + expect(transform()).toBeVisible(); + + await user.click(screen.getAllByText("←")[0]); + expect(transform()).not.toBeInTheDocument(); + }); + + it("shows the fields inside a list in the source tree, named as a rule names them", async () => { + const { user } = await openWithSample(mapperBackend()); + + // `sku` sits inside `order.line`, and a rule in a list over that list reads it as `sku`. + // Hiding these meant the only way to see what was in a list was to read the sample somewhere + // else. + expect(screen.getByRole("button", { name: "sku" })).toBeVisible(); + expect(screen.getByRole("button", { name: "qty" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Collapse order.line" })); + expect(screen.queryByRole("button", { name: "sku" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Expand order.line" })); + expect(screen.getByRole("button", { name: "sku" })).toBeVisible(); + }); + + it("ticks a checkbox in a list's settings by its text", async () => { + // Standing in for the engine's filter, which is DocumentMapperTests.Loop_Filter_SkipsItems + // and Loop_EveryFilterOperator. + const backend = mapperBackend({ + preview: ({ rules }) => ({ + outputDocument: JSON.stringify( + { + lines: + rules.lists[0]?.where?.operator === "greaterThan" + ? [{ qty: 2 }] + : [{ qty: 2 }, { qty: 0 }], + }, + null, + 2, + ), + }), + }); + const { user } = await openWithSample(backend); + + const lines = await addList(user, "lines", "order.line"); + await addListField(user, lines, "lines", "qty", "qty"); + await user.click(screen.getByRole("button", { name: "Settings for the list lines" })); + + // Clicking the words, not the box — which is what anyone does, and what a test that checks + // the input directly never exercises. The row click that opens these settings used to swallow + // it: the box ticked and the panel folded away in the same tick, so it looked like the click + // did nothing. + await user.click(screen.getByText("Only some entries")); + + expect(screen.getByRole("textbox", { name: "Filter field" })).toBeVisible(); + await user.type(screen.getByRole("textbox", { name: "Filter field" }), "qty"); + await user.selectOptions(screen.getByRole("combobox", { name: "Filter comparison" }), "greaterThan"); + await user.type(screen.getByRole("textbox", { name: "Filter value" }), "0"); + + await expectSent(backend, { + lists: [ + { + target: ["lines"], + over: "order.line", + where: { field: "qty", operator: "greaterThan", value: "0" }, + fields: [{ target: ["qty"], from: { kind: "path", path: "qty" } }], + }, + ], + }); + // The entry with qty 0 is gone, which is the whole point of the checkbox. + await expectPreview('"qty": 2'); + expect(preview()).not.toHaveTextContent('"qty": 0'); + }); + + it("ticks a checkbox in a rule's detail by its text", async () => { + // Standing in for the engine's table, which is DocumentMapperTests.Lookup_SubstitutesAValue. + const backend = mapperBackend({ + preview: ({ rules }) => ({ + outputDocument: JSON.stringify( + { countryName: rules.fields[0]?.lookup?.table?.JO ?? "JO" }, + null, + 2, + ), + }), + }); + const { user } = await openWithSample(backend, { country: "JO" }); + + await addPathRule(user, "countryName", "country"); + await openDetail(user, "countryName"); + + await user.click(screen.getByText("Substitute values from a table")); + expect(screen.getByRole("button", { name: "Add incoming value" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Add incoming value" })); + await user.type(screen.getByRole("textbox", { name: "Incoming value 1" }), "JO"); + await user.type(screen.getByRole("textbox", { name: "Becomes 1" }), "Jordan"); + + await expectSent(backend, { + fields: [ + { + target: ["countryName"], + from: { kind: "path", path: "country" }, + lookup: { table: { JO: "Jordan" } }, + }, + ], + }); + await expectPreview('"countryName": "Jordan"'); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/storedMappings.test.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/storedMappings.test.tsx new file mode 100644 index 00000000..ff8af750 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/storedMappings.test.tsx @@ -0,0 +1,178 @@ +import { screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { MappingRules } from "../../../lib/nativeMapper/types"; +import { + addFixedRule, + addPathRule, + expectPreview, + expectSent, + mapperBackend, + type MapperBackend, + mountEditor, + openDetail, + openEditor, + openWithSample, + reopen, +} from "./editorHarness"; + +/** + * What the editor does with a mapping that is already stored: refuse the ones it cannot read, show + * the ones it can exactly as they were saved, and save back what it was given. + * + * Reading the stored rules is src/lib/nativeMapper/__tests__/rules.test.ts ("saving and loading", + * "rules saved before lists were renamed"). These are about what that looks like on the page, and + * what a save sends. + */ + +const savedRules = (backend: MapperBackend) => + JSON.parse(backend.saves[backend.saves.length - 1].mapperProperties.MappingRules) as MappingRules; + +const REFUSED = [ + { + what: "are not readable at all", + rules: "{ this is not json", + says: /could not be read/, + }, + { + what: "come from a newer version of Bitween", + rules: JSON.stringify({ version: 99, fields: [], lists: [] }), + says: /version 99/, + }, + { + what: "were saved before lists were renamed", + rules: JSON.stringify({ version: 1, fields: [], loops: [{ over: "x", target: ["y"] }] }), + says: /before lists were renamed/, + }, +]; + +describe("a stored mapping", { timeout: 20000 }, () => { + it.each(REFUSED)("refuses to open rules that $what, rather than starting blank", async ({ rules, says }) => { + const backend = mapperBackend({ mapperProperties: { MappingRules: rules } }); + // Mounted without waiting for Save, which is the thing a refused mapping never shows. + mountEditor(backend); + + // Opening blank and letting someone press Save would replace a working mapping with nothing, + // which is worse than refusing to open. + expect(await screen.findByText(says, {}, { timeout: 5000 })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Back to the subscription" })).toBeVisible(); + }); + + it("still shows a date format the dropdown never offered, and saves it back", async () => { + // The engine formats with any .NET pattern, so a saved mapping can hold one this closed list + // does not offer — set through the API, or offered here under a label that has since changed. + // A select with no matching option shows nothing selected, which reads as "no format chosen". + const backend = mapperBackend({ + mapperProperties: { + MappingRules: JSON.stringify({ + version: 1, + sourceFormat: "json", + targetFormat: "json", + fields: [ + { + target: ["shipped"], + from: { kind: "path", path: "order.date" }, + transform: { fn: "formatDate", format: "d MMMM" }, + }, + ], + lists: [], + }), + }, + }); + const { user } = await openEditor(backend); + await openDetail(user, "shipped"); + + expect(screen.getByRole("combobox", { name: "Format a date — Format" })).toHaveValue("d MMMM"); + + // And saving the mapping for some unrelated reason must not quietly replace it. + await addFixedRule(user, "channel", "web"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Saved")).toBeVisible(); + expect(savedRules(backend).fields).toMatchObject([ + { target: ["shipped"], transform: { fn: "formatDate", format: "d MMMM" } }, + { target: ["channel"], from: { kind: "fixed", value: "web" } }, + ]); + + const reopened = await reopen(backend); + await openDetail(reopened.user, "shipped"); + expect(screen.getByRole("combobox", { name: "Format a date — Format" })).toHaveValue("d MMMM"); + }); + + it("makes a date that could be read two ways say which", async () => { + // What the server answers, standing in for the engine. The engine's side of this — + // refused at year-first, read either way round otherwise — is + // DocumentMapperTests.SourceDateOrder_DecidesHowTheTransformsReadADate and the + // TransformsTests.FormatDate_* cases, whose refusal message this is. + const backend = mapperBackend({ + preview: ({ rules }) => { + const format = rules.fields[0]?.transform?.format; + if (!format) return { outputDocument: '{\n "shipDate": "04.09.2026"\n}' }; + if (!rules.sourceDateOrder || rules.sourceDateOrder === "yearFirst") + return { + ruleErrors: [ + { + target: "shipDate", + reason: + "formatDate could not read '04.09.2026' as a date. If the day or the month comes " + + "first, say so under the source document.", + }, + ], + }; + return { + outputDocument: `{\n "shipDate": "${rules.sourceDateOrder === "dayFirst" ? "2026-09-04" : "2026-04-09"}"\n}`, + }; + }, + }); + + // A real CargoNet shipping date: the 4th of September, French style. + const { user } = await openWithSample(backend, { order: { shippingdate: "04.09.2026" } }); + + await addPathRule(user, "shipDate", "order.shippingdate"); + await openDetail(user, "shipDate"); + await user.selectOptions(screen.getByRole("combobox", { name: "Transform" }), "formatDate"); + + // One control on the row, and it is a closed list: what the date should look like on the way + // out, shown as the date itself rather than as yyyy-MM-dd letters. + const format = screen.getByRole("combobox", { name: "Format a date — Format" }); + const offered = within(format).getAllByRole("option").map((o) => o.textContent); + expect(offered.slice(0, 3)).toEqual(["Format…", "2026-09-04", "04/09/2026"]); + await user.selectOptions(format, "yyyy-MM-dd"); + + // Refused rather than guessed. The invariant parser reads this as the 9th of April perfectly + // happily, which would date a shipment five months out with nothing said. Year-first is the + // default, so it is left off what is sent. + await expectSent(backend, { + fields: [{ target: ["shipDate"], transform: { fn: "formatDate", format: "yyyy-MM-dd" } }], + }); + expect(backend.lastPreview().rules.sourceDateOrder).toBeUndefined(); + expect((await screen.findAllByText(/could not read '04\.09\.2026'/))[0]).toBeVisible(); + + // Answered once for the document, under the sample it describes — a partner writes dates one + // way throughout, so this is not a per-rule question. + const dates = screen.getByRole("combobox", { name: "Dates in the incoming document" }); + expect(within(dates).getAllByRole("option").map((o) => o.textContent)).toEqual([ + "Year first — 2026-09-04", + "Day first — 04.09.2026", + "Month first — 09.04.2026", + ]); + + await user.selectOptions(dates, "dayFirst"); + await expectSent(backend, { sourceDateOrder: "dayFirst" }); + await expectPreview('"shipDate": "2026-09-04"'); + + // And the other way round, from the same characters. + await user.selectOptions(dates, "monthFirst"); + await expectSent(backend, { sourceDateOrder: "monthFirst" }); + await expectPreview('"shipDate": "2026-04-09"'); + + // It is part of the mapping, so it is saved with it and comes back with it. + await user.click(screen.getByRole("button", { name: "Save" })); + expect(await screen.findByText("Saved")).toBeVisible(); + expect(savedRules(backend).sourceDateOrder).toBe("monthFirst"); + + await reopen(backend); + expect(screen.getByRole("combobox", { name: "Dates in the incoming document" })).toHaveValue( + "monthFirst", + ); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/toolbarAndKeys.test.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/toolbarAndKeys.test.tsx new file mode 100644 index 00000000..513f79a6 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/__tests__/toolbarAndKeys.test.tsx @@ -0,0 +1,135 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { MappingRules } from "../../../lib/nativeMapper/types"; +import { + PARTNER, + addNamedRule, + addPathRule, + expectPreview, + expectSent, + fill, + mapperBackend, + openWithSample, + suggestionsFor, +} from "./editorHarness"; + +/** + * The toolbar and the keyboard: hiding the preview, choosing whose values it runs with, and + * undo, redo and save without the mouse. + */ + +const customerRule = { target: ["customer"], from: { kind: "path", path: "order.customer" } }; +const customerPreview = () => ({ outputDocument: '{\n "customer": "Ali"\n}' }); + +describe("the toolbar and the keyboard", { timeout: 20000 }, () => { + it("gives the rules the whole width when the preview is hidden", async () => { + const backend = mapperBackend({ preview: customerPreview }); + const { user } = await openWithSample(backend); + await addPathRule(user, "customer", "order.customer"); + await expectSent(backend, { fields: [customerRule] }); + await expectPreview('"customer": "Ali"'); + + await user.click(screen.getByRole("button", { name: "Hide the preview" })); + expect(screen.queryByText("— what a partner would receive")).not.toBeInTheDocument(); + + // Hidden, not switched off: the rules are untouched and it comes back as it was. + await user.click(screen.getByRole("button", { name: "Show the preview" })); + await expectPreview('"customer": "Ali"'); + expect(backend.lastPreview().rules).toMatchObject({ fields: [customerRule] }); + }); + + it("offers the keys the previewed partner actually has in the partner key box", async () => { + // Standing in for the server resolving the partner's value, which is + // MappingPreviewTests.Partner_values_are_available_when_a_partner_is_named (integration) and + // DocumentMapperTests.PartnerSource_ReadsTheContext. + const backend = mapperBackend({ + preview: ({ rules, partnerId }) => { + const key = rules.fields[0]?.from.kind === "partner" ? rules.fields[0].from.key : ""; + const properties: Record = partnerId === PARTNER.id ? PARTNER.properties : {}; + return { + outputDocument: JSON.stringify({ warehouse: properties[key ?? ""] ?? null }, null, 2), + }; + }, + }); + const { user } = await openWithSample(backend, { order: { customer: "Ali" } }); + + await addNamedRule(user, "warehouse"); + const partnerSegments = screen.getAllByRole("radio", { name: "Partner" }); + await user.click(partnerSegments[partnerSegments.length - 1]); + + const key = screen.getByRole("combobox", { name: "Partner property key" }); + + // With no partner chosen there is nothing to suggest, and the box is still a box: the mapping + // runs against whichever partner the exchange belongs to, not this one. + expect(await suggestionsFor(user, key)).toEqual([]); + + await user.selectOptions( + screen.getByRole("combobox", { name: "Preview as partner" }), + screen.getByRole("option", { name: `${PARTNER.name} · 2 properties` }), + ); + // Fetched for the chosen partner, so the box fills in a moment rather than at once. + await waitFor(async () => + expect(await suggestionsFor(user, key)).toEqual( + expect.arrayContaining(["WarehouseCode", "SenderId"]), + ), + ); + + await fill(user, key, "WarehouseCode"); + await expectSent(backend, { + fields: [{ target: ["warehouse"], from: { kind: "partner", key: "WarehouseCode" } }], + }); + expect(backend.lastPreview().partnerId).toBe(PARTNER.id); + await expectPreview('"warehouse": "WH-7"'); + expect(screen.queryByText("⚠")).not.toBeInTheDocument(); + + // A key that partner does not have is flagged rather than refused. + await fill(user, key, "NotAProperty"); + expect(screen.getByText("⚠")).toBeVisible(); + }); + + it("undoes, redoes and saves from the keyboard", async () => { + const backend = mapperBackend({ preview: customerPreview }); + const { user } = await openWithSample(backend); + + await addPathRule(user, "customer", "order.customer"); + const source = screen.getByRole("combobox", { name: "Source field" }); + expect(source).toHaveValue("order.customer"); + + // Focus is in a box after typing, and Ctrl+Z there belongs to the box. Clicking the panel's + // own space takes it back. + await user.click(screen.getByText("Output", { exact: true })); + + // One step is one change, so this undoes pointing the rule somewhere — not the whole rule, + // which was three changes ago. + await user.keyboard("{Control>}z{/Control}"); + expect(source).toHaveValue(""); + + await user.keyboard("{Control>}y{/Control}"); + expect(source).toHaveValue("order.customer"); + + await user.keyboard("{Control>}z{/Control}"); + await user.keyboard("{Control>}{Shift>}z{/Shift}{/Control}"); + expect(source).toHaveValue("order.customer"); + + await user.keyboard("{Control>}s{/Control}"); + expect(await screen.findByText("Saved")).toBeVisible(); + const saved = backend.saves[backend.saves.length - 1]; + expect(saved.mapperId).toBe("NativeMapper"); + expect(JSON.parse(saved.mapperProperties.MappingRules) as MappingRules).toMatchObject({ + fields: [customerRule], + }); + }); + + it("leaves Ctrl+Z inside a box to the box, rather than undoing the mapping", async () => { + const { user } = await openWithSample(mapperBackend({ preview: customerPreview })); + await addPathRule(user, "customer", "order.customer"); + + await user.click(screen.getByRole("textbox", { name: "Output field name" })); + await user.keyboard("{Control>}z{/Control}"); + + // The rule is still there, still pointed where it was. The old editor took this key in both + // cases, so fixing a mistyped name meant undoing a change somewhere else entirely. + expect(screen.getAllByRole("textbox", { name: "Output field name" })).toHaveLength(1); + expect(screen.getByRole("combobox", { name: "Source field" })).toHaveValue("order.customer"); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/rules.test.ts b/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/rules.test.ts index 681178f6..ed1e54b8 100644 --- a/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/rules.test.ts +++ b/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/rules.test.ts @@ -273,6 +273,17 @@ describe("editing rules", () => { expect(state.rules.root).toBeUndefined(); }); + it("keeps the field rules through a switch to a list-shaped output and back", () => { + // Put aside, not thrown away: ticking the box by mistake must not cost the mapping. + let state = run([ + { type: "ADD_FIELD", listId: null, target: ["customerName"] }, + { type: "SET_ROOT_LIST", enabled: true }, + ]); + state = rulesEditorReducer(state, { type: "SET_ROOT_LIST", enabled: false }); + + expect(state.rules.fields.map((f) => f.target)).toEqual([["customerName"]]); + }); + it("finds a field inside the root list", () => { let state = run([{ type: "SET_ROOT_LIST", enabled: true }]); const rootId = state.rules.root!.id; @@ -826,4 +837,16 @@ describe("delimited-text options off the wire", () => { expect(loaded.sourceCsv).toBeUndefined(); }); + + it("sends the options chosen for the output with the mapping", () => { + // The server writes the file with whatever arrives here, so this is the editor's half of + // a delimiter or a byte-order mark actually reaching the document. + const options = { delimiter: ";", hasHeader: true, byteOrderMark: true }; + const state = run([ + { type: "SET_TARGET_FORMAT", format: "csv" }, + { type: "SET_CSV_OPTIONS", side: "target", options }, + ]); + + expect(toWire(state.rules).targetCsv).toEqual(options); + }); }); diff --git a/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/transformArgs.test.ts b/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/transformArgs.test.ts new file mode 100644 index 00000000..612d2881 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/transformArgs.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { TRANSFORMS } from "../types"; + +/** + * The editor names each transform's arguments, and the engine reads them back by those + * names. Nothing else ties the two together: a rename on either side still type-checks, + * saves, and previews — the argument simply goes unread and the transform does nothing. + * + * So this reads the engine's source rather than a copy of its names, and fails the day + * the two disagree. + */ +const ENGINE = readFileSync( + new URL("../../../../../../SW.Bitween.NativeAdapters/Mapper/Transforms.cs", import.meta.url), + "utf8", +); + +/** The argument names the engine reads for one function, from the code that handles it. */ +function engineArgs(fn: string): string[] { + const special = ENGINE.match(new RegExp(`if \\(rule\\.Fn == "${fn}"\\)\\s*\\{([\\s\\S]*?)\\n\\s*\\}`)); + // Each case ends by reporting success, and labels that share a body (`case "multiply": + // case "add":`) run on into it — so a case is everything up to its `return true`. + const cased = ENGINE.match(new RegExp(`case "${fn}":([\\s\\S]*?)\\breturn true;`)); + const body = special?.[1] ?? cased?.[1]; + if (!body) return []; + + const read = [...body.matchAll(/Arg\w*\(rule, "(\w+)"\)/g)].map((m) => m[1]); + // multiply and add read one operand whose name depends on the function. + const chosen = [...body.matchAll(/\? "(\w+)" : "(\w+)"/g)].flatMap((m) => [m[1], m[2]]); + return [...read, ...chosen]; +} + +describe("transform arguments", () => { + it("are the names the engine reads", () => { + for (const transform of TRANSFORMS) { + const engine = engineArgs(transform.fn); + if (transform.args.length > 0) + expect(engine, `the engine reads no arguments for ${transform.fn}`).not.toHaveLength(0); + for (const arg of transform.args) + expect(engine, `${transform.fn} sends "${arg.name}"`).toContain(arg.name); + } + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/xmlSampleTree.test.ts b/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/xmlSampleTree.test.ts index 1ac78c1f..c6e10abf 100644 --- a/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/xmlSampleTree.test.ts +++ b/SW.Bitween.Web/ClientApp/src/lib/nativeMapper/__tests__/xmlSampleTree.test.ts @@ -247,6 +247,25 @@ describe("building rules from a sample of an XML output", () => { expect(tag?.kind).toBe("list"); }); + it("makes two rules for an element that holds both an attribute and a value", () => { + // `0.940` is one element carrying two things: `@unit` for + // the attribute and `#text` for its own value, the same convention reading uses. + const rules = emptyRules(); + rules.targetFormat = "xml"; + + const tally = scaffoldFromTarget( + rules, + parseSample(`0`, "xml", "target").root, + null, + ); + + expect(tally.problem).toBeNull(); + expect(rules.fields.map((f) => f.target.join("."))).toEqual([ + "order.weight.@unit", + "order.weight.#text", + ]); + }); + it("reads a document that contains an element called parsererror", () => { // A browser reports a parse failure by handing back a document holding its own // complaint, so the name alone cannot be the signal: a partner is entitled to an diff --git a/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/AuditPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/AuditPage.test.tsx new file mode 100644 index 00000000..4e997f5c --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/AuditPage.test.tsx @@ -0,0 +1,56 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +import { auditRow, auditTrail } from "./trail"; + +/** + * The trail page. Which rows a filter returns is `Audit/Search.cs`'s business; what's left here + * is that each filter reaches the query, and what the page does with the answer. + */ +const members = http.get(apiPath("/accounts"), () => HttpResponse.json({ result: [], totalCount: 0 })); + +/** One save that touched two rows — a partner and its key — beside an unrelated one. */ +const ROWS = [ + auditRow({ entityName: "Partner", entityKey: "42", correlationId: "save-a", sequence: 0 }), + auditRow({ entityName: "ApiCredential", entityKey: "42:primary", correlationId: "save-a", sequence: 1 }), + auditRow({ entityName: "WorkGroup", entityKey: "7", correlationId: "save-b" }), +]; + +describe("the audit trail page", () => { + it("filters, groups one save, and clears", async () => { + const trail = auditTrail(ROWS); + const { user, router } = renderApp("/audit", { handlers: [trail.handler, members] }); + + expect(await screen.findByRole("heading", { name: "Audit trail" })).toBeVisible(); + expect(await screen.findByText("WorkGroup")).toBeVisible(); + + // Narrowing to this one row proves the entity filters reach the query, not just the URL. + await act(() => router.navigate("/audit?entityName=Partner&entityKey=42")); + await waitFor(() => expect(screen.queryByText("WorkGroup", { selector: "td *" })).not.toBeInTheDocument()); + expect(trail.asked.at(-1)?.get("entityName")).toBe("Partner"); + expect(trail.asked.at(-1)?.get("entityKey")).toBe("42"); + expect(screen.getByRole("row", { name: /Partner/ })).toBeVisible(); + expect(screen.getByRole("link", { name: "42" })).toHaveAttribute("href", "/partners/42"); + + // "Same save" pivots to the correlation id — every row one SaveChanges wrote. + await user.click(screen.getAllByRole("button", { name: "Same save" })[0]); + expect(router.state.location.search).toMatch(/correlationId=save-a/); + expect(await screen.findByText("Showing one save only")).toBeVisible(); + expect(trail.asked.at(-1)?.get("correlationId")).toBe("save-a"); + + await user.click(screen.getByRole("button", { name: "Clear filters" })); + expect(router.state.location.pathname).toBe("/audit"); + expect(router.state.location.search).toBe(""); + }); + + // Number("bad") is NaN, which used to go out on the wire as offset=NaN. + it.each(["bad", "-5"])("doesn't break on a junk offset of %s in the URL", async (offset) => { + const trail = auditTrail(ROWS); + renderApp(`/audit?offset=${offset}`, { handlers: [trail.handler, members] }); + + expect(await screen.findByRole("heading", { name: "Audit trail" })).toBeVisible(); + expect(await screen.findByText(/Showing 1[–-]/)).toBeVisible(); + expect(trail.asked.map((q) => q.get("offset"))).toEqual(["0"]); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/HistoryCard.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/HistoryCard.test.tsx new file mode 100644 index 00000000..95846099 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/HistoryCard.test.tsx @@ -0,0 +1,251 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse, type JsonBodyType, type RequestHandler } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +import { auditRow, auditTrail } from "./trail"; + +/** + * The History card on every entity page that has one. Its absence without `audit.view` stays in + * e2e/audit-trail.spec.ts, beside the API refusing the same session. + */ + +const empty = { result: [], totalCount: 0 }; +const none = (path: string) => http.get(apiPath(path), () => HttpResponse.json(empty)); +const json = (path: string, body: JsonBodyType) => http.get(apiPath(path), () => HttpResponse.json(body)); + +/** GET /documents/{id}: `RawDocument` in src/api/http/documents.ts. */ +const PURCHASE_ORDER = { + id: 5, + code: "PURCHASE_ORDER", + name: "Purchase order", + documentFormat: "Json", + busEnabled: false, + busMessageTypeName: null, + duplicateInterval: 0, + disregardsUnfilteredMessages: false, + promotedProperties: [], + usedByCount: 0, + retiredOn: null, +}; + +/** What an information type's detail asks for besides the type itself. */ +const informationTypeDetail = [ + json("/documents/5", PURCHASE_ORDER), + none("/subscriptions"), + none("/busgateways"), + none("/xchanges"), + none("/partners"), +]; + +/** The History panel on an entity page. */ +const historyPanel = async () => + (await screen.findByRole("heading", { name: "History", level: 2 })).closest("section")!; + +/** + * Each area opened straight at its page, with just enough of an entity behind it to render. The + * e2e this replaced took the first row of each area from whatever database it ran against and + * skipped the areas that happened to be empty; here every one of them is always reached. + */ +const areas: { + label: string; + path: string; + entityName: string; + entityKey: string; + handlers: RequestHandler[]; +}[] = [ + { + label: "partner", + path: "/partners/11", + entityName: "Partner", + entityKey: "11", + handlers: [ + json("/partners/11", { name: "Northwind", apiCredentials: [], adapterProperties: {}, secretProperties: [] }), + none("/partners"), + none("/apigateways"), + none("/busgateways"), + none("/xchanges"), + none("/subscriptions"), + ], + }, + { + label: "information type", + path: "/information-types/5", + entityName: "Document", + entityKey: "5", + handlers: informationTypeDetail, + }, + { + label: "work group", + path: "/work-groups/12", + entityName: "WorkGroup", + entityKey: "12", + handlers: [ + json("/workgroups", { + result: [{ id: 12, name: "Nightly", busMessageName: "nightly", options: null, processorNodeCount: 0, usedByCount: 0 }], + totalCount: 1, + }), + none("/subscriptions"), + // The group's live queue numbers: a broker with nothing on it. + json("/ops/summary", { + totalConsumers: 0, + unhealthyConsumers: 0, + disconnectedConsumers: 0, + totalQueueDepth: 0, + totalRetryBacklog: 0, + totalDeadLetterBacklog: 0, + totalIncomingRate: 0, + totalAckRate: 0, + lastUpdatedUtc: "2026-09-20T10:00:00Z", + }), + ...["consumers", "retries", "deadletters", "alerts", "unattendedqueues"].map((p) => json(`/ops/${p}`, [])), + ], + }, + { + label: "global value set", + path: "/global-values/shared", + entityName: "GlobalAdapterValuesSet", + entityKey: "shared", + handlers: [ + json("/globaladaptervaluessets/shared", { id: "shared", name: "Shared", values: {}, secretProperties: [] }), + none("/subscriptions"), + ], + }, + { + label: "retry policy", + path: "/retry-policies/13", + entityName: "RetryPolicy", + entityKey: "13", + handlers: [ + json("/retrypolicies/13", { name: "Default", groups: [], alertHandlerId: null, alertHandlerProperties: null }), + none("/subscriptions"), + http.post(apiPath("/retrypolicies/13/usage"), () => HttpResponse.json([])), + ], + }, + { + label: "notifier", + path: "/notifiers/14", + entityName: "Notifier", + entityKey: "14", + handlers: [ + json("/notifiers/14", { + id: 14, + name: "Ops", + inactive: false, + handlerId: null, + handlerProperties: [], + runOnSuccessfulResult: false, + runOnBadResult: false, + runOnFailedResult: true, + runOnSubscriptions: [], + }), + none("/notifications"), + json("/adapters/Catalog", []), + none("/subscriptions"), + ], + }, + { + label: "API gateway", + path: "/api-gateways/15", + entityName: "ApiGateway", + entityKey: "15", + handlers: [ + json("/apigateways/15", { id: 15, name: "Public", urlName: "public", partnersCount: 0, inactive: false, partners: [] }), + none("/apigateways/attachments"), + // What the attachments table resolves each wired subscription's columns from. + ...["/subscriptions", "/documents", "/partners", "/workgroups", "/retrypolicies"].map((p) => none(p)), + ], + }, + { + label: "subscription", + path: "/subscriptions/16", + entityName: "Subscription", + entityKey: "16", + handlers: [ + json("/subscriptions/16", { + id: 16, + name: "Push orders", + documentId: 5, + partnerId: null, + aggregationForId: null, + type: "ApiCall", + handlerId: null, + mapperId: null, + receiverId: null, + dataSourceId: null, + validatorId: null, + inactive: false, + temporary: false, + categoryId: null, + handlerProperties: [], + mapperProperties: [], + receiverProperties: [], + validatorProperties: [], + documentFilter: [], + matchExpression: null, + workGroupId: null, + retryPolicyId: null, + customRetryPolicy: null, + schedules: [], + responseSubscriptionId: null, + responseMessageTypeName: null, + receiveOn: null, + aggregateOn: null, + pausedOn: null, + isRunning: false, + consecutiveFailures: 0, + lastException: null, + }), + none("/apigateways"), + json("/adapters/Catalog", []), + json("/datasources/Providers", []), + none("/workgroups"), + none("/retrypolicies"), + http.post(apiPath("/subscriptions/16/retryusage"), () => HttpResponse.json([])), + ...informationTypeDetail, + ], + }, +]; + +describe("the history card", () => { + it.each(areas)("is on a $label's page, showing that $label's own history", async (area) => { + const trail = auditTrail([auditRow({ entityName: area.entityName, entityKey: area.entityKey })]); + renderApp(area.path, { handlers: [...area.handlers, trail.handler] }); + + const panel = await historyPanel(); + expect(await within(panel).findByRole("row", { name: /Added/ })).toBeVisible(); + // The card is one line per page, so the thing each page can get wrong is which row it asks about. + expect(trail.asked.at(-1)?.get("entityName")).toBe(area.entityName); + expect(trail.asked.at(-1)?.get("entityKey")).toBe(area.entityKey); + }); + + it("is on the settings page, covering the whole area", async () => { + const trail = auditTrail([auditRow({ entityName: "Setting", entityKey: "Bitween.JwtExpiryMinutes" })]); + renderApp("/settings", { + handlers: [ + json("/settings", [ + { + key: "Bitween.JwtExpiryMinutes", + section: "API behavior", + label: "Sign-in session length (minutes)", + description: "", + kind: "number", + defaultValue: "60", + value: "60", + secret: false, + overridden: false, + hasValue: true, + editable: true, + access: "editable", + }, + ]), + trail.handler, + ], + }); + + // Settings has no per-row page, so its card covers the whole area. + const panel = await historyPanel(); + expect(await within(panel).findByRole("row", { name: /Added/ })).toBeVisible(); + expect(trail.asked.at(-1)?.get("entityName")).toBe("Setting"); + expect(trail.asked.at(-1)?.has("entityKey")).toBe(false); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/trail.ts b/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/trail.ts new file mode 100644 index 00000000..7aad454a --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/audit/__tests__/trail.ts @@ -0,0 +1,55 @@ +import { http, HttpResponse } from "msw"; +import { apiPath } from "../../../__tests__/support/renderApp"; + +/** One row of the trail as `GET /audit` sends it (AuditEntryModel). */ +export interface AuditRow { + id: string; + correlationId: string; + sequence: number; + occurredOn: string; + userId: string | null; + userDisplayName: string | null; + entityName: string; + entityKey: string; + state: "Added" | "Modified" | "Deleted"; + changes: Record; +} + +let next = 1; + +export const auditRow = (row: Partial & Pick): AuditRow => ({ + id: `audit-${next++}`, + correlationId: "save-1", + sequence: 0, + occurredOn: "2026-09-20T10:00:00Z", + userId: "9999", + userDisplayName: "Test Admin", + state: "Added", + changes: { Name: { old: null, new: "Something" } }, + ...row, +}); + +/** + * The trail as a tiny server: it applies the same filters `Audit/Search.cs` does, so a page that + * narrows its query sees its list narrow, and records every query it was asked so a test can say + * what went out on the wire. + */ +export function auditTrail(rows: AuditRow[]) { + const asked: URLSearchParams[] = []; + const handler = http.get(apiPath("/audit"), ({ request }) => { + const q = new URL(request.url).searchParams; + asked.push(q); + const matching = rows.filter( + (r) => + (!q.get("entityName") || r.entityName === q.get("entityName")) && + (!q.get("entityKey") || r.entityKey === q.get("entityKey")) && + (!q.get("userId") || r.userId === q.get("userId")) && + (!q.get("correlationId") || r.correlationId === q.get("correlationId")), + ); + const offset = Number(q.get("offset") ?? 0); + const limit = Number(q.get("limit") ?? 20); + return HttpResponse.json({ result: matching.slice(offset, offset + limit), totalCount: matching.length }); + }); + return { handler, asked }; +} + diff --git a/SW.Bitween.Web/ClientApp/src/pages/auth/__tests__/LoginPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/auth/__tests__/LoginPage.test.tsx new file mode 100644 index 00000000..08bdbed0 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/auth/__tests__/LoginPage.test.tsx @@ -0,0 +1,43 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { renderApp } from "../../../__tests__/support/renderApp"; + +/** + * What the sign-in page offers is driven by the anonymous config endpoint, so each case is just + * a different answer from it — which is also why these never touch the real setting: flipping + * `Bitween.DisableEmailPasswordLogin` on a real instance with no Microsoft app locks everyone out. + */ +const passwordField = () => document.querySelector("#login-password"); +const microsoftButton = () => screen.queryByRole("button", { name: "Continue with Microsoft" }); + +describe("the sign-in page", () => { + it("asks for an email and password by default", async () => { + renderApp("/login", { as: null }); + + expect(await screen.findByRole("button", { name: "Sign in" })).toBeVisible(); + expect(passwordField()).toBeInTheDocument(); + }); + + it("hides the password form when sign-in is Microsoft-only, instead of letting it fail", async () => { + renderApp("/login", { + as: null, + config: { disableEmailPasswordLogin: true, msalClientId: "00000000-0000-0000-0000-000000000000" }, + }); + + // The backend rejects email/password outright in this mode, so the form must not be offered. + expect(await screen.findByRole("button", { name: "Continue with Microsoft" })).toBeVisible(); + expect(passwordField()).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Sign in" })).not.toBeInTheDocument(); + // With nothing above it, the divider has nothing to divide. + expect(screen.queryByText("or", { exact: true })).not.toBeInTheDocument(); + }); + + it("explains itself when sign-in is Microsoft-only but no Microsoft app is configured", async () => { + renderApp("/login", { as: null, config: { disableEmailPasswordLogin: true, msalClientId: null } }); + + // Both doors are shut. Saying so beats an empty card that looks like a failed page load. + expect(await screen.findByText(/Microsoft sign-in isn't configured/)).toBeVisible(); + expect(passwordField()).not.toBeInTheDocument(); + expect(microsoftButton()).not.toBeInTheDocument(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx new file mode 100644 index 00000000..21584b18 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/dashboard/__tests__/DashboardPage.test.tsx @@ -0,0 +1,83 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +const none = { result: [], totalCount: 0 }; + +/** A subscription row as GET /subscriptions sends it: `RawSubscription` in src/api/http/subscriptions.ts. */ +const subscription = (i: number, { failures = 0, paused = false } = {}) => ({ + id: 900000 + i, + name: `Health page ${i + 1}`, + documentId: 3, + partnerId: null, + aggregationForId: null, + type: "Receiving", + handlerId: "NativeHttpHandler", + mapperId: null, + receiverId: "NativeHttpReceiver", + dataSourceId: null, + validatorId: null, + inactive: false, + temporary: false, + categoryId: null, + handlerProperties: [], + mapperProperties: [], + receiverProperties: [], + validatorProperties: [], + documentFilter: [], + matchExpression: null, + workGroupId: null, + retryPolicyId: null, + customRetryPolicy: null, + schedules: [], + responseSubscriptionId: null, + responseMessageTypeName: null, + receiveOn: null, + aggregateOn: null, + pausedOn: paused ? "2026-09-24T08:00:00Z" : null, + isRunning: false, + consecutiveFailures: failures, + lastException: failures ? "Request failed with status NotFound" : null, +}); + +/** Everything the dashboard reads, with `subscriptions` as the rows behind the health panel. */ +const dashboard = (subscriptions: ReturnType[]) => [ + http.get(apiPath("/xchanges"), () => HttpResponse.json(none)), + http.get(apiPath("/delayedretries"), () => HttpResponse.json(none)), + http.get(apiPath("/dashboard/retrysummary"), () => + HttpResponse.json({ retriesLast7Days: { finished: 0, succeeded: 0 }, failingChains: [] }), + ), + http.get(apiPath("/ops/alerts"), () => HttpResponse.json([])), + http.get(apiPath("/subscriptions"), () => + HttpResponse.json({ result: subscriptions, totalCount: subscriptions.length }), + ), + // Read alongside the rows to name what each one uses. + http.get(apiPath("/documents"), () => HttpResponse.json(none)), + http.get(apiPath("/partners"), () => HttpResponse.json(none)), +]; + +describe("the dashboard", () => { + it("pages subscription health instead of letting it grow without bound", async () => { + // Fourteen unhealthy subscriptions: eleven failing, then three paused. + const rows = Array.from({ length: 14 }, (_, i) => + subscription(i, i < 11 ? { failures: i + 1 } : { paused: true }), + ); + const { user } = renderApp("/dashboard", { handlers: dashboard(rows) }); + + const heading = await screen.findByRole("heading", { name: "Subscription health" }); + const panel = within(heading.closest("section")!); + + expect(await panel.findByText("1–10 of 14")).toBeVisible(); + expect(panel.getAllByRole("listitem")).toHaveLength(10); + expect(panel.getByText("Health page 1", { exact: true })).toBeVisible(); + + await user.click(panel.getByRole("button", { name: "Next →" })); + + expect(panel.getByText("11–14 of 14")).toBeVisible(); + expect(panel.getAllByRole("listitem")).toHaveLength(4); + expect(panel.getByText("Health page 11", { exact: true })).toBeVisible(); + expect(panel.getAllByText("Paused")).toHaveLength(3); + expect(panel.getByRole("button", { name: "Next →" })).toBeDisabled(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx new file mode 100644 index 00000000..f72c5f45 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx @@ -0,0 +1,253 @@ +import { cleanup, screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * The exchanges list: selecting across a whole filter, narrowing to the newest attempt of each + * chain, and the promoted-property chips each row is named by. + */ + +/** GET /xchanges: `RawXchangeRow` in src/api/http/exchanges.ts. A failed exchange. */ +const exchange = (n: number, promotedProperties: Record | null = null) => ({ + id: `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`, + subscriptionId: 16, + subscriptionName: "Push orders", + documentId: 5, + documentName: "PURCHASE_ORDER", + mapperId: "NativeMapper", + status: false, + exception: "Connection refused", + finishedOn: "2026-09-20T10:00:02Z", + startedOn: "2026-09-20T10:00:00Z", + inputFileName: null, + outputFileName: null, + responseFileName: null, + inputFileSize: 0, + outputFileSize: 0, + responseFileSize: 0, + inputKey: null, + outputKey: null, + responseKey: null, + promotedProperties, + retryFor: null, + aggregationXchangeId: null, + responseBad: null, + correlationId: null, + partnerId: null, + scheduledRetryOn: null, + hasRetry: false, +}); + +const PAGE = 25; + +/** + * The list endpoint, answering `total` matches a page at a time, and keeping every query it was + * asked. `total` may depend on the query, the way a filter changes the count. + */ +function exchanges({ + total, + properties = null, +}: { + total: number | ((q: URLSearchParams) => number); + properties?: Record | null; +}) { + const asked: URLSearchParams[] = []; + const handler = http.get(apiPath("/xchanges"), ({ request }) => { + const q = new URL(request.url).searchParams; + asked.push(q); + const count = typeof total === "number" ? total : total(q); + const page = Number(q.get("page") ?? 0); + const result = Array.from({ length: Math.max(0, Math.min(PAGE, count - page * PAGE)) }, (_, i) => + exchange(page * PAGE + i, properties), + ); + return HttpResponse.json({ result, totalCount: count }); + }); + return { handler, asked }; +} + +/** The pickers above the list. Nothing in them matters here. */ +const filterOptions = ["/partners", "/subscriptions", "/documents"].map((p) => + http.get(apiPath(p), () => HttpResponse.json({ result: [], totalCount: 0 })), +); + +const rowCheckboxes = () => screen.getAllByRole("checkbox", { name: /^Select (?!all\b)/ }); + +describe("the exchanges list", () => { + /** + * A selection has to be able to mean "everything this filter matches", or a 200-exchange + * recovery is 8 pages of ticking boxes. Stops at the confirm — what it says is the point, and + * running it would retry the whole filter. + */ + describe("select all matching", () => { + /** Ticks the whole page, takes up the offer to go further, then opens the confirm. */ + async function selectAllMatching(user: ReturnType["user"]) { + await user.click(await screen.findByRole("checkbox", { name: "Select all on this page" })); + await user.click(screen.getByRole("button", { name: "Select all 60 matching this filter" })); + } + + it("covers the whole filter, and the confirm says what will run", async () => { + const list = exchanges({ total: 60 }); + const previews: unknown[] = []; + const { user } = renderApp("/exchanges?status=failed", { + handlers: [ + list.handler, + ...filterOptions, + http.post(apiPath("/xchanges/bulkretrypreview"), async ({ request }) => { + previews.push(await request.json()); + return HttpResponse.json({ + selected: 59, + willRetry: 59, + limit: 500, + overLimit: false, + substituted: [], + skipped: [], + properties: {}, + }); + }), + ], + }); + + await selectAllMatching(user); + expect(screen.getByText(/everything this filter matches/)).toBeVisible(); + expect(screen.getByText("60")).toBeVisible(); + + // Unticking a row in this mode records an exclusion rather than dropping out of it. + await user.click(rowCheckboxes()[0]); + expect(screen.getByText(/1 unticked/)).toBeVisible(); + expect(screen.getByText("59")).toBeVisible(); + expect(screen.getByText(/everything this filter matches/)).toBeVisible(); + + // The confirm describes the selection the server resolved, not the rows on screen. + await user.click(screen.getByRole("button", { name: "Retry selected…" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("Retry 59 exchanges?")).toBeVisible(); + expect(await within(dialog).findByText(/^59 exchanges will run again/)).toBeVisible(); + // What it asked about is the filter itself, minus the row unticked — not 24 ids off one page. + expect(previews).toEqual([ + { filter: "filter=StatusFilter%3A1%3A3", excludeIds: [exchange(0).id], reason: "Bulk retry", reset: false }, + ]); + + // Nothing is retried from here: the retry endpoint has no handler, so asking it would fail. + await user.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it.each([ + { + answer: "past the cap, it refuses", + plan: { selected: 60, willRetry: 0, limit: 50, overLimit: true }, + says: /more than the 50 a single retry will carry out/, + }, + { + answer: "with nothing retryable, it says so", + plan: { selected: 60, willRetry: 0, limit: 500, overLimit: false }, + says: /^Nothing here can be retried\.$/, + }, + ])("$answer, and offers only Close", async ({ plan, says }) => { + const { user } = renderApp("/exchanges?status=failed", { + handlers: [ + exchanges({ total: 60 }).handler, + ...filterOptions, + http.post(apiPath("/xchanges/bulkretrypreview"), () => + HttpResponse.json({ ...plan, substituted: [], skipped: [], properties: {} }), + ), + ], + }); + + await selectAllMatching(user); + await user.click(screen.getByRole("button", { name: "Retry selected…" })); + const dialog = await screen.findByRole("dialog"); + expect(await within(dialog).findByText(says)).toBeVisible(); + expect(within(dialog).queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + + // By text, not accessible name: the dialog's own × is also called "Close". + await user.click(within(dialog).getByText("Close", { selector: "button" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + /** + * A chain is one piece of work however many attempts it took, so the list needs to be able to + * show the newest attempt of each — otherwise a chain retried nine times fills nine rows, none + * of which is the current state of anything. + * + * Which rows that leaves is the server's: Exchange_search_can_return_only_the_newest_attempt_of_each_chain + * in RetryChainTests. What's left here is the toggle, and what it asks for. + */ + it("can show only the newest attempt of each chain", async () => { + const latest = (q: URLSearchParams) => q.getAll("filter").includes("LatestOnly:1:true"); + const list = exchanges({ total: (q) => (latest(q) ? 30 : 40) }); + const { user, router } = renderApp("/exchanges?status=failed", { handlers: [list.handler, ...filterOptions] }); + + expect(await screen.findByText(/Showing 1–25 of 40/)).toBeVisible(); + const pill = screen.getByRole("button", { name: "Latest attempt only" }); + expect(pill).toHaveAttribute("aria-pressed", "false"); + expect(latest(list.asked.at(-1)!)).toBe(false); + + await user.click(pill); + expect(router.state.location.search).toMatch(/latest=1/); + expect(pill).toHaveAttribute("aria-pressed", "true"); + expect(await screen.findByText(/Showing 1–25 of 30/)).toBeVisible(); + // Narrowing whatever the status picked, not replacing it. + expect(list.asked.at(-1)!.getAll("filter")).toEqual(["StatusFilter:1:3", "LatestOnly:1:true"]); + + // And it survives a reload, since it lives in the URL like every other filter. + const { pathname, search } = router.state.location; + cleanup(); + list.asked.length = 0; + renderApp(pathname + search, { handlers: [list.handler, ...filterOptions] }); + expect(await screen.findByRole("button", { name: "Latest attempt only" })).toHaveAttribute("aria-pressed", "true"); + expect(await screen.findByText(/Showing 1–25 of 30/)).toBeVisible(); + expect(latest(list.asked[0])).toBe(true); + }); + + it("opens promoted properties in a panel, not just a tooltip", async () => { + // Ten properties, one value too long for a chip, and a null — the value shape that used to + // take the page down on paging. + const properties = { + "Trace Code": "SHOR020", + "Agent Code": null, + "First Time": "True", + CreatedBy: "madebydaily.shopify.com", + "Order Ref": "SO-2026-0088341-RETURN-LINE-2", + Weight: "2.4kg", + Destination: "FR-75011", + Service: "EXPRESS", + Attempt: "3", + Manifest: "M-88214", + }; + const { user } = renderApp("/exchanges", { + handlers: [exchanges({ total: 3, properties }).handler, ...filterOptions], + }); + + await user.click((await screen.findAllByRole("button", { name: "Show all 10 promoted properties" }))[0]); + + // Every property, in full — including the one too long to have fitted a chip. + expect(screen.getByText("10 promoted properties")).toBeVisible(); + expect(screen.getByText("SO-2026-0088341-RETURN-LINE-2")).toBeVisible(); + + // Opening the panel is not a request to expand the row underneath it. + expect(screen.queryByText("Exchange id")).not.toBeInTheDocument(); + + // user-event stands in a clipboard for the page; this reads back what the page wrote to it. + await user.click(screen.getByRole("button", { name: "Copy all" })); + const copied = await navigator.clipboard.readText(); + expect(copied).toContain("Order Ref=SO-2026-0088341-RETURN-LINE-2"); + expect(copied.split("\n")).toHaveLength(10); + }); + + it("pages through rows whose promoted values are null", async () => { + // A promoted path that resolved to nothing arrives as null, not "". + const list = exchanges({ total: 40, properties: { "Agent Code": null, "Trace Code": null, "First Time": "True" } }); + const { user } = renderApp("/exchanges", { handlers: [list.handler, ...filterOptions] }); + + expect(await screen.findByText(/Showing 1–25 of 40/)).toBeVisible(); + await user.click(screen.getAllByRole("button", { name: "Next" })[0]); + + expect(await screen.findByText(/Showing 26–40 of 40/)).toBeVisible(); + expect(list.asked.at(-1)?.get("page")).toBe("1"); + expect(screen.getAllByText("True").length).toBeGreaterThan(0); + expect(screen.queryByText("Unexpected Application Error")).not.toBeInTheDocument(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/rawDocument.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/rawDocument.test.tsx new file mode 100644 index 00000000..7f8fb553 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/rawDocument.test.tsx @@ -0,0 +1,98 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * The exchange drawer's Raw/Formatted toggle. + * + * Laying a payload out and colouring it are pinned in src/lib/__tests__/documentPreview.test.ts + * and documentHighlight.test.ts; this is about the drawer asking for neither once Raw is on. + */ + +const EXCHANGE_ID = "3f2a9c1e-0000-4000-8000-000000000001"; + +/** One line, no spaces: how a partner sends it. */ +const MINIFIED_JSON = `{"order":{"customer":"Ali","line":[{"sku":"A1"}]}}`; + +/** What the drawer fetches per stage, keyed by the row's `*Key`. */ +const DOCUMENTS: Record = { + "in-1": MINIFIED_JSON, + "out-1": `{"who":"Ali"}`, +}; + +/** GET /xchanges: `RawXchangeRow` in src/api/http/exchanges.ts. Received and mapped, not yet handled. */ +const EXCHANGE = { + id: EXCHANGE_ID, + subscriptionId: 12, + subscriptionName: "Orders in", + documentId: 3, + documentName: "SHIPMENT_ORDER", + mapperId: "NativeMapper", + status: null, + exception: null, + finishedOn: null, + startedOn: "2026-09-24T08:00:00Z", + inputFileName: "input.json", + outputFileName: "mapped.json", + responseFileName: null, + inputFileSize: MINIFIED_JSON.length, + outputFileSize: 13, + responseFileSize: 0, + inputKey: "in-1", + outputKey: "out-1", + responseKey: null, + promotedProperties: {}, + retryFor: null, + aggregationXchangeId: null, + responseBad: null, + correlationId: null, + partnerId: null, + scheduledRetryOn: null, + hasRetry: false, +}; + +const NONE = { result: [], totalCount: 0 }; + +function openExchanges(documents = DOCUMENTS) { + return renderApp(`/exchanges?ids=${EXCHANGE_ID}`, { + handlers: [ + http.get(apiPath("/xchanges"), () => HttpResponse.json({ result: [EXCHANGE], totalCount: 1 })), + http.get(apiPath("/bitweendocs"), ({ request }) => { + const key = new URL(request.url).searchParams.get("documentKey")!; + return HttpResponse.json({ key, data: documents[key] }); + }), + // The filters' options, none of which this is about. + http.get(apiPath("/subscriptions"), () => HttpResponse.json(NONE)), + http.get(apiPath("/partners"), () => HttpResponse.json(NONE)), + http.get(apiPath("/documents"), () => HttpResponse.json(NONE)), + ], + }); +} + +describe("an exchange's document", () => { + it("shows the bytes as they arrived, uncoloured, under Raw", async () => { + // The Raw toggle's whole promise is that nothing has been done to the document. Colour is a + // claim about its structure, and the pane was making that claim on both sides of the + // toggle — including for a payload that never parsed, where the parts a grammar still + // recognises would come out looking fine. + const { user } = openExchanges(); + + const row = (await screen.findAllByRole("row"))[1]; + await user.click(within(row).getAllByRole("cell").at(-1)!); + // The drawer opens on the furthest stage with a document, which here is the mapped one. + // Raw is a promise about what arrived, so ask for that. + await user.click(screen.getByTitle("Show the Input document")); + + // Formatted is the default, and it parsed, so it is coloured. + const pane = () => document.querySelector(".doc-hl-dark")!; + await waitFor(() => expect(pane()).toHaveTextContent('"customer"')); + expect(pane().querySelector(".hljs-attr")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Raw" })); + + // The same document, one line again, and no token spans anywhere in it. + expect(pane()).toHaveTextContent(MINIFIED_JSON, { normalizeWhitespace: false }); + expect(pane().querySelectorAll("span")).toHaveLength(0); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/__tests__/UsedByPanel.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/__tests__/UsedByPanel.test.tsx new file mode 100644 index 00000000..868c3c78 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/__tests__/UsedByPanel.test.tsx @@ -0,0 +1,74 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * A panel list once it runs long: an information type carried by 45 subscriptions. Whether the + * list still fits its ~360px panel is layout, which only a real browser can measure, so that half + * stays in e2e/table-layout.spec.ts. + */ +const LONG_NAMES = [ + "Customer Aggregation Trace Out Manifest - Sodexi Cassini EDI", + "Agent Tracing - Colissimo EDI Daily Reconciliation", + "Customer_Aggregation_Scan_Out_CUSTOMS_Chronopost_Returns", +]; + +/** GET /documents/{id}: `RawDocument` in src/api/http/documents.ts. */ +const PURCHASE_ORDER = { + id: 5, + code: "PURCHASE_ORDER", + name: "Purchase order", + documentFormat: "Json", + busEnabled: false, + busMessageTypeName: null, + duplicateInterval: 0, + disregardsUnfilteredMessages: false, + promotedProperties: [], + usedByCount: 45, + retiredOn: null, +}; + +const empty = { result: [], totalCount: 0 }; + +describe("an information type's Used by panel", () => { + it("pages and filters once it runs long", async () => { + const { user } = renderApp("/information-types/5", { + handlers: [ + http.get(apiPath("/documents/5"), () => HttpResponse.json(PURCHASE_ORDER)), + // The subscriptions carrying the type — `RawSubscriptionRef`. + http.get(apiPath("/subscriptions"), () => + HttpResponse.json({ + result: Array.from({ length: 45 }, (_, i) => ({ + id: 900000 + i, + name: `${LONG_NAMES[i % LONG_NAMES.length]} ${i}`, + type: "ApiCall", + })), + totalCount: 45, + }), + ), + ...["/busgateways", "/xchanges", "/partners", "/audit"].map((p) => + http.get(apiPath(p), () => HttpResponse.json(empty)), + ), + ], + }); + + const panel = (await screen.findByRole("heading", { name: "Used by" })).closest("section")!; + expect(within(panel).getByRole("columnheader", { name: "Type" })).toBeInTheDocument(); + expect(within(panel).getByText("1–10 of 45")).toBeVisible(); + expect(within(panel).getAllByRole("link")).toHaveLength(10); + + // The pager counts what the search matched, not the whole list: 15 of the 45 share this name. + const box = within(panel).getByPlaceholderText("Search 45 subscriptions"); + await user.type(box, LONG_NAMES[0].slice(0, 20)); + expect(within(panel).getByText("1–10 of 15")).toBeVisible(); + expect(within(panel).queryByText(/of 45$/)).not.toBeInTheDocument(); + + // Filtering to one page takes the pager away but leaves the box that got you there. + await user.type(box, " Trace Out Manifest - Sodexi Cassini EDI 3"); + expect(within(panel).getAllByRole("link")).toHaveLength(5); // 3, 30, 33, 36, 39 + expect(within(panel).queryByText(/\d+–\d+ of \d+/)).not.toBeInTheDocument(); + expect(within(panel).queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); + expect(box).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/information-types/__tests__/ViewGuards.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/information-types/__tests__/ViewGuards.test.tsx new file mode 100644 index 00000000..6fd5c256 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/information-types/__tests__/ViewGuards.test.tsx @@ -0,0 +1,64 @@ +import { screen } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * Reads are permission-guarded too, which is easy to get wrong in the other direction: a page can + * legitimately need data from an area the viewer has no business browsing. That the server refuses + * the read is ReadGuardTests.One_view_permission_reads_its_own_list_and_no_other; this is the page + * that has to survive the refusal. + */ +const DOCS_ONLY = { + id: 77, + email: "no.subscriptions@test.local", + name: "No Subscriptions", + roles: [{ id: 12, name: "No Subscriptions" }], + permissions: ["documents.view"], +}; + +/** An information type as the list returns it (DocumentRow). */ +const PURCHASE_ORDER = { + id: 1, + code: "PURCHASE_ORDER", + name: "Purchase order", + documentFormat: "Json", + busEnabled: false, + busMessageTypeName: null, + duplicateInterval: 0, + disregardsUnfilteredMessages: false, + promotedProperties: [], + usedByCount: 1, + retiredOn: null, +}; + +describe("a page reading another area's list", () => { + it("still loads when the area behind its Used by count is refused", async () => { + let refusals = 0; + renderApp("/information-types", { + as: DOCS_ONLY, + handlers: [ + http.get(apiPath("/documents"), () => HttpResponse.json({ result: [PURCHASE_ORDER], totalCount: 1 })), + // EnsurePermission throws SWUnauthorizedException, which CqApi answers with a bare 401 — + // the same one a missing token gets. So the client refreshes and asks again, and is + // refused again. + http.get(apiPath("/subscriptions"), () => { + refusals++; + return HttpResponse.json( + { type: "https://tools.ietf.org/html/rfc9110#section-15.5.2", title: "Unauthorized", status: 401 }, + { status: 401, headers: { "Content-Type": "application/problem+json" } }, + ); + }), + http.post(apiPath("/accounts/login"), () => HttpResponse.json({ jwt: "refreshed-token", mustChangePassword: false })), + ], + }); + + // The information types list counts how many subscriptions use each type, which needs the + // subscriptions list this role can't read. The count is what's expendable, not the page. + expect(await screen.findByRole("table")).toBeVisible(); + expect(screen.getByText("Purchase order")).toBeVisible(); + await expect.poll(() => refusals).toBe(2); + expect(screen.queryByText("You don't have access to this page")).not.toBeInTheDocument(); + expect(screen.queryAllByText(/failed|error/i)).toHaveLength(0); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx new file mode 100644 index 00000000..a13abd72 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/settings/__tests__/SettingsPage.test.tsx @@ -0,0 +1,243 @@ +import { cleanup, screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { afterEach, describe, expect, it } from "vitest"; +import type { SettingRow } from "../../../api"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +import { server } from "../../../__tests__/support/server"; +import { settingsDraft } from "../../../lib/settingsDraft"; + +/** + * Which sections exist, which rows are environment-owned and which are editable are facts about + * the backend's catalog, pinned by SettingsCatalogTests. What's left here is how the page renders + * each kind of row it is sent. + */ +const DEFAULT_CRON = "0 * * * * ?"; + +/** A stored, changeable row as Settings.Get sends one: untouched, so still at its default. */ +const editable = (row: Pick): SettingRow => ({ + description: "", + value: row.defaultValue, + secret: false, + overridden: false, + hasValue: row.defaultValue !== "", + editable: true, + access: "editable", + ...row, +}); + +/** An environment row as Settings.Get sends one: no default, nothing to reset, never writable. */ +const environment = ( + row: Pick, +): SettingRow => ({ + description: "", + defaultValue: "", + secret: false, + overridden: false, + editable: false, + ...row, +}); + +/** A few rows from each section, in catalog order — which is the order the page lists them in. */ +const ROWS: SettingRow[] = [ + editable({ + key: "Bitween.AreXChangeFilesPrivate", + section: "Documents & storage", + label: "Keep exchange files private", + kind: "boolean", + defaultValue: "false", + }), + editable({ + key: "Bitween.JwtExpiryMinutes", + section: "API behavior", + label: "Sign-in session length (minutes)", + kind: "number", + defaultValue: "60", + }), + editable({ + key: "Bitween.MsalClientId", + section: "Single sign-on (Microsoft)", + label: "Azure AD client ID", + kind: "string", + defaultValue: "", + }), + editable({ + key: "Bitween.DisableEmailPasswordLogin", + section: "Single sign-on (Microsoft)", + label: "Microsoft sign-in only", + kind: "boolean", + defaultValue: "false", + }), + environment({ + key: "Bitween.AdapterPath", + section: "Adapters", + label: "Custom adapter path", + kind: "string", + access: "readonly", + value: "adapters", + hasValue: true, + }), + editable({ + key: "Bitween.RetryJobCron", + section: "Reliability & jobs", + label: "Retry poll schedule", + kind: "string", + defaultValue: DEFAULT_CRON, + }), + environment({ + key: "Bitween.QueuePrefix", + section: "Messaging", + label: "Queue name prefix", + kind: "string", + access: "readonly", + value: "bitween", + hasValue: true, + }), + environment({ + key: "Bitween.UseAzureManagedIdentity", + section: "Database", + label: "Use Azure managed identity", + kind: "boolean", + access: "readonly", + value: "false", + hasValue: true, + }), + // A presence row's value never leaves the server: Settings.Get sends null either way. + environment({ + key: "Bitween.AzureManagedIdentityClientId", + section: "Database", + label: "Managed identity client ID", + kind: "string", + access: "presence", + value: null, + hasValue: false, + }), + environment({ + key: "Bitween.SettingsEncryptionKey", + section: "Security", + label: "Settings encryption key", + kind: "string", + access: "presence", + value: null, + hasValue: true, + }), + editable({ + key: "Theme.PrimaryColor", + section: "Brand & theme", + label: "Primary color", + kind: "color", + defaultValue: "#e3311d", + }), +]; + +const pageHandlers = [ + http.get(apiPath("/settings"), () => HttpResponse.json(ROWS)), + // The history card underneath; nothing has changed on this instance yet. + http.get(apiPath("/audit"), () => HttpResponse.json({ result: [], totalCount: 0 })), +]; + +const openSettings = (section?: string) => + renderApp(section ? `/settings?section=${encodeURIComponent(section)}` : "/settings", { + handlers: pageHandlers, + }); + +/** Sections are real links — a section is a URL you can paste into a ticket — not buttons. */ +const sectionNav = async () => within(await screen.findByRole("navigation", { name: "Settings sections" })); + +// The draft is module state mirrored to sessionStorage, which setup.ts doesn't clear — so a test +// that fails with an edit staged would otherwise hand it to the next one. Unmounted first, so +// clearing it doesn't re-render a page nobody is looking at any more. +afterEach(() => { + cleanup(); + settingsDraft.discardAll(); +}); + +describe("the settings page", () => { + it("lists a section per catalog section, in catalog order, with no restart-required rows", async () => { + openSettings(); + + const links = (await sectionNav()).getAllByRole("link"); + expect(links.map((l) => l.textContent)).toEqual([ + "Documents & storage", + "API behavior", + "Single sign-on (Microsoft)", + "Adapters", + "Reliability & jobs", + "Messaging", + "Database", + "Security", + "Brand & theme", + ]); + + // Nothing carries a restart badge: a setting that couldn't take effect immediately is shown + // as an environment value instead of being offered as an edit that needs a restart to land. + expect(screen.queryByText("Restart", { exact: true })).not.toBeInTheDocument(); + }); + + it("shows environment settings but doesn't offer them as edits", async () => { + const { user } = openSettings(); + await user.click((await sectionNav()).getByRole("link", { name: "Database" })); + + // A read-only row renders its value as text — there's no control carrying its label… + expect(await screen.findByText("Use Azure managed identity")).toBeVisible(); + expect(screen.getByText("Off", { exact: true })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Use Azure managed identity" })).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + + // …and a presence row reports only whether a value is set, never the value itself. + expect(screen.getByText("Not set", { exact: true })).toBeVisible(); + expect(screen.queryByRole("textbox", { name: "Managed identity client ID" })).not.toBeInTheDocument(); + + // Neither kind can be reset, because neither is stored. + expect(screen.queryByRole("button", { name: "Reset to default" })).not.toBeInTheDocument(); + expect(screen.getAllByText("Environment")).toHaveLength(2); + }); + + it("offers Microsoft-only sign-in as a toggle, not an environment value", async () => { + openSettings("Single sign-on (Microsoft)"); + + // It applies per request — the Login handler and the config endpoint both read it live — so it + // belongs in the catalog as an edit rather than a read-only environment row. + const toggle = await screen.findByRole("checkbox", { name: "Off" }); + expect(toggle).toBeEnabled(); + expect(toggle).not.toBeChecked(); + expect(screen.getByText("Microsoft sign-in only")).toBeVisible(); + expect(screen.queryByText("Environment")).not.toBeInTheDocument(); + }); + + it("keeps an invalid retry schedule as an unsaved draft when the backend refuses it", async () => { + let posted: unknown; + server.use( + http.post(apiPath("/settings/Bitween.RetryJobCron"), async ({ request }) => { + posted = await request.json(); + // What Settings.Update throws for a bad expression, as the framework serializes it. + return HttpResponse.json( + { + SETTING_INVALID_VALUE: ["Retry poll schedule: 'not a cron' is not a valid cron expression."], + }, + { status: 400 }, + ); + }), + ); + const { user } = openSettings("Reliability & jobs"); + + const cron = await screen.findByRole("textbox", { name: "Retry poll schedule" }); + expect(cron).toHaveValue(DEFAULT_CRON); + + // The backend validates the expression before storing it, because a bad one would break the + // startup job seeding — so a rejected save leaves the draft dirty rather than silently passing. + await user.clear(cron); + await user.type(cron, "not a cron"); + await user.tab(); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + expect(await screen.findByText(/not a valid cron expression/)).toBeVisible(); + expect(posted).toEqual({ value: "not a cron" }); + expect(cron).toHaveValue("not a cron"); + expect(screen.getByText("Unsaved", { exact: true })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Discard" })); + expect(cron).toHaveValue(DEFAULT_CRON); + expect(screen.queryByText("Unsaved", { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/MembersPage.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/MembersPage.test.tsx new file mode 100644 index 00000000..a40dc337 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/MembersPage.test.tsx @@ -0,0 +1,152 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { ADMIN, apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** The built-in roles, as the server seeds them (BitweenDbContext.SystemRoleSeed). */ +const ROLES = [ + { id: 1, name: "Administrator", description: "Full access to everything, including members, roles and settings." }, + { id: 2, name: "Member", description: "Runs and configures integrations. Can't manage members, roles or settings." }, + { id: 3, name: "Viewer", description: "Read-only access to integrations, exchanges and configuration." }, +].map((r) => ({ ...r, isSystem: true, permissions: [], memberCount: 0, createdOn: "2026-01-01T00:00:00Z" })); + +/** An account as the member list returns it (AccountModel). */ +const account = (id: number, name: string, email: string, roleIds: number[], disabled = false) => ({ + id, + name, + email, + role: "Member", + disabled, + lockoutEnd: null, + createdOn: "2026-01-01T00:00:00Z", + roles: ROLES.filter((r) => roleIds.includes(r.id)).map(({ id, name }) => ({ id, name })), +}); + +const self = () => account(ADMIN.id, ADMIN.name, ADMIN.email, [1]); + +/** + * The accounts held the way the server holds them, so a write is answered and then read back + * through the member list. The page re-reads after every save rather than trusting what it sent, + * and these tests should see it do that. What each write carried is kept in `sent`. + */ +function team(...accounts: ReturnType[]) { + const sent: Record = { setRoles: [], setDisabled: [] }; + const find = (id: unknown) => accounts.find((a) => a.id === Number(id))!; + const handlers = [ + http.get(apiPath("/accounts"), () => HttpResponse.json({ result: accounts, totalCount: accounts.length })), + http.get(apiPath("/roles"), () => HttpResponse.json({ result: ROLES, totalCount: ROLES.length })), + // The drawer's History section. + http.get(apiPath("/audit"), () => HttpResponse.json({ result: [], totalCount: 0 })), + http.post(apiPath("/accounts/:id/setRoles"), async ({ params, request }) => { + const body = (await request.json()) as { roleIds: number[] }; + sent.setRoles.push(body); + find(params.id).roles = account(0, "", "", body.roleIds).roles; + return new HttpResponse(null, { status: 204 }); + }), + http.post(apiPath("/accounts/:id/setDisabled"), async ({ params, request }) => { + const body = (await request.json()) as { disabled: boolean }; + sent.setDisabled.push(body); + find(params.id).disabled = body.disabled; + return new HttpResponse(null, { status: 204 }); + }), + ]; + return { handlers, sent }; +} + +const row = (email: string) => screen.getByRole("row", { name: new RegExp(email) }); +const drawer = () => screen.getByRole("dialog", { name: "Member details" }); + +describe("the member list", () => { + it("changes which roles a member holds", async () => { + const { handlers, sent } = team(self(), account(42, "Role Swap", "role.swap@test.local", [3])); + const { user } = renderApp("/team/members", { handlers }); + + await user.click(await screen.findByRole("row", { name: /role.swap@test.local/ })); + const panel = within(drawer()); + await user.click(await panel.findByRole("checkbox", { name: /^Viewer/ })); + await user.click(panel.getByRole("checkbox", { name: /^Member/ })); + await user.click(panel.getByRole("button", { name: "Save roles" })); + await expect.poll(() => panel.queryByRole("button", { name: "Save roles" })).toBeNull(); + + // The whole set is replaced, so what goes over the wire is the set the member ends up with. + expect(sent.setRoles).toEqual([{ roleIds: [2] }]); + // Read back from the server rather than trusting the optimistic UI. That the write reaches + // the database is TeamMemberTests.Setting_a_members_roles_replaces_the_ones_they_held. + await expect.poll(() => row("role.swap@test.local").textContent).toContain("Member"); + expect(row("role.swap@test.local")).not.toHaveTextContent("Viewer"); + }); + + it("disables a member, then re-enables them", async () => { + const { handlers, sent } = team(self(), account(43, "On Leave", "on.leave@test.local", [3])); + const { user } = renderApp("/team/members", { handlers }); + + await user.click(await screen.findByRole("row", { name: /on.leave@test.local/ })); + const panel = within(drawer()); + await user.click(await panel.findByRole("button", { name: "Disable account" })); + expect(await panel.findByRole("button", { name: "Re-enable account" })).toBeVisible(); + expect(sent.setDisabled).toEqual([{ disabled: true }]); + // Read back, as above; TeamMemberTests.A_disabled_member_stays_disabled_until_re_enabled + // holds the write itself. + await expect.poll(() => row("on.leave@test.local").textContent).toContain("Disabled"); + + // A disabled account keeps its roles and history but must not be able to sign in — that + // refusal is the server's, and LoginTests.A_disabled_account_cannot_sign_in holds it. + + await user.click(panel.getByRole("button", { name: "Re-enable account" })); + expect(await panel.findByRole("button", { name: "Disable account" })).toBeVisible(); + expect(sent.setDisabled).toEqual([{ disabled: true }, { disabled: false }]); + await expect.poll(() => row("on.leave@test.local").textContent).toContain("Active"); + }); + + it("won't let the last administrator be removed or disabled", async () => { + const { handlers } = team(self(), account(44, "Someone Else", "someone@test.local", [3])); + // The guard itself is the server's, held by + // TeamGuardTests.The_last_administrator_cannot_lose_the_role_be_disabled_or_be_removed. This is + // the refusal it sends, as CqApi renders an SWValidationException: a 400 carrying the code and + // its message. + const refusal = http.post(apiPath("/accounts/:id/setRoles"), () => + HttpResponse.json( + { + LAST_ADMINISTRATOR: [ + "This is the only member with the Administrator role. Give it to someone else first.", + ], + }, + { status: 400 }, + ), + ); + const { user } = renderApp("/team/members", { handlers: [refusal, ...handlers] }); + + await user.click(await screen.findByRole("row", { name: new RegExp(ADMIN.email) })); + const panel = within(drawer()); + const role = await panel.findByRole("checkbox", { name: /^Administrator/ }); + + // Nothing destructive is even offered on your own account. + expect(panel.queryByRole("button", { name: "Remove from team" })).not.toBeInTheDocument(); + expect(panel.queryByRole("button", { name: "Disable account" })).not.toBeInTheDocument(); + + // Dropping the role is offered, but the server refuses it — and says why. + await user.click(role); + await user.click(panel.getByRole("button", { name: "Save roles" })); + expect(await panel.findByText(/only member with the Administrator role/i)).toBeVisible(); + // The unchecked box in the drawer is a rejected draft, not what the server holds. + expect(row(ADMIN.email)).toHaveTextContent("Administrator"); + }); + + it("filters and searches the member list", async () => { + const { handlers } = team(self(), account(45, "Findable Person", "findable@test.local", [3])); + const { user } = renderApp("/team/members", { handlers }); + + const search = await screen.findByLabelText("Search members"); + await screen.findByRole("row", { name: /findable@test.local/ }); + await user.type(search, "Findable"); + expect(row("findable@test.local")).toBeVisible(); + expect(screen.queryByRole("row", { name: new RegExp(ADMIN.email) })).not.toBeInTheDocument(); + + await user.clear(search); + await user.click(screen.getByRole("button", { name: "Disabled" })); + expect(screen.queryByRole("row", { name: /findable@test.local/ })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Active" })); + expect(row("findable@test.local")).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/RoleEditor.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/RoleEditor.test.tsx new file mode 100644 index 00000000..3b85cae7 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/RoleEditor.test.tsx @@ -0,0 +1,141 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { screen, waitFor, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; + +/** + * The catalog GET /permissions serves, read out of PermissionCatalog.Areas in the C# rather than + * copied: the matrix renders whatever the server sends, so a hand-made one would only test itself. + */ +const CATALOG = readFileSync(resolve(process.cwd(), "../../SW.Bitween.Sdk/Model/Permissions.cs"), "utf8") + .split("List Areas =")[1] + .split("];")[0] + .replace(/\/\/.*$/gm, "") + .split("Area(") + .slice(1) + .map((area) => { + const strings = [...area.matchAll(/"([^"]*)"/g)].map((m) => m[1]); + const actions = [...area.matchAll(/\((View|Create|Edit|Delete|Operate), "([^"]*)"\)/g)].map((m) => ({ + id: m[1].toLowerCase(), + description: m[2], + })); + const [id, label, group, ...rest] = strings; + // What's left once the actions' own descriptions are off the end is the area's, which the C# + // sometimes splits across concatenated literals. + return { id, label, group, description: rest.slice(0, rest.length - actions.length).join(""), actions }; + }); + +/** A role as GET /roles and GET /roles/{id} return it (RoleRow). */ +const role = (id: number, name: string, permissions: string[], isSystem = false) => ({ + id, + name, + description: isSystem ? "Full access to everything, including members, roles and settings." : "", + isSystem, + permissions, + memberCount: isSystem ? 1 : 0, + createdOn: "2026-01-01T00:00:00Z", +}); + +// A built-in role's grants are computed on read, and Administrator's are the whole catalog. +const ADMINISTRATOR = role(1, "Administrator", CATALOG.flatMap((a) => a.actions.map((x) => `${a.id}.${x.id}`)), true); + +/** The roles, held the way the server holds them so a created one comes back in the list. */ +function roles(...custom: ReturnType[]) { + const all = [ADMINISTRATOR, ...custom]; + const created: unknown[] = []; + const handlers = [ + http.get(apiPath("/permissions"), () => HttpResponse.json(CATALOG)), + http.get(apiPath("/roles"), () => HttpResponse.json({ result: all, totalCount: all.length })), + http.get(apiPath("/roles/:id"), ({ params }) => HttpResponse.json(all.find((r) => r.id === Number(params.id)))), + http.post(apiPath("/roles"), async ({ request }) => { + const body = (await request.json()) as { name: string; description: string; permissions: string[] }; + created.push(body); + const id = Math.max(...all.map((r) => r.id)) + 1; + all.push({ ...role(id, body.name, body.permissions), description: body.description }); + return HttpResponse.json(id); + }), + // A custom role's History card. + http.get(apiPath("/audit"), () => HttpResponse.json({ result: [], totalCount: 0 })), + ]; + return { handlers, created }; +} + +const box = (name: string) => screen.getByRole("checkbox", { name }); + +describe("the role editor", () => { + it("grants View along with any action, and takes the row with it when View is cleared", async () => { + const { user } = renderApp("/team/roles/new", { handlers: roles().handlers }); + + // An action you can't view is an action you can't reach, so View comes along. + const edit = await screen.findByRole("checkbox", { name: "Partners: Edit" }); + const view = box("Partners: View"); + const del = box("Partners: Delete"); + + // Only the count granted is asserted, not the catalog size — that changes whenever a + // permission is added or dropped, and it isn't what this test is about. + const granted = (n: number) => new RegExp(`\\b${n}/\\d+ permissions granted`); + + await user.click(edit); + expect(view).toBeChecked(); + expect(screen.getByText(granted(2))).toBeVisible(); + + await user.click(del); + expect(screen.getByText(granted(3))).toBeVisible(); + + // Removing View takes the whole area with it. + await user.click(view); + expect(edit).not.toBeChecked(); + expect(del).not.toBeChecked(); + expect(screen.getByText(granted(0))).toBeVisible(); + }); + + it("previews what members with the role would see", async () => { + const { user } = renderApp("/team/roles/new", { handlers: roles().handlers }); + + expect(await screen.findByText("No pages yet — grant a View permission.")).toBeVisible(); + // Scoped to the preview: the signed-in admin's own sidebar lists every page too. + const preview = within(screen.getByText("What members with this role see").closest("div")!); + + await user.click(box("Partners: View")); + expect(preview.getByText("Partners")).toBeVisible(); + expect(preview.queryByText("Exchanges")).not.toBeInTheDocument(); + + await user.click(box("Exchanges: View")); + expect(preview.getByText("Exchanges")).toBeVisible(); + }); + + it("shows a built-in role read-only", async () => { + const { user } = renderApp("/team/roles", { handlers: roles().handlers }); + + await user.click(await screen.findByRole("link", { name: /Administrator/ })); + + expect(await screen.findByText(/This role is built in/)).toBeVisible(); + expect(box("Partners: View")).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Delete role" })).not.toBeInTheDocument(); + // Name and description aren't even rendered for a built-in. + expect(document.querySelector("#role-name")).not.toBeInTheDocument(); + }); + + it("duplicates a role", async () => { + const { handlers, created } = roles(role(7, "Operator", ["partners.view", "partners.edit"])); + const { user, router } = renderApp("/team/roles", { handlers }); + + await user.click(await screen.findByRole("link", { name: /Operator/ })); + await user.click(await screen.findByRole("button", { name: "Duplicate" })); + + const name = await screen.findByDisplayValue("Copy of Operator"); + expect(name).toHaveAttribute("id", "role-name"); + expect(box("Partners: Edit")).toBeChecked(); + + await user.clear(name); + await user.type(name, "Night operator"); + await user.click(screen.getByRole("button", { name: "Create role" })); + await waitFor(() => expect(router.state.location.pathname).toBe("/team/roles")); + + // A new role in its own right, carrying the source's grants — not an edit of the source. + expect(created).toEqual([{ name: "Night operator", description: "", permissions: ["partners.view", "partners.edit"] }]); + expect(await screen.findByRole("link", { name: /Night operator/ })).toBeVisible(); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/TeamHistory.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/TeamHistory.test.tsx new file mode 100644 index 00000000..80ca90b8 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/pages/team/__tests__/TeamHistory.test.tsx @@ -0,0 +1,105 @@ +import { screen, within } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; +import { apiPath, renderApp } from "../../../__tests__/support/renderApp"; +import { auditRow, auditTrail } from "../../audit/__tests__/trail"; + +/** + * The members and roles pages carry their own history, like every other entity. + * + * GET /roles and /roles/{id}: `RawRole` in src/api/http/team.ts. + */ +const VIEWER = { + id: 3, + name: "Viewer", + description: "Sees everything, changes nothing.", + isSystem: true, + permissions: ["partners.view"], + memberCount: 1, + createdOn: "2026-01-01T00:00:00Z", +}; +const AUDITORS = { + id: 12, + name: "Auditors", + description: "Partners, read-only.", + isSystem: false, + permissions: ["partners.view"], + memberCount: 0, + createdOn: "2026-09-01T00:00:00Z", +}; + +const roles = http.get(apiPath("/roles"), () => HttpResponse.json({ result: [VIEWER, AUDITORS], totalCount: 2 })); +const catalog = http.get(apiPath("/permissions"), () => + HttpResponse.json([ + { + id: "partners", + label: "Partners", + group: "Configuration", + description: "", + actions: [{ id: "view", description: "See partners." }], + }, + ]), +); + +describe("team history", () => { + it("a custom role's page carries its history", async () => { + // Built-in roles are deliberately excluded — their grants are computed rather than stored, + // so nothing ever edits one — which makes a custom role the case worth covering. + const trail = auditTrail([auditRow({ entityName: "Role", entityKey: "12" })]); + const { user } = renderApp("/team/roles", { + handlers: [ + roles, + catalog, + http.get(apiPath("/roles/12"), () => HttpResponse.json(AUDITORS)), + trail.handler, + ], + }); + + await user.click(await screen.findByRole("link", { name: /Auditors/ })); + const panel = (await screen.findByRole("heading", { name: "History", level: 2 })).closest("section")!; + expect(await within(panel).findByRole("row", { name: /Added/ })).toBeVisible(); + expect(trail.asked.at(-1)?.get("entityName")).toBe("Role"); + expect(trail.asked.at(-1)?.get("entityKey")).toBe("12"); + }); + + it("a member drawer shows that member's history", async () => { + const trail = auditTrail([auditRow({ entityName: "Account", entityKey: "7" })]); + const { user } = renderApp("/team/members", { + handlers: [ + // `RawAccount` in src/api/http/team.ts. + http.get(apiPath("/accounts"), () => + HttpResponse.json({ + result: [ + { + id: 7, + name: "Playwright Drawer", + email: "drawer@test.local", + role: "Member", + disabled: false, + lockoutEnd: null, + createdOn: "2026-09-01T00:00:00Z", + roles: [{ id: 3, name: "Viewer" }], + }, + ], + totalCount: 1, + }), + ), + roles, + trail.handler, + ], + }); + + await user.click(await screen.findByRole("row", { name: /drawer@test\.local/ })); + const drawer = await screen.findByRole("dialog", { name: "Member details" }); + + expect(await within(drawer).findByRole("heading", { name: "History" })).toBeVisible(); + expect(await within(drawer).findByRole("row", { name: /Added/ })).toBeVisible(); + expect(trail.asked.at(-1)?.get("entityName")).toBe("Account"); + expect(trail.asked.at(-1)?.get("entityKey")).toBe("7"); + // The other question worth asking about a person: what they changed, not what was done to them. + expect(within(drawer).getByRole("link", { name: "What this member changed" })).toHaveAttribute( + "href", + "/audit?userId=7", + ); + }); +}); diff --git a/SW.Bitween.Web/ClientApp/src/router.tsx b/SW.Bitween.Web/ClientApp/src/router.tsx index fb2c954c..ce60f779 100644 --- a/SW.Bitween.Web/ClientApp/src/router.tsx +++ b/SW.Bitween.Web/ClientApp/src/router.tsx @@ -1,4 +1,4 @@ -import { Navigate, createBrowserRouter } from "react-router"; +import { Navigate, createBrowserRouter, type RouteObject } from "react-router"; import { RequireAuth, RequirePermission } from "./auth/guards"; import { useSession } from "./auth/SessionContext"; import { AppShell } from "./components/layout/AppShell"; @@ -73,7 +73,8 @@ const placeholderRoutes = NAV_GROUPS.flatMap((group) => group.items) /** "/" → undefined (no basename); "/prefix/" → "/prefix" if ever remounted. */ const basename = import.meta.env.BASE_URL.replace(/\/+$/, "") || undefined; -export const router = createBrowserRouter([ +/** Exported apart from the router so the component tests can mount the same routes in memory. */ +export const routes: RouteObject[] = [ { path: "/login", element: }, { element: , @@ -449,4 +450,6 @@ export const router = createBrowserRouter([ }, ], }, -], { basename }); +]; + +export const router = createBrowserRouter(routes, { basename }); diff --git a/SW.Bitween.Web/ClientApp/tsconfig.test.json b/SW.Bitween.Web/ClientApp/tsconfig.test.json index e0e49912..7fe6b625 100644 --- a/SW.Bitween.Web/ClientApp/tsconfig.test.json +++ b/SW.Bitween.Web/ClientApp/tsconfig.test.json @@ -14,5 +14,8 @@ "include": ["src/**/__tests__/**/*", "e2e", "playwright.config.ts", "vite.config.ts"], // The old mapper's own tests, left out for the same reason `tsconfig.app.json` // leaves out its source: it is verbatim-ported code that goes when it goes. - "exclude": ["src/lib/mapping/**"] + "exclude": ["src/lib/mapping/**"], + // Not inherited through `extends`: without it the component tests, which mount the app's routes, + // would pull the old mapper in as source and check it under rules it was never written to. + "references": [{ "path": "./tsconfig.mapping.json" }] } diff --git a/SW.Bitween.Web/ClientApp/vitest.config.ts b/SW.Bitween.Web/ClientApp/vitest.config.ts index fd17082e..b5fda2ba 100644 --- a/SW.Bitween.Web/ClientApp/vitest.config.ts +++ b/SW.Bitween.Web/ClientApp/vitest.config.ts @@ -7,6 +7,21 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - include: ["src/**/__tests__/**/*.test.ts"], + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/__tests__/**/*.test.ts"] }, + }, + { + // Whole pages rendered in jsdom against a mock network: see src/__tests__/support. + extends: true, + test: { + name: "component", + include: ["src/**/__tests__/**/*.test.tsx"], + environment: "jsdom", + setupFiles: ["./src/__tests__/support/setup.ts"], + }, + }, + ], }, }); diff --git a/SW.Bitween.Web/ClientApp/yarn.lock b/SW.Bitween.Web/ClientApp/yarn.lock index 366893f7..4d714792 100644 --- a/SW.Bitween.Web/ClientApp/yarn.lock +++ b/SW.Bitween.Web/ClientApp/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.4.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.5.0.tgz#b5b71a25a4d16afa2482592ddfa62fccc60bc7d1" + integrity sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q== + "@asamuzakjp/css-color@^6.0.5": version "6.0.7" resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-6.0.7.tgz#8f9f67452e6636930949abe047dd553b13276939" @@ -35,7 +40,21 @@ resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-15.17.0.tgz#ae3c03378c852642b1c9a303380e945c2b897f02" integrity sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw== -"@babel/runtime@^7.18.6": +"@babel/code-frame@^7.10.4": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + +"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.6": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== @@ -261,6 +280,42 @@ "@tanstack/react-virtual" "^3.13.9" use-sync-external-store "^1.5.0" +"@inquirer/ansi@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@inquirer/ansi/-/ansi-2.0.8.tgz#0308f3ed790dfa960f0f1f60045fa67e804de38e" + integrity sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ== + +"@inquirer/confirm@^6.0.11": + version "6.3.2" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-6.3.2.tgz#c4691ec8e852f4619496606376a2061490b99e26" + integrity sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg== + dependencies: + "@inquirer/core" "^12.0.3" + "@inquirer/type" "4.1.1" + +"@inquirer/core@^12.0.3": + version "12.0.3" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-12.0.3.tgz#8bbcfb31c3e04008102429d88a2d66b84257fb55" + integrity sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA== + dependencies: + "@inquirer/ansi" "^2.0.8" + "@inquirer/figures" "^2.0.9" + "@inquirer/type" "4.1.1" + cli-width "^4.1.0" + fast-wrap-ansi "^0.2.0" + mute-stream "^3.0.0" + signal-exit "^4.1.0" + +"@inquirer/figures@^2.0.9": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-2.0.9.tgz#8c04fdba3a78af0e57c0b3127cbd319ce604b1c1" + integrity sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg== + +"@inquirer/type@4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-4.1.1.tgz#f865ee99f39e0951a0279f9c45ad1d260c0ce0dd" + integrity sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A== + "@internationalized/date@^3.12.2": version "3.12.2" resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.12.2.tgz#08a65edd2a29775e22c168ddc029fb54bf9b8a85" @@ -340,6 +395,18 @@ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz#42c2aea61cda307cdb1347444792452d7b5dbfb4" integrity sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ== +"@mswjs/interceptors@^0.41.3": + version "0.41.9" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" + integrity sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w== + dependencies: + "@open-draft/deferred-promise" "^2.2.0" + "@open-draft/logger" "^0.3.0" + "@open-draft/until" "^2.0.0" + is-node-process "^1.2.0" + outvariant "^1.4.3" + strict-event-emitter "^0.5.1" + "@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6": version "1.1.6" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz#ed33806d0f9be98dc76d0c3d4fd872fda701b5d5" @@ -347,6 +414,29 @@ dependencies: "@tybys/wasm-util" "^0.10.3" +"@open-draft/deferred-promise@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd" + integrity sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA== + +"@open-draft/deferred-promise@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz#9725acc5afe8ecde690e9e198a094859fdbf2e45" + integrity sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA== + +"@open-draft/logger@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@open-draft/logger/-/logger-0.3.0.tgz#2b3ab1242b360aa0adb28b85f5d7da1c133a0954" + integrity sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ== + dependencies: + is-node-process "^1.2.0" + outvariant "^1.4.0" + +"@open-draft/until@^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" + integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== + "@oxc-project/types@=0.139.0": version "0.139.0" resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.139.0.tgz#38d76b9dbf934c2a02be174fb32ceebf182fe742" @@ -703,6 +793,44 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz#26d1307f6e544e8428e2c022616cdabda59ef77e" integrity sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw== +"@testing-library/dom@^10.4.2": + version "10.4.2" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.2.tgz#e996827c4e5e1f13589527293b4b3958de2f709f" + integrity sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + +"@testing-library/jest-dom@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz#649a9a8b2039f28d29f9f7a46eba9a8b727f811e" + integrity sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw== + dependencies: + "@adobe/css-tools" "^4.4.0" + aria-query "^5.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + picocolors "^1.1.1" + redent "^3.0.0" + +"@testing-library/react@^16.3.3": + version "16.3.3" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.3.tgz#426907e7716f37038dab8aa69d8f0459f9ec24b2" + integrity sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg== + dependencies: + "@babel/runtime" "^7.12.5" + +"@testing-library/user-event@^14.6.7": + version "14.6.7" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.7.tgz#c453ccfcaffed110cc79048ed18fccb0f4224f3b" + integrity sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg== + "@tybys/wasm-util@^0.10.2", "@tybys/wasm-util@^0.10.3": version "0.10.3" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" @@ -710,6 +838,11 @@ dependencies: tslib "^2.4.0" +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" @@ -733,6 +866,13 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== +"@types/node@*": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.2.tgz#11bfb8e00bafe728d2113e474a7637becc9ab279" + integrity sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g== + dependencies: + undici-types "~8.9.0" + "@types/node@^24.13.2": version "24.13.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.13.3.tgz#49f18bd3c647866dcda51a0756c145e14590ce16" @@ -752,6 +892,18 @@ dependencies: csstype "^3.2.2" +"@types/set-cookie-parser@^2.4.10": + version "2.4.10" + resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz#ad3a807d6d921db9720621ea3374c5d92020bcbc" + integrity sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw== + dependencies: + "@types/node" "*" + +"@types/statuses@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/statuses/-/statuses-2.0.6.tgz#66748315cc9a96d63403baa8671b2c124f8633aa" + integrity sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA== + "@uiw/codemirror-extensions-basic-setup@4.25.11": version "4.25.11" resolved "https://registry.yarnpkg.com/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz#a45e0604eb6a29fffa152b684d0c7702c6e69bdf" @@ -844,6 +996,23 @@ convert-source-map "^2.0.0" tinyrainbow "^3.1.0" +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + aria-hidden@^1.2.3: version "1.2.6" resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.6.tgz#73051c9b088114c795b1ea414e9c0fff874ffc1a" @@ -851,6 +1020,18 @@ aria-hidden@^1.2.3: dependencies: tslib "^2.0.0" +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + +aria-query@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -868,6 +1049,20 @@ chai@^6.2.2: resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== +cli-width@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" + integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + clsx@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" @@ -886,6 +1081,18 @@ codemirror@^6.0.0: "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.0.0" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + commander@7: version "7.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" @@ -901,6 +1108,11 @@ cookie-es@^3.1.1: resolved "https://registry.yarnpkg.com/cookie-es/-/cookie-es-3.1.1.tgz#c4a8a16cf88cb5a185b23f4a61d6e9a85eb53287" integrity sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg== +cookie@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" + integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== + crelt@^1.0.5, crelt@^1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.7.tgz#3b441b2ddfa73161d6a2770aa4cd677f895eaf28" @@ -914,6 +1126,11 @@ css-tree@^3.0.0, css-tree@^3.2.1: mdn-data "2.27.1" source-map-js "^1.2.1" +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + csstype@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" @@ -941,11 +1158,31 @@ decimal.js@^10.6.0: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + detect-libc@^2.0.3: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + enhanced-resolve@5.21.6: version "5.21.6" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz#aa207b43cf658e6ab3ba06896edc00c13c3127c6" @@ -964,6 +1201,11 @@ es-module-lexer@^2.0.0: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.1.tgz#5bf2df06999dbbe5f006a5f46a11fb9f5b7b391b" integrity sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA== +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" @@ -976,6 +1218,25 @@ expect-type@^1.3.0: resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== +fast-string-truncated-width@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz#23afe0da67d752ca0727538f1e6967759728ce49" + integrity sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g== + +fast-string-width@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/fast-string-width/-/fast-string-width-3.0.2.tgz#16dbabb491ce5585b5ecb675b65c165d71688eeb" + integrity sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg== + dependencies: + fast-string-truncated-width "^3.0.2" + +fast-wrap-ansi@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz#95e952a0145bce3f59ad56e179f84c48d4072935" + integrity sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q== + dependencies: + fast-string-width "^3.0.2" + fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" @@ -991,11 +1252,29 @@ fsevents@~2.3.3: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +graphql@^16.13.2: + version "16.14.2" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.14.2.tgz#83faf25869e3df727cc855161db5da85b0e5b2c0" + integrity sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA== + +headers-polyfill@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/headers-polyfill/-/headers-polyfill-5.0.1.tgz#9554eb2892b666db1c7a3380a91b6cfd467a6b19" + integrity sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA== + dependencies: + "@types/set-cookie-parser" "^2.4.10" + set-cookie-parser "^3.0.1" + highlight.js@^11.12.0: version "11.12.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.12.0.tgz#470d918fd556a76debc40fe5e4b540b6b9cb9890" @@ -1020,6 +1299,21 @@ immer@^11.1.8: resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.11.tgz#bbf825a333ae1b16fd450d8da5f61d54de6a553d" integrity sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw== +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-node-process@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-node-process/-/is-node-process-1.2.0.tgz#ea02a1b90ddb3934a19aea414e88edef7e11d134" + integrity sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -1030,6 +1324,11 @@ jiti@^2.7.0: resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64" integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ== +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + jsdom@^30.0.1: version "30.0.1" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-30.0.1.tgz#1b0751cbd0abce86762c48697583b5e91433f6e4" @@ -1141,6 +1440,11 @@ lucide-react@^1.24.0: resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-1.24.0.tgz#5f6da7f37d9107d192e54b7654a99b3468a212c6" integrity sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA== +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + magic-string@^0.30.21: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" @@ -1153,6 +1457,40 @@ mdn-data@2.27.1: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e" integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + +msw@^2.15.0: + version "2.15.0" + resolved "https://registry.yarnpkg.com/msw/-/msw-2.15.0.tgz#4028ba3d887af8c166d45aa3bf37116f73f21cec" + integrity sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ== + dependencies: + "@inquirer/confirm" "^6.0.11" + "@mswjs/interceptors" "^0.41.3" + "@open-draft/deferred-promise" "^3.0.0" + "@types/statuses" "^2.0.6" + cookie "^1.1.1" + graphql "^16.13.2" + headers-polyfill "^5.0.1" + is-node-process "^1.2.0" + outvariant "^1.4.3" + path-to-regexp "^6.3.0" + picocolors "^1.1.1" + rettime "^0.11.11" + statuses "^2.0.2" + strict-event-emitter "^0.5.1" + tough-cookie "^6.0.1" + type-fest "^5.5.0" + until-async "^3.0.2" + yargs "^17.7.2" + +mute-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-3.0.0.tgz#cd8014dd2acb72e1e91bb67c74f0019e620ba2d1" + integrity sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw== + nanoid@^3.3.12: version "3.3.16" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" @@ -1163,6 +1501,11 @@ obug@^2.1.1: resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.3.tgz#c02c60f95abd603409330e767db7f2823193331e" integrity sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg== +outvariant@^1.4.0, outvariant@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/outvariant/-/outvariant-1.4.3.tgz#221c1bfc093e8fec7075497e7799fdbf43d14873" + integrity sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== + oxlint@^1.71.0: version "1.74.0" resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.74.0.tgz#c84d5fd55417f4b8fa10cec10d3354ca06a0ec6e" @@ -1195,12 +1538,17 @@ parse5@^8.0.1: dependencies: entities "^8.0.0" +path-to-regexp@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" + integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== + pathe@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -1233,6 +1581,15 @@ postcss@^8.5.16: picocolors "^1.1.1" source-map-js "^1.2.1" +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -1260,6 +1617,11 @@ react-dom@^19.2.7: dependencies: scheduler "^0.27.0" +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-router@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-8.2.0.tgz#194809cb06a1b9ed83654882fd549b7fd47a20fd" @@ -1284,11 +1646,29 @@ react@^19.2.7: resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + require-from-string@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +rettime@^0.11.11: + version "0.11.11" + resolved "https://registry.yarnpkg.com/rettime/-/rettime-0.11.11.tgz#fe8fb192e1877bb0080fc1a640cb08eededd7d12" + integrity sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ== + rolldown@~1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.1.5.tgz#339aae250844351fc55b74e2652d3ebd6fba389d" @@ -1335,11 +1715,21 @@ scheduler@^0.27.0: resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== +set-cookie-parser@^3.0.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz#f4e490298759d756a68eabcbcd0fc9261ad0fee0" + integrity sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw== + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -1350,11 +1740,44 @@ stackback@0.0.2: resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== +statuses@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + std-env@^4.0.0-rc.1: version "4.2.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== +strict-event-emitter@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93" + integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ== + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + style-mod@^4.0.0, style-mod@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73" @@ -1370,6 +1793,11 @@ tabbable@^6.0.0: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.0.tgz#a65101385a4fd6cbd580b7546da0170f307b535d" integrity sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA== +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + tailwindcss@4.3.2, tailwindcss@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.2.tgz#408ee67d767a0fef7b174674bb9c5ce136a5ace1" @@ -1415,7 +1843,7 @@ tldts@^7.0.5: dependencies: tldts-core "^7.4.12" -tough-cookie@^6.0.2: +tough-cookie@^6.0.1, tough-cookie@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.2.tgz#7b1f22fcf2daf06c4ff9d53ec1845f44c6627062" integrity sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA== @@ -1434,6 +1862,13 @@ tslib@^2.0.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +type-fest@^5.5.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.10.0.tgz#bf0131fdf662889c94c409d37402e364fb2016f7" + integrity sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA== + dependencies: + tagged-tag "^1.0.0" + typescript@~6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" @@ -1444,11 +1879,21 @@ undici-types@~7.18.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== +undici-types@~8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" + integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== + undici@^8.9.0: version "8.10.2" resolved "https://registry.yarnpkg.com/undici/-/undici-8.10.2.tgz#960bcb0b43c267f86910ea7ca408ada843a44bc7" integrity sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ== +until-async@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/until-async/-/until-async-3.0.2.tgz#447f1531fdd7bb2b4c7a98869bdb1a4c2a23865f" + integrity sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw== + use-sync-external-store@^1.5.0, use-sync-external-store@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" @@ -1541,6 +1986,15 @@ why-is-node-running@^2.3.0: siginfo "^2.0.0" stackback "0.0.2" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + xml-name-validator@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" @@ -1550,3 +2004,26 @@ xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1"