feat: add release communications
All checks were successful
deploy-socialize / image (push) Successful in 1m12s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-07 21:00:59 -04:00
parent 7a8a0a44bf
commit b6eb348605
61 changed files with 8594 additions and 4 deletions

View File

@@ -0,0 +1,192 @@
using Socialize.Api.Modules.ReleaseCommunications.Contracts;
using Socialize.Api.Modules.ReleaseCommunications.Data;
using Socialize.Api.Modules.ReleaseCommunications.Services;
namespace Socialize.Tests.ReleaseCommunications;
public class ReleaseUpdateRulesTests
{
[Theory]
[InlineData("Feature", ReleaseUpdateCategory.Feature)]
[InlineData("improvement", ReleaseUpdateCategory.Improvement)]
[InlineData("Breaking Change", ReleaseUpdateCategory.BreakingChange)]
[InlineData("BreakingChange", ReleaseUpdateCategory.BreakingChange)]
internal void TryParseCategory_accepts_supported_categories(string value, ReleaseUpdateCategory expected)
{
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out ReleaseUpdateCategory category);
Assert.True(parsed);
Assert.Equal(expected, category);
}
[Theory]
[InlineData("")]
[InlineData("Security")]
[InlineData("Maintenance")]
public void TryParseCategory_rejects_unsupported_categories(string value)
{
bool parsed = ReleaseUpdateRules.TryParseCategory(value, out _);
Assert.False(parsed);
}
[Theory]
[InlineData("Normal", ReleaseUpdateImportance.Normal)]
[InlineData("important", ReleaseUpdateImportance.Important)]
internal void TryParseImportance_accepts_supported_importance(string value, ReleaseUpdateImportance expected)
{
bool parsed = ReleaseUpdateRules.TryParseImportance(value, out ReleaseUpdateImportance importance);
Assert.True(parsed);
Assert.Equal(expected, importance);
}
[Theory]
[InlineData("Everyone", ReleaseUpdateAudience.Everyone)]
[InlineData("Organization Owners", ReleaseUpdateAudience.OrganizationOwners)]
[InlineData("developers", ReleaseUpdateAudience.Developers)]
internal void TryParseAudience_accepts_supported_audiences(string value, ReleaseUpdateAudience expected)
{
bool parsed = ReleaseUpdateRules.TryParseAudience(value, out ReleaseUpdateAudience audience);
Assert.True(parsed);
Assert.Equal(expected, audience);
}
[Fact]
public void ToDto_formats_breaking_change_category_for_display()
{
ReleaseUpdate update = new()
{
Id = Guid.NewGuid(),
Title = "API change",
Summary = "A workflow API changed.",
Category = ReleaseUpdateCategory.BreakingChange,
Importance = ReleaseUpdateImportance.Important,
Audience = ReleaseUpdateAudience.Developers,
Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
PublishedAt = DateTimeOffset.UtcNow,
CreatedByUserId = Guid.NewGuid(),
};
ReleaseUpdateDto dto = update.ToDto(isRead: true);
Assert.Equal("Breaking Change", dto.Category);
Assert.True(dto.IsRead);
}
[Fact]
public void VisibleTo_returns_everyone_updates_for_any_authenticated_user()
{
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: false))
.ToList();
Assert.Same(update, Assert.Single(visibleUpdates));
}
[Fact]
public void VisibleTo_rejects_unpublished_updates()
{
ReleaseUpdate update = NewPublishedUpdate(ReleaseUpdateAudience.Everyone);
update.Status = ReleaseUpdateStatus.Draft;
List<ReleaseUpdate> visibleUpdates = new[] { update }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: true))
.ToList();
Assert.Empty(visibleUpdates);
}
[Fact]
public void VisibleTo_requires_matching_restricted_audience()
{
ReleaseUpdate ownerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.OrganizationOwners);
ReleaseUpdate developerUpdate = NewPublishedUpdate(ReleaseUpdateAudience.Developers);
List<ReleaseUpdate> ownerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: false, IsOrganizationOwner: true))
.ToList();
List<ReleaseUpdate> developerVisibleUpdates = new[] { ownerUpdate, developerUpdate }
.AsQueryable()
.VisibleTo(new ReleaseUpdateAudienceContext(IsDeveloper: true, IsOrganizationOwner: false))
.ToList();
Assert.Same(ownerUpdate, Assert.Single(ownerVisibleUpdates));
Assert.Same(developerUpdate, Assert.Single(developerVisibleUpdates));
}
[Fact]
public void CreateMissingReadReceipts_creates_receipts_only_for_unread_visible_updates()
{
Guid userId = Guid.NewGuid();
Guid unreadUpdateId = Guid.NewGuid();
Guid readUpdateId = Guid.NewGuid();
DateTimeOffset readAt = DateTimeOffset.UtcNow;
IReadOnlyCollection<ReleaseUpdateReadReceipt> receipts =
ReleaseUpdateReadState.CreateMissingReadReceipts(
userId,
[unreadUpdateId, readUpdateId],
new HashSet<Guid> { readUpdateId },
readAt);
ReleaseUpdateReadReceipt receipt = Assert.Single(receipts);
Assert.Equal(unreadUpdateId, receipt.ReleaseUpdateId);
Assert.Equal(userId, receipt.UserId);
Assert.Equal(readAt, receipt.ReadAt);
}
[Fact]
public void IsInactive_allows_never_authenticated_and_old_activity()
{
DateTimeOffset inactiveBefore = DateTimeOffset.UtcNow.AddHours(-24);
Assert.True(ReleaseUpdateEmailRules.IsInactive(null, inactiveBefore));
Assert.True(ReleaseUpdateEmailRules.IsInactive(inactiveBefore.AddMinutes(-1), inactiveBefore));
}
[Fact]
public void IsInactive_rejects_recent_activity()
{
DateTimeOffset inactiveBefore = DateTimeOffset.UtcNow.AddHours(-24);
Assert.False(ReleaseUpdateEmailRules.IsInactive(inactiveBefore.AddMinutes(1), inactiveBefore));
}
[Fact]
public void CanSendDigest_enforces_send_interval()
{
DateTimeOffset lastSentBefore = DateTimeOffset.UtcNow.AddHours(-24);
Assert.True(ReleaseUpdateEmailRules.CanSendDigest(null, lastSentBefore));
Assert.True(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(-1), lastSentBefore));
Assert.False(ReleaseUpdateEmailRules.CanSendDigest(lastSentBefore.AddMinutes(1), lastSentBefore));
}
private static ReleaseUpdate NewPublishedUpdate(ReleaseUpdateAudience audience)
{
return new ReleaseUpdate
{
Id = Guid.NewGuid(),
Title = "Update",
Summary = "Something changed.",
Category = ReleaseUpdateCategory.Improvement,
Importance = ReleaseUpdateImportance.Normal,
Audience = audience,
Status = ReleaseUpdateStatus.Published,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
PublishedAt = DateTimeOffset.UtcNow,
CreatedByUserId = Guid.NewGuid(),
};
}
}