sqlinsight-pro/tests/SQLInsight.IntegrationTests/HubPortalSupportTests.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

108 lines
5.7 KiB
C#

using System.Net;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Hub.Data;
namespace SQLInsight.IntegrationTests;
/// <summary>Support cases in the portal: suggested case numbers and the support report.</summary>
public partial class HubPortalTests
{
private async Task<int> CustomerIdAsync(string customer)
{
await using var scope = hub.Factory.Services.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<HubDbContext>().Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
}
[Fact]
public async Task New_case_suggests_the_next_number_and_refuses_a_taken_one()
{
var customer = $"Forslag {Guid.NewGuid():N}"[..22];
var finding = await SeedAsync(customer, hub.License(customer));
var id = await CustomerIdAsync(customer);
var client = await SignedInAsync();
Assert.Contains("value=\"SUP-000001\"", await client.GetStringAsync($"/portal/fund/{finding}"), StringComparison.Ordinal);
var created = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Telefonopkald"), ("description", "Ringede om backup"));
Assert.Equal($"/portal/kunder/{id}", created.Headers.Location?.OriginalString);
var page = await client.GetStringAsync($"/portal/kunder/{id}");
Assert.Contains("SUP-000001", page, StringComparison.Ordinal);
Assert.Contains("value=\"SUP-000002\"", page, StringComparison.Ordinal);
var duplicate = await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", "SUP-000001"), ("title", "Igen"));
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);
}
[Fact]
public async Task Support_report_shows_cases_and_exports_csv()
{
var customer = $"Rapport {Guid.NewGuid():N}"[..22];
await SeedAsync(customer, hub.License(customer));
var id = await CustomerIdAsync(customer);
var client = await SignedInAsync();
await PostFormAsync(client, $"/portal/kunder/{id}", $"/portal/actions/customers/{id}/cases", ("reference", ""), ("title", "Til rapporten"));
var response = await client.GetAsync($"/portal/rapporter/support?kunde={id}");
var page = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, page);
var csv = await client.GetAsync($"/portal/actions/reports/support.csv?kunde={id}");
var bytes = await csv.Content.ReadAsByteArrayAsync();
Assert.Contains("Til rapporten", page, StringComparison.Ordinal);
Assert.Contains("median løsningstid", page, StringComparison.Ordinal);
Assert.Equal("text/csv", csv.Content.Headers.ContentType?.MediaType);
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
Assert.Contains("SUP-000001;" + customer + ";Til rapporten;SEOSoft;", System.Text.Encoding.UTF8.GetString(bytes), StringComparison.Ordinal);
}
[Fact]
public async Task Customer_page_filters_findings_to_one_instance()
{
var customer = $"Instans {Guid.NewGuid():N}"[..22];
var license = hub.License(customer);
await SeedAsync(customer, license);
using (var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = System.Net.Http.Json.JsonContent.Create(HubApiTests.Submission("sql02", DateTimeOffset.UtcNow, ("SQLI-CFG-001", "", "High"))),
})
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("SQLInsight-License", license);
(await hub.Factory.CreateClient().SendAsync(request)).EnsureSuccessStatusCode();
}
var other = $"Fremmed {Guid.NewGuid():N}"[..22];
await SeedAsync(other, hub.License(other));
int id, sql02, foreign;
await using (var scope = hub.Factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<HubDbContext>();
id = await db.Customers.Where(c => c.Name == customer).Select(c => c.Id).SingleAsync();
sql02 = await db.Instances.Where(i => i.CustomerId == id && i.InstanceKey == "sql02").Select(i => i.Id).SingleAsync();
foreign = await db.Instances.Where(i => i.Customer!.Name == other).Select(i => i.Id).SingleAsync();
}
var client = await SignedInAsync();
var all = await client.GetStringAsync($"/portal/kunder/{id}");
var filtered = await client.GetStringAsync($"/portal/kunder/{id}?instans={sql02}");
var ignored = await client.GetStringAsync($"/portal/kunder/{id}?instans={foreign}");
Assert.Contains("SQLI-DB-007", all, StringComparison.Ordinal);
Assert.Contains("SQLI-CFG-001", all, StringComparison.Ordinal);
Assert.Contains($"?instans={sql02}", all, StringComparison.Ordinal);
Assert.Contains("Aktive fund · sql02", filtered, StringComparison.Ordinal);
Assert.Contains("Vis alle instanser", filtered, StringComparison.Ordinal);
Assert.Contains("SQLI-CFG-001", filtered, StringComparison.Ordinal);
Assert.DoesNotContain("SQLI-DB-007", filtered, StringComparison.Ordinal);
Assert.Contains("SQLI-DB-007", ignored, StringComparison.Ordinal);
Assert.Contains("SQLI-CFG-001", ignored, StringComparison.Ordinal);
Assert.DoesNotContain("Vis alle instanser", ignored, StringComparison.Ordinal);
}
[Fact]
public async Task Support_report_csv_requires_login()
{
var response = await Client(hub.Factory).GetAsync("/portal/actions/reports/support.csv");
Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
}
}