using System.Net;
using System.Net.Http.Json;
using DodoSSH.Contracts;

namespace DodoSSH.Api.Tests;

/// <summary>
/// Teams, membership, invitations and the vault key grants that make a team vault readable.
/// </summary>
/// <remarks>
/// <para>
/// The half of M3 that runs on the server, which is the half that decides <em>what will be served</em>.
/// Whether a member can decrypt what they are served is decided by holding a key, and no test here can
/// assert it — that lives in the client suite, where a key exists. The two are separate on purpose and
/// these tests are written to keep them separate: none of them checks that a wrapped key is right,
/// because the server cannot.
/// </para>
/// <para>
/// The cases worth having are the ones where a mistake would be invisible. A member removed but still
/// served; a viewer allowed to push; a vault visible to a team it does not belong to; a grant accepted
/// for a key its recipient no longer holds. Each of those looks exactly like working software from the
/// outside.
/// </para>
/// <para>
/// Two boundaries in here are load-bearing beyond their own endpoint, and both are of that invisible
/// kind. An admin who can archive a team or hand it away is an admin who can take it from the person
/// who promoted them, and nothing about the response would say so. An invitation claimed on an address
/// the identity provider never vouched for is a way into somebody else's team, and every other part of
/// that request succeeds. Neither has a symptom; each has a test.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class TeamEndpointTests(ApiFixture fixture)
{
    private const string TeamsUrl = "/api/v1/teams";
    private const string MeUrl = "/api/v1/me";
    private const string EnrollUrl = "/api/v1/me/enrollment";

    // ---- Creating and listing ----

    [Fact]
    public async Task CreatingATeam_MakesTheCallerItsOwner()
    {
        var client = await EnrolledClientAsync("team-owner");

        var team = await CreateTeamAsync(client, "Platform");

        team.Role.ShouldBe(TeamMemberRole.Owner);
        team.MemberCount.ShouldBe(1);
        team.VaultCount.ShouldBe(0);

        var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);

        listed.ShouldContain(row => row.TeamId == team.TeamId);
    }

    /// <remarks>
    /// The same body twice, as a client whose response was lost would send it. Enrollment behaves this way
    /// and a team create has the same shape — a client-chosen id — so it has to behave the same or a lost
    /// response leaves somebody with two teams under one name.
    /// </remarks>
    [Fact]
    public async Task RepeatingACreate_ReturnsTheSameTeamRatherThanASecondOne()
    {
        var client = await EnrolledClientAsync("team-retry");

        var request = new CreateTeamRequest(
            Guid.CreateVersion7(), "Retry", $"retry-{Guid.CreateVersion7():N}", null);

        var first = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);
        var second = await PostAsync<CreateTeamRequest, TeamSummary>(client, TeamsUrl, request);

        second.TeamId.ShouldBe(first.TeamId);

        var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(client, TeamsUrl);

        listed.Count(row => row.TeamId == first.TeamId).ShouldBe(1);
    }

    [Fact]
    public async Task ASlugAlreadyInUse_IsRefusedWithItsOwnCode()
    {
        var client = await EnrolledClientAsync("team-slug");
        var slug = $"taken-{Guid.CreateVersion7():N}";

        await PostAsync<CreateTeamRequest, TeamSummary>(
            client, TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "First", slug, null));

        var response = await client.PostContractAsync(
            TeamsUrl, new CreateTeamRequest(Guid.CreateVersion7(), "Second", slug, null));

        await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamSlugTaken);
    }

    /// <remarks>
    /// A team somebody is not in answers 404, not 403. Distinguishing them would let a caller confirm
    /// which team ids exist, and team ids travel in URLs.
    /// </remarks>
    [Fact]
    public async Task ATeamTheCallerIsNotIn_IsIndistinguishableFromOneThatDoesNotExist()
    {
        var owner = await EnrolledClientAsync("team-private-owner");
        var stranger = await EnrolledClientAsync("team-private-stranger");

        var team = await CreateTeamAsync(owner, "Private");

        var real = await GetAsync(stranger, MembersUrl(team.TeamId));
        var invented = await GetAsync(stranger, MembersUrl(Guid.CreateVersion7()));

        real.StatusCode.ShouldBe(HttpStatusCode.NotFound);
        invented.StatusCode.ShouldBe(real.StatusCode);
    }

    // ---- Renaming ----

    /// <remarks>
    /// The rename is read back from the list rather than from the response body, because the list is
    /// what a member's screen is built from and it is assembled by different code. The slug is asserted
    /// unchanged because it is unique only among live teams: a rename that moved it could take a slug an
    /// archived team still holds, and that archived team could then never be brought back.
    /// </remarks>
    [Fact]
    public async Task RenamingATeam_ChangesTheListedNameAndLeavesTheSlugAlone()
    {
        var owner = await EnrolledClientAsync("rename-owner");

        var team = await CreateTeamAsync(owner, "Before");

        var response = await owner.PutContractAsync(
            TeamUrl(team.TeamId), new UpdateTeamRequest("After", "A description it did not have."));

        response.StatusCode.ShouldBe(HttpStatusCode.OK);

        var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);
        var renamed = listed.Where(row => row.TeamId == team.TeamId).ShouldHaveSingleItem();

        renamed.Name.ShouldBe("After");
        renamed.Description.ShouldBe("A description it did not have.");
        renamed.Slug.ShouldBe(team.Slug);
    }

    /// <remarks>
    /// Null clears the description rather than leaving it alone. A PUT that treated an absent value as
    /// "no change" would make a cleared description impossible to express at all, since there is no
    /// other verb for it.
    /// </remarks>
    [Fact]
    public async Task RenamingWithoutADescription_ClearsTheOneThatWasThere()
    {
        var owner = await EnrolledClientAsync("rename-clear-owner");

        var team = await PostAsync<CreateTeamRequest, TeamSummary>(
            owner,
            TeamsUrl,
            new CreateTeamRequest(
                Guid.CreateVersion7(), "Described", $"team-{Guid.CreateVersion7():N}", "Something."));

        team.Description.ShouldBe("Something.");

        var response = await owner.PutContractAsync(
            TeamUrl(team.TeamId), new UpdateTeamRequest("Described", null));

        response.EnsureSuccessStatusCode();

        var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);

        listed.Where(row => row.TeamId == team.TeamId)
            .ShouldHaveSingleItem()
            .Description.ShouldBeNull();
    }

    /// <remarks>
    /// A member is refused with 403 rather than 404: the team is visible to them, so naming the reason
    /// leaks nothing and "you are not an admin" is a more useful answer than "no such team".
    /// </remarks>
    [Fact]
    public async Task APlainMember_CanRenameNothingAndCanNeitherArchiveNorHandOverTheTeam()
    {
        var owner = await EnrolledClientAsync("member-limits-owner", "mlowner@example.com");
        var member = await EnrolledClientAsync("member-limits-member", "mlmember@example.com");

        var team = await CreateTeamAsync(owner, "Limits");
        var entry = await LookupAsync(owner, "mlmember@example.com");
        var memberMe = await ReadAsync<MeResponse>(member, MeUrl);

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var renamed = await member.PutContractAsync(
            TeamUrl(team.TeamId), new UpdateTeamRequest("Theirs now", null));

        await ShouldBeProblemAsync(renamed, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);

        var archived = await DeleteAsync(member, TeamUrl(team.TeamId));

        await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);

        var transferred = await member.PostContractAsync(
            OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(memberMe.UserId));

        await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
    }

    /// <remarks>
    /// <para>
    /// <b>The <c>TeamAccess.IsOwner</c> boundary, and the most important authorization test in this
    /// feature.</b> An admin may manage members and vaults, and that is deliberately not the same
    /// permission as deciding whether the team continues to exist or who controls it. If either of
    /// these checks were widened to <c>CanAdminister</c> — which is what every neighbouring endpoint
    /// uses, so it is the easy mistake — anybody the owner promoted could archive the team out from
    /// under them or take it outright, and nothing in the response of any other test would change.
    /// </para>
    /// <para>
    /// The rename is asserted in the same test on purpose: it is what shows the admin genuinely holds
    /// administrative rights here, so the two refusals are the boundary rather than a broken role.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AnAdminWhoIsNotTheOwner_MayRenameButMayNotArchiveOrHandOverTheTeam()
    {
        var owner = await EnrolledClientAsync("admin-boundary-owner", "abowner@example.com");
        var admin = await EnrolledClientAsync("admin-boundary-admin", "abadmin@example.com");

        var team = await CreateTeamAsync(owner, "Boundary");
        var entry = await LookupAsync(owner, "abadmin@example.com");
        var adminMe = await ReadAsync<MeResponse>(admin, MeUrl);

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Admin);

        // They really are an admin: this one succeeds.
        var renamed = await admin.PutContractAsync(
            TeamUrl(team.TeamId), new UpdateTeamRequest("Renamed by an admin", null));

        renamed.StatusCode.ShouldBe(HttpStatusCode.OK);

        var archived = await DeleteAsync(admin, TeamUrl(team.TeamId));

        await ShouldBeProblemAsync(archived, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);

        var transferred = await admin.PostContractAsync(
            OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(adminMe.UserId));

        await ShouldBeProblemAsync(transferred, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);

        // And nothing moved: the team is still there and still owned by the person who made it.
        var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
            owner, MembersUrl(team.TeamId));

        var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);

        members.Where(row => row.UserId == ownerMe.UserId)
            .ShouldHaveSingleItem()
            .Role.ShouldBe(TeamMemberRole.Owner);
    }

    // ---- Archiving ----

    /// <remarks>
    /// Archiving hides a team from every member's list at once, and a team vault resolves through
    /// membership — so archiving one that still owned vaults would take those vaults away from
    /// everybody holding a key, including the caller, with no way back because nothing in this product
    /// deletes a vault. The refusal is the end of that road rather than a step on it, which is why the
    /// team is asserted still listed afterwards.
    /// </remarks>
    [Fact]
    public async Task ArchivingATeamThatOwnsAVault_IsRefusedAndLeavesTheTeamListed()
    {
        var owner = await EnrolledClientAsync("archive-vault-owner");

        var team = await CreateTeamAsync(owner, "Occupied");
        await CreateVaultAsync(owner, team.TeamId);

        var response = await DeleteAsync(owner, TeamUrl(team.TeamId));

        await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.TeamNotEmpty);

        var listed = await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl);

        listed.ShouldContain(row => row.TeamId == team.TeamId);
    }

    /// <remarks>
    /// Every member's list, not just the caller's. Memberships are archived with the team in the same
    /// transaction, and a live membership pointing at an archived team would leave the other member
    /// still seeing it — which is the shape a half-applied archive takes.
    /// </remarks>
    [Fact]
    public async Task ArchivingAnEmptyTeam_RemovesItFromEveryMembersList()
    {
        var owner = await EnrolledClientAsync("archive-owner", "aowner@example.com");
        var member = await EnrolledClientAsync("archive-member", "amember@example.com");

        var team = await CreateTeamAsync(owner, "Wound up");
        var entry = await LookupAsync(owner, "amember@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
            .ShouldContain(row => row.TeamId == team.TeamId);

        var response = await DeleteAsync(owner, TeamUrl(team.TeamId));

        response.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
            .ShouldNotContain(row => row.TeamId == team.TeamId);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(member, TeamsUrl))
            .ShouldNotContain(row => row.TeamId == team.TeamId);

        // And it is gone the way a team nobody is in is gone, rather than merely unlisted.
        (await GetAsync(owner, MembersUrl(team.TeamId))).StatusCode.ShouldBe(HttpStatusCode.NotFound);
    }

    // ---- Ownership ----

    /// <remarks>
    /// <b>Both roles, because either alone would pass while the team was broken.</b> Asserting only
    /// that the recipient is now owner would pass with the team owned twice; asserting only that the
    /// outgoing owner is an admin would pass with it owned by nobody. Ownership is sole and the two
    /// writes are one transaction precisely so that neither of those states can exist, so the count of
    /// owners is asserted too.
    /// </remarks>
    [Fact]
    public async Task TransferringOwnership_MakesTheTargetOwnerAndTheOutgoingOwnerAnAdmin()
    {
        var owner = await EnrolledClientAsync("transfer-owner", "towner@example.com");
        var successor = await EnrolledClientAsync("transfer-successor", "tsuccessor@example.com");

        var team = await CreateTeamAsync(owner, "Handover");
        var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
        var entry = await LookupAsync(owner, "tsuccessor@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var response = await owner.PostContractAsync(
            OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));

        response.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
            owner, MembersUrl(team.TeamId));

        members.Where(row => row.UserId == entry.UserId)
            .ShouldHaveSingleItem()
            .Role.ShouldBe(TeamMemberRole.Owner);

        members.Where(row => row.UserId == ownerMe.UserId)
            .ShouldHaveSingleItem()
            .Role.ShouldBe(TeamMemberRole.Admin, "the outgoing owner is demoted, not removed");

        members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);

        // And the recipient is told so by the endpoint their own screen reads.
        (await ReadAsync<IReadOnlyList<TeamSummary>>(successor, TeamsUrl))
            .Where(row => row.TeamId == team.TeamId)
            .ShouldHaveSingleItem()
            .Role.ShouldBe(TeamMemberRole.Owner);
    }

    /// <remarks>
    /// The thing that was impossible before this endpoint existed. An owner could not be removed and
    /// could not be demoted, so somebody who left the company owning a team left it owned by them for
    /// ever — a state only an operator with database access could fix.
    /// </remarks>
    [Fact]
    public async Task AfterATransfer_TheFormerOwnerCanFinallyBeRemoved()
    {
        var owner = await EnrolledClientAsync("departing-owner", "downer@example.com");
        var successor = await EnrolledClientAsync("departing-successor", "dsuccessor@example.com");

        var team = await CreateTeamAsync(owner, "Departure");
        var ownerMe = await ReadAsync<MeResponse>(owner, MeUrl);
        var entry = await LookupAsync(owner, "dsuccessor@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var transferred = await owner.PostContractAsync(
            OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));

        transferred.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        var removed = await DeleteAsync(successor, MemberUrl(team.TeamId, ownerMe.UserId));

        removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(owner, TeamsUrl))
            .ShouldNotContain(row => row.TeamId == team.TeamId);
    }

    /// <remarks>
    /// Adding somebody and handing them the team in one step would let an id supplied once take it, so
    /// the recipient has to be an active member already. This is the same refusal that stops a stranger
    /// being made owner by pasting their id.
    /// </remarks>
    [Fact]
    public async Task TransferringToSomebodyWhoIsNotAMember_IsRefused()
    {
        var owner = await EnrolledClientAsync("transfer-closed-owner", "tcowner@example.com");
        await EnrolledClientAsync("transfer-outsider", "toutsider@example.com");

        var team = await CreateTeamAsync(owner, "Closed handover");
        var entry = await LookupAsync(owner, "toutsider@example.com");

        var response = await owner.PostContractAsync(
            OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));

        await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
    }

    [Fact]
    public async Task TransferringToYourself_IsRefused()
    {
        var owner = await EnrolledClientAsync("transfer-self-owner");

        var team = await CreateTeamAsync(owner, "Already mine");
        var me = await ReadAsync<MeResponse>(owner, MeUrl);

        var response = await owner.PostContractAsync(
            OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(me.UserId));

        await ShouldBeProblemAsync(response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeam);
    }

    // ---- Membership ----

    /// <remarks>
    /// The membership half of M3 in one test: a team vault appears in the other member's <c>/me</c> the
    /// moment they are added, and it appears <em>without</em> a wrapped key. That null is the whole
    /// design — the server can grant access to the ciphertext and cannot grant the ability to read it.
    /// </remarks>
    [Fact]
    public async Task AnAddedMember_SeesTheTeamVaultWithNoKeyUntilSomebodyWrapsOne()
    {
        var owner = await EnrolledClientAsync("grant-owner", "owner@example.com");
        var member = await EnrolledClientAsync("grant-member", "member@example.com");

        var team = await CreateTeamAsync(owner, "Sharing");
        var vaultId = await CreateVaultAsync(owner, team.TeamId);

        var entry = await LookupAsync(owner, "member@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var me = await ReadAsync<MeResponse>(member, MeUrl);
        var vault = me.Vaults.SingleOrDefault(summary => summary.VaultId == vaultId);

        vault.ShouldNotBeNull("membership is what makes a team vault visible");
        vault.WrappedVaultKey.ShouldBeNull("and it is not what makes it readable");
        vault.IsPersonal.ShouldBeFalse();
        vault.TeamId.ShouldBe(team.TeamId);
    }

    /// <remarks>
    /// A viewer may read the vault and may not write to it. The failure this guards is the quiet one: a
    /// role that resolved to the wrong flags would let somebody who was added to look at a vault change
    /// what everybody else connects with.
    /// </remarks>
    [Fact]
    public async Task AViewer_MayPullAndMayNotPush()
    {
        var owner = await EnrolledClientAsync("viewer-owner", "vowner@example.com");
        var viewer = await EnrolledClientAsync("viewer-member", "viewer@example.com");

        var team = await CreateTeamAsync(owner, "Read only");
        var vaultId = await CreateVaultAsync(owner, team.TeamId);

        var entry = await LookupAsync(owner, "viewer@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Viewer);

        var pull = await viewer.PostContractAsync(
            $"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));

        pull.StatusCode.ShouldBe(HttpStatusCode.OK);

        var push = await viewer.PostContractAsync(
            $"/api/v1/vaults/{vaultId}/sync/push", new SyncPushRequest([]));

        await ShouldBeProblemAsync(push, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
    }

    /// <remarks>
    /// And the reverse, which is what removal has to mean: the vault stops being served at all. Note what
    /// is <em>not</em> asserted — that they have forgotten anything. They have not, and ADR 0001 says so.
    /// </remarks>
    [Fact]
    public async Task ARemovedMember_StopsBeingServedTheTeamsVault()
    {
        var owner = await EnrolledClientAsync("removal-owner", "rowner@example.com");
        var member = await EnrolledClientAsync("removal-member", "rmember@example.com");

        var team = await CreateTeamAsync(owner, "Departures");
        var vaultId = await CreateVaultAsync(owner, team.TeamId);

        var entry = await LookupAsync(owner, "rmember@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var before = await ReadAsync<MeResponse>(member, MeUrl);
        before.Vaults.ShouldContain(summary => summary.VaultId == vaultId);

        var removed = await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));

        removed.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        var after = await ReadAsync<MeResponse>(member, MeUrl);
        after.Vaults.ShouldNotContain(summary => summary.VaultId == vaultId);

        var pull = await member.PostContractAsync(
            $"/api/v1/vaults/{vaultId}/sync/pull", new SyncPullRequest(null, null, null));

        pull.StatusCode.ShouldBe(HttpStatusCode.NotFound);
    }

    /// <remarks>
    /// Removing a member leaves the vault flagged for rekey, which is a promise the server records and
    /// cannot keep on its own: rekeying re-wraps every item's data key and only a client holding the
    /// current one can do that. The flag is what the interface reads to say so; M5 is what acts on it.
    /// </remarks>
    [Fact]
    public async Task RemovingAMember_FlagsTheTeamsVaultsForRekey()
    {
        var owner = await EnrolledClientAsync("rekey-owner", "kowner@example.com");
        await EnrolledClientAsync("rekey-member", "kmember@example.com");

        var team = await CreateTeamAsync(owner, "Rekeys");
        var vaultId = await CreateVaultAsync(owner, team.TeamId);

        var entry = await LookupAsync(owner, "kmember@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        await DeleteAsync(owner, MemberUrl(team.TeamId, entry.UserId));

        var grants = await ReadAsync<VaultGrantsResponse>(owner, $"/api/v1/vaults/{vaultId}/grants");

        grants.RekeyRequired.ShouldBeTrue();
    }

    /// <remarks>
    /// The owner cannot be removed even now that ownership can be handed over, and this is the standing
    /// guarantee rather than a leftover: transferring is what makes the team's next owner exist, so
    /// removing the current one first would still leave it with nobody who can manage it. Refused with a
    /// code the client can act on rather than a bare 400, since "you cannot do that" and "you did that
    /// wrong" lead somewhere different.
    /// </remarks>
    [Fact]
    public async Task TheOwner_CannotBeRemoved()
    {
        var owner = await EnrolledClientAsync("sole-owner", "sole@example.com");

        var team = await CreateTeamAsync(owner, "Sole");
        var me = await ReadAsync<MeResponse>(owner, MeUrl);

        var response = await DeleteAsync(owner, MemberUrl(team.TeamId, me.UserId));

        await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);
    }

    /// <remarks>
    /// The other half of the same guarantee, and the one a transfer endpoint could plausibly have
    /// loosened. Demoting the owner through the role endpoint cannot appoint a replacement in the same
    /// breath, so it would leave the team ownerless — which is exactly the state
    /// <c>TransferOwnershipAsync</c> does two writes in one transaction to avoid.
    /// </remarks>
    [Fact]
    public async Task TheOwner_CannotBeDemotedOnTheirOwn()
    {
        var owner = await EnrolledClientAsync("demote-owner", "demote@example.com");

        var team = await CreateTeamAsync(owner, "Undemotable");
        var me = await ReadAsync<MeResponse>(owner, MeUrl);

        var response = await owner.PutContractAsync(
            MemberRoleUrl(team.TeamId, me.UserId),
            new ChangeTeamMemberRoleRequest(TeamMemberRole.Admin));

        await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.LastTeamOwner);

        var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
            owner, MembersUrl(team.TeamId));

        members.Where(row => row.UserId == me.UserId)
            .ShouldHaveSingleItem()
            .Role.ShouldBe(TeamMemberRole.Owner);
    }

    /// <remarks>
    /// A real value, not a placeholder. <c>LastActiveAt</c> is the field the teams screen uses to say
    /// whether a colleague has been here at all, and the failure it guards against is the one that made
    /// the column impossible to offer before: a null for somebody who has plainly been making requests
    /// reads as "never", which is a lie about a person.
    /// </remarks>
    [Fact]
    public async Task AMemberListing_ReportsWhenAnAccountWasLastActive()
    {
        var owner = await EnrolledClientAsync("last-seen-owner");

        var team = await CreateTeamAsync(owner, "Last seen");
        var me = await ReadAsync<MeResponse>(owner, MeUrl);

        var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
            owner, MembersUrl(team.TeamId));

        var self = members.Where(row => row.UserId == me.UserId).ShouldHaveSingleItem();

        self.LastActiveAt.ShouldNotBeNull(
            "this account has made several authenticated requests already");

        // Within the window the server writes on, which is the whole of the precision this carries.
        self.LastActiveAt.Value.ShouldBeGreaterThan(TimeProvider.System.GetUtcNow().AddHours(-1));
    }

    // ---- Invitations ----

    /// <remarks>
    /// The expiry is asserted rather than merely present. An invitation that never lapsed would be a
    /// standing offer against an address, and company addresses are handed to the next person to hold
    /// the job — so the person who inherits the mailbox would inherit the team.
    /// </remarks>
    [Fact]
    public async Task InvitingAnAddress_ListsItAsPendingWithItsRoleAndAnExpiry()
    {
        var owner = await EnrolledClientAsync("invite-owner");
        var address = NewAddress();

        var team = await CreateTeamAsync(owner, "Invitations");
        var created = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Admin);

        var listed = await FindInvitationAsync(owner, team.TeamId, created.InvitationId);

        listed.Email.ShouldBe(address);
        listed.Role.ShouldBe(TeamMemberRole.Admin);
        listed.State.ShouldBe(TeamInvitationState.Pending);
        listed.AcceptedAt.ShouldBeNull();
        (listed.ExpiresAt - listed.CreatedAt).ShouldBe(TimeSpan.FromDays(14));
    }

    /// <remarks>
    /// The same body twice, as a client whose response was lost would send it — the shape team and
    /// vault creation already have. Two invitations to one address would show the same person twice on
    /// the teams screen and take two revocations to withdraw.
    /// </remarks>
    [Fact]
    public async Task RepeatingAnInvitation_ReturnsTheSameOneRatherThanASecond()
    {
        var owner = await EnrolledClientAsync("invite-retry-owner");
        var address = NewAddress();

        var team = await CreateTeamAsync(owner, "Retried invitations");

        var request = new CreateTeamInvitationRequest(
            Guid.CreateVersion7(), address, TeamMemberRole.Member);

        var first = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
            owner, InvitationsUrl(team.TeamId), request);

        var second = await PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
            owner, InvitationsUrl(team.TeamId), request);

        second.InvitationId.ShouldBe(first.InvitationId);

        var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
            owner, InvitationsUrl(team.TeamId));

        // Case-insensitively, because the column is citext and two addresses differing only in case
        // are one address — a second row under a different casing would still be a second invitation.
        listed.Count(row => string.Equals(row.Email, address, StringComparison.OrdinalIgnoreCase))
            .ShouldBe(1);
    }

    [Fact]
    public async Task ASecondInvitationToTheSameAddress_IsRefused()
    {
        var owner = await EnrolledClientAsync("invite-duplicate-owner");
        var address = NewAddress();

        var team = await CreateTeamAsync(owner, "Duplicate invitations");

        await InviteAsync(owner, team.TeamId, address);

        // A different id, so this is a second invitation rather than a retry of the first.
        var response = await owner.PostContractAsync(
            InvitationsUrl(team.TeamId),
            new CreateTeamInvitationRequest(Guid.CreateVersion7(), address, TeamMemberRole.Admin));

        await ShouldBeProblemAsync(
            response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
    }

    /// <remarks>
    /// Ownership is sole and is handed over deliberately. An invitation that conferred it would let an
    /// address typed once take the team the moment somebody signed in with it — and the invitee is by
    /// definition somebody nobody here has met.
    /// </remarks>
    [Fact]
    public async Task InvitingSomebodyAsOwner_IsRefused()
    {
        var owner = await EnrolledClientAsync("invite-owner-role-owner");

        var team = await CreateTeamAsync(owner, "Not for sale");

        var response = await owner.PostContractAsync(
            InvitationsUrl(team.TeamId),
            new CreateTeamInvitationRequest(
                Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Owner));

        await ShouldBeProblemAsync(
            response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
    }

    [Fact]
    public async Task AMalformedAddress_IsRefused()
    {
        var owner = await EnrolledClientAsync("invite-malformed-owner");

        var team = await CreateTeamAsync(owner, "Shapes");

        var response = await owner.PostContractAsync(
            InvitationsUrl(team.TeamId),
            new CreateTeamInvitationRequest(
                Guid.CreateVersion7(), "not an address", TeamMemberRole.Member));

        await ShouldBeProblemAsync(
            response, HttpStatusCode.BadRequest, ProblemCodes.InvalidTeamInvitation);
    }

    /// <remarks>
    /// A withdrawn invitation stays in the listing rather than vanishing, so the screen can show that it
    /// was withdrawn rather than letting it read as never sent. Withdrawing it twice is 404, for the
    /// reason revoking a device grant gives: a caller driving towards "that invitation will not let
    /// anybody in" can treat 404 as having arrived.
    /// </remarks>
    [Fact]
    public async Task RevokingAnInvitation_MarksItRevokedAndCannotBeDoneTwice()
    {
        var owner = await EnrolledClientAsync("invite-revoke-owner");

        var team = await CreateTeamAsync(owner, "Withdrawals");
        var invitation = await InviteAsync(owner, team.TeamId, NewAddress());

        var revoked = await DeleteAsync(
            owner, InvitationUrl(team.TeamId, invitation.InvitationId));

        revoked.StatusCode.ShouldBe(HttpStatusCode.NoContent);

        var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);

        listed.State.ShouldBe(TeamInvitationState.Revoked);

        var again = await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));

        again.StatusCode.ShouldBe(HttpStatusCode.NotFound);
    }

    /// <remarks>
    /// An invitation is a fact about the team, so any member may read the list — whoever is about to be
    /// handed a vault key needs to see who else is on their way in — but only an admin may write one.
    /// </remarks>
    [Fact]
    public async Task APlainMember_MayReadInvitationsAndMayNotIssueThem()
    {
        var owner = await EnrolledClientAsync("invite-reader-owner", "irowner@example.com");
        var member = await EnrolledClientAsync("invite-reader-member", "irmember@example.com");

        var team = await CreateTeamAsync(owner, "Readable invitations");
        var entry = await LookupAsync(owner, "irmember@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var invitation = await InviteAsync(owner, team.TeamId, NewAddress());

        var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
            member, InvitationsUrl(team.TeamId));

        listed.ShouldContain(row => row.InvitationId == invitation.InvitationId);

        var refused = await member.PostContractAsync(
            InvitationsUrl(team.TeamId),
            new CreateTeamInvitationRequest(
                Guid.CreateVersion7(), NewAddress(), TeamMemberRole.Member));

        await ShouldBeProblemAsync(refused, HttpStatusCode.Forbidden, ProblemCodes.Forbidden);
    }

    // ---- Claiming an invitation at sign-in ----

    /// <remarks>
    /// The heart of the feature, and the only path by which an invitation becomes anything. There is no
    /// token and no mail: the row says "the next account to sign in with this address joins this team",
    /// and just-in-time provisioning is what reads it. What it creates is a membership and not a key —
    /// the vault key still has to be wrapped to them from a machine that holds one.
    /// </remarks>
    [Fact]
    public async Task SigningInWithAnInvitedAddress_JoinsTheTeamAndMarksTheInvitationAccepted()
    {
        var owner = await EnrolledClientAsync("claim-owner");
        var address = NewAddress();

        var team = await CreateTeamAsync(owner, "Claimed");
        var invitation = await InviteAsync(owner, team.TeamId, address, TeamMemberRole.Member);

        var (invitee, inviteeUserId) = await SignInAsync(address);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl))
            .Where(row => row.TeamId == team.TeamId)
            .ShouldHaveSingleItem()
            .Role.ShouldBe(TeamMemberRole.Member);

        var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(
            owner, MembersUrl(team.TeamId));

        var joined = members.Where(row => row.UserId == inviteeUserId).ShouldHaveSingleItem();

        joined.Status.ShouldBe(TeamMemberStatus.Active);
        joined.Role.ShouldBe(TeamMemberRole.Member);
        joined.IsEnrolled.ShouldBeFalse("membership is authorization, and they hold no key yet");

        var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);

        listed.State.ShouldBe(TeamInvitationState.Accepted);
        listed.AcceptedAt.ShouldNotBeNull();
    }

    /// <remarks>
    /// <b>The security test this feature stands on.</b> An invitation is authorization: claiming one is
    /// what decides that this server will serve somebody a team's vaults. An address the identity
    /// provider has not vouched for is an address anybody able to obtain a token can name, so an
    /// unverified one must confer nothing — that is the same attack <c>AllowEmailLinking</c> exists to
    /// refuse, arriving by a different door. The failure would be silent by construction: the account is
    /// provisioned either way and every other part of the request succeeds, so nothing but this would
    /// notice that the wrong person had walked into the team.
    /// </remarks>
    [Fact]
    public async Task AnAddressTheProviderHasNotVerified_ClaimsNothing()
    {
        var owner = await EnrolledClientAsync("unverified-owner");
        var address = NewAddress();

        var team = await CreateTeamAsync(owner, "Verified only");
        var invitation = await InviteAsync(owner, team.TeamId, address);

        var (impostor, _) = await SignInAsync(address, emailVerified: false);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(impostor, TeamsUrl)).ShouldBeEmpty();

        var listed = await FindInvitationAsync(owner, team.TeamId, invitation.InvitationId);

        listed.State.ShouldBe(TeamInvitationState.Pending);
        listed.AcceptedAt.ShouldBeNull();
    }

    /// <remarks>
    /// A withdrawn invitation is withdrawn, which the claim path has to honour independently — it reads
    /// the invitation table directly rather than going back through the endpoint that refused.
    /// </remarks>
    [Fact]
    public async Task SigningInAfterAnInvitationWasWithdrawn_JoinsNothing()
    {
        var owner = await EnrolledClientAsync("withdrawn-owner");
        var address = NewAddress();

        var team = await CreateTeamAsync(owner, "Withdrawn");
        var invitation = await InviteAsync(owner, team.TeamId, address);

        await DeleteAsync(owner, InvitationUrl(team.TeamId, invitation.InvitationId));

        var (invitee, _) = await SignInAsync(address);

        (await ReadAsync<IReadOnlyList<TeamSummary>>(invitee, TeamsUrl)).ShouldBeEmpty();
    }

    // ---- Vault key grants ----

    /// <remarks>
    /// A grant to somebody outside the team is refused. It would be a row that looks like sharing and
    /// does nothing, because the access check will go on refusing them the vault — and a sharing screen
    /// listing a grant whose holder cannot fetch anything is worse than an error.
    /// </remarks>
    [Fact]
    public async Task AGrantToANonMember_IsRefused()
    {
        var owner = await EnrolledClientAsync("outsider-owner", "oowner@example.com");
        await EnrolledClientAsync("outsider", "outsider@example.com");

        var team = await CreateTeamAsync(owner, "Closed");
        var vaultId = await CreateVaultAsync(owner, team.TeamId);

        var entry = await LookupAsync(owner, "outsider@example.com");

        var response = await owner.PostContractAsync(
            $"/api/v1/vaults/{vaultId}/grants",
            new IssueVaultGrantRequest(
                entry.UserId,
                entry.Fingerprint,
                KeyGeneration: 1,
                WrappedVaultKey: new byte[110],
                KeyLogHead: new byte[32],
                GrantSignature: new byte[64],
                GrantedAt: DateTimeOffset.UnixEpoch));

        await ShouldBeProblemAsync(
            response, HttpStatusCode.BadRequest, ProblemCodes.InvalidVaultGrant);
    }

    /// <remarks>
    /// A fingerprint that is not the recipient's current key is refused. The server cannot tell whether
    /// the wrap contains the right key — nothing on that machine can — but it can tell that this grant
    /// was made for a key nobody holds, which would otherwise surface at the far end days later as a tag
    /// failure indistinguishable from corruption.
    /// </remarks>
    [Fact]
    public async Task AGrantForAKeyTheRecipientDoesNotHold_IsRefused()
    {
        var owner = await EnrolledClientAsync("stale-owner", "sowner@example.com");
        await EnrolledClientAsync("stale-member", "smember@example.com");

        var team = await CreateTeamAsync(owner, "Stale");
        var vaultId = await CreateVaultAsync(owner, team.TeamId);

        var entry = await LookupAsync(owner, "smember@example.com");

        await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);

        var response = await owner.PostContractAsync(
            $"/api/v1/vaults/{vaultId}/grants",
            new IssueVaultGrantRequest(
                entry.UserId,
                RecipientKeyFingerprint: new byte[32],
                KeyGeneration: 1,
                WrappedVaultKey: new byte[110],
                KeyLogHead: new byte[32],
                GrantSignature: new byte[64],
                GrantedAt: DateTimeOffset.UnixEpoch));

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
    }

    // ---- The directory and the key log ----

    /// <remarks>
    /// The directory has no search. Asserting it rather than trusting the implementation, because a
    /// prefix match added later for convenience turns a server that stores addresses in plaintext into a
    /// way to enumerate an organisation's staff.
    /// </remarks>
    [Fact]
    public async Task TheDirectory_MatchesAnExactAddressAndNothingElse()
    {
        var client = await EnrolledClientAsync("directory-self", "findme@example.com");
        var address = addresses["findme@example.com"];

        var exact = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
            client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");

        exact.Count.ShouldBe(1);

        // Case-insensitive, because the column is citext and two addresses differing only in case are
        // one account. That is a match, not a search.
        var cased = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
            client, $"/api/v1/directory?email={Uri.EscapeDataString(address.ToUpperInvariant())}");

        cased.Count.ShouldBe(1);

        // The address with its last character removed. A directory that answered this would be a way to
        // walk an organisation's staff list out of a server that stores addresses in plaintext.
        var prefix = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
            client, $"/api/v1/directory?email={Uri.EscapeDataString(address[..^1])}");

        prefix.ShouldBeEmpty();
    }

    /// <remarks>
    /// The key log has to verify from genesis with the hashes the server publishes, because that is the
    /// whole of what a client can check. A chain that only the server could reproduce would make key
    /// transparency a claim rather than a mechanism.
    /// </remarks>
    [Fact]
    public async Task TheKeyLog_ChainsFromGenesisWithTheHashesItPublishes()
    {
        var client = await EnrolledClientAsync("keylog-reader", "keylog@example.com");

        var page = await ReadAsync<KeyLogPage>(client, "/api/v1/keylog?after=0");

        page.Entries.ShouldNotBeEmpty();

        var previous = Crypto.KeyLogChain.CreateGenesisPreviousHash();

        foreach (var entry in page.Entries)
        {
            entry.PreviousHash.ShouldBe(previous);

            Crypto.KeyLogChain.ComputeEntryHash(
                entry.PreviousHash,
                entry.UserId,
                entry.Generation,
                entry.EncryptionPublicKey,
                entry.SigningPublicKey,
                entry.StatementSignature,
                entry.CreatedAt).ShouldBe(entry.Hash);

            previous = entry.Hash;
        }

        // And the head the page reports is the last link, or a client that paged to the end could not
        // tell whether it had seen the whole log.
        if (!page.HasMore)
        {
            page.Head.ShouldBe(previous);
        }
    }

    // ---- Helpers ----

    private static string TeamUrl(Guid teamId) => $"{TeamsUrl}/{teamId}";

    private static string OwnerUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/owner";

    private static string MembersUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/members";

    private static string MemberUrl(Guid teamId, Guid userId) => $"{MembersUrl(teamId)}/{userId}";

    private static string MemberRoleUrl(Guid teamId, Guid userId) =>
        $"{MemberUrl(teamId, userId)}/role";

    private static string InvitationsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/invitations";

    private static string InvitationUrl(Guid teamId, Guid invitationId) =>
        $"{InvitationsUrl(teamId)}/{invitationId}";

    private static string TeamVaultsUrl(Guid teamId) => $"{TeamsUrl}/{teamId}/vaults";

    /// <summary>An address no account holds, uniquified because the container is shared.</summary>
    private static string NewAddress() => $"invitee-{Guid.CreateVersion7():N}@example.com";

    private async Task<HttpClient> EnrolledClientAsync(string subject, string? email = null)
    {
        var unique = $"{subject}-{Guid.CreateVersion7():N}";
        var address = email is null ? null : $"{Guid.CreateVersion7():N}-{email}";

        using var enrollment = new TestEnrollment(fixture.IdentityProvider, unique, address);

        var client = fixture.CreateClientFor(unique, address);

        var response = await client.PostContractAsync(EnrollUrl, enrollment.Build());
        response.EnsureSuccessStatusCode();

        // The address is remembered on the client so a later directory lookup can name it: the tests
        // uniquify addresses so that runs against a shared container cannot collide.
        if (address is not null)
        {
            addresses[email!] = address;
        }

        return client;
    }

    /// <summary>Signs a brand-new account in, which is what claims any invitation to its address.</summary>
    /// <remarks>
    /// No enrollment, because an invitee has no key and the claim path must not need one — requiring it
    /// would be requiring it of exactly the person who cannot yet supply it. Driving <c>/me</c> is what
    /// runs just-in-time provisioning, and provisioning is where the claim happens.
    /// </remarks>
    private async Task<(HttpClient Client, Guid UserId)> SignInAsync(
        string email,
        bool emailVerified = true)
    {
        var client = fixture.CreateClientFor(
            $"invitee-{Guid.CreateVersion7():N}", email, emailVerified);

        var me = await ReadAsync<MeResponse>(client, MeUrl);

        return (client, me.UserId);
    }

    /// <summary>Uniquified addresses, keyed on the readable one a test wrote.</summary>
    private readonly Dictionary<string, string> addresses = new(StringComparer.OrdinalIgnoreCase);

    /// <remarks>
    /// The slug is generated rather than derived from the name, because a slug is lowercase letters,
    /// digits and hyphens and a display name is not — deriving one would make these tests depend on a
    /// transformation the product does not perform. It is uniquified because the container is shared
    /// across every class in this assembly and the slug is unique deployment-wide.
    /// </remarks>
    private static Task<TeamSummary> CreateTeamAsync(HttpClient client, string name) =>
        PostAsync<CreateTeamRequest, TeamSummary>(
            client,
            TeamsUrl,
            new CreateTeamRequest(
                Guid.CreateVersion7(), name, $"team-{Guid.CreateVersion7():N}", null));

    /// <remarks>
    /// The wrapped key and the signature are the right shape and nothing more. The server stores both
    /// opaquely and verifies neither — see docs/crypto.md §6 — so a real seal here would be testing the
    /// crypto library rather than the endpoint.
    /// </remarks>
    private static async Task<Guid> CreateVaultAsync(HttpClient client, Guid teamId)
    {
        var vault = await PostAsync<CreateTeamVaultRequest, VaultSummary>(
            client,
            TeamVaultsUrl(teamId),
            new CreateTeamVaultRequest(
                Guid.CreateVersion7(),
                "Team vault",
                WrappedVaultKey: new byte[110],
                GrantSignature: new byte[64],
                GrantedAt: DateTimeOffset.UnixEpoch));

        return vault.VaultId;
    }

    private async Task<DirectoryEntry> LookupAsync(HttpClient client, string email)
    {
        var address = addresses.GetValueOrDefault(email, email);

        var found = await ReadAsync<IReadOnlyList<DirectoryEntry>>(
            client, $"/api/v1/directory?email={Uri.EscapeDataString(address)}");

        return found.ShouldHaveSingleItem();
    }

    private static async Task AddMemberAsync(
        HttpClient client,
        Guid teamId,
        Guid userId,
        TeamMemberRole role)
    {
        var response = await client.PostContractAsync(
            MembersUrl(teamId), new AddTeamMemberRequest(userId, role));

        response.EnsureSuccessStatusCode();
    }

    private static Task<TeamInvitationSummary> InviteAsync(
        HttpClient client,
        Guid teamId,
        string email,
        TeamMemberRole role = TeamMemberRole.Member) =>
        PostAsync<CreateTeamInvitationRequest, TeamInvitationSummary>(
            client,
            InvitationsUrl(teamId),
            new CreateTeamInvitationRequest(Guid.CreateVersion7(), email, role));

    /// <remarks>
    /// Filtered by id rather than taken from a position in the list, because the container is shared and
    /// every other class's invitations are in the same table. A count over the whole listing would pass
    /// or fail depending on what else ran.
    /// </remarks>
    private static async Task<TeamInvitationSummary> FindInvitationAsync(
        HttpClient client,
        Guid teamId,
        Guid invitationId)
    {
        var listed = await ReadAsync<IReadOnlyList<TeamInvitationSummary>>(
            client, InvitationsUrl(teamId));

        return listed.Where(row => row.InvitationId == invitationId).ShouldHaveSingleItem();
    }

    private static async Task<TResponse> PostAsync<TRequest, TResponse>(
        HttpClient client,
        string url,
        TRequest body)
    {
        var response = await client.PostContractAsync(url, body);

        response.EnsureSuccessStatusCode();

        return (await response.Content.ReadContractAsync<TResponse>())!;
    }

    private static async Task<T> ReadAsync<T>(HttpClient client, string url)
    {
        var response = await GetAsync(client, url);

        response.EnsureSuccessStatusCode();

        return (await response.Content.ReadContractAsync<T>())!;
    }

    private static Task<HttpResponseMessage> GetAsync(HttpClient client, string url) =>
        client.GetAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);

    private static Task<HttpResponseMessage> DeleteAsync(HttpClient client, string url) =>
        client.DeleteAsync(new Uri(url, UriKind.Relative), TestContext.Current.CancellationToken);

    /// <remarks>
    /// Asserts on the <c>code</c> extension and never on the prose, for the reason
    /// <see cref="JsonProblem"/> gives: the code is the contract and the wording is not.
    /// </remarks>
    private static async Task ShouldBeProblemAsync(
        HttpResponseMessage response,
        HttpStatusCode expectedStatus,
        string expectedCode)
    {
        response.StatusCode.ShouldBe(expectedStatus);

        var problem = await response.Content.ReadProblemAsync();
        problem.ShouldNotBeNull();
        problem.Code.ShouldBe(expectedCode);
    }
}
