From 31cd0267fb4468101211248f8425c440a02c0348 Mon Sep 17 00:00:00 2001
From: Vladyslav Yeromenko <55078728+skyp1nus@users.noreply.github.com>
Date: Thu, 25 Jun 2026 21:56:48 +0000
Subject: [PATCH] Add 156 unit tests for under-covered modules
New SharedKernel.Tests project:
- ResultTests: Success/Failure factories, implicit operators, Error constants
- PagedResultTests: TotalPages, HasNext/Previous, PageRequest clamping
- CsvTests: RFC 4180 field quoting, row joining, document generation
Infrastructure.Tests additions:
- PasswordHasherTests: bcrypt hash/verify, nondeterminism, invalid hash
- IdentityTokenServiceTests: sign/verify roundtrip, expiry, key validation
- HooklineUserTests: role hierarchy, Anonymous/System/Authenticated factories
YouTubeUploads.Tests additions:
- SlackMrkdwnTests: entity expansion, HTML escapes, emoji conversion
- SlackEmojiTests: shortcode mapping, skin-tone filtering, unknown passthrough
- ProgressTrackerTests: Set/Get/Remove ops, Percent clamping
- ApiUsageReportTests: report structure, metric summation, scope labels
YouTubeComments.Tests additions:
- PacificTimeTests: Today/TodayKey/StartOfToday/UntilMidnight
- GoogleApiExceptionExtensionsTests: transient status, HasReason matching
- PollingFrequencyTests: ToCron/ToInterval for both frequency enums
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
backend/Hookline.slnx | 1 +
.../HooklineUserTests.cs | 60 ++++++++
.../IdentityTokenServiceTests.cs | 110 ++++++++++++++
.../PasswordHasherTests.cs | 53 +++++++
.../GoogleApiExceptionExtensionsTests.cs | 95 ++++++++++++
.../PacificTimeTests.cs | 77 ++++++++++
.../PollingFrequencyTests.cs | 48 ++++++
.../ApiUsageReportTests.cs | 140 ++++++++++++++++++
.../ProgressTrackerTests.cs | 76 ++++++++++
.../SlackEmojiTests.cs | 62 ++++++++
.../SlackMrkdwnTests.cs | 99 +++++++++++++
.../Hookline.SharedKernel.Tests/CsvTests.cs | 61 ++++++++
.../Hookline.SharedKernel.Tests.csproj | 18 +++
.../PagedResultTests.cs | 120 +++++++++++++++
.../ResultTests.cs | 100 +++++++++++++
15 files changed, 1120 insertions(+)
create mode 100644 backend/tests/Hookline.Infrastructure.Tests/HooklineUserTests.cs
create mode 100644 backend/tests/Hookline.Infrastructure.Tests/IdentityTokenServiceTests.cs
create mode 100644 backend/tests/Hookline.Infrastructure.Tests/PasswordHasherTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeComments.Tests/GoogleApiExceptionExtensionsTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeComments.Tests/PacificTimeTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeComments.Tests/PollingFrequencyTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeUploads.Tests/ApiUsageReportTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeUploads.Tests/ProgressTrackerTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackEmojiTests.cs
create mode 100644 backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackMrkdwnTests.cs
create mode 100644 backend/tests/Hookline.SharedKernel.Tests/CsvTests.cs
create mode 100644 backend/tests/Hookline.SharedKernel.Tests/Hookline.SharedKernel.Tests.csproj
create mode 100644 backend/tests/Hookline.SharedKernel.Tests/PagedResultTests.cs
create mode 100644 backend/tests/Hookline.SharedKernel.Tests/ResultTests.cs
diff --git a/backend/Hookline.slnx b/backend/Hookline.slnx
index c6681db..5843216 100644
--- a/backend/Hookline.slnx
+++ b/backend/Hookline.slnx
@@ -16,5 +16,6 @@
+
diff --git a/backend/tests/Hookline.Infrastructure.Tests/HooklineUserTests.cs b/backend/tests/Hookline.Infrastructure.Tests/HooklineUserTests.cs
new file mode 100644
index 0000000..ef7aa08
--- /dev/null
+++ b/backend/tests/Hookline.Infrastructure.Tests/HooklineUserTests.cs
@@ -0,0 +1,60 @@
+using Hookline.Infrastructure.Auth;
+using Hookline.SharedKernel.Auth;
+
+namespace Hookline.Infrastructure.Tests;
+
+public sealed class HooklineUserTests
+{
+ [Fact]
+ public void Anonymous_is_not_authenticated()
+ {
+ Assert.False(HooklineUser.Anonymous.IsAuthenticated);
+ Assert.Null(HooklineUser.Anonymous.UserId);
+ Assert.Null(HooklineUser.Anonymous.Role);
+ Assert.False(HooklineUser.Anonymous.IsSystem);
+ }
+
+ [Fact]
+ public void System_is_authenticated_with_owner_role()
+ {
+ Assert.True(HooklineUser.System.IsAuthenticated);
+ Assert.Equal(UserRole.Owner, HooklineUser.System.Role);
+ Assert.True(HooklineUser.System.IsSystem);
+ }
+
+ [Fact]
+ public void Authenticated_factory_sets_all_fields()
+ {
+ var id = Guid.NewGuid();
+ var user = HooklineUser.Authenticated(id, "user@example.com", UserRole.Admin);
+
+ Assert.True(user.IsAuthenticated);
+ Assert.Equal(id, user.UserId);
+ Assert.Equal("user@example.com", user.Email);
+ Assert.Equal(UserRole.Admin, user.Role);
+ Assert.False(user.IsSystem);
+ }
+
+ [Theory]
+ [InlineData(UserRole.Owner, UserRole.Owner, true)]
+ [InlineData(UserRole.Owner, UserRole.Admin, true)]
+ [InlineData(UserRole.Owner, UserRole.Member, true)]
+ [InlineData(UserRole.Admin, UserRole.Admin, true)]
+ [InlineData(UserRole.Admin, UserRole.Member, true)]
+ [InlineData(UserRole.Admin, UserRole.Owner, false)]
+ [InlineData(UserRole.Member, UserRole.Member, true)]
+ [InlineData(UserRole.Member, UserRole.Admin, false)]
+ [InlineData(UserRole.Member, UserRole.Owner, false)]
+ public void HasAtLeast_compares_role_hierarchy(UserRole actual, UserRole required, bool expected)
+ {
+ var user = HooklineUser.Authenticated(Guid.NewGuid(), null, actual);
+
+ Assert.Equal(expected, user.HasAtLeast(required));
+ }
+
+ [Fact]
+ public void HasAtLeast_returns_false_for_anonymous()
+ {
+ Assert.False(HooklineUser.Anonymous.HasAtLeast(UserRole.Member));
+ }
+}
diff --git a/backend/tests/Hookline.Infrastructure.Tests/IdentityTokenServiceTests.cs b/backend/tests/Hookline.Infrastructure.Tests/IdentityTokenServiceTests.cs
new file mode 100644
index 0000000..7459d37
--- /dev/null
+++ b/backend/tests/Hookline.Infrastructure.Tests/IdentityTokenServiceTests.cs
@@ -0,0 +1,110 @@
+using Hookline.Infrastructure.Auth;
+using Hookline.SharedKernel.Auth;
+
+namespace Hookline.Infrastructure.Tests;
+
+public sealed class IdentityTokenServiceTests
+{
+ private const string Key = "test-signing-key-at-least-32-bytes-long!!";
+ private readonly IdentityTokenService _svc = new(Key);
+
+ [Fact]
+ public void Constructor_throws_when_signing_key_is_null()
+ {
+ Assert.Throws(() => new IdentityTokenService(null));
+ }
+
+ [Fact]
+ public void Constructor_throws_when_signing_key_is_empty()
+ {
+ Assert.Throws(() => new IdentityTokenService(""));
+ }
+
+ [Fact]
+ public void Constructor_throws_when_signing_key_is_whitespace()
+ {
+ Assert.Throws(() => new IdentityTokenService(" "));
+ }
+
+ [Fact]
+ public void Sign_produces_two_part_token()
+ {
+ var token = _svc.Sign(Guid.NewGuid(), UserRole.Member, TimeSpan.FromMinutes(5));
+
+ Assert.Contains('.', token);
+ var parts = token.Split('.');
+ Assert.Equal(2, parts.Length);
+ Assert.NotEmpty(parts[0]);
+ Assert.NotEmpty(parts[1]);
+ }
+
+ [Fact]
+ public void Verify_roundtrips_user_and_role()
+ {
+ var userId = Guid.NewGuid();
+ var token = _svc.Sign(userId, UserRole.Admin, TimeSpan.FromMinutes(5));
+
+ var identity = _svc.Verify(token);
+
+ Assert.NotNull(identity);
+ Assert.Equal(userId, identity.UserId);
+ Assert.Equal(UserRole.Admin, identity.Role);
+ Assert.True(identity.ExpiresAt > DateTimeOffset.UtcNow);
+ }
+
+ [Fact]
+ public void Verify_returns_null_for_null_token()
+ {
+ Assert.Null(_svc.Verify(null));
+ }
+
+ [Fact]
+ public void Verify_returns_null_for_empty_token()
+ {
+ Assert.Null(_svc.Verify(""));
+ }
+
+ [Fact]
+ public void Verify_returns_null_when_no_dot()
+ {
+ Assert.Null(_svc.Verify("nodothere"));
+ }
+
+ [Fact]
+ public void Verify_returns_null_when_dot_at_start()
+ {
+ Assert.Null(_svc.Verify(".signature"));
+ }
+
+ [Fact]
+ public void Verify_returns_null_when_dot_at_end()
+ {
+ Assert.Null(_svc.Verify("payload."));
+ }
+
+ [Fact]
+ public void Verify_rejects_tampered_signature()
+ {
+ var token = _svc.Sign(Guid.NewGuid(), UserRole.Member, TimeSpan.FromMinutes(5));
+ var tampered = token[..^1] + (token[^1] == 'A' ? 'B' : 'A');
+
+ Assert.Null(_svc.Verify(tampered));
+ }
+
+ [Fact]
+ public void Verify_rejects_token_signed_with_different_key()
+ {
+ var other = new IdentityTokenService("a-completely-different-signing-key!!");
+ var token = other.Sign(Guid.NewGuid(), UserRole.Owner, TimeSpan.FromMinutes(5));
+
+ Assert.Null(_svc.Verify(token));
+ }
+
+ [Fact]
+ public void Verify_rejects_expired_token()
+ {
+ var token = _svc.Sign(Guid.NewGuid(), UserRole.Member, TimeSpan.FromSeconds(-1));
+
+ Assert.Null(_svc.Verify(token));
+ }
+}
diff --git a/backend/tests/Hookline.Infrastructure.Tests/PasswordHasherTests.cs b/backend/tests/Hookline.Infrastructure.Tests/PasswordHasherTests.cs
new file mode 100644
index 0000000..3f87d26
--- /dev/null
+++ b/backend/tests/Hookline.Infrastructure.Tests/PasswordHasherTests.cs
@@ -0,0 +1,53 @@
+using Hookline.Infrastructure.Auth;
+
+namespace Hookline.Infrastructure.Tests;
+
+public sealed class PasswordHasherTests
+{
+ private readonly PasswordHasher _hasher = new();
+
+ [Fact]
+ public void Hash_produces_bcrypt_format()
+ {
+ var hash = _hasher.Hash("password123");
+
+ Assert.StartsWith("$2", hash);
+ }
+
+ [Fact]
+ public void Hash_is_nondeterministic()
+ {
+ var h1 = _hasher.Hash("same");
+ var h2 = _hasher.Hash("same");
+
+ Assert.NotEqual(h1, h2);
+ }
+
+ [Fact]
+ public void Verify_returns_true_for_correct_password()
+ {
+ var hash = _hasher.Hash("secret");
+
+ Assert.True(_hasher.Verify("secret", hash));
+ }
+
+ [Fact]
+ public void Verify_returns_false_for_wrong_password()
+ {
+ var hash = _hasher.Hash("correct");
+
+ Assert.False(_hasher.Verify("wrong", hash));
+ }
+
+ [Fact]
+ public void Verify_returns_false_for_invalid_hash()
+ {
+ Assert.False(_hasher.Verify("anything", "not-a-bcrypt-hash"));
+ }
+
+ [Fact]
+ public void Verify_throws_for_empty_hash()
+ {
+ Assert.ThrowsAny(() => _hasher.Verify("anything", ""));
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeComments.Tests/GoogleApiExceptionExtensionsTests.cs b/backend/tests/Hookline.Modules.YouTubeComments.Tests/GoogleApiExceptionExtensionsTests.cs
new file mode 100644
index 0000000..7a7189f
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeComments.Tests/GoogleApiExceptionExtensionsTests.cs
@@ -0,0 +1,95 @@
+using System.Net;
+
+using Google;
+using Google.Apis.Requests;
+
+using Hookline.Modules.YouTubeComments.Infrastructure;
+
+namespace Hookline.Modules.YouTubeComments.Tests;
+
+public sealed class GoogleApiExceptionExtensionsTests
+{
+ [Theory]
+ [InlineData(HttpStatusCode.InternalServerError, true)]
+ [InlineData(HttpStatusCode.BadGateway, true)]
+ [InlineData(HttpStatusCode.ServiceUnavailable, true)]
+ [InlineData(HttpStatusCode.GatewayTimeout, true)]
+ [InlineData(HttpStatusCode.TooManyRequests, true)]
+ [InlineData(HttpStatusCode.BadRequest, false)]
+ [InlineData(HttpStatusCode.Forbidden, false)]
+ [InlineData(HttpStatusCode.NotFound, false)]
+ [InlineData(HttpStatusCode.OK, false)]
+ public void IsTransientStatus_classifies_correctly(HttpStatusCode status, bool expected)
+ {
+ Assert.Equal(expected, GoogleApiExceptionExtensions.IsTransientStatus(status));
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.InternalServerError, true)]
+ [InlineData(HttpStatusCode.TooManyRequests, true)]
+ [InlineData(HttpStatusCode.Forbidden, false)]
+ public void IsTransient_extension_uses_HttpStatusCode(HttpStatusCode status, bool expected)
+ {
+ var ex = new GoogleApiException("test") { HttpStatusCode = status };
+
+ Assert.Equal(expected, ex.IsTransient());
+ }
+
+ [Fact]
+ public void HasReason_returns_true_when_reason_matches()
+ {
+ var ex = new GoogleApiException("test")
+ {
+ Error = new RequestError
+ {
+ Errors = [new SingleError { Reason = "quotaExceeded" }]
+ }
+ };
+
+ Assert.True(ex.HasReason("quotaExceeded"));
+ }
+
+ [Fact]
+ public void HasReason_is_case_insensitive()
+ {
+ var ex = new GoogleApiException("test")
+ {
+ Error = new RequestError
+ {
+ Errors = [new SingleError { Reason = "QUOTAEXCEEDED" }]
+ }
+ };
+
+ Assert.True(ex.HasReason("quotaExceeded"));
+ }
+
+ [Fact]
+ public void HasReason_returns_false_when_no_match()
+ {
+ var ex = new GoogleApiException("test")
+ {
+ Error = new RequestError
+ {
+ Errors = [new SingleError { Reason = "notFound" }]
+ }
+ };
+
+ Assert.False(ex.HasReason("quotaExceeded"));
+ }
+
+ [Fact]
+ public void HasReason_returns_false_when_errors_null()
+ {
+ var ex = new GoogleApiException("test");
+
+ Assert.False(ex.HasReason("quotaExceeded"));
+ }
+
+ [Fact]
+ public void HasReason_returns_false_when_error_property_null()
+ {
+ var ex = new GoogleApiException("test") { Error = null };
+
+ Assert.False(ex.HasReason("quotaExceeded"));
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeComments.Tests/PacificTimeTests.cs b/backend/tests/Hookline.Modules.YouTubeComments.Tests/PacificTimeTests.cs
new file mode 100644
index 0000000..941b071
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeComments.Tests/PacificTimeTests.cs
@@ -0,0 +1,77 @@
+using Hookline.Modules.YouTubeComments.Infrastructure;
+
+namespace Hookline.Modules.YouTubeComments.Tests;
+
+public sealed class PacificTimeTests
+{
+ [Fact]
+ public void Today_returns_valid_date()
+ {
+ var today = PacificTime.Today();
+
+ Assert.True(today >= new DateOnly(2020, 1, 1));
+ Assert.True(today <= DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)));
+ }
+
+ [Fact]
+ public void TodayKey_matches_yyyy_MM_dd_format()
+ {
+ var key = PacificTime.TodayKey();
+
+ Assert.Matches(@"^\d{4}-\d{2}-\d{2}$", key);
+ }
+
+ [Fact]
+ public void TodayKey_parses_back_to_Today()
+ {
+ var key = PacificTime.TodayKey();
+ var parsed = DateOnly.ParseExact(key, "yyyy-MM-dd");
+
+ Assert.Equal(PacificTime.Today(), parsed);
+ }
+
+ [Fact]
+ public void StartOfToday_is_in_the_past()
+ {
+ var start = PacificTime.StartOfToday();
+
+ Assert.True(start <= DateTimeOffset.UtcNow);
+ }
+
+ [Fact]
+ public void StartOfToday_is_within_last_24_hours()
+ {
+ var start = PacificTime.StartOfToday();
+
+ Assert.True(DateTimeOffset.UtcNow - start < TimeSpan.FromHours(25));
+ }
+
+ [Fact]
+ public void UntilMidnight_is_positive()
+ {
+ var remaining = PacificTime.UntilMidnight();
+
+ Assert.True(remaining > TimeSpan.Zero);
+ }
+
+ [Fact]
+ public void UntilMidnight_is_less_than_25_hours()
+ {
+ var remaining = PacificTime.UntilMidnight();
+
+ Assert.True(remaining < TimeSpan.FromHours(25));
+ }
+
+ [Fact]
+ public void StartOfToday_plus_UntilMidnight_is_roughly_next_midnight()
+ {
+ var start = PacificTime.StartOfToday();
+ var remaining = PacificTime.UntilMidnight();
+ var now = DateTimeOffset.UtcNow;
+ var nextMidnightApprox = now + remaining;
+
+ Assert.True(nextMidnightApprox > start);
+ Assert.True(nextMidnightApprox - start >= TimeSpan.FromHours(23));
+ Assert.True(nextMidnightApprox - start <= TimeSpan.FromHours(26));
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeComments.Tests/PollingFrequencyTests.cs b/backend/tests/Hookline.Modules.YouTubeComments.Tests/PollingFrequencyTests.cs
new file mode 100644
index 0000000..726dfa0
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeComments.Tests/PollingFrequencyTests.cs
@@ -0,0 +1,48 @@
+using Hookline.Modules.YouTubeComments.Domain;
+
+namespace Hookline.Modules.YouTubeComments.Tests;
+
+public sealed class PollingFrequencyTests
+{
+ [Theory]
+ [InlineData(PollingFrequency.OneMinute, "* * * * *")]
+ [InlineData(PollingFrequency.FiveMinutes, "*/5 * * * *")]
+ [InlineData(PollingFrequency.FifteenMinutes, "*/15 * * * *")]
+ [InlineData(PollingFrequency.ThirtyMinutes, "*/30 * * * *")]
+ [InlineData(PollingFrequency.OneHour, "0 * * * *")]
+ [InlineData(PollingFrequency.SixHours, "0 */6 * * *")]
+ public void ToCron_maps_correctly(PollingFrequency frequency, string expected)
+ {
+ Assert.Equal(expected, frequency.ToCron());
+ }
+
+ [Theory]
+ [InlineData(PollingFrequency.OneMinute, 1)]
+ [InlineData(PollingFrequency.FiveMinutes, 5)]
+ [InlineData(PollingFrequency.FifteenMinutes, 15)]
+ [InlineData(PollingFrequency.ThirtyMinutes, 30)]
+ [InlineData(PollingFrequency.OneHour, 60)]
+ [InlineData(PollingFrequency.SixHours, 360)]
+ public void ToInterval_returns_correct_minutes(PollingFrequency frequency, int expectedMinutes)
+ {
+ Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), frequency.ToInterval());
+ }
+}
+
+public sealed class ReplyScanFrequencyTests
+{
+ [Theory]
+ [InlineData(ReplyScanFrequency.Hourly, "0 * * * *")]
+ [InlineData(ReplyScanFrequency.EverySixHours, "0 */6 * * *")]
+ [InlineData(ReplyScanFrequency.Daily, "0 4 * * *")]
+ public void ToCron_maps_correctly(ReplyScanFrequency frequency, string expected)
+ {
+ Assert.Equal(expected, frequency.ToCron());
+ }
+
+ [Fact]
+ public void ToCron_returns_null_for_Off()
+ {
+ Assert.Null(ReplyScanFrequency.Off.ToCron());
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/ApiUsageReportTests.cs b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/ApiUsageReportTests.cs
new file mode 100644
index 0000000..ea09b42
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/ApiUsageReportTests.cs
@@ -0,0 +1,140 @@
+using Hookline.Modules.YouTubeUploads.Infrastructure;
+
+namespace Hookline.Modules.YouTubeUploads.Tests;
+
+public sealed class ApiUsageReportTests
+{
+ private static readonly Guid Client1 = Guid.NewGuid();
+ private static readonly Guid Client2 = Guid.NewGuid();
+
+ [Fact]
+ public void Build_returns_three_groups()
+ {
+ var report = ApiUsageReport.Build("2025-01-01", [], [], 100);
+
+ Assert.Equal("2025-01-01", report.Date);
+ Assert.Equal(3, report.Groups.Count);
+ Assert.Equal("YouTube", report.Groups[0].Group);
+ Assert.Equal("Drive", report.Groups[1].Group);
+ Assert.Equal("Slack", report.Groups[2].Group);
+ }
+
+ [Fact]
+ public void YouTube_metrics_reflect_client_quotas()
+ {
+ var clients = new[]
+ {
+ new ApiUsageReport.ClientQuota(Client1, "Project A",
+ new QuotaStatus(UsedUploads: 3, UploadLimit: 10, UsedUnits: 50, CapUnits: 1000)),
+ };
+
+ var report = ApiUsageReport.Build("2025-01-01", clients, [], 100);
+ var yt = report.Groups[0];
+
+ Assert.Equal(2, yt.Metrics.Count);
+ var uploads = yt.Metrics[0];
+ Assert.Equal(ApiMetrics.YouTubeUpload, uploads.Key);
+ Assert.Equal(3, uploads.Used);
+ Assert.Equal(10, uploads.Limit);
+
+ var units = yt.Metrics[1];
+ Assert.Equal(ApiMetrics.YouTubeUnits, units.Key);
+ Assert.Equal(50, units.Used);
+ Assert.Equal(1000, units.Limit);
+ }
+
+ [Fact]
+ public void Drive_metrics_reflect_usage_entries()
+ {
+ var usage = new[]
+ {
+ new UsageEntry(Client1.ToString(), ApiMetrics.DriveQueries, 42),
+ new UsageEntry(Client1.ToString(), ApiMetrics.DriveBytes, 1024),
+ };
+
+ var report = ApiUsageReport.Build("2025-01-01", [], usage, 500);
+ var drive = report.Groups[1];
+
+ Assert.Equal(2, drive.Metrics.Count);
+ var queries = drive.Metrics[0];
+ Assert.Equal(42, queries.Used);
+ Assert.Equal(500, queries.Limit);
+
+ var bytes = drive.Metrics[1];
+ Assert.Equal(1024, bytes.Used);
+ Assert.Null(bytes.Limit); // bytes have no limit
+ }
+
+ [Fact]
+ public void Slack_metrics_sorted_descending_by_value()
+ {
+ var usage = new[]
+ {
+ new UsageEntry(ApiMetrics.SlackScope, "slack.chat.postMessage", 10),
+ new UsageEntry(ApiMetrics.SlackScope, "slack.conversations.history", 50),
+ };
+
+ var report = ApiUsageReport.Build("2025-01-01", [], usage, 100);
+ var slack = report.Groups[2];
+
+ Assert.Equal(2, slack.Metrics.Count);
+ Assert.Equal("slack.conversations.history", slack.Metrics[0].Key);
+ Assert.Equal(50, slack.Metrics[0].Used);
+ Assert.Equal("slack.chat.postMessage", slack.Metrics[1].Key);
+ Assert.Equal(10, slack.Metrics[1].Used);
+ }
+
+ [Fact]
+ public void Slack_label_strips_prefix()
+ {
+ var usage = new[]
+ {
+ new UsageEntry(ApiMetrics.SlackScope, "slack.chat.update", 5),
+ };
+
+ var report = ApiUsageReport.Build("2025-01-01", [], usage, 100);
+ var metric = report.Groups[2].Metrics[0];
+
+ Assert.Equal("chat.update", metric.Label);
+ }
+
+ [Fact]
+ public void Multiple_clients_have_summed_totals()
+ {
+ var clients = new[]
+ {
+ new ApiUsageReport.ClientQuota(Client1, "A",
+ new QuotaStatus(UsedUploads: 2, UploadLimit: 5, UsedUnits: 100, CapUnits: 500)),
+ new ApiUsageReport.ClientQuota(Client2, "B",
+ new QuotaStatus(UsedUploads: 3, UploadLimit: 10, UsedUnits: 200, CapUnits: 1000)),
+ };
+
+ var report = ApiUsageReport.Build("2025-01-01", clients, [], 100);
+ var yt = report.Groups[0];
+
+ Assert.Equal(5, yt.Metrics[0].Used); // 2+3 uploads
+ Assert.Equal(15, yt.Metrics[0].Limit); // 5+10 upload limit
+ Assert.Equal(300, yt.Metrics[1].Used); // 100+200 units
+ Assert.Equal(1500, yt.Metrics[1].Limit); // 500+1000 cap
+ }
+
+ [Fact]
+ public void Client_labels_are_used_for_scope_names()
+ {
+ var clients = new[]
+ {
+ new ApiUsageReport.ClientQuota(Client1, "My Project",
+ new QuotaStatus(UsedUploads: 1, UploadLimit: 5, UsedUnits: 10, CapUnits: 100)),
+ };
+
+ var usage = new[]
+ {
+ new UsageEntry(Client1.ToString(), ApiMetrics.DriveQueries, 7),
+ };
+
+ var report = ApiUsageReport.Build("2025-01-01", clients, usage, 100);
+ var driveQueries = report.Groups[1].Metrics[0];
+
+ Assert.Equal("My Project", driveQueries.PerScope[0].Scope);
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/ProgressTrackerTests.cs b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/ProgressTrackerTests.cs
new file mode 100644
index 0000000..ecdfe60
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/ProgressTrackerTests.cs
@@ -0,0 +1,76 @@
+using Hookline.Modules.YouTubeUploads.Domain;
+using Hookline.Modules.YouTubeUploads.Infrastructure;
+
+namespace Hookline.Modules.YouTubeUploads.Tests;
+
+public sealed class ProgressTrackerTests
+{
+ private readonly ProgressTracker _tracker = new();
+
+ [Fact]
+ public void Get_returns_null_for_unknown_job()
+ {
+ Assert.Null(_tracker.Get(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Set_then_Get_returns_progress()
+ {
+ var id = Guid.NewGuid();
+ var progress = new JobProgress(JobState.Downloading, 50, 100, "phase1");
+
+ _tracker.Set(id, progress);
+
+ Assert.Equal(progress, _tracker.Get(id));
+ }
+
+ [Fact]
+ public void Set_overwrites_previous()
+ {
+ var id = Guid.NewGuid();
+ _tracker.Set(id, new JobProgress(JobState.Downloading, 10, 100, null));
+ var updated = new JobProgress(JobState.Uploading, 80, 100, "phase2");
+
+ _tracker.Set(id, updated);
+
+ Assert.Equal(updated, _tracker.Get(id));
+ }
+
+ [Fact]
+ public void Remove_clears_entry()
+ {
+ var id = Guid.NewGuid();
+ _tracker.Set(id, new JobProgress(JobState.Downloading, 50, 100, null));
+
+ _tracker.Remove(id);
+
+ Assert.Null(_tracker.Get(id));
+ }
+
+ [Fact]
+ public void Remove_is_idempotent()
+ {
+ _tracker.Remove(Guid.NewGuid()); // no throw
+ }
+
+ [Theory]
+ [InlineData(50, 100, 50)]
+ [InlineData(0, 100, 0)]
+ [InlineData(100, 100, 100)]
+ [InlineData(200, 100, 100)] // clamped to 100
+ [InlineData(0, 0, 0)] // BytesTotal = 0 → 0
+ public void Percent_calculates_correctly(long transferred, long total, int expected)
+ {
+ var progress = new JobProgress(JobState.Uploading, transferred, total, null);
+
+ Assert.Equal(expected, progress.Percent);
+ }
+
+ [Fact]
+ public void Percent_never_goes_negative()
+ {
+ var progress = new JobProgress(JobState.Uploading, -10, 100, null);
+
+ Assert.Equal(0, progress.Percent);
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackEmojiTests.cs b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackEmojiTests.cs
new file mode 100644
index 0000000..de6546d
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackEmojiTests.cs
@@ -0,0 +1,62 @@
+using Hookline.Modules.YouTubeUploads.Infrastructure;
+
+namespace Hookline.Modules.YouTubeUploads.Tests;
+
+public sealed class SlackEmojiTests
+{
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ public void Empty_or_null_returns_empty(string? input, string expected)
+ {
+ Assert.Equal(expected, SlackEmoji.ShortcodesToUnicode(input));
+ }
+
+ [Fact]
+ public void Known_shortcode_is_replaced()
+ {
+ Assert.Equal("🔥", SlackEmoji.ShortcodesToUnicode(":fire:"));
+ }
+
+ [Theory]
+ [InlineData(":+1:", "👍")]
+ [InlineData(":rocket:", "🚀")]
+ [InlineData(":tada:", "🎉")]
+ [InlineData(":heart:", "❤️")]
+ [InlineData(":smile:", "😄")]
+ [InlineData(":100:", "💯")]
+ public void Common_shortcodes_map_correctly(string input, string expected)
+ {
+ Assert.Equal(expected, SlackEmoji.ShortcodesToUnicode(input));
+ }
+
+ [Fact]
+ public void Unknown_shortcode_passes_through()
+ {
+ Assert.Equal(":custom_emoji:", SlackEmoji.ShortcodesToUnicode(":custom_emoji:"));
+ }
+
+ [Fact]
+ public void Skin_tone_modifier_is_stripped()
+ {
+ Assert.Equal("", SlackEmoji.ShortcodesToUnicode(":skin-tone-3:"));
+ }
+
+ [Fact]
+ public void Text_without_colons_is_unchanged()
+ {
+ Assert.Equal("no emoji here", SlackEmoji.ShortcodesToUnicode("no emoji here"));
+ }
+
+ [Fact]
+ public void Multiple_shortcodes_in_text()
+ {
+ Assert.Equal("I 🔥 and 🚀", SlackEmoji.ShortcodesToUnicode("I :fire: and :rocket:"));
+ }
+
+ [Fact]
+ public void Colon_delimited_non_emoji_is_unchanged()
+ {
+ Assert.Equal("16:9 ratio", SlackEmoji.ShortcodesToUnicode("16:9 ratio"));
+ }
+}
diff --git a/backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackMrkdwnTests.cs b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackMrkdwnTests.cs
new file mode 100644
index 0000000..13d4050
--- /dev/null
+++ b/backend/tests/Hookline.Modules.YouTubeUploads.Tests/SlackMrkdwnTests.cs
@@ -0,0 +1,99 @@
+using Hookline.Modules.YouTubeUploads.Infrastructure;
+
+namespace Hookline.Modules.YouTubeUploads.Tests;
+
+public sealed class SlackMrkdwnTests
+{
+ [Theory]
+ [InlineData(null, "")]
+ [InlineData("", "")]
+ public void Empty_or_null_returns_empty(string? input, string expected)
+ {
+ Assert.Equal(expected, SlackMrkdwn.ToPlainText(input));
+ }
+
+ [Fact]
+ public void Plain_text_passes_through()
+ {
+ Assert.Equal("hello world", SlackMrkdwn.ToPlainText("hello world"));
+ }
+
+ [Fact]
+ public void User_mention_with_label_expands()
+ {
+ Assert.Equal("@alice", SlackMrkdwn.ToPlainText("<@U123|alice>"));
+ }
+
+ [Fact]
+ public void User_mention_without_label_keeps_at_target()
+ {
+ Assert.Equal("@U123", SlackMrkdwn.ToPlainText("<@U123>"));
+ }
+
+ [Fact]
+ public void Channel_mention_with_label_expands()
+ {
+ Assert.Equal("#general", SlackMrkdwn.ToPlainText("<#C123|general>"));
+ }
+
+ [Fact]
+ public void Special_command_here()
+ {
+ Assert.Equal("@here", SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Special_command_channel()
+ {
+ Assert.Equal("@channel", SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Special_command_everyone()
+ {
+ Assert.Equal("@everyone", SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Subteam_with_label_returns_label()
+ {
+ Assert.Equal("@eng", SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Plain_url_entity_becomes_text()
+ {
+ Assert.Equal("https://example.com", SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Url_with_different_label_includes_both()
+ {
+ Assert.Equal("Click here (https://example.com)",
+ SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Mailto_strips_prefix()
+ {
+ Assert.Equal("user@example.com", SlackMrkdwn.ToPlainText(""));
+ }
+
+ [Fact]
+ public void Html_escapes_are_decoded()
+ {
+ Assert.Equal("a & b", SlackMrkdwn.ToPlainText("a & b"));
+ }
+
+ [Fact]
+ public void Literal_angle_brackets_from_escapes_are_stripped()
+ {
+ Assert.Equal("ab", SlackMrkdwn.ToPlainText("<a>b"));
+ }
+
+ [Fact]
+ public void Emoji_shortcodes_are_converted()
+ {
+ Assert.Equal("🔥", SlackMrkdwn.ToPlainText(":fire:"));
+ }
+}
diff --git a/backend/tests/Hookline.SharedKernel.Tests/CsvTests.cs b/backend/tests/Hookline.SharedKernel.Tests/CsvTests.cs
new file mode 100644
index 0000000..5a67835
--- /dev/null
+++ b/backend/tests/Hookline.SharedKernel.Tests/CsvTests.cs
@@ -0,0 +1,61 @@
+using Hookline.SharedKernel.Common;
+
+namespace Hookline.SharedKernel.Tests;
+
+public sealed class CsvTests
+{
+ [Theory]
+ [InlineData("simple", "simple")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ public void Field_returns_value_unchanged_when_no_special_chars(string? input, string expected)
+ {
+ Assert.Equal(expected, Csv.Field(input));
+ }
+
+ [Theory]
+ [InlineData("a,b", "\"a,b\"")]
+ [InlineData("say \"hi\"", "\"say \"\"hi\"\"\"")]
+ [InlineData("line\none", "\"line\none\"")]
+ [InlineData("line\rone", "\"line\rone\"")]
+ public void Field_quotes_special_characters(string input, string expected)
+ {
+ Assert.Equal(expected, Csv.Field(input));
+ }
+
+ [Fact]
+ public void Row_joins_fields_with_commas()
+ {
+ Assert.Equal("a,b,c", Csv.Row("a", "b", "c"));
+ }
+
+ [Fact]
+ public void Row_quotes_fields_that_need_it()
+ {
+ Assert.Equal("a,\"b,c\",d", Csv.Row("a", "b,c", "d"));
+ }
+
+ [Fact]
+ public void Row_empty_returns_empty_string()
+ {
+ Assert.Equal("", Csv.Row());
+ }
+
+ [Fact]
+ public void Document_produces_crlf_terminated_output()
+ {
+ var doc = Csv.Document(
+ ["Name", "Age"],
+ [["Alice", "30"], ["Bob", "25"]]);
+
+ Assert.Equal("Name,Age\r\nAlice,30\r\nBob,25\r\n", doc);
+ }
+
+ [Fact]
+ public void Document_with_no_data_rows_has_header_only()
+ {
+ var doc = Csv.Document(["H1"], []);
+
+ Assert.Equal("H1\r\n", doc);
+ }
+}
diff --git a/backend/tests/Hookline.SharedKernel.Tests/Hookline.SharedKernel.Tests.csproj b/backend/tests/Hookline.SharedKernel.Tests/Hookline.SharedKernel.Tests.csproj
new file mode 100644
index 0000000..04a652a
--- /dev/null
+++ b/backend/tests/Hookline.SharedKernel.Tests/Hookline.SharedKernel.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/tests/Hookline.SharedKernel.Tests/PagedResultTests.cs b/backend/tests/Hookline.SharedKernel.Tests/PagedResultTests.cs
new file mode 100644
index 0000000..1b1bfec
--- /dev/null
+++ b/backend/tests/Hookline.SharedKernel.Tests/PagedResultTests.cs
@@ -0,0 +1,120 @@
+using Hookline.SharedKernel.Common;
+
+namespace Hookline.SharedKernel.Tests;
+
+public sealed class PagedResultTests
+{
+ [Fact]
+ public void TotalPages_rounds_up()
+ {
+ var paged = new PagedResult([1, 2, 3], Page: 1, PageSize: 2, Total: 5);
+
+ Assert.Equal(3, paged.TotalPages);
+ }
+
+ [Fact]
+ public void TotalPages_zero_when_pageSize_is_zero()
+ {
+ var paged = new PagedResult([], Page: 1, PageSize: 0, Total: 10);
+
+ Assert.Equal(0, paged.TotalPages);
+ }
+
+ [Fact]
+ public void TotalPages_zero_when_pageSize_is_negative()
+ {
+ var paged = new PagedResult([], Page: 1, PageSize: -1, Total: 10);
+
+ Assert.Equal(0, paged.TotalPages);
+ }
+
+ [Fact]
+ public void HasNext_true_when_more_items_remain()
+ {
+ var paged = new PagedResult([1], Page: 1, PageSize: 10, Total: 20);
+
+ Assert.True(paged.HasNext);
+ }
+
+ [Fact]
+ public void HasNext_false_on_last_page()
+ {
+ var paged = new PagedResult([1], Page: 2, PageSize: 10, Total: 20);
+
+ Assert.False(paged.HasNext);
+ }
+
+ [Fact]
+ public void HasPrevious_false_on_first_page()
+ {
+ var paged = new PagedResult([1], Page: 1, PageSize: 10, Total: 20);
+
+ Assert.False(paged.HasPrevious);
+ }
+
+ [Fact]
+ public void HasPrevious_true_after_first_page()
+ {
+ var paged = new PagedResult([1], Page: 2, PageSize: 10, Total: 20);
+
+ Assert.True(paged.HasPrevious);
+ }
+
+ [Fact]
+ public void Empty_creates_zero_total()
+ {
+ var paged = PagedResult.Empty();
+
+ Assert.Empty(paged.Items);
+ Assert.Equal(1, paged.Page);
+ Assert.Equal(20, paged.PageSize);
+ Assert.Equal(0, paged.Total);
+ Assert.Equal(0, paged.TotalPages);
+ Assert.False(paged.HasNext);
+ Assert.False(paged.HasPrevious);
+ }
+
+ [Fact]
+ public void PageRequest_clamps_page_below_one()
+ {
+ var req = new PageRequest(0, 10);
+ Assert.Equal(1, req.SafePage);
+
+ var neg = new PageRequest(-5, 10);
+ Assert.Equal(1, neg.SafePage);
+ }
+
+ [Fact]
+ public void PageRequest_clamps_pageSize_to_default()
+ {
+ var zero = new PageRequest(1, 0);
+ Assert.Equal(20, zero.SafePageSize);
+
+ var neg = new PageRequest(1, -1);
+ Assert.Equal(20, neg.SafePageSize);
+
+ var huge = new PageRequest(1, 999);
+ Assert.Equal(20, huge.SafePageSize);
+ }
+
+ [Fact]
+ public void PageRequest_accepts_valid_pageSize()
+ {
+ var req = new PageRequest(1, PageRequest.MaxPageSize);
+ Assert.Equal(PageRequest.MaxPageSize, req.SafePageSize);
+ }
+
+ [Fact]
+ public void PageRequest_Skip_computes_offset()
+ {
+ var req = new PageRequest(3, 10);
+ Assert.Equal(20, req.Skip);
+ }
+
+ [Fact]
+ public void PageRequest_Skip_uses_safe_values()
+ {
+ var req = new PageRequest(0, 0);
+ Assert.Equal(0, req.Skip); // (SafePage=1 - 1) * SafePageSize=20 = 0
+ }
+}
diff --git a/backend/tests/Hookline.SharedKernel.Tests/ResultTests.cs b/backend/tests/Hookline.SharedKernel.Tests/ResultTests.cs
new file mode 100644
index 0000000..70ca071
--- /dev/null
+++ b/backend/tests/Hookline.SharedKernel.Tests/ResultTests.cs
@@ -0,0 +1,100 @@
+using Hookline.SharedKernel.Common;
+
+namespace Hookline.SharedKernel.Tests;
+
+public sealed class ResultTests
+{
+ [Fact]
+ public void Success_has_no_error()
+ {
+ var result = Result.Success();
+
+ Assert.True(result.IsSuccess);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Failure_carries_the_error()
+ {
+ var error = Error.Validation("bad input");
+ var result = Result.Failure(error);
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal(error, result.Error);
+ }
+
+ [Fact]
+ public void Implicit_conversion_from_Error_produces_failure()
+ {
+ Result result = Error.NotFound;
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal(Error.NotFound, result.Error);
+ }
+
+ [Fact]
+ public void Generic_Success_carries_the_value()
+ {
+ var result = Result.Success(42);
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal(42, result.Value);
+ Assert.Null(result.Error);
+ }
+
+ [Fact]
+ public void Generic_Failure_has_default_value()
+ {
+ var error = Error.Unauthorized;
+ var result = Result.Failure(error);
+
+ Assert.False(result.IsSuccess);
+ Assert.Null(result.Value);
+ Assert.Equal(error, result.Error);
+ }
+
+ [Fact]
+ public void Generic_implicit_conversion_from_value()
+ {
+ Result result = "hello";
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("hello", result.Value);
+ }
+
+ [Fact]
+ public void Generic_implicit_conversion_from_error()
+ {
+ Result result = Error.Forbidden;
+
+ Assert.False(result.IsSuccess);
+ Assert.Equal(Error.Forbidden, result.Error);
+ }
+
+ [Fact]
+ public void Error_factory_Conflict_uses_409()
+ {
+ var error = Error.Conflict("duplicate");
+
+ Assert.Equal("conflict", error.Code);
+ Assert.Equal("duplicate", error.Message);
+ Assert.Equal(409, error.Status);
+ }
+
+ [Fact]
+ public void Error_factory_Validation_uses_400()
+ {
+ var error = Error.Validation("oops");
+
+ Assert.Equal("validation", error.Code);
+ Assert.Equal(400, error.Status);
+ }
+
+ [Fact]
+ public void Predefined_errors_have_correct_status_codes()
+ {
+ Assert.Equal(404, Error.NotFound.Status);
+ Assert.Equal(401, Error.Unauthorized.Status);
+ Assert.Equal(403, Error.Forbidden.Status);
+ }
+}