Skip to content
Merged
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Resources/Documents/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ async public Task<object> Handle(SearchyRequest searchyRequest, bool lookup = fa
BusEnabled = document.BusEnabled,
DuplicateInterval = document.DuplicateInterval,
PromotedProperties = document.PromotedProperties.ToKeyAndValueCollection(),
DocumentFormat = document.DocumentFormat
DocumentFormat = document.DocumentFormat,
// A correlated count, so the "used by" column the UI shows costs one
// subquery per row instead of the whole Subscription table over the wire.
UsedByCount = dbContext.Set<Subscription>()
.Count(subscription => subscription.DocumentId == document.Id)
};

query = query.AsNoTracking();
Expand Down
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Resources/RetryPolicies/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ public async Task<object> Handle(SearchyRequest searchyRequest, bool lookup = fa
{
Id = policy.Id,
Name = policy.Name,
GroupCount = policy.Groups.Count
GroupCount = policy.Groups.Count,
// A correlated count, so the "used by" column the UI shows costs one subquery per
// row instead of the whole Subscription table over the wire.
UsedByCount = _dbContext.Set<Subscription>()
.Count(subscription => subscription.RetryPolicyId == policy.Id)
};

query = query.AsNoTracking();
Expand Down
10 changes: 10 additions & 0 deletions SW.Bitween.Api/Resources/WorkGroups/Search.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -35,6 +36,14 @@ public async Task<object> Handle(SearchWorkGroupModel request)
var workGroups = await dbContext.Set<WorkGroup>().AsNoTracking()
.Where(w => request.Name == null || w.Name.Contains(request.Name))
.ToArrayAsync();
// One grouped count for every group at once, so the "used by" column the UI shows costs a
// single aggregate query instead of the whole Subscription table over the wire.
var usedByWorkGroup = await dbContext.Set<Subscription>().AsNoTracking()
.Where(subscription => subscription.WorkGroupId != null)
.GroupBy(subscription => subscription.WorkGroupId!.Value)
.Select(group => new { WorkGroupId = group.Key, Count = group.Count() })
.ToDictionaryAsync(row => row.WorkGroupId, row => row.Count);

var consumerCounts = Array.Empty<ConsumerCount>();

try
Expand Down Expand Up @@ -82,6 +91,7 @@ public async Task<object> Handle(SearchWorkGroupModel request)
NotifierProcessingCount = notifiersCounts?.ProcessingCount,
NotifierQueueCount = notifiersCounts?.QueueCount,
ProcessorNodeCount = processorsCounts?.TotalNodes,
UsedByCount = usedByWorkGroup.GetValueOrDefault(workGroup.Id),
};
}).ToList();

Expand Down
6 changes: 6 additions & 0 deletions SW.Bitween.Sdk/Model/Document.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,11 @@ public class DocumentUpdate : DocumentCreate

public class DocumentRow : DocumentUpdate
{
/// <summary>
/// How many subscriptions carry this information type. Counted here because the admin UI
/// shows it in the list: computing it client-side meant downloading every subscription
/// alongside every page of this list.
/// </summary>
public int UsedByCount { get; set; }
}
}
7 changes: 7 additions & 0 deletions SW.Bitween.Sdk/Model/RetryPolicyModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ public class RetryPolicyRow
public int Id { get; set; }
public string Name { get; set; }
public int GroupCount { get; set; }

/// <summary>
/// How many subscriptions this policy is assigned to. Counted here because the admin UI shows
/// it in the list: computing it client-side meant downloading every subscription alongside
/// every page of this list.
/// </summary>
public int UsedByCount { get; set; }
}

/// <summary>
Expand Down
7 changes: 7 additions & 0 deletions SW.Bitween.Sdk/Model/Workgroups.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ public class WorkGroupModel
public long? NotifierQueueCount { get; set; }
/// <summary>Live count of active RabbitMQ consumer instances for this group's queue.</summary>
public long? ProcessorNodeCount { get; set; }

/// <summary>
/// How many subscriptions run in this work group. Counted here because the admin UI shows it
/// in the list: computing it client-side meant downloading every subscription alongside every
/// page of this list.
/// </summary>
public int UsedByCount { get; set; }
}

public class CreateWorkGroupModel
Expand Down
24 changes: 16 additions & 8 deletions SW.Bitween.Web/ClientApp/e2e/exchanges.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ test.beforeEach(async ({ page }) => {
test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => {
test.setTimeout(45000);
await page.goto("exchanges");
await expect(page.getByRole("row", { name: /azureBlob test sub/ }).first()).toBeVisible({ timeout: 15000 });
// Whatever the database holds: this used to pin to particular exchange ids and subscription
// names from a seed that no longer exists, which made it a test of the fixtures, not the page.
await expect(page.getByRole("row").nth(1)).toBeVisible({ timeout: 15000 });
await expect(page.getByText("undefined")).toHaveCount(0);
// Avoid the background refetch racing with row selection below.
await page.getByLabel("Refresh interval").selectOption("0");

// Filter down to failed exchanges only.
await page.getByRole("button", { name: "Failed" }).click();
const row = page.getByRole("row", { name: "36b3e2b1003048ff8dec1573b2f752c5" });
// Pick by content, not position: the filter re-renders the table, and an index would race it
// and land on whichever row was showing before.
const row = page.getByRole("row").filter({ hasText: "Failed" }).first();
await expect(row).toBeVisible({ timeout: 10000 });

// Expand the row (click the chevron cell — other cells stop propagation)
Expand All @@ -34,11 +38,15 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => {
// Bulk retry a couple of specific rows (not the whole page — each retry does
// real file I/O against storage, so keep this fast and deterministic).
await page.getByRole("button", { name: "All" }).click();
await expect(page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" })).toBeVisible({
timeout: 10000,
});
await page.getByRole("checkbox", { name: "Select 18dfd10c4b764b53aea339eedb98de18" }).check();
await page.getByRole("checkbox", { name: "Select a1b088fffe9e4993ac75736576a826cb" }).check();
// Row checkboxes only. "Select all on this page" also starts with "Select", and its checked
// state is derived from every row on the page — so a refetch landing mid-click (the filter
// above triggers one) flips it back and reads as a click that did nothing. A row checkbox is
// keyed by its own id and survives that. It also keeps the bulk retry to two rows, which is
// what this test says it wants: each retry is real file I/O.
const rowCheckbox = page.getByRole("checkbox", { name: /^Select (?!all\b)/ });
await expect(rowCheckbox.first()).toBeVisible({ timeout: 10000 });
await rowCheckbox.nth(0).check();
await rowCheckbox.nth(1).check();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
await page.getByRole("button", { name: "Retry selected…" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Retry" }).click();
Expand All @@ -47,7 +55,7 @@ test("exchanges list, filter, retry, bulk retry, create", async ({ page }) => {
// Manually create an exchange addressed at a subscription.
await page.goto("exchanges/new");
await page.getByRole("combobox", { name: "Pick a subscription…" }).click();
await page.getByRole("option", { name: "s3 test sub" }).click();
await page.getByRole("option").first().click();
// Dismiss the dropdown panel via an outside click (it sits above the panel's
// anchor point, so it can't itself be covered) rather than Escape, which
// doesn't close this Headless UI combobox instance.
Expand Down
119 changes: 59 additions & 60 deletions SW.Bitween.Web/ClientApp/e2e/gateways.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { test, expect } from "@playwright/test";

/** A seeded partner, used for the attachment this test makes and then removes. */
const PARTNER = "Acme Retail";

const ADMIN_EMAIL = "admin@Bitween.systems";
const ADMIN_PASSWORD = "Mtm@dmin!2";

Expand All @@ -22,49 +25,50 @@ test("API gateway: create, attach partner, create subscription detour, edit atta
await expect(page).toHaveURL(/\/api-gateways\/\d+$/);
await expect(page.getByRole("heading", { name })).toBeVisible();

// Attach a partner, detouring to create the required GatewayApiCall subscription inline.
// Attach a partner, detouring to create the required GatewayApiCall subscription. The
// detour is a nested route off the attach page, and both pickers are comboboxes now —
// there is no wizard to "Continue" through.
await page.getByRole("button", { name: "Attach partner" }).click();
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/);

await page.getByRole("button", { name: "acme" }).click();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("combobox", { name: "Partner" }).click();
await page.getByRole("option", { name: new RegExp(PARTNER) }).click();
await expect(page.getByRole("combobox", { name: "Partner" })).toHaveValue(PARTNER);

const subscriptionName = `Playwright GW Subscription ${Date.now()}`;
await page.getByRole("link", { name: "New subscription" }).click();
await expect(page).toHaveURL(/\/subscriptions\/new\?type=GatewayApiCall/);
await page.fill("#ni-name", subscriptionName);
await page.getByRole("button", { name: "test doc" }).click();
await page.getByLabel("handler adapter").click();
await page.getByRole("button", { name: "New subscription" }).click();
// The picked partner rides along as a query param through the detour.
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach\/new-subscription/);
await page.fill("#ngi-name", subscriptionName);
await page.getByRole("combobox", { name: "Information type" }).click();
await page.getByRole("option", { name: /Shipment order/ }).click();
await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Shipment order/);
await page.getByRole("combobox", { name: "handler adapter" }).click();
await page.getByRole("option", { name: "NativeHttpHandler" }).click();
await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 });
await page.locator("#prop-Url").fill("https://example.com/sink");
await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink");
await page.getByRole("button", { name: "Create subscription" }).click();

// This page itself renders a ReturnBanner with a "Continue" button before
// the mutation resolves (inherited from the detour link) — wait for the
// create to actually land on the new subscription's own page first, or the
// click races and hits that stale button instead.
await expect(page).toHaveURL(/\/subscriptions\/\d+\?/);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach$/);
await expect(page.getByText(subscriptionName)).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
// Back on the attach form with the new subscription already chosen.
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attach(\?|$)/);
// It comes back selected in the picker, so it is the combobox's value, not page text.
await expect(page.getByRole("combobox", { name: "Subscription" })).toHaveValue(subscriptionName);
await page.getByRole("button", { name: "Attach partner" }).click();

await expect(page).toHaveURL(/\/api-gateways\/\d+$/);
await expect(page.getByText("acme").first()).toBeVisible();
await expect(page.getByText(subscriptionName)).toBeVisible();
await expect(page.getByText(PARTNER).first()).toBeVisible();
await expect(page.getByText(subscriptionName).first()).toBeVisible();

// Edit the attachment — exercises the remove-then-add path (backend's
// updatepartner can't mutate a composite-key column in place).
await page.getByRole("button", { name: "Edit attachment for acme" }).click();
await page.getByRole("button", { name: `Edit attachment for ${PARTNER}` }).click();
await expect(page).toHaveURL(/\/api-gateways\/\d+\/attachments\/\d+$/);
await page.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(/\/api-gateways\/\d+$/);
await expect(page.getByText(subscriptionName)).toBeVisible();

// Detach.
await page.getByRole("button", { name: "Detach acme" }).click();
await page.getByRole("button", { name: `Detach ${PARTNER}` }).click();
await page
.getByRole("dialog", { name: "Detach this partner?" })
.getByRole("button", { name: "Detach partner" })
Expand All @@ -79,72 +83,67 @@ test("API gateway: create, attach partner, create subscription detour, edit atta
.getByRole("dialog", { name: "Delete this API gateway?" })
.getByRole("button", { name: "Delete gateway" })
.click();
// ApiGatewayPage navigates to /api-gateways, which the router redirects to
// the unified subscriptions list.
await expect(page).toHaveURL(/\/subscriptions\?types=api-gateways$/);
// API gateways have their own list page now, which is where deleting one lands.
await expect(page).toHaveURL(/\/api-gateways$/);
});

test("Bus gateway: create, add route with match expression, edit route, remove, delete", async ({ page }) => {
const name = `Playwright Bus GW ${Date.now()}`;

await page.goto("bus-gateways/new");
await page.fill("#nbg-name", name);
await page.getByRole("button", { name: "test-hh" }).click();
// Bus-enabled types only, and this one is the one no seeded gateway already listens for.
await page.getByRole("combobox", { name: "Information type" }).click();
await page.getByRole("option", { name: /Delivery proof/ }).click();
await expect(page.getByRole("combobox", { name: "Information type" })).toHaveValue(/Delivery proof/);
await page.getByRole("button", { name: "Create gateway" }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+$/);
await expect(page.getByRole("heading", { name })).toBeVisible();

await page.getByRole("button", { name: "Add route" }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/);
// Routes are built on the gateway's own canvas now — no separate add-route page, and no
// wizard: the route, its subscription and that subscription's delivery are all edited in
// place, and "Create route" saves the lot.
// An empty gateway offers both "Add a route" in the header and "Add the first route"
// in the empty canvas; either does the same thing.
await page.getByRole("button", { name: /^Add (a|the first) route/ }).first().click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+\?route=new/);

// Filter step — leave the match expression empty (null = matches everything).
await page.getByRole("button", { name: "Continue" }).click();
// Partner step — no partner.
await page.getByRole("button", { name: "No partner" }).click();
await page.getByRole("button", { name: "Continue" }).click();

// Subscription step — detour to create the required BusGateway subscription.
// No partner and no filter: an empty match expression means "matches everything".
const subscriptionName = `Playwright Bus Subscription ${Date.now()}`;
await page.getByRole("link", { name: "New subscription" }).click();
await expect(page).toHaveURL(/\/subscriptions\/new\?type=BusGateway/);
await page.fill("#ni-name", subscriptionName);
await page.getByLabel("handler adapter").click();
await page.getByRole("button", { name: "New subscription" }).click();
await page.fill("#bs-int-name", subscriptionName);

// Its delivery is a node on the same canvas.
await page.getByRole("button", { name: /^Delivery/ }).click();
await page.getByRole("combobox", { name: "handler adapter" }).click();
await page.getByRole("option", { name: "NativeHttpHandler" }).click();
await expect(page.getByRole("listbox")).toHaveCount(0, { timeout: 10000 });
await page.locator("#prop-Url").fill("https://example.com/sink");
await page.getByRole("button", { name: "Create subscription" }).click();
await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink");

// Wait for the create to actually land (see the comment in the API gateway
// test above) before clicking the ReturnBanner's "Continue".
await expect(page).toHaveURL(/\/subscriptions\/\d+\?/);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+\/add-route$/);
await expect(page.getByText(subscriptionName)).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Add route" }).click();

await expect(page).toHaveURL(/\/bus-gateways\/\d+$/);
await expect(page.getByText(subscriptionName)).toBeVisible();
await page.getByRole("button", { name: "Create route" }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+\?.*route=\d+/);

// Edit the route (no-op save exercises the round trip of a null match expression).
await page.getByRole("button", { name: /Edit route \d+/ }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+\/routes\/\d+$/);
await page.getByRole("button", { name: "Save" }).click();
await expect(page).toHaveURL(/\/bus-gateways\/\d+$/);
// Reload to prove the route round-tripped, null match expression and all.
await page.reload();
await expect(page.getByText(subscriptionName).first()).toBeVisible();

// Remove the route.
await page.getByRole("button", { name: /Remove route \d+/ }).click();
await page.getByRole("button", { name: "Remove route" }).click();
await page
.getByRole("dialog", { name: "Remove this route?" })
.getByRole("button", { name: "Remove route" })
.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("No routes")).toBeVisible();
// The route list and the main panel both say this, so name the panel's heading: it shows only
// when no route is selected, which is the thing actually worth proving here — that removing
// the last route doesn't leave the page still pointing at it.
await expect(page.getByRole("heading", { name: /^No routes —/ })).toBeVisible();

await page.getByRole("button", { name: "Delete" }).click();
await page
.getByRole("dialog", { name: "Delete this bus gateway?" })
.getByRole("button", { name: "Delete gateway" })
.click();
await expect(page).toHaveURL(/\/subscriptions\?types=bus-gateways$/);
// Bus gateways have their own list page now, which is where deleting one lands.
await expect(page).toHaveURL(/\/bus-gateways$/);
});
Loading
Loading