sqlinsight-pro/tests/SQLInsight.IntegrationTests/HubSupportApiTests.cs
Søren Eggert Lundsteen Olsen 83c247d536 Hub: support case numbers, customer case API, mail, support report
- Case numbers SUP-000001, SUP-000002 … per customer, unique per customer
  (new index; a migration step renames any old duplicates first). The
  portal suggests the next number on the finding page and under the new
  "Ny sag" on the customer page; an empty field gets the next number, a
  typed one that is taken is refused. Concurrent creates retry.
- SupportCase gains Source (Staff/Customer), description, contact name,
  e-mail, phone, the customer's Windows user and ClientRequestId.
- POST/GET /api/v1/cases for the customer's agent (license with priority
  support): creates a case idempotently on ClientRequestId, links the
  finding it came from, lists the customer's cases incl. staff-created.
  Customer/license creation is shared with the findings endpoint.
- Optional mail to support for customer cases (MailKit; prefers AUTH LOGIN
  as Microsoft 365 needs). A failed mail is logged, never fails the call.
  deploy-hub.yml writes optional SUPPORT_SMTP_* secrets to .env.
- Supportrapport: created, closed, open now, average/median resolution
  time, oldest open case and customer-created share per customer for a
  period (default 90 days), plus the cases; CSV for Danish Excel.
- Cases page: newest first, customer cases marked, contact/description.
- Customer page: findings can be filtered to one instance (?instans=id,
  ignored for another customer's instance); active findings per instance
  by severity.
- dotnet-ef as a local tool (dotnet-tools.json) for the migration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:33:55 +02:00

142 lines
6.2 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Contracts;
using SQLInsight.Hub.Data;
using SQLInsight.Licensing;
namespace SQLInsight.IntegrationTests;
[Trait("Category", "Integration")]
public class HubSupportApiTests(HubFixture hub) : IClassFixture<HubFixture>
{
private static SupportCaseRequest Request(Guid? id = null, string title = "Backup fejler") =>
new(id ?? Guid.NewGuid(), title, "Siden i går", "Anna Andersen", "anna@kunde.dk", "12345678", @"KUNDE\anna");
private async Task<HttpResponseMessage> PostAsync(string license, SupportCaseRequest request)
{
using var message = new HttpRequestMessage(HttpMethod.Post, "/api/v1/cases") { Content = JsonContent.Create(request) };
message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
return await hub.Factory.CreateClient().SendAsync(message);
}
private async Task<SupportCaseDto> CreateAsync(string license, SupportCaseRequest request)
{
var response = await PostAsync(license, request);
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
return (await response.Content.ReadFromJsonAsync<SupportCaseDto>(JsonSerializerOptions.Web))!;
}
private static string Name(string prefix) => $"{prefix} {Guid.NewGuid():N}"[..24];
[Fact]
public async Task Customer_cases_are_numbered_SUP_000001_then_SUP_000002()
{
var customer = Name("Nummer");
var license = hub.License(customer);
var first = await CreateAsync(license, Request());
var second = await CreateAsync(license, Request());
Assert.Equal("SUP-000001", first.Reference);
Assert.Equal("SUP-000002", second.Reference);
Assert.Equal(SupportCaseSources.Customer, first.Source);
Assert.True(first.IsOpen);
await using var scope = hub.Factory.Services.CreateAsyncScope();
var stored = await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.SingleAsync(c => c.Customer!.Name == customer && c.Reference == "SUP-000001");
Assert.Equal(("Anna Andersen", "anna@kunde.dk", @"KUNDE\anna"), (stored.ContactName, stored.ContactEmail, stored.RequestedBy));
}
[Fact]
public async Task A_retried_request_returns_the_same_case()
{
var customer = Name("Igen");
var license = hub.License(customer);
var request = Request();
var first = await CreateAsync(license, request);
var again = await CreateAsync(license, request);
Assert.Equal(first.Reference, again.Reference);
await using var scope = hub.Factory.Services.CreateAsyncScope();
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<HubDbContext>().SupportCases.CountAsync(c => c.Customer!.Name == customer));
}
[Fact]
public async Task Each_customer_has_its_own_sequence()
{
await CreateAsync(hub.License(Name("Første")), Request());
var other = await CreateAsync(hub.License(Name("Anden")), Request());
Assert.Equal("SUP-000001", other.Reference);
}
[Fact]
public async Task Community_license_cannot_create_cases()
{
var response = await PostAsync(hub.License(Name("Gratis"), LicensePlan.Community), Request());
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Invalid_request_is_rejected()
{
var response = await PostAsync(hub.License(Name("Ugyldig")), Request() with { ContactEmail = "ingen-snabel-a" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Customer_sees_its_own_cases_including_those_support_created()
{
var customer = Name("Liste");
var license = hub.License(customer);
await CreateAsync(license, Request(title: "Fra kunden"));
await CreateAsync(hub.License(Name("Fremmed")), Request(title: "Andens sag"));
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
var customerId = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
await SQLInsight.Hub.Support.SupportCaseStore.AddAsync(db, new SupportCase
{
CustomerId = customerId, Reference = string.Empty, Title = "Fra SEOSoft", CreatedAt = DateTime.UtcNow, IsOpen = false, ClosedAt = DateTime.UtcNow,
});
}
using var message = new HttpRequestMessage(HttpMethod.Get, "/api/v1/cases");
message.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
var cases = (await (await hub.Factory.CreateClient().SendAsync(message)).Content.ReadFromJsonAsync<List<SupportCaseDto>>(JsonSerializerOptions.Web))!;
Assert.Equal(["Fra SEOSoft", "Fra kunden"], cases.Select(c => c.Title));
Assert.Equal("SUP-000002", cases[0].Reference);
Assert.False(cases[0].IsOpen);
}
[Fact]
public async Task Case_created_from_a_finding_is_linked_to_it()
{
var customer = Name("Fund");
var license = hub.License(customer);
using (var submit = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))),
})
{
submit.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
(await hub.Factory.CreateClient().SendAsync(submit)).EnsureSuccessStatusCode();
}
var created = await CreateAsync(license, Request() with { InstanceName = "sql01", RuleId = "SQLI-DB-007", Subject = "database: Sales" });
await using var scope = hub.Factory.Services.CreateAsyncScope();
var finding = await scope.ServiceProvider.GetRequiredService<HubDbContext>().Findings.Include(f => f.SupportCase)
.SingleAsync(f => f.Instance!.Customer!.Name == customer);
Assert.Equal(created.Reference, finding.SupportCase?.Reference);
}
}