- 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>
99 lines
4.6 KiB
C#
99 lines
4.6 KiB
C#
using SQLInsight.Contracts;
|
|
using SQLInsight.Hub.Support;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
/// <summary>Pure logic behind support cases: no database, so these run with the unit tests.</summary>
|
|
public class HubSupportLogicTests
|
|
{
|
|
[Fact]
|
|
public void First_case_number_is_SUP_000001() => Assert.Equal("SUP-000001", CaseNumbers.Next([]));
|
|
|
|
[Fact]
|
|
public void Next_number_follows_the_highest_SUP_number() =>
|
|
Assert.Equal("SUP-000013", CaseNumbers.Next(["SUP-000002", "sup-000012", "SUP-000009"]));
|
|
|
|
[Fact]
|
|
public void Hand_typed_references_do_not_affect_the_sequence() =>
|
|
Assert.Equal("SUP-000004", CaseNumbers.Next(["SUP-000003", "INC-99999", "SUP-12A", "Sag fra telefon"]));
|
|
|
|
[Fact]
|
|
public void Old_short_numbers_continue_in_six_digits() => Assert.Equal("SUP-001235", CaseNumbers.Next(["SUP-1234"]));
|
|
|
|
private static readonly DateTime T0 = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static SupportReportCase Case(int id, int customer, double createdDays, double? closedAfterHours, string source = SupportCaseSources.Staff) =>
|
|
new(id, customer, $"Kunde {customer}", $"SUP-{id:D6}", "Titel", source, T0.AddDays(createdDays), closedAfterHours is { } h ? T0.AddDays(createdDays).AddHours(h) : null);
|
|
|
|
[Fact]
|
|
public void Report_counts_created_closed_and_open_and_computes_average_and_median()
|
|
{
|
|
var cases = new[]
|
|
{
|
|
Case(1, 1, 1, 2, SupportCaseSources.Customer),
|
|
Case(2, 1, 2, 4),
|
|
Case(3, 1, 3, 30),
|
|
Case(4, 1, 4, null, SupportCaseSources.Customer),
|
|
Case(5, 2, -40, 1), // created and closed before the period: not counted
|
|
Case(6, 2, -40, null), // created before, still open: open now
|
|
};
|
|
|
|
var report = SupportReport.Build(cases, T0, T0.AddDays(30), T0.AddDays(10));
|
|
var first = report.Customers.Single(c => c.CustomerId == 1);
|
|
var second = report.Customers.Single(c => c.CustomerId == 2);
|
|
|
|
Assert.Equal((4, 3, 1), (first.Created, first.Closed, first.OpenNow));
|
|
Assert.Equal(TimeSpan.FromHours(12), first.AverageResolution);
|
|
Assert.Equal(TimeSpan.FromHours(4), first.MedianResolution);
|
|
Assert.Equal(TimeSpan.FromDays(6), first.OldestOpenAge);
|
|
Assert.Equal(0.5, first.CustomerCreatedShare);
|
|
Assert.Equal((0, 0, 1), (second.Created, second.Closed, second.OpenNow));
|
|
Assert.Equal(TimeSpan.FromDays(50), second.OldestOpenAge);
|
|
Assert.Equal((4, 3, 2), (report.Total.Created, report.Total.Closed, report.Total.OpenNow));
|
|
Assert.Equal([4, 3, 2, 1], report.Cases.Select(c => c.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public void Median_of_an_even_count_is_the_mean_of_the_middle_two() =>
|
|
Assert.Equal(TimeSpan.FromHours(3), SupportReport.Median([TimeSpan.FromHours(1), TimeSpan.FromHours(2), TimeSpan.FromHours(4), TimeSpan.FromHours(9)]));
|
|
|
|
[Theory]
|
|
[InlineData(30, "30 min")]
|
|
[InlineData(90, "1,5 t")]
|
|
[InlineData(4320, "3 dage")]
|
|
public void Durations_are_readable(double minutes, string expected) => Assert.Equal(expected, SupportReport.Duration(TimeSpan.FromMinutes(minutes)));
|
|
|
|
[Fact]
|
|
public void Default_period_is_the_last_90_days_including_today()
|
|
{
|
|
var (from, to) = SupportReportPeriod.Resolve(null, null, new DateTime(2026, 10, 1, 14, 0, 0, DateTimeKind.Utc));
|
|
|
|
Assert.Equal(new DateTime(2026, 10, 2, 0, 0, 0, DateTimeKind.Utc), to);
|
|
Assert.Equal(to.AddDays(-90), from);
|
|
}
|
|
|
|
[Fact]
|
|
public void Csv_has_bom_semicolons_and_defuses_formulas()
|
|
{
|
|
var report = SupportReport.Build([Case(1, 1, 1, 6) with { Title = "=HYPERLINK(\"x\"); ond" }], T0, T0.AddDays(30), T0.AddDays(10));
|
|
|
|
var bytes = SupportReport.Csv(report);
|
|
var text = System.Text.Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
|
|
|
|
Assert.Equal([0xEF, 0xBB, 0xBF], bytes[..3]);
|
|
Assert.StartsWith("Sag;Kunde;Titel;Kilde;", text, StringComparison.Ordinal);
|
|
Assert.Contains("\"'=HYPERLINK(\"\"x\"\"); ond\"", text, StringComparison.Ordinal);
|
|
Assert.Contains(";6,0\r\n", text, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Request_validation_rejects_missing_contact_and_long_titles()
|
|
{
|
|
var valid = new SupportCaseRequest(Guid.NewGuid(), "Langsom server", null, "Anna", "anna@kunde.dk", null, null);
|
|
|
|
Assert.Null(valid.Validate());
|
|
Assert.NotNull((valid with { ContactEmail = "anna" }).Validate());
|
|
Assert.NotNull((valid with { Title = new string('x', 201) }).Validate());
|
|
Assert.NotNull((valid with { ClientRequestId = Guid.Empty }).Validate());
|
|
}
|
|
}
|