From 454a5bd3ac584e084a99280c6d38772d80598806 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 11:20:19 -0800 Subject: [PATCH 1/7] testing systems --- .../auth/internal/handler/token_handler.go | 17 +++++++++++++++++ src/services/testing/src/testing/runner.py | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/services/auth/internal/handler/token_handler.go b/src/services/auth/internal/handler/token_handler.go index 0754340..50e0d51 100644 --- a/src/services/auth/internal/handler/token_handler.go +++ b/src/services/auth/internal/handler/token_handler.go @@ -122,6 +122,12 @@ func (h *TokenHandler) Validate(ctx context.Context, token string) (*jwt.Claims, return claims, nil } +// servicePermissions defines permissions granted to specific service accounts +var servicePermissions = map[string]map[string]bool{ + "testing-service": {"admin.panel": true}, + "health-service": {"admin.panel": true}, +} + func (h *TokenHandler) GenerateServiceToken(serviceID string) (string, error) { claims := &jwt.Claims{ UserID: "service:" + serviceID, @@ -130,6 +136,17 @@ func (h *TokenHandler) GenerateServiceToken(serviceID string) (string, error) { "type": "service", }, } + + // Add permissions if defined for this service + if perms, ok := servicePermissions[serviceID]; ok && len(perms) > 0 { + permJSON, err := json.Marshal(perms) + if err != nil { + slog.Error("failed to marshal service permissions", "serviceID", serviceID, "error", err) + } else { + claims.Custom["permissions"] = string(permJSON) + } + } + return h.jwtService.GenerateAccessToken(claims, time.Hour) } diff --git a/src/services/testing/src/testing/runner.py b/src/services/testing/src/testing/runner.py index f317246..c3a93b4 100644 --- a/src/services/testing/src/testing/runner.py +++ b/src/services/testing/src/testing/runner.py @@ -22,6 +22,10 @@ logger = structlog.get_logger() +# Delay between test categories to avoid hitting gateway rate limits +# Auth endpoints are limited to 10 requests/minute +CATEGORY_DELAY_SECONDS = 25 + class TestRunner: """Manages test execution and scheduling.""" @@ -402,14 +406,26 @@ async def run_e2e_tests(self) -> TestRunSummary: return summary async def run_all_tests(self) -> list[TestRunSummary]: - """Run all test categories.""" + """Run all test categories with delays to avoid rate limiting.""" summaries = [] + summaries.append(await self.run_smoke_tests()) + await asyncio.sleep(CATEGORY_DELAY_SECONDS) + summaries.append(await self.run_contract_tests()) + await asyncio.sleep(CATEGORY_DELAY_SECONDS) + summaries.append(await self.run_error_tests()) + await asyncio.sleep(CATEGORY_DELAY_SECONDS) + summaries.append(await self.run_integration_tests()) + await asyncio.sleep(CATEGORY_DELAY_SECONDS) + summaries.append(await self.run_golden_tests()) + await asyncio.sleep(CATEGORY_DELAY_SECONDS) + summaries.append(await self.run_e2e_tests()) + return summaries async def start(self) -> None: From d01610938a73a34c262501b34c3c38a432474ca2 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 12:04:48 -0800 Subject: [PATCH 2/7] tests --- .../user/User.Tests/AuthorizationTests.fs | 214 +++++++ src/services/user/User.Tests/MappingTests.fs | 287 +++++++++ .../user/User.Tests/User.Tests.fsproj | 26 + .../user/User.Tests/ValidationTests.fs | 211 +++++++ .../internal/processor/thumbnails_test.go | 299 ++++++++++ .../internal/dispatcher/dispatcher_test.go | 188 ++++++ .../internal/dispatcher/transformer_test.go | 547 ++++++++++++++++++ 7 files changed, 1772 insertions(+) create mode 100644 src/services/user/User.Tests/AuthorizationTests.fs create mode 100644 src/services/user/User.Tests/MappingTests.fs create mode 100644 src/services/user/User.Tests/User.Tests.fsproj create mode 100644 src/services/user/User.Tests/ValidationTests.fs create mode 100644 src/workers/images/internal/processor/thumbnails_test.go create mode 100644 src/workers/webhooks/internal/dispatcher/dispatcher_test.go create mode 100644 src/workers/webhooks/internal/dispatcher/transformer_test.go diff --git a/src/services/user/User.Tests/AuthorizationTests.fs b/src/services/user/User.Tests/AuthorizationTests.fs new file mode 100644 index 0000000..c19ad67 --- /dev/null +++ b/src/services/user/User.Tests/AuthorizationTests.fs @@ -0,0 +1,214 @@ +namespace User.Tests + +open System +open Xunit +open HellArch.DataTypes.User + +/// Tests for authorization and ownership verification logic. +/// These verify the patterns used to check user ownership of resources. +module AuthorizationTests = + + /// Tests for GUID parsing used in authorization headers. + module GuidParsingTests = + + [] + let ``Valid GUID string parses successfully`` () = + let guidString = "12345678-1234-1234-1234-123456789012" + match Guid.TryParse(guidString) with + | true, guid -> Assert.Equal(Guid.Parse(guidString), guid) + | false, _ -> Assert.Fail("Expected successful parse") + + [] + let ``Empty string fails to parse as GUID`` () = + match Guid.TryParse("") with + | true, _ -> Assert.Fail("Expected parse failure") + | false, _ -> Assert.True(true) + + [] + let ``Whitespace string fails to parse as GUID`` () = + match Guid.TryParse(" ") with + | true, _ -> Assert.Fail("Expected parse failure") + | false, _ -> Assert.True(true) + + [] + let ``Malformed GUID fails to parse`` () = + let malformed = [ + "not-a-guid" + "12345678-1234-1234-1234" + "12345678-1234-1234-1234-12345678901" // Too short + "12345678-1234-1234-1234-1234567890123" // Too long + "ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ" // Invalid hex + ] + + for s in malformed do + match Guid.TryParse(s) with + | true, _ -> Assert.Fail($"Expected parse failure for: {s}") + | false, _ -> () + + [] + let ``GUID parsing is case-insensitive`` () = + let lower = "12345678-abcd-1234-abcd-123456789012" + let upper = "12345678-ABCD-1234-ABCD-123456789012" + let mixed = "12345678-AbCd-1234-aBcD-123456789012" + + let (_, lowerGuid) = Guid.TryParse(lower) + let (_, upperGuid) = Guid.TryParse(upper) + let (_, mixedGuid) = Guid.TryParse(mixed) + + Assert.Equal(lowerGuid, upperGuid) + Assert.Equal(upperGuid, mixedGuid) + + /// Tests for ownership verification patterns. + module OwnershipTests = + + let createProfileWithAuthUser (authUserId: Guid) : UserProfile = + { + Id = Guid.NewGuid() + AuthUserId = authUserId + Email = "test@example.com" + DisplayName = None + AvatarUrl = None + Bio = None + CreatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow + Preferences = None + } + + [] + let ``Same AuthUserId passes ownership check`` () = + let authUserId = Guid.NewGuid() + let profile = createProfileWithAuthUser authUserId + let requestingUserId = authUserId + + let isOwner = profile.AuthUserId = requestingUserId + + Assert.True(isOwner) + + [] + let ``Different AuthUserId fails ownership check`` () = + let profileOwner = Guid.NewGuid() + let profile = createProfileWithAuthUser profileOwner + let requestingUserId = Guid.NewGuid() + + let isOwner = profile.AuthUserId = requestingUserId + + Assert.False(isOwner) + + [] + let ``Empty GUID does not match valid GUID`` () = + let profileOwner = Guid.NewGuid() + let profile = createProfileWithAuthUser profileOwner + let emptyGuid = Guid.Empty + + let isOwner = profile.AuthUserId = emptyGuid + + Assert.False(isOwner) + + [] + let ``Profile ID is not the same as AuthUserId`` () = + let authUserId = Guid.NewGuid() + let profile = createProfileWithAuthUser authUserId + + // Profile.Id should be different from AuthUserId + Assert.NotEqual(profile.Id, profile.AuthUserId) + + [] + let ``Ownership check uses AuthUserId not Profile Id`` () = + let authUserId = Guid.NewGuid() + let profile = createProfileWithAuthUser authUserId + + // Attempting to use Profile.Id for ownership check should fail + let wrongCheck = profile.Id = authUserId + let correctCheck = profile.AuthUserId = authUserId + + Assert.False(wrongCheck) + Assert.True(correctCheck) + + /// Tests for preferences ownership patterns. + module PreferencesOwnershipTests = + + let createPreferencesForProfile (profileId: Guid) : UserPreferences = + { + Id = Guid.NewGuid() + UserProfileId = profileId + Theme = Theme.System + Locale = Some "en-US" + Timezone = Some "UTC" + EmailNotifications = true + PushNotifications = true + InAppNotifications = true + CreatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow + } + + [] + let ``Preferences are linked to profile via UserProfileId`` () = + let profileId = Guid.NewGuid() + let prefs = createPreferencesForProfile profileId + + Assert.Equal(profileId, prefs.UserProfileId) + + [] + let ``Preferences ID is different from UserProfileId`` () = + let profileId = Guid.NewGuid() + let prefs = createPreferencesForProfile profileId + + Assert.NotEqual(prefs.Id, prefs.UserProfileId) + + /// Tests for header value handling patterns. + module HeaderValueTests = + + [] + let ``Single value header can be parsed`` () = + let headerValue = "12345678-1234-1234-1234-123456789012" + match Guid.TryParse(headerValue.ToString()) with + | true, guid -> Assert.NotEqual(Guid.Empty, guid) + | false, _ -> Assert.Fail("Expected successful parse") + + [] + let ``Header with leading/trailing whitespace still parses`` () = + let headerValue = " 12345678-1234-1234-1234-123456789012 " + match Guid.TryParse(headerValue.Trim()) with + | true, guid -> Assert.NotEqual(Guid.Empty, guid) + | false, _ -> Assert.Fail("Expected successful parse after trim") + + [] + let ``Multiple values in header uses first value pattern`` () = + // Simulating comma-separated header values + let headerValue = "12345678-1234-1234-1234-123456789012, 87654321-4321-4321-4321-210987654321" + let firstValue = headerValue.Split(',').[0].Trim() + + match Guid.TryParse(firstValue) with + | true, _ -> Assert.True(true) + | false, _ -> Assert.Fail("Expected successful parse of first value") + + /// Tests for authorization error scenarios. + module AuthorizationErrorTests = + + [] + let ``Missing header results in empty string`` () = + let missingHeader: string option = None + + match missingHeader with + | None -> Assert.True(true, "Missing header detected") + | Some _ -> Assert.Fail("Expected missing header") + + [] + let ``Empty header value is handled`` () = + let emptyHeader = Some "" + + match emptyHeader with + | Some "" -> Assert.True(true, "Empty header detected") + | _ -> Assert.Fail("Expected empty header") + + [] + let ``Ownership denial is distinct from not found`` () = + // These are different error conditions that should be handled differently + let notFoundError = User.Domain.UserError.NotFound (Guid.NewGuid()) + let forbiddenScenario = "forbidden" + + match notFoundError with + | User.Domain.UserError.NotFound _ -> Assert.True(true) + | _ -> Assert.Fail("Expected NotFound error type") + + Assert.Equal("forbidden", forbiddenScenario) diff --git a/src/services/user/User.Tests/MappingTests.fs b/src/services/user/User.Tests/MappingTests.fs new file mode 100644 index 0000000..cbbcb0a --- /dev/null +++ b/src/services/user/User.Tests/MappingTests.fs @@ -0,0 +1,287 @@ +namespace User.Tests + +open System +open Xunit +open User.Domain +open HellArch.DataTypes.User + +/// Tests for domain mapping functions. +/// These verify correct conversion between domain entities and DTOs. +module MappingTests = + + /// Tests for Theme mapping between domain and DTO types. + module ThemeTests = + + [] + [] + [] + [] + let ``Theme.toDto converts Dark theme correctly`` (_: string) = + let result = Mapping.Theme.toDto Theme.Dark + Assert.Equal(HellArch.User.V1.Theme.Dark, result) + + [] + [] + [] + [] + let ``Theme.toDto converts Light theme correctly`` (_: string) = + let result = Mapping.Theme.toDto Theme.Light + Assert.Equal(HellArch.User.V1.Theme.Light, result) + + [] + let ``Theme.toDto converts System theme correctly`` () = + let result = Mapping.Theme.toDto Theme.System + Assert.Equal(HellArch.User.V1.Theme.System, result) + + [] + let ``Theme.fromDto roundtrips Dark correctly`` () = + let original = Theme.Dark + let dto = Mapping.Theme.toDto original + let result = Mapping.Theme.fromDto dto + Assert.Equal(original, result) + + [] + let ``Theme.fromDto roundtrips Light correctly`` () = + let original = Theme.Light + let dto = Mapping.Theme.toDto original + let result = Mapping.Theme.fromDto dto + Assert.Equal(original, result) + + [] + let ``Theme.fromDto roundtrips System correctly`` () = + let original = Theme.System + let dto = Mapping.Theme.toDto original + let result = Mapping.Theme.fromDto dto + Assert.Equal(original, result) + + /// Tests for UserProfile mapping. + module UserProfileTests = + + let createTestProfile () : UserProfile = + { + Id = Guid.NewGuid() + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = Some "Test User" + AvatarUrl = Some "https://example.com/avatar.png" + Bio = Some "A test bio" + CreatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow + Preferences = None + } + + [] + let ``UserProfile.toDto maps all fields correctly`` () = + let profile = createTestProfile() + let dto = Mapping.UserProfile.toDto profile + + Assert.Equal(profile.Id.ToString(), dto.Id) + Assert.Equal(profile.AuthUserId.ToString(), dto.AuthUserId) + Assert.Equal(profile.Email, dto.Email) + Assert.Equal("Test User", dto.DisplayName) + Assert.Equal("https://example.com/avatar.png", dto.AvatarUrl) + Assert.Equal("A test bio", dto.Bio) + + [] + let ``UserProfile.toDto handles None DisplayName as empty string`` () = + let profile = { createTestProfile() with DisplayName = None } + let dto = Mapping.UserProfile.toDto profile + + Assert.Equal("", dto.DisplayName) + + [] + let ``UserProfile.toDto handles None AvatarUrl as empty string`` () = + let profile = { createTestProfile() with AvatarUrl = None } + let dto = Mapping.UserProfile.toDto profile + + Assert.Equal("", dto.AvatarUrl) + + [] + let ``UserProfile.toDto handles None Bio as empty string`` () = + let profile = { createTestProfile() with Bio = None } + let dto = Mapping.UserProfile.toDto profile + + Assert.Equal("", dto.Bio) + + [] + let ``UserProfile.toDto handles all optional fields as None`` () = + let profile = + { createTestProfile() with + DisplayName = None + AvatarUrl = None + Bio = None + } + let dto = Mapping.UserProfile.toDto profile + + Assert.Equal("", dto.DisplayName) + Assert.Equal("", dto.AvatarUrl) + Assert.Equal("", dto.Bio) + + [] + let ``UserProfile.toDto preserves GUID format`` () = + let id = Guid.Parse("12345678-1234-1234-1234-123456789012") + let authId = Guid.Parse("87654321-4321-4321-4321-210987654321") + let profile = { createTestProfile() with Id = id; AuthUserId = authId } + let dto = Mapping.UserProfile.toDto profile + + Assert.Equal("12345678-1234-1234-1234-123456789012", dto.Id) + Assert.Equal("87654321-4321-4321-4321-210987654321", dto.AuthUserId) + + /// Tests for UserPreferences mapping. + module UserPreferencesTests = + + let createTestPreferences (userId: Guid) : UserPreferences = + { + Id = Guid.NewGuid() + UserProfileId = userId + Theme = Theme.Dark + Locale = Some "en-US" + Timezone = Some "America/New_York" + EmailNotifications = true + PushNotifications = false + InAppNotifications = true + CreatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow + } + + [] + let ``UserPreferences.toDto maps theme correctly`` () = + let prefs = createTestPreferences (Guid.NewGuid()) + let dto = Mapping.UserPreferences.toDto prefs + + Assert.Equal(HellArch.User.V1.Theme.Dark, dto.Theme) + + [] + let ``UserPreferences.toDto defaults locale to en-US when None`` () = + let prefs = { createTestPreferences (Guid.NewGuid()) with Locale = None } + let dto = Mapping.UserPreferences.toDto prefs + + Assert.Equal("en-US", dto.Locale) + + [] + let ``UserPreferences.toDto defaults timezone to UTC when None`` () = + let prefs = { createTestPreferences (Guid.NewGuid()) with Timezone = None } + let dto = Mapping.UserPreferences.toDto prefs + + Assert.Equal("UTC", dto.Timezone) + + [] + let ``UserPreferences.toDto maps notification settings correctly`` () = + let prefs = + { createTestPreferences (Guid.NewGuid()) with + EmailNotifications = true + PushNotifications = false + InAppNotifications = true + } + let dto = Mapping.UserPreferences.toDto prefs + + Assert.True(dto.Notifications.IsSome) + let notifications = dto.Notifications.Value + Assert.True(notifications.EmailEnabled) + Assert.False(notifications.PushEnabled) + Assert.True(notifications.InAppEnabled) + + [] + let ``UserPreferences.fromDto creates new ID`` () = + let userId = Guid.NewGuid() + let dto: HellArch.User.V1.UserPreferences = + { + Theme = HellArch.User.V1.Theme.Light + Locale = "fr-FR" + Timezone = "Europe/Paris" + Notifications = Some { EmailEnabled = true; PushEnabled = true; InAppEnabled = true } + Custom = None + } + + let prefs1 = Mapping.UserPreferences.fromDto userId dto + let prefs2 = Mapping.UserPreferences.fromDto userId dto + + Assert.NotEqual(prefs1.Id, prefs2.Id) + + [] + let ``UserPreferences.fromDto preserves UserProfileId`` () = + let userId = Guid.NewGuid() + let dto: HellArch.User.V1.UserPreferences = + { + Theme = HellArch.User.V1.Theme.System + Locale = "en-GB" + Timezone = "Europe/London" + Notifications = None + Custom = None + } + + let prefs = Mapping.UserPreferences.fromDto userId dto + + Assert.Equal(userId, prefs.UserProfileId) + + [] + let ``UserPreferences.fromDto handles empty locale as None`` () = + let userId = Guid.NewGuid() + let dto: HellArch.User.V1.UserPreferences = + { + Theme = HellArch.User.V1.Theme.Dark + Locale = "" + Timezone = "UTC" + Notifications = None + Custom = None + } + + let prefs = Mapping.UserPreferences.fromDto userId dto + + Assert.True(prefs.Locale.IsNone) + + [] + let ``UserPreferences.fromDto handles empty timezone as None`` () = + let userId = Guid.NewGuid() + let dto: HellArch.User.V1.UserPreferences = + { + Theme = HellArch.User.V1.Theme.Dark + Locale = "en-US" + Timezone = "" + Notifications = None + Custom = None + } + + let prefs = Mapping.UserPreferences.fromDto userId dto + + Assert.True(prefs.Timezone.IsNone) + + [] + let ``UserPreferences.fromDto defaults notifications to all true when None`` () = + let userId = Guid.NewGuid() + let dto: HellArch.User.V1.UserPreferences = + { + Theme = HellArch.User.V1.Theme.Dark + Locale = "en-US" + Timezone = "UTC" + Notifications = None + Custom = None + } + + let prefs = Mapping.UserPreferences.fromDto userId dto + + Assert.True(prefs.EmailNotifications) + Assert.True(prefs.PushNotifications) + Assert.True(prefs.InAppNotifications) + + /// Tests for NotificationSettings mapping. + module NotificationSettingsTests = + + [] + let ``NotificationSettings.fromDto handles Some correctly`` () = + let dto: HellArch.User.V1.NotificationSettings = + { EmailEnabled = false; PushEnabled = true; InAppEnabled = false } + + let (email, push, inApp) = Mapping.NotificationSettings.fromDto (Some dto) + + Assert.False(email) + Assert.True(push) + Assert.False(inApp) + + [] + let ``NotificationSettings.fromDto defaults to all true when None`` () = + let (email, push, inApp) = Mapping.NotificationSettings.fromDto None + + Assert.True(email) + Assert.True(push) + Assert.True(inApp) diff --git a/src/services/user/User.Tests/User.Tests.fsproj b/src/services/user/User.Tests/User.Tests.fsproj new file mode 100644 index 0000000..a13c167 --- /dev/null +++ b/src/services/user/User.Tests/User.Tests.fsproj @@ -0,0 +1,26 @@ + + + + net10.0 + false + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/services/user/User.Tests/ValidationTests.fs b/src/services/user/User.Tests/ValidationTests.fs new file mode 100644 index 0000000..8333fa2 --- /dev/null +++ b/src/services/user/User.Tests/ValidationTests.fs @@ -0,0 +1,211 @@ +namespace User.Tests + +open System +open Xunit +open User.Domain + +/// Tests for validation logic and error handling. +/// These verify the service properly validates inputs and returns appropriate errors. +module ValidationTests = + + /// Tests for UserError types and their usage. + module ErrorTypeTests = + + [] + let ``UserError.NotFound contains the user ID`` () = + let userId = Guid.NewGuid() + let error = UserError.NotFound userId + + match error with + | UserError.NotFound id -> Assert.Equal(userId, id) + | _ -> Assert.Fail("Expected NotFound error") + + [] + let ``UserError.ValidationError contains the message`` () = + let message = "Invalid email format" + let error = UserError.ValidationError message + + match error with + | UserError.ValidationError msg -> Assert.Equal(message, msg) + | _ -> Assert.Fail("Expected ValidationError") + + [] + let ``UserError.DatabaseError contains the message`` () = + let message = "Connection timeout" + let error = UserError.DatabaseError message + + match error with + | UserError.DatabaseError msg -> Assert.Equal(message, msg) + | _ -> Assert.Fail("Expected DatabaseError") + + [] + let ``UserError.Conflict contains the message`` () = + let message = "Profile already exists" + let error = UserError.Conflict message + + match error with + | UserError.Conflict msg -> Assert.Equal(message, msg) + | _ -> Assert.Fail("Expected Conflict error") + + /// Tests for UserResult type alias behavior. + module ResultTests = + + [] + let ``UserResult Ok can hold a profile`` () = + let profile: HellArch.DataTypes.User.UserProfile = + { + Id = Guid.NewGuid() + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = Some "Test" + AvatarUrl = None + Bio = None + CreatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow + Preferences = None + } + + let result: UserResult = Ok profile + + match result with + | Ok p -> Assert.Equal(profile.Id, p.Id) + | Error _ -> Assert.Fail("Expected Ok result") + + [] + let ``UserResult Error can hold any UserError type`` () = + let errors = [ + UserError.NotFound (Guid.NewGuid()) + UserError.ValidationError "test" + UserError.DatabaseError "test" + UserError.Conflict "test" + ] + + for error in errors do + let result: UserResult = Error error + match result with + | Ok _ -> Assert.Fail("Expected Error result") + | Error e -> Assert.Equal(error, e) + + /// Tests for CreateProfileFromEvent validation. + module CreateProfileValidationTests = + + [] + let ``CreateProfileFromEvent requires valid AuthUserId`` () = + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.Empty + Email = "test@example.com" + DisplayName = Some "Test" + AvatarUrl = None + } + + // Empty GUID is technically valid but represents uninitialized state + Assert.Equal(Guid.Empty, request.AuthUserId) + + [] + let ``CreateProfileFromEvent allows empty DisplayName`` () = + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = None + AvatarUrl = None + } + + Assert.True(request.DisplayName.IsNone) + + [] + let ``CreateProfileFromEvent allows empty AvatarUrl`` () = + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = Some "Test" + AvatarUrl = None + } + + Assert.True(request.AvatarUrl.IsNone) + + [] + let ``CreateProfileFromEvent preserves whitespace in DisplayName`` () = + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = Some " Spaced Name " + AvatarUrl = None + } + + Assert.Equal(Some " Spaced Name ", request.DisplayName) + + /// Tests for edge cases in data handling. + module EdgeCaseTests = + + [] + let ``Empty email string is stored as-is`` () = + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "" + DisplayName = None + AvatarUrl = None + } + + Assert.Equal("", request.Email) + + [] + let ``Very long display name is accepted`` () = + let longName = String.replicate 1000 "a" + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = Some longName + AvatarUrl = None + } + + Assert.Equal(1000, request.DisplayName.Value.Length) + + [] + let ``Unicode characters in display name are preserved`` () = + let unicodeName = "Test \u4e2d\u6587 \u0420\u0443\u0441\u0441\u043a\u0438\u0439 \ud83d\ude00" + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = Some unicodeName + AvatarUrl = None + } + + Assert.Equal(unicodeName, request.DisplayName.Value) + + [] + let ``Special characters in bio are preserved`` () = + let specialBio = "Line1\nLine2\tTabbed\r\nWindows line" + let profile: HellArch.DataTypes.User.UserProfile = + { + Id = Guid.NewGuid() + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = None + AvatarUrl = None + Bio = Some specialBio + CreatedAt = DateTime.UtcNow + UpdatedAt = DateTime.UtcNow + Preferences = None + } + + Assert.Equal(specialBio, profile.Bio.Value) + + [] + let ``URL with query parameters in AvatarUrl is preserved`` () = + let urlWithParams = "https://example.com/avatar.png?size=200&format=webp" + let request: CreateProfileFromEvent = + { + AuthUserId = Guid.NewGuid() + Email = "test@example.com" + DisplayName = None + AvatarUrl = Some urlWithParams + } + + Assert.Equal(urlWithParams, request.AvatarUrl.Value) diff --git a/src/workers/images/internal/processor/thumbnails_test.go b/src/workers/images/internal/processor/thumbnails_test.go new file mode 100644 index 0000000..cd47c1e --- /dev/null +++ b/src/workers/images/internal/processor/thumbnails_test.go @@ -0,0 +1,299 @@ +package processor + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "strings" + "testing" +) + +// createTestPNG creates a simple PNG image for testing +func createTestPNG(width, height int) []byte { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + // Fill with a solid color + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes() +} + +// createTestJPEG creates a simple JPEG image for testing +func createTestJPEG(width, height int) []byte { + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + } + } + + var buf bytes.Buffer + jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) + return buf.Bytes() +} + +func TestGenerateThumbnail(t *testing.T) { + t.Run("generates thumbnail from PNG", func(t *testing.T) { + input := createTestPNG(800, 600) + + result, err := GenerateThumbnail(input, 256) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) == 0 { + t.Error("expected non-empty result") + } + + // Verify it's a valid JPEG + img, format, err := image.DecodeConfig(bytes.NewReader(result)) + if err != nil { + t.Fatalf("failed to decode result: %v", err) + } + if format != "jpeg" { + t.Errorf("expected jpeg format, got %s", format) + } + + // Verify dimensions are within maxSize + if img.Width > 256 || img.Height > 256 { + t.Errorf("thumbnail exceeds maxSize: %dx%d", img.Width, img.Height) + } + }) + + t.Run("generates thumbnail from JPEG", func(t *testing.T) { + input := createTestJPEG(1024, 768) + + result, err := GenerateThumbnail(input, 200) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + img, _, err := image.DecodeConfig(bytes.NewReader(result)) + if err != nil { + t.Fatalf("failed to decode result: %v", err) + } + + if img.Width > 200 || img.Height > 200 { + t.Errorf("thumbnail exceeds maxSize: %dx%d", img.Width, img.Height) + } + }) + + t.Run("preserves aspect ratio for landscape image", func(t *testing.T) { + // 800x400 = 2:1 aspect ratio + input := createTestPNG(800, 400) + + result, err := GenerateThumbnail(input, 200) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + img, _, _ := image.DecodeConfig(bytes.NewReader(result)) + + // Should be 200x100 (maintaining 2:1 ratio) + ratio := float64(img.Width) / float64(img.Height) + expectedRatio := 2.0 + + if ratio < expectedRatio-0.1 || ratio > expectedRatio+0.1 { + t.Errorf("aspect ratio not preserved: got %dx%d (ratio %.2f), expected ~2:1", + img.Width, img.Height, ratio) + } + }) + + t.Run("preserves aspect ratio for portrait image", func(t *testing.T) { + // 400x800 = 1:2 aspect ratio + input := createTestPNG(400, 800) + + result, err := GenerateThumbnail(input, 200) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + img, _, _ := image.DecodeConfig(bytes.NewReader(result)) + + // Should be 100x200 (maintaining 1:2 ratio) + ratio := float64(img.Height) / float64(img.Width) + expectedRatio := 2.0 + + if ratio < expectedRatio-0.1 || ratio > expectedRatio+0.1 { + t.Errorf("aspect ratio not preserved: got %dx%d (ratio %.2f), expected ~1:2", + img.Width, img.Height, ratio) + } + }) + + t.Run("handles square image", func(t *testing.T) { + input := createTestPNG(500, 500) + + result, err := GenerateThumbnail(input, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + img, _, _ := image.DecodeConfig(bytes.NewReader(result)) + + if img.Width != img.Height { + t.Errorf("square image should remain square: got %dx%d", img.Width, img.Height) + } + if img.Width > 100 { + t.Errorf("thumbnail exceeds maxSize: %d", img.Width) + } + }) + + t.Run("small image not upscaled", func(t *testing.T) { + // Create image smaller than maxSize + input := createTestPNG(50, 50) + + result, err := GenerateThumbnail(input, 256) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + img, _, _ := image.DecodeConfig(bytes.NewReader(result)) + + // imaging.Fit doesn't upscale, so it should stay small + if img.Width > 50 || img.Height > 50 { + t.Errorf("small image should not be upscaled: got %dx%d", img.Width, img.Height) + } + }) + + t.Run("returns error for invalid image data", func(t *testing.T) { + invalidData := []byte("not an image") + + _, err := GenerateThumbnail(invalidData, 256) + if err == nil { + t.Error("expected error for invalid image data") + } + }) + + t.Run("returns error for empty data", func(t *testing.T) { + _, err := GenerateThumbnail([]byte{}, 256) + if err == nil { + t.Error("expected error for empty data") + } + }) + + t.Run("returns error for truncated image", func(t *testing.T) { + validPNG := createTestPNG(100, 100) + truncated := validPNG[:len(validPNG)/2] + + _, err := GenerateThumbnail(truncated, 256) + if err == nil { + t.Error("expected error for truncated image") + } + }) + + t.Run("error message includes format info", func(t *testing.T) { + invalidData := []byte("not an image at all") + + _, err := GenerateThumbnail(invalidData, 256) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "failed to decode") { + t.Errorf("error should mention decode failure: %v", err) + } + }) + + t.Run("output is smaller than input for large images", func(t *testing.T) { + input := createTestPNG(2000, 2000) + + result, err := GenerateThumbnail(input, 256) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Thumbnail should be significantly smaller + if len(result) >= len(input) { + t.Errorf("thumbnail (%d bytes) should be smaller than input (%d bytes)", + len(result), len(input)) + } + }) + + t.Run("handles various maxSize values", func(t *testing.T) { + input := createTestPNG(500, 500) + + sizes := []int{32, 64, 128, 256, 512} + for _, size := range sizes { + result, err := GenerateThumbnail(input, size) + if err != nil { + t.Errorf("maxSize %d: unexpected error: %v", size, err) + continue + } + + img, _, _ := image.DecodeConfig(bytes.NewReader(result)) + if img.Width > size || img.Height > size { + t.Errorf("maxSize %d: thumbnail exceeds size: %dx%d", size, img.Width, img.Height) + } + } + }) + + t.Run("consistent output for same input", func(t *testing.T) { + input := createTestPNG(300, 200) + + result1, _ := GenerateThumbnail(input, 100) + result2, _ := GenerateThumbnail(input, 100) + + // Same input should produce same dimensions + img1, _, _ := image.DecodeConfig(bytes.NewReader(result1)) + img2, _, _ := image.DecodeConfig(bytes.NewReader(result2)) + + if img1.Width != img2.Width || img1.Height != img2.Height { + t.Errorf("inconsistent dimensions: %dx%d vs %dx%d", + img1.Width, img1.Height, img2.Width, img2.Height) + } + }) +} + +func TestGenerateThumbnailFormats(t *testing.T) { + t.Run("always outputs JPEG regardless of input format", func(t *testing.T) { + pngInput := createTestPNG(200, 200) + + result, err := GenerateThumbnail(pngInput, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check JPEG magic bytes + if len(result) < 2 || result[0] != 0xFF || result[1] != 0xD8 { + t.Error("output should be JPEG format (magic bytes FF D8)") + } + }) + + t.Run("JPEG input produces JPEG output", func(t *testing.T) { + jpegInput := createTestJPEG(200, 200) + + result, err := GenerateThumbnail(jpegInput, 100) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) < 2 || result[0] != 0xFF || result[1] != 0xD8 { + t.Error("output should be JPEG format") + } + }) +} + +func BenchmarkGenerateThumbnail(b *testing.B) { + input := createTestPNG(1920, 1080) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + GenerateThumbnail(input, 256) + } +} + +func BenchmarkGenerateThumbnailSmall(b *testing.B) { + input := createTestPNG(400, 300) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + GenerateThumbnail(input, 128) + } +} diff --git a/src/workers/webhooks/internal/dispatcher/dispatcher_test.go b/src/workers/webhooks/internal/dispatcher/dispatcher_test.go new file mode 100644 index 0000000..3a862a6 --- /dev/null +++ b/src/workers/webhooks/internal/dispatcher/dispatcher_test.go @@ -0,0 +1,188 @@ +package dispatcher + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func TestComputeSignature(t *testing.T) { + t.Run("produces sha256 prefixed signature", func(t *testing.T) { + payload := []byte(`{"test": "data"}`) + secret := "test-secret" + + result := computeSignature(payload, secret) + + if !strings.HasPrefix(result, "sha256=") { + t.Errorf("expected sha256= prefix, got %q", result) + } + }) + + t.Run("produces valid HMAC-SHA256", func(t *testing.T) { + payload := []byte(`{"test": "data"}`) + secret := "test-secret" + + result := computeSignature(payload, secret) + + // Verify manually + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if result != expected { + t.Errorf("signature mismatch:\ngot: %s\nwant: %s", result, expected) + } + }) + + t.Run("different payloads produce different signatures", func(t *testing.T) { + secret := "same-secret" + sig1 := computeSignature([]byte(`{"a": 1}`), secret) + sig2 := computeSignature([]byte(`{"b": 2}`), secret) + + if sig1 == sig2 { + t.Error("expected different signatures for different payloads") + } + }) + + t.Run("different secrets produce different signatures", func(t *testing.T) { + payload := []byte(`{"test": "data"}`) + sig1 := computeSignature(payload, "secret-1") + sig2 := computeSignature(payload, "secret-2") + + if sig1 == sig2 { + t.Error("expected different signatures for different secrets") + } + }) + + t.Run("same payload and secret produce same signature", func(t *testing.T) { + payload := []byte(`{"test": "data"}`) + secret := "consistent-secret" + + sig1 := computeSignature(payload, secret) + sig2 := computeSignature(payload, secret) + + if sig1 != sig2 { + t.Error("expected same signature for same inputs") + } + }) + + t.Run("empty payload produces valid signature", func(t *testing.T) { + result := computeSignature([]byte{}, "secret") + + if !strings.HasPrefix(result, "sha256=") { + t.Errorf("expected sha256= prefix for empty payload, got %q", result) + } + // Should have 64 hex chars after prefix + hexPart := strings.TrimPrefix(result, "sha256=") + if len(hexPart) != 64 { + t.Errorf("expected 64 hex chars, got %d", len(hexPart)) + } + }) + + t.Run("empty secret produces valid signature", func(t *testing.T) { + result := computeSignature([]byte(`{"test": "data"}`), "") + + if !strings.HasPrefix(result, "sha256=") { + t.Errorf("expected sha256= prefix for empty secret, got %q", result) + } + }) + + t.Run("signature is lowercase hex", func(t *testing.T) { + result := computeSignature([]byte(`test`), "secret") + hexPart := strings.TrimPrefix(result, "sha256=") + + for _, c := range hexPart { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("expected lowercase hex, found char %q", string(c)) + } + } + }) + + t.Run("can verify signature with standard library", func(t *testing.T) { + payload := []byte(`{"webhook": "test"}`) + secret := "webhook-secret-key" + + signature := computeSignature(payload, secret) + hexSig := strings.TrimPrefix(signature, "sha256=") + + // Verify using standard approach + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + + actualMAC, err := hex.DecodeString(hexSig) + if err != nil { + t.Fatalf("failed to decode hex: %v", err) + } + + if !hmac.Equal(actualMAC, expectedMAC) { + t.Error("signature verification failed") + } + }) +} + +func TestSignatureVerification(t *testing.T) { + // Test that signatures can be used for webhook verification + t.Run("webhook receiver can verify signature", func(t *testing.T) { + payload := []byte(`{"event": "user.created", "user_id": "123"}`) + secret := "shared-webhook-secret" + + // Sender computes signature + signature := computeSignature(payload, secret) + + // Receiver verifies (simulating webhook receiver) + hexSig := strings.TrimPrefix(signature, "sha256=") + receivedMAC, _ := hex.DecodeString(hexSig) + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + + if !hmac.Equal(receivedMAC, expectedMAC) { + t.Error("receiver should be able to verify signature") + } + }) + + t.Run("tampered payload fails verification", func(t *testing.T) { + originalPayload := []byte(`{"amount": 100}`) + tamperedPayload := []byte(`{"amount": 999}`) + secret := "secure-secret" + + // Signature computed with original + signature := computeSignature(originalPayload, secret) + + // Verify with tampered payload (should fail) + hexSig := strings.TrimPrefix(signature, "sha256=") + receivedMAC, _ := hex.DecodeString(hexSig) + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(tamperedPayload) + expectedMAC := mac.Sum(nil) + + if hmac.Equal(receivedMAC, expectedMAC) { + t.Error("tampered payload should fail verification") + } + }) + + t.Run("wrong secret fails verification", func(t *testing.T) { + payload := []byte(`{"test": "data"}`) + senderSecret := "sender-secret" + wrongSecret := "wrong-secret" + + signature := computeSignature(payload, senderSecret) + + // Try to verify with wrong secret + hexSig := strings.TrimPrefix(signature, "sha256=") + receivedMAC, _ := hex.DecodeString(hexSig) + + mac := hmac.New(sha256.New, []byte(wrongSecret)) + mac.Write(payload) + expectedMAC := mac.Sum(nil) + + if hmac.Equal(receivedMAC, expectedMAC) { + t.Error("wrong secret should fail verification") + } + }) +} diff --git a/src/workers/webhooks/internal/dispatcher/transformer_test.go b/src/workers/webhooks/internal/dispatcher/transformer_test.go new file mode 100644 index 0000000..32b8f6b --- /dev/null +++ b/src/workers/webhooks/internal/dispatcher/transformer_test.go @@ -0,0 +1,547 @@ +package dispatcher + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/nechja/hellarch/golibs/events" +) + +func TestFormatEventTitle(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "standard event type", + input: "hellarch.auth.user.created.v1", + expected: "User Created", + }, + { + name: "session event", + input: "hellarch.auth.session.deleted.v1", + expected: "Session Deleted", + }, + { + name: "file event", + input: "hellarch.files.file.uploaded.v1", + expected: "File Uploaded", + }, + { + name: "short event type returns as-is", + input: "short.event", + expected: "short.event", + }, + { + name: "three part event returns as-is", + input: "one.two.three", + expected: "one.two.three", + }, + { + name: "single word returns as-is", + input: "event", + expected: "event", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatEventTitle(tt.input) + if result != tt.expected { + t.Errorf("formatEventTitle(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGetColorForEventType(t *testing.T) { + tests := []struct { + name string + eventType string + expectedColor int + colorName string + }{ + // Success events (green) + {"created event is green", "user.created", 0x2ECC71, "green"}, + {"completed event is green", "task.completed", 0x2ECC71, "green"}, + {"recovered event is green", "service.recovered", 0x2ECC71, "green"}, + {"unlocked event is green", "account.unlocked", 0x2ECC71, "green"}, + {"linked event is green", "account.linked", 0x2ECC71, "green"}, + + // Error events (red) + {"deleted event is red", "user.deleted", 0xE74C3C, "red"}, + {"failed event is red", "task.failed", 0xE74C3C, "red"}, + {"unhealthy event is red", "service.unhealthy", 0xE74C3C, "red"}, + {"locked event is red", "account.locked", 0xE74C3C, "red"}, + {"suspicious event is red", "login.suspicious", 0xE74C3C, "red"}, + {"revoked event is red", "token.revoked", 0xE74C3C, "red"}, + + // Warning events (orange) + {"expiring event is orange", "cert.expiring", 0xE67E22, "orange"}, + {"threshold event is orange", "quota.threshold", 0xE67E22, "orange"}, + {"exceeded event is orange", "rate.exceeded", 0xE67E22, "orange"}, + {"failing event is orange", "health.failing", 0xE67E22, "orange"}, + {"rolledback event is orange", "deploy.rolledback", 0xE67E22, "orange"}, + + // Info events (blue) + {"updated event is blue", "user.updated", 0x3498DB, "blue"}, + {"changed event is blue", "config.changed", 0x3498DB, "blue"}, + {"started event is blue", "job.started", 0x3498DB, "blue"}, + {"ended event is blue", "session.ended", 0x3498DB, "blue"}, + + // Default (purple) + {"unknown event is purple", "some.random.event", 0x9B59B6, "purple"}, + {"empty string is purple", "", 0x9B59B6, "purple"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getColorForEventType(tt.eventType) + if result != tt.expectedColor { + t.Errorf("getColorForEventType(%q) = 0x%X, want 0x%X (%s)", + tt.eventType, result, tt.expectedColor, tt.colorName) + } + }) + } +} + +func TestFormatFieldName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"snake_case", "user_id", "User Id"}, + {"single word", "email", "Email"}, + {"multiple underscores", "first_name_field", "First Name Field"}, + {"already capitalized", "UserID", "Userid"}, + {"empty string", "", ""}, + {"leading underscore", "_private", " Private"}, + {"trailing underscore", "field_", "Field "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatFieldName(tt.input) + if result != tt.expected { + t.Errorf("formatFieldName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestFormatValue(t *testing.T) { + tests := []struct { + name string + input any + expected string + }{ + {"string value", "hello", "hello"}, + {"empty string", "", ""}, + {"integer as float64", float64(42), "42"}, + {"float value", float64(3.14159), "3.14"}, + {"negative integer", float64(-10), "-10"}, + {"zero", float64(0), "0"}, + {"true boolean", true, "true"}, + {"false boolean", false, "false"}, + {"nil value", nil, ""}, + {"map value", map[string]any{"key": "value"}, `{"key":"value"}`}, + {"array value", []any{"a", "b"}, `["a","b"]`}, + {"nested map", map[string]any{"nested": map[string]any{"deep": "value"}}, `{"nested":{"deep":"value"}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatValue(tt.input) + if result != tt.expected { + t.Errorf("formatValue(%v) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + expected string + }{ + {"short string unchanged", "hello", 10, "hello"}, + {"exact length unchanged", "hello", 5, "hello"}, + {"truncated with ellipsis", "hello world", 8, "hello..."}, + {"empty string", "", 10, ""}, + {"single char truncation", "abcdefghij", 5, "ab..."}, + {"maxLen less than 4", "hello", 3, "..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := truncate(tt.input, tt.maxLen) + if result != tt.expected { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected) + } + }) + } +} + +func TestExtractFields(t *testing.T) { + t.Run("empty data returns nil", func(t *testing.T) { + result := extractFields(nil) + if result != nil { + t.Errorf("extractFields(nil) = %v, want nil", result) + } + }) + + t.Run("invalid JSON returns nil", func(t *testing.T) { + result := extractFields([]byte("not json")) + if result != nil { + t.Errorf("extractFields(invalid) = %v, want nil", result) + } + }) + + t.Run("extracts simple fields", func(t *testing.T) { + data := []byte(`{"user_id": "123", "email": "test@example.com"}`) + result := extractFields(data) + if result == nil { + t.Fatal("expected fields, got nil") + } + if len(result) != 2 { + t.Errorf("expected 2 fields, got %d", len(result)) + } + }) + + t.Run("respects Discord 25 field limit", func(t *testing.T) { + // Create data with 30 fields + dataMap := make(map[string]any) + for i := 0; i < 30; i++ { + dataMap[string(rune('a'+i))] = "value" + } + data, _ := json.Marshal(dataMap) + + result := extractFields(data) + if len(result) > 25 { + t.Errorf("expected max 25 fields, got %d", len(result)) + } + }) + + t.Run("truncates long field values", func(t *testing.T) { + longValue := strings.Repeat("a", 2000) + data, _ := json.Marshal(map[string]any{"field": longValue}) + + result := extractFields(data) + if result == nil || len(result) == 0 { + t.Fatal("expected fields, got nil or empty") + } + if len(result[0].Value) > 1024 { + t.Errorf("expected value truncated to 1024, got %d", len(result[0].Value)) + } + }) + + t.Run("sets inline for short values", func(t *testing.T) { + data := []byte(`{"short": "hi", "long": "this is a much longer value that should not be inline because it exceeds fifty characters easily"}`) + result := extractFields(data) + + var shortField, longField *DiscordEmbedField + for i := range result { + if result[i].Name == "Short" { + shortField = &result[i] + } + if result[i].Name == "Long" { + longField = &result[i] + } + } + + if shortField != nil && !shortField.Inline { + t.Error("expected short field to be inline") + } + if longField != nil && longField.Inline { + t.Error("expected long field to not be inline") + } + }) +} + +func TestExtractSlackFields(t *testing.T) { + t.Run("empty data returns nil", func(t *testing.T) { + result := extractSlackFields(nil) + if result != nil { + t.Errorf("extractSlackFields(nil) = %v, want nil", result) + } + }) + + t.Run("respects Slack 10 field limit", func(t *testing.T) { + dataMap := make(map[string]any) + for i := 0; i < 15; i++ { + dataMap[string(rune('a'+i))] = "value" + } + data, _ := json.Marshal(dataMap) + + result := extractSlackFields(data) + if len(result) > 10 { + t.Errorf("expected max 10 fields, got %d", len(result)) + } + }) + + t.Run("formats as mrkdwn", func(t *testing.T) { + data := []byte(`{"user_id": "123"}`) + result := extractSlackFields(data) + + if result == nil || len(result) == 0 { + t.Fatal("expected fields") + } + if result[0].Type != "mrkdwn" { + t.Errorf("expected type mrkdwn, got %s", result[0].Type) + } + }) +} + +func TestTransformPayload(t *testing.T) { + validCloudEvent := []byte(`{ + "specversion": "1.0", + "type": "hellarch.test.event.created.v1", + "source": "test-service", + "id": "test-123", + "time": "2024-01-01T00:00:00Z", + "data": {"user_id": "456"} + }`) + + t.Run("unknown format returns original payload", func(t *testing.T) { + result, contentType, err := TransformPayload(validCloudEvent, events.WebhookFormat("unknown")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contentType != "application/cloudevents+json" { + t.Errorf("expected cloudevents content type, got %s", contentType) + } + if string(result) != string(validCloudEvent) { + t.Error("expected original payload returned") + } + }) + + t.Run("raw format returns original payload", func(t *testing.T) { + result, contentType, err := TransformPayload(validCloudEvent, events.WebhookFormatRaw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contentType != "application/cloudevents+json" { + t.Errorf("expected cloudevents content type, got %s", contentType) + } + if string(result) != string(validCloudEvent) { + t.Error("expected original payload returned") + } + }) + + t.Run("Discord format transforms payload", func(t *testing.T) { + result, contentType, err := TransformPayload(validCloudEvent, events.WebhookFormatDiscord) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contentType != "application/json" { + t.Errorf("expected application/json, got %s", contentType) + } + + var discord DiscordPayload + if err := json.Unmarshal(result, &discord); err != nil { + t.Fatalf("failed to parse Discord payload: %v", err) + } + if len(discord.Embeds) != 1 { + t.Errorf("expected 1 embed, got %d", len(discord.Embeds)) + } + if discord.Embeds[0].Title != "Event Created" { + t.Errorf("expected title 'Event Created', got %q", discord.Embeds[0].Title) + } + }) + + t.Run("Slack format transforms payload", func(t *testing.T) { + result, contentType, err := TransformPayload(validCloudEvent, events.WebhookFormatSlack) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contentType != "application/json" { + t.Errorf("expected application/json, got %s", contentType) + } + + var slack SlackPayload + if err := json.Unmarshal(result, &slack); err != nil { + t.Fatalf("failed to parse Slack payload: %v", err) + } + if slack.Text != "Event Created" { + t.Errorf("expected text 'Event Created', got %q", slack.Text) + } + }) + + t.Run("Discord transform fails on invalid JSON", func(t *testing.T) { + _, _, err := TransformPayload([]byte("not json"), events.WebhookFormatDiscord) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) + + t.Run("Slack transform fails on invalid JSON", func(t *testing.T) { + _, _, err := TransformPayload([]byte("not json"), events.WebhookFormatSlack) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) +} + +func TestTransformToDiscord(t *testing.T) { + t.Run("includes subject in description", func(t *testing.T) { + event := []byte(`{ + "specversion": "1.0", + "type": "test.event.v1", + "source": "test", + "id": "123", + "subject": "user-456" + }`) + + result, _, err := transformToDiscord(event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var discord DiscordPayload + json.Unmarshal(result, &discord) + + if !strings.Contains(discord.Embeds[0].Description, "user-456") { + t.Error("expected subject in description") + } + }) + + t.Run("sets footer with source", func(t *testing.T) { + event := []byte(`{ + "specversion": "1.0", + "type": "test.event.v1", + "source": "auth-service", + "id": "123" + }`) + + result, _, err := transformToDiscord(event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var discord DiscordPayload + json.Unmarshal(result, &discord) + + if discord.Embeds[0].Footer == nil { + t.Fatal("expected footer") + } + if !strings.Contains(discord.Embeds[0].Footer.Text, "auth-service") { + t.Error("expected source in footer") + } + }) + + t.Run("preserves timestamp", func(t *testing.T) { + event := []byte(`{ + "specversion": "1.0", + "type": "test.event.v1", + "source": "test", + "id": "123", + "time": "2024-01-15T10:30:00Z" + }`) + + result, _, err := transformToDiscord(event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var discord DiscordPayload + json.Unmarshal(result, &discord) + + if discord.Embeds[0].Timestamp != "2024-01-15T10:30:00Z" { + t.Errorf("expected timestamp preserved, got %q", discord.Embeds[0].Timestamp) + } + }) +} + +func TestTransformToSlack(t *testing.T) { + t.Run("creates header block", func(t *testing.T) { + event := []byte(`{ + "specversion": "1.0", + "type": "hellarch.test.user.created.v1", + "source": "test", + "id": "123" + }`) + + result, _, err := transformToSlack(event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var slack SlackPayload + json.Unmarshal(result, &slack) + + if len(slack.Blocks) == 0 { + t.Fatal("expected blocks") + } + if slack.Blocks[0].Type != "header" { + t.Errorf("expected header block first, got %s", slack.Blocks[0].Type) + } + }) + + t.Run("includes subject in info section", func(t *testing.T) { + event := []byte(`{ + "specversion": "1.0", + "type": "test.event.v1", + "source": "test", + "id": "123", + "subject": "important-subject" + }`) + + result, _, err := transformToSlack(event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var slack SlackPayload + json.Unmarshal(result, &slack) + + found := false + for _, block := range slack.Blocks { + if block.Text != nil && strings.Contains(block.Text.Text, "important-subject") { + found = true + break + } + } + if !found { + t.Error("expected subject in info section") + } + }) + + t.Run("adds context block with timestamp", func(t *testing.T) { + event := []byte(`{ + "specversion": "1.0", + "type": "test.event.v1", + "source": "test", + "id": "123", + "time": "2024-01-15T10:30:00Z" + }`) + + result, _, err := transformToSlack(event) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var slack SlackPayload + json.Unmarshal(result, &slack) + + hasContext := false + for _, block := range slack.Blocks { + if block.Type == "context" { + hasContext = true + break + } + } + if !hasContext { + t.Error("expected context block with timestamp") + } + }) +} From 4e28a38227786e77f7d5a1ab01c05f269db26cb5 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 13:19:43 -0800 Subject: [PATCH 3/7] tests --- .../events/internal/handler/publish_test.go | 86 ++++ .../internal/service/content_type_test.go | 163 +++++++ src/services/health/test/health_test.gleam | 418 +++++++++++++++++- src/workers/dlq.tests/RetryCountTests.cs | 137 ++++++ src/workers/dlq/Services/DlqConsumer.cs | 2 +- .../infra/internal/handlers/handlers_test.go | 225 ++++++++++ 6 files changed, 1027 insertions(+), 4 deletions(-) create mode 100644 src/services/events/internal/handler/publish_test.go create mode 100644 src/services/files/internal/service/content_type_test.go create mode 100644 src/workers/dlq.tests/RetryCountTests.cs create mode 100644 src/workers/infra/internal/handlers/handlers_test.go diff --git a/src/services/events/internal/handler/publish_test.go b/src/services/events/internal/handler/publish_test.go new file mode 100644 index 0000000..75bc535 --- /dev/null +++ b/src/services/events/internal/handler/publish_test.go @@ -0,0 +1,86 @@ +package handler + +import ( + "testing" +) + +func TestEventTypePatternValidation(t *testing.T) { + valid := []struct { + name string + eventType string + }{ + {"simple two parts", "user.created"}, + {"three parts", "user.profile.updated"}, + {"four parts", "system.health.check.failed"}, + {"with numbers", "v2.user.created"}, + {"numbers in middle", "user.v2.created"}, + {"all lowercase with numbers", "api1.user2.event3"}, + } + + for _, tt := range valid { + t.Run("valid: "+tt.name, func(t *testing.T) { + if !publishEventTypePattern.MatchString(tt.eventType) { + t.Errorf("expected %q to be valid", tt.eventType) + } + }) + } + + invalid := []struct { + name string + eventType string + }{ + {"single word", "created"}, + {"uppercase", "User.Created"}, + {"mixed case", "user.Created"}, + {"starts with number", "1user.created"}, + {"underscore", "user_profile.created"}, + {"hyphen", "user-profile.created"}, + {"leading dot", ".user.created"}, + {"trailing dot", "user.created."}, + {"double dot", "user..created"}, + {"spaces", "user. created"}, + {"empty string", ""}, + {"just dots", "..."}, + {"special chars", "user@created.event"}, + {"part starts with number", "user.1created"}, + } + + for _, tt := range invalid { + t.Run("invalid: "+tt.name, func(t *testing.T) { + if publishEventTypePattern.MatchString(tt.eventType) { + t.Errorf("expected %q to be invalid", tt.eventType) + } + }) + } +} + +func TestEventTypePatternBoundaries(t *testing.T) { + // These test edge cases that could break with regex changes + + t.Run("minimum valid event type", func(t *testing.T) { + // Smallest possible valid event type: "a.b" + if !publishEventTypePattern.MatchString("a.b") { + t.Error("expected 'a.b' to be valid (minimum length)") + } + }) + + t.Run("single char parts are valid", func(t *testing.T) { + if !publishEventTypePattern.MatchString("a.b.c.d.e") { + t.Error("expected single char parts to be valid") + } + }) + + t.Run("long event type", func(t *testing.T) { + longType := "service.domain.subdomain.action.detail.extra" + if !publishEventTypePattern.MatchString(longType) { + t.Errorf("expected long event type %q to be valid", longType) + } + }) + + t.Run("number only part after letter start", func(t *testing.T) { + // "a1.b2" - starts with letter, has numbers + if !publishEventTypePattern.MatchString("a1.b2") { + t.Error("expected 'a1.b2' to be valid") + } + }) +} diff --git a/src/services/files/internal/service/content_type_test.go b/src/services/files/internal/service/content_type_test.go new file mode 100644 index 0000000..bfa1333 --- /dev/null +++ b/src/services/files/internal/service/content_type_test.go @@ -0,0 +1,163 @@ +package service + +import ( + "errors" + "testing" +) + +func TestValidateContentType(t *testing.T) { + t.Run("uses detected type when no claim", func(t *testing.T) { + result, err := validateContentType("", "image/png") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/png" { + t.Errorf("expected image/png, got %s", result) + } + }) + + t.Run("uses detected type when claim is octet-stream", func(t *testing.T) { + result, err := validateContentType("application/octet-stream", "image/jpeg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/jpeg" { + t.Errorf("expected image/jpeg, got %s", result) + } + }) + + t.Run("trusts claimed type when detection fails", func(t *testing.T) { + // When detected is octet-stream, we trust the claim for uncommon types + result, err := validateContentType("application/x-custom", "application/octet-stream") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "application/x-custom" { + t.Errorf("expected application/x-custom, got %s", result) + } + }) +} + +func TestValidateContentTypeSecurityBlocks(t *testing.T) { + // These tests verify we block dangerous content type mismatches + + t.Run("blocks executable disguised as safe type", func(t *testing.T) { + dangerousCases := []struct { + claimed string + detected string + }{ + {"image/png", "application/x-executable"}, + {"image/jpeg", "application/x-msdos-program"}, + {"text/plain", "application/x-msdownload"}, + {"application/pdf", "application/x-executable"}, + } + + for _, tc := range dangerousCases { + _, err := validateContentType(tc.claimed, tc.detected) + if !errors.Is(err, ErrContentTypeMismatch) { + t.Errorf("claimed=%s detected=%s: expected ErrContentTypeMismatch, got %v", + tc.claimed, tc.detected, err) + } + } + }) + + t.Run("blocks non-image disguised as image", func(t *testing.T) { + // Claiming image/* but detected as non-image should fail + cases := []struct { + claimed string + detected string + }{ + {"image/png", "text/html"}, + {"image/jpeg", "application/javascript"}, + {"image/gif", "text/plain"}, + {"image/webp", "application/xml"}, + } + + for _, tc := range cases { + _, err := validateContentType(tc.claimed, tc.detected) + if !errors.Is(err, ErrContentTypeMismatch) { + t.Errorf("claimed=%s detected=%s: expected ErrContentTypeMismatch, got %v", + tc.claimed, tc.detected, err) + } + } + }) + + t.Run("allows matching image types", func(t *testing.T) { + // When both claimed and detected are images, should succeed + result, err := validateContentType("image/png", "image/png") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/png" { + t.Errorf("expected image/png, got %s", result) + } + }) + + t.Run("allows different image subtypes", func(t *testing.T) { + // Claimed png but detected jpeg - both are images, should use detected + result, err := validateContentType("image/png", "image/jpeg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/jpeg" { + t.Errorf("expected image/jpeg (detected), got %s", result) + } + }) +} + +func TestValidateContentTypeNormalization(t *testing.T) { + t.Run("strips charset parameter", func(t *testing.T) { + // Content types often have parameters like charset + result, err := validateContentType("text/plain; charset=utf-8", "text/plain; charset=us-ascii") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should use the detected type + if result != "text/plain; charset=us-ascii" { + t.Errorf("expected detected type, got %s", result) + } + }) + + t.Run("case insensitive comparison", func(t *testing.T) { + // IMAGE/PNG should be treated same as image/png + result, err := validateContentType("IMAGE/PNG", "image/png") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/png" { + t.Errorf("expected image/png, got %s", result) + } + }) + + t.Run("handles whitespace", func(t *testing.T) { + result, err := validateContentType(" image/png ", "image/png") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != "image/png" { + t.Errorf("expected image/png, got %s", result) + } + }) +} + +func TestValidateContentTypeAllowsNonDangerous(t *testing.T) { + // Non-image type mismatches that aren't security risks should be allowed + cases := []struct { + name string + claimed string + detected string + }{ + {"text types can differ", "text/plain", "text/html"}, + {"json vs text", "application/json", "text/plain"}, + {"xml variations", "application/xml", "text/xml"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := validateContentType(tc.claimed, tc.detected) + if err != nil { + t.Errorf("expected no error for %s->%s, got %v", tc.claimed, tc.detected, err) + } + }) + } +} diff --git a/src/services/health/test/health_test.gleam b/src/services/health/test/health_test.gleam index 3831e7a..de57809 100644 --- a/src/services/health/test/health_test.gleam +++ b/src/services/health/test/health_test.gleam @@ -1,12 +1,424 @@ +import birl +import gleam/option import gleeunit import gleeunit/should +import health/types.{ + type HealthCheck, type ServiceStatus, type Status, type StatusConfig, + Degraded, HealthCheck, Healthy, ServiceStatus, StatusConfig, Unhealthy, +} pub fn main() { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - 1 +// --- Test Helpers --- + +fn make_check(status: Status, latency_ms: Int) -> HealthCheck { + HealthCheck( + status: status, + latency_ms: latency_ms, + checked_at: birl.now(), + error: option.None, + ) +} + +fn make_service_status(name: String, status: Status) -> ServiceStatus { + ServiceStatus( + name: name, + status: status, + latency_ms: 50, + last_check: birl.now(), + error: option.None, + history: [], + ) +} + +fn test_config() -> StatusConfig { + StatusConfig( + unhealthy_failure_threshold: 3, + degraded_latency_threshold_ms: 100, + history_depth: 10, + ) +} + +// --- combine_status tests --- + +pub fn combine_status_healthy_healthy_test() { + types.combine_status(Healthy, Healthy) + |> should.equal(Healthy) +} + +pub fn combine_status_healthy_degraded_test() { + types.combine_status(Healthy, Degraded) + |> should.equal(Degraded) +} + +pub fn combine_status_degraded_healthy_test() { + types.combine_status(Degraded, Healthy) + |> should.equal(Degraded) +} + +pub fn combine_status_healthy_unhealthy_test() { + types.combine_status(Healthy, Unhealthy) + |> should.equal(Unhealthy) +} + +pub fn combine_status_unhealthy_healthy_test() { + types.combine_status(Unhealthy, Healthy) + |> should.equal(Unhealthy) +} + +pub fn combine_status_degraded_degraded_test() { + types.combine_status(Degraded, Degraded) + |> should.equal(Degraded) +} + +pub fn combine_status_degraded_unhealthy_test() { + types.combine_status(Degraded, Unhealthy) + |> should.equal(Unhealthy) +} + +pub fn combine_status_unhealthy_degraded_test() { + types.combine_status(Unhealthy, Degraded) + |> should.equal(Unhealthy) +} + +pub fn combine_status_unhealthy_unhealthy_test() { + types.combine_status(Unhealthy, Unhealthy) + |> should.equal(Unhealthy) +} + +// --- count_consecutive_failures tests --- + +pub fn count_consecutive_failures_empty_history_test() { + types.count_consecutive_failures([]) + |> should.equal(0) +} + +pub fn count_consecutive_failures_no_failures_test() { + let history = [ + make_check(Healthy, 50), + make_check(Healthy, 45), + make_check(Healthy, 55), + ] + + types.count_consecutive_failures(history) + |> should.equal(0) +} + +pub fn count_consecutive_failures_one_failure_test() { + let history = [ + make_check(Unhealthy, 0), + make_check(Healthy, 50), + make_check(Healthy, 45), + ] + + types.count_consecutive_failures(history) |> should.equal(1) } + +pub fn count_consecutive_failures_multiple_failures_test() { + let history = [ + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Healthy, 50), + ] + + types.count_consecutive_failures(history) + |> should.equal(3) +} + +pub fn count_consecutive_failures_all_failures_test() { + let history = [ + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + ] + + types.count_consecutive_failures(history) + |> should.equal(3) +} + +pub fn count_consecutive_failures_failure_not_at_start_test() { + let history = [ + make_check(Healthy, 50), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + ] + + types.count_consecutive_failures(history) + |> should.equal(0) +} + +pub fn count_consecutive_failures_degraded_not_counted_test() { + let history = [ + make_check(Degraded, 150), + make_check(Unhealthy, 0), + make_check(Healthy, 50), + ] + + types.count_consecutive_failures(history) + |> should.equal(0) +} + +// --- calculate_status tests --- + +pub fn calculate_status_empty_history_healthy_test() { + types.calculate_status([], test_config()) + |> should.equal(Healthy) +} + +pub fn calculate_status_all_healthy_test() { + let history = [ + make_check(Healthy, 50), + make_check(Healthy, 45), + make_check(Healthy, 55), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Healthy) +} + +pub fn calculate_status_single_failure_degraded_test() { + let history = [ + make_check(Unhealthy, 0), + make_check(Healthy, 50), + make_check(Healthy, 45), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Degraded) +} + +pub fn calculate_status_two_failures_degraded_test() { + let history = [ + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Healthy, 50), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Degraded) +} + +pub fn calculate_status_threshold_failures_unhealthy_test() { + let history = [ + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Healthy, 50), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Unhealthy) +} + +pub fn calculate_status_slow_response_degraded_test() { + let history = [ + make_check(Healthy, 150), + make_check(Healthy, 50), + make_check(Healthy, 45), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Degraded) +} + +pub fn calculate_status_at_latency_threshold_healthy_test() { + let history = [ + make_check(Healthy, 100), + make_check(Healthy, 50), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Healthy) +} + +pub fn calculate_status_custom_threshold_test() { + let config = + StatusConfig( + unhealthy_failure_threshold: 2, + degraded_latency_threshold_ms: 50, + history_depth: 5, + ) + + let history = [ + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Healthy, 30), + ] + + types.calculate_status(history, config) + |> should.equal(Unhealthy) +} + +pub fn calculate_status_custom_latency_threshold_test() { + let config = + StatusConfig( + unhealthy_failure_threshold: 3, + degraded_latency_threshold_ms: 50, + history_depth: 5, + ) + + let history = [ + make_check(Healthy, 60), + make_check(Healthy, 30), + ] + + types.calculate_status(history, config) + |> should.equal(Degraded) +} + +pub fn calculate_status_failure_takes_precedence_over_latency_test() { + let history = [ + make_check(Unhealthy, 150), + make_check(Unhealthy, 150), + make_check(Unhealthy, 150), + ] + + types.calculate_status(history, test_config()) + |> should.equal(Unhealthy) +} + +// --- aggregate_services tests --- + +pub fn aggregate_services_empty_list_test() { + types.aggregate_services([]) + |> should.equal(Healthy) +} + +pub fn aggregate_services_single_healthy_test() { + let services = [make_service_status("api", Healthy)] + + types.aggregate_services(services) + |> should.equal(Healthy) +} + +pub fn aggregate_services_all_healthy_test() { + let services = [ + make_service_status("api", Healthy), + make_service_status("db", Healthy), + make_service_status("cache", Healthy), + ] + + types.aggregate_services(services) + |> should.equal(Healthy) +} + +pub fn aggregate_services_one_degraded_test() { + let services = [ + make_service_status("api", Healthy), + make_service_status("db", Degraded), + make_service_status("cache", Healthy), + ] + + types.aggregate_services(services) + |> should.equal(Degraded) +} + +pub fn aggregate_services_one_unhealthy_test() { + let services = [ + make_service_status("api", Healthy), + make_service_status("db", Unhealthy), + make_service_status("cache", Healthy), + ] + + types.aggregate_services(services) + |> should.equal(Unhealthy) +} + +pub fn aggregate_services_mixed_degraded_unhealthy_test() { + let services = [ + make_service_status("api", Degraded), + make_service_status("db", Unhealthy), + make_service_status("cache", Healthy), + ] + + types.aggregate_services(services) + |> should.equal(Unhealthy) +} + +pub fn aggregate_services_all_degraded_test() { + let services = [ + make_service_status("api", Degraded), + make_service_status("db", Degraded), + ] + + types.aggregate_services(services) + |> should.equal(Degraded) +} + +pub fn aggregate_services_all_unhealthy_test() { + let services = [ + make_service_status("api", Unhealthy), + make_service_status("db", Unhealthy), + ] + + types.aggregate_services(services) + |> should.equal(Unhealthy) +} + +// --- Edge case tests for status calculation --- + +pub fn status_transitions_healthy_to_degraded_test() { + let healthy_history = [ + make_check(Healthy, 50), + make_check(Healthy, 45), + ] + + types.calculate_status(healthy_history, test_config()) + |> should.equal(Healthy) + + let degraded_history = [ + make_check(Unhealthy, 0), + make_check(Healthy, 50), + make_check(Healthy, 45), + ] + + types.calculate_status(degraded_history, test_config()) + |> should.equal(Degraded) +} + +pub fn status_history_order_matters_test() { + let failures_first = [ + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Healthy, 50), + ] + + let failures_last = [ + make_check(Healthy, 50), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + ] + + types.calculate_status(failures_first, test_config()) + |> should.equal(Degraded) + + types.calculate_status(failures_last, test_config()) + |> should.equal(Degraded) +} + +pub fn old_failures_ignored_when_recent_checks_healthy_test() { + // Tests that only the most recent N checks matter for failure threshold + // (where N = unhealthy_failure_threshold) + let config = + StatusConfig( + unhealthy_failure_threshold: 2, + degraded_latency_threshold_ms: 100, + history_depth: 5, + ) + + // Recent checks (first 2) are healthy, old failures at end don't trigger unhealthy + let history = [ + make_check(Healthy, 50), + make_check(Healthy, 50), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + make_check(Unhealthy, 0), + ] + + types.calculate_status(history, config) + |> should.equal(Healthy) +} diff --git a/src/workers/dlq.tests/RetryCountTests.cs b/src/workers/dlq.tests/RetryCountTests.cs new file mode 100644 index 0000000..74f66d3 --- /dev/null +++ b/src/workers/dlq.tests/RetryCountTests.cs @@ -0,0 +1,137 @@ +using DlqWorker.Services; + +namespace DlqWorker.Tests; + +/// +/// Tests for retry count extraction from RabbitMQ headers. +/// RabbitMQ can serialize header values as different types (int, long, byte[]) +/// depending on protocol version and client library - we must handle all. +/// +public class RetryCountTests +{ + [Fact] + public void ReturnsZeroForNullHeaders() + { + var result = DlqConsumer.GetRetryCount(null); + + Assert.Equal(0, result); + } + + [Fact] + public void ReturnsZeroForEmptyHeaders() + { + var headers = new Dictionary(); + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(0, result); + } + + [Fact] + public void ReturnsZeroWhenHeaderMissing() + { + var headers = new Dictionary + { + ["other-header"] = 42 + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(0, result); + } + + [Fact] + public void ExtractsIntValue() + { + var headers = new Dictionary + { + ["x-dlq-retry-count"] = 3 + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(3, result); + } + + [Fact] + public void ExtractsLongValue() + { + // RabbitMQ sometimes sends numbers as long + var headers = new Dictionary + { + ["x-dlq-retry-count"] = 5L + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(5, result); + } + + [Fact] + public void ExtractsByteArrayValue() + { + // AMQP protocol can encode small integers as byte arrays + var headers = new Dictionary + { + ["x-dlq-retry-count"] = BitConverter.GetBytes(7) + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(7, result); + } + + [Fact] + public void ReturnsZeroForUnexpectedType() + { + // If someone puts a string or other unexpected type, default to 0 + var headers = new Dictionary + { + ["x-dlq-retry-count"] = "three" + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(0, result); + } + + [Fact] + public void ReturnsZeroForNullValue() + { + var headers = new Dictionary + { + ["x-dlq-retry-count"] = null + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(0, result); + } + + [Fact] + public void HandlesZeroRetryCount() + { + var headers = new Dictionary + { + ["x-dlq-retry-count"] = 0 + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(0, result); + } + + [Fact] + public void HandlesLargeRetryCount() + { + // Edge case: very large retry count (shouldn't happen in practice) + var headers = new Dictionary + { + ["x-dlq-retry-count"] = 1000 + }; + + var result = DlqConsumer.GetRetryCount(headers); + + Assert.Equal(1000, result); + } +} diff --git a/src/workers/dlq/Services/DlqConsumer.cs b/src/workers/dlq/Services/DlqConsumer.cs index 29f6dfb..fed4b11 100644 --- a/src/workers/dlq/Services/DlqConsumer.cs +++ b/src/workers/dlq/Services/DlqConsumer.cs @@ -307,7 +307,7 @@ await _publishChannel.BasicPublishAsync( } } - private static int GetRetryCount(IDictionary? headers) + internal static int GetRetryCount(IDictionary? headers) { if (headers?.TryGetValue("x-dlq-retry-count", out var countObj) == true) { diff --git a/src/workers/infra/internal/handlers/handlers_test.go b/src/workers/infra/internal/handlers/handlers_test.go new file mode 100644 index 0000000..9f3040f --- /dev/null +++ b/src/workers/infra/internal/handlers/handlers_test.go @@ -0,0 +1,225 @@ +package handlers + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/nechja/hellarch/cloudevents/infra" +) + +// mockOrchestrator implements orchestrator.Orchestrator for testing +type mockOrchestrator struct { + restartCalls []string + mu sync.Mutex + shouldFail bool +} + +func (m *mockOrchestrator) RestartService(_ context.Context, serviceName string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.restartCalls = append(m.restartCalls, serviceName) + if m.shouldFail { + return errors.New("restart failed") + } + return nil +} + +func (m *mockOrchestrator) Close() error { return nil } + +func (m *mockOrchestrator) getRestartCalls() []string { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]string, len(m.restartCalls)) + copy(result, m.restartCalls) + return result +} + +func TestExcludedServicesNotRestarted(t *testing.T) { + mock := &mockOrchestrator{} + h := New(mock, nil, Config{ + CooldownSeconds: 0, + MaxRestarts: 10, + RestartWindowSecs: 60, + ExcludeServices: []string{"custom-excluded"}, + }) + + tests := []struct { + name string + service string + want bool // true = should restart + }{ + {"infra-worker always excluded", "infra-worker", false}, + {"health always excluded", "health", false}, + {"custom excluded", "custom-excluded", false}, + {"normal service restarts", "api-gateway", true}, + {"another normal service", "user-service", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock.restartCalls = nil + + err := h.HandleServiceUnhealthy(context.Background(), infra.ServiceUnhealthy{ + ServiceName: tt.service, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + calls := mock.getRestartCalls() + got := len(calls) > 0 + + if got != tt.want { + t.Errorf("service %q: got restart=%v, want restart=%v", tt.service, got, tt.want) + } + }) + } +} + +func TestCooldownPreventsRapidRestarts(t *testing.T) { + mock := &mockOrchestrator{} + h := New(mock, nil, Config{ + CooldownSeconds: 1, // 1 second cooldown + MaxRestarts: 10, + RestartWindowSecs: 60, + }) + + event := infra.ServiceUnhealthy{ServiceName: "test-service"} + + // First restart should work + h.HandleServiceUnhealthy(context.Background(), event) + if len(mock.getRestartCalls()) != 1 { + t.Fatal("first restart should succeed") + } + + // Immediate second restart should be blocked by cooldown + h.HandleServiceUnhealthy(context.Background(), event) + if len(mock.getRestartCalls()) != 1 { + t.Error("second restart should be blocked by cooldown") + } + + // Wait for cooldown to expire + time.Sleep(1100 * time.Millisecond) + + // Now restart should work again + h.HandleServiceUnhealthy(context.Background(), event) + if len(mock.getRestartCalls()) != 2 { + t.Error("restart after cooldown should succeed") + } +} + +func TestMaxRestartsInWindowEnforced(t *testing.T) { + mock := &mockOrchestrator{} + h := New(mock, nil, Config{ + CooldownSeconds: 0, // No cooldown for this test + MaxRestarts: 3, + RestartWindowSecs: 60, + }) + + event := infra.ServiceUnhealthy{ServiceName: "flaky-service"} + + // Should allow exactly MaxRestarts + for i := 0; i < 3; i++ { + h.HandleServiceUnhealthy(context.Background(), event) + } + + if len(mock.getRestartCalls()) != 3 { + t.Fatalf("expected 3 restarts, got %d", len(mock.getRestartCalls())) + } + + // 4th restart should be blocked + h.HandleServiceUnhealthy(context.Background(), event) + if len(mock.getRestartCalls()) != 3 { + t.Error("4th restart should be blocked by max restarts limit") + } +} + +func TestRestartWindowExpiry(t *testing.T) { + mock := &mockOrchestrator{} + h := New(mock, nil, Config{ + CooldownSeconds: 0, + MaxRestarts: 2, + RestartWindowSecs: 1, // 1 second window + }) + + event := infra.ServiceUnhealthy{ServiceName: "test-service"} + + // Use up all restarts + h.HandleServiceUnhealthy(context.Background(), event) + h.HandleServiceUnhealthy(context.Background(), event) + + if len(mock.getRestartCalls()) != 2 { + t.Fatal("expected 2 restarts") + } + + // Should be blocked + h.HandleServiceUnhealthy(context.Background(), event) + if len(mock.getRestartCalls()) != 2 { + t.Error("should be blocked after max restarts") + } + + // Wait for window to expire + time.Sleep(1100 * time.Millisecond) + + // Now should be allowed again + h.HandleServiceUnhealthy(context.Background(), event) + if len(mock.getRestartCalls()) != 3 { + t.Error("restart should be allowed after window expires") + } +} + +func TestDifferentServicesTrackedSeparately(t *testing.T) { + mock := &mockOrchestrator{} + h := New(mock, nil, Config{ + CooldownSeconds: 0, + MaxRestarts: 1, + RestartWindowSecs: 60, + }) + + // Max out service A + h.HandleServiceUnhealthy(context.Background(), infra.ServiceUnhealthy{ServiceName: "service-a"}) + h.HandleServiceUnhealthy(context.Background(), infra.ServiceUnhealthy{ServiceName: "service-a"}) + + // Service B should still be able to restart + h.HandleServiceUnhealthy(context.Background(), infra.ServiceUnhealthy{ServiceName: "service-b"}) + + calls := mock.getRestartCalls() + if len(calls) != 2 { + t.Fatalf("expected 2 total restarts, got %d", len(calls)) + } + + // Verify it's one of each + aCount, bCount := 0, 0 + for _, c := range calls { + if c == "service-a" { + aCount++ + } else if c == "service-b" { + bCount++ + } + } + + if aCount != 1 || bCount != 1 { + t.Errorf("expected 1 restart each, got service-a=%d, service-b=%d", aCount, bCount) + } +} + +func TestOrchestratorErrorPropagated(t *testing.T) { + mock := &mockOrchestrator{shouldFail: true} + h := New(mock, nil, Config{ + CooldownSeconds: 0, + MaxRestarts: 10, + RestartWindowSecs: 60, + }) + + err := h.HandleServiceUnhealthy(context.Background(), infra.ServiceUnhealthy{ + ServiceName: "failing-service", + }) + + if err == nil { + t.Error("expected error from failed orchestrator") + } +} From 189927e8ee9d322c5aceab8c2508f9c45d87229f Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 13:24:57 -0800 Subject: [PATCH 4/7] testing --- docs/guide/testing.md | 66 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 2cc21b4..6621cea 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -1,6 +1,70 @@ # Testing -**Python Testing Service** +## Philosophy + +Tests exist to catch bugs before they ship. Every test should justify its existence by protecting against real failures. + +### No "true = true" Tests + +Don't write tests that just verify constants or trivial mappings: + +```go +// Bad - this catches nothing +func TestConfigDefault(t *testing.T) { + cfg := DefaultConfig() + if cfg.Timeout != 30 { + t.Error("expected 30") + } +} + +// Good - this catches real logic bugs +func TestCooldownPreventsRapidRestarts(t *testing.T) { + h := NewHandler(cfg) + h.HandleUnhealthy(ctx, event) // First restart + h.HandleUnhealthy(ctx, event) // Should be blocked + if restartCount != 1 { + t.Error("cooldown should prevent second restart") + } +} +``` + +### Test Logic That Breaks + +Focus on code paths where bugs actually hide: + +| Worth Testing | Not Worth Testing | +|--------------|-------------------| +| Validation logic (regex, bounds) | String constants | +| State machines (cooldowns, retries) | Simple getters | +| Security checks (auth, permissions) | Configuration defaults | +| Type coercion (parsing headers) | Direct field access | +| Business rules (thresholds, limits) | Constructor assignment | + +### Tests Should Find Dev Errors Early + +Good tests catch mistakes during development, not just verify existing behavior: + +- **Regex validation** - catches invalid patterns before they reject good input or accept bad +- **Time-based logic** - catches race conditions and off-by-one errors in cooldowns/windows +- **Security boundaries** - catches permission bypasses before they ship +- **Protocol handling** - catches type mismatches when external systems send unexpected formats + +### Keep Tests Focused + +Each test should verify one behavior. If a test name needs "and" in it, split it: + +```go +// Bad - tests two things +func TestValidatesInputAndSavesToDatabase(t *testing.T) { ... } + +// Good - separate concerns +func TestRejectsInvalidInput(t *testing.T) { ... } +func TestPersistsValidInput(t *testing.T) { ... } +``` + +--- + +## Integration Testing Service Centralized test orchestration service with scheduled execution, API-triggered runs, and browser automation via Playwright. From 4f86447fa4e36aeca53c8feecffa5f12daac53c1 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 13:25:25 -0800 Subject: [PATCH 5/7] ci for tests --- .github/workflows/ci.yml | 388 +++++++++++++++++++++++++++++++++++++++ .gitignore | 5 +- 2 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d58d040 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,388 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test-auth-service: + name: Test Auth Service (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/services/auth + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/services/auth/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: auth-coverage + path: src/services/auth/coverage.out + + test-files-service: + name: Test Files Service (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/services/files + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/services/files/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: files-coverage + path: src/services/files/coverage.out + + test-events-service: + name: Test Events Service (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/services/events + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/services/events/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: events-coverage + path: src/services/events/coverage.out + + test-gateway: + name: Test Gateway (C#) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/gateway + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + include-prerelease: true + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Run tests + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: gateway-coverage + path: src/gateway/Gateway.Tests/TestResults/**/coverage.cobertura.xml + + test-identity-service: + name: Test Identity Service (F#) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/services/identity + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + include-prerelease: true + + - name: Restore dependencies + run: dotnet restore Identity.Tests + + - name: Build + run: dotnet build Identity.Tests --no-restore + + - name: Run tests + run: dotnet test Identity.Tests --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: identity-coverage + path: src/services/identity/Identity.Tests/TestResults/**/coverage.cobertura.xml + + test-user-service: + name: Test User Service (F#) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/services/user + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + include-prerelease: true + + - name: Restore dependencies + run: dotnet restore User.Tests + + - name: Build + run: dotnet build User.Tests --no-restore + + - name: Run tests + run: dotnet test User.Tests --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: user-coverage + path: src/services/user/User.Tests/TestResults/**/coverage.cobertura.xml + + test-audit-worker: + name: Test Audit Worker (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/workers/audit + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/workers/audit/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: audit-coverage + path: src/workers/audit/coverage.out + + test-webhooks-worker: + name: Test Webhooks Worker (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/workers/webhooks + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/workers/webhooks/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: webhooks-coverage + path: src/workers/webhooks/coverage.out + + test-images-worker: + name: Test Images Worker (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/workers/images + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/workers/images/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: images-coverage + path: src/workers/images/coverage.out + + test-infra-worker: + name: Test Infra Worker (Go) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/workers/infra + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache-dependency-path: src/workers/infra/go.sum + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: infra-coverage + path: src/workers/infra/coverage.out + + test-dlq-worker: + name: Test DLQ Worker (C#) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/workers + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + include-prerelease: true + + - name: Restore dependencies + run: dotnet restore dlq.tests + + - name: Build + run: dotnet build dlq.tests --no-restore + + - name: Run tests + run: dotnet test dlq.tests --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: dlq-coverage + path: src/workers/dlq.tests/TestResults/**/coverage.cobertura.xml + + test-realtime-worker: + name: Test Realtime Worker (C#) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/workers + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + include-prerelease: true + + - name: Restore dependencies + run: dotnet restore realtime.tests + + - name: Build + run: dotnet build realtime.tests --no-restore + + - name: Run tests + run: dotnet test realtime.tests --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: realtime-coverage + path: src/workers/realtime.tests/TestResults/**/coverage.cobertura.xml + + test-health-service: + name: Test Health Service (Gleam) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/services/health + + steps: + - uses: actions/checkout@v4 + + - name: Set up Gleam + uses: erlef/setup-beam@v1 + with: + otp-version: '27' + gleam-version: '1.7.0' + + - name: Download dependencies + run: gleam deps download + + - name: Run tests + run: gleam test + + build-check: + name: Build Check + runs-on: ubuntu-latest + needs: [test-auth-service, test-files-service, test-events-service, test-gateway, test-identity-service, test-user-service, test-audit-worker, test-webhooks-worker, test-images-worker, test-infra-worker, test-dlq-worker, test-realtime-worker, test-health-service] + steps: + - run: echo "All tests passed" diff --git a/.gitignore b/.gitignore index 2862d44..b6550df 100644 --- a/.gitignore +++ b/.gitignore @@ -259,4 +259,7 @@ src/clients/*/dist/ !docs/*.md !docs/architecture/** !docs/guide/** -!docs/types/** \ No newline at end of file +!docs/types/** + + +!.github/ \ No newline at end of file From 2fbcac59ee528e2de572c8ea214b145f8ef3a97a Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 22:07:17 -0800 Subject: [PATCH 6/7] tests --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58d040..18a7516 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,13 +108,13 @@ jobs: include-prerelease: true - name: Restore dependencies - run: dotnet restore + run: dotnet restore Gateway.Tests - name: Build - run: dotnet build --no-restore + run: dotnet build Gateway.Tests --no-restore - name: Run tests - run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" + run: dotnet test Gateway.Tests --no-build --verbosity normal --collect:"XPlat Code Coverage" - name: Upload coverage uses: actions/upload-artifact@v4 @@ -372,7 +372,7 @@ jobs: uses: erlef/setup-beam@v1 with: otp-version: '27' - gleam-version: '1.7.0' + gleam-version: '1.14.0' - name: Download dependencies run: gleam deps download From e56d3ac61119d2bf36bfd9256c7357bd0f074176 Mon Sep 17 00:00:00 2001 From: Kayla Date: Mon, 19 Jan 2026 22:15:55 -0800 Subject: [PATCH 7/7] rebar --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18a7516..a76d0f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,6 +373,7 @@ jobs: with: otp-version: '27' gleam-version: '1.14.0' + rebar3-version: '3.24' - name: Download dependencies run: gleam deps download