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); + } +}