Skip to content

Commit ea70a71

Browse files
committed
Cover reconciler, plan-source, and tenant cascade telemetry events with tests
1 parent 49a175f commit ea70a71

3 files changed

Lines changed: 216 additions & 0 deletions

File tree

application/account/Tests/FeatureFlags/PlanBasedFeatureFlagEvaluatorTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,55 @@ public async Task EvaluatePlanFlags_WhenAlreadyActive_ShouldBeIdempotent()
9494
);
9595
secondEnabledAt.Should().Be(firstEnabledAt);
9696
}
97+
98+
[Fact]
99+
public async Task EvaluatePlanFlags_WhenUpgradingToPremium_ShouldEmitPlanOverrideActivatedEvent()
100+
{
101+
// Arrange
102+
var tenantId = DatabaseSeeder.Tenant1.Id;
103+
TelemetryEventsCollectorSpy.Reset();
104+
105+
// Act
106+
await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None);
107+
await _dbContext.SaveChangesAsync();
108+
109+
// Assert
110+
TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "FeatureFlagPlanOverrideActivated");
111+
}
112+
113+
[Fact]
114+
public async Task EvaluatePlanFlags_WhenDowngradingFromPremium_ShouldEmitPlanOverrideDeactivatedEvent()
115+
{
116+
// Arrange
117+
var tenantId = DatabaseSeeder.Tenant1.Id;
118+
await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None);
119+
await _dbContext.SaveChangesAsync();
120+
TelemetryEventsCollectorSpy.Reset();
121+
122+
// Act
123+
await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Basis, CancellationToken.None);
124+
await _dbContext.SaveChangesAsync();
125+
126+
// Assert
127+
TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "FeatureFlagPlanOverrideDeactivated");
128+
}
129+
130+
[Fact]
131+
public async Task EvaluatePlanFlags_WhenNoChange_ShouldNotEmitTelemetryEvent()
132+
{
133+
// Arrange
134+
var tenantId = DatabaseSeeder.Tenant1.Id;
135+
await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None);
136+
await _dbContext.SaveChangesAsync();
137+
TelemetryEventsCollectorSpy.Reset();
138+
139+
// Act
140+
await _service.EvaluatePlanFlagsForTenantAsync(tenantId, SubscriptionPlan.Premium, CancellationToken.None);
141+
await _dbContext.SaveChangesAsync();
142+
143+
// Assert — handler is idempotent; a no-op pass must not emit any plan-override events.
144+
TelemetryEventsCollectorSpy.CollectedEvents.Should().NotContain(e =>
145+
e.GetType().Name == "FeatureFlagPlanOverrideActivated" || e.GetType().Name == "FeatureFlagPlanOverrideDeactivated"
146+
);
147+
}
97148
}

application/account/Tests/Tenants/DeleteTenantTests.cs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System.Net;
22
using Account.Database;
3+
using Account.Features.FeatureFlags.Domain;
34
using Account.Features.Subscriptions.Domain;
5+
using Account.Features.Tenants.Domain;
46
using FluentAssertions;
57
using SharedKernel.Domain;
68
using SharedKernel.Tests;
@@ -51,6 +53,70 @@ public async Task DeleteTenant_WhenValid_ShouldSoftDeleteTenant()
5153
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
5254
}
5355

56+
[Fact]
57+
public async Task DeleteTenant_WhenTenantHasFeatureFlagOverrides_ShouldRemoveOnlyDeletedTenantRowsAndReportRowCount()
58+
{
59+
// Arrange — seed two tenants each with one Manual and one Plan override on a shared key, then
60+
// delete tenant A and verify only A's rows are gone, B's survive, and the TenantDeleted event
61+
// reports the correct feature_flag_rows_removed count.
62+
var deletedTenantId = DatabaseSeeder.Tenant1.Id;
63+
var survivingTenantId = TenantId.NewId();
64+
Connection.Insert("tenants", [
65+
("id", survivingTenantId.Value),
66+
("created_at", TimeProvider.GetUtcNow()),
67+
("modified_at", null),
68+
("name", "Surviving tenant"),
69+
("state", nameof(TenantState.Active)),
70+
("logo", """{"Url":null,"Version":0}"""),
71+
("plan", nameof(SubscriptionPlan.Basis)),
72+
("rollout_bucket", 42)
73+
]
74+
);
75+
InsertTenantFeatureFlagOverride("sso", deletedTenantId, "Plan");
76+
InsertTenantFeatureFlagOverride("beta-features", deletedTenantId, "Manual");
77+
InsertTenantFeatureFlagOverride("sso", survivingTenantId, "Plan");
78+
InsertTenantFeatureFlagOverride("beta-features", survivingTenantId, "Manual");
79+
80+
// Act
81+
var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/internal-api/account/tenants/{deletedTenantId}");
82+
83+
// Assert
84+
response.ShouldHaveEmptyHeaderAndLocationOnSuccess();
85+
86+
var deletedTenantRowCount = Connection.ExecuteScalar<long>(
87+
"SELECT COUNT(*) FROM feature_flags WHERE tenant_id = @tenantId", [new { tenantId = deletedTenantId.Value }]
88+
);
89+
deletedTenantRowCount.Should().Be(0, "the cascade must remove every feature_flag row owned by the deleted tenant");
90+
91+
var survivingTenantRowCount = Connection.ExecuteScalar<long>(
92+
"SELECT COUNT(*) FROM feature_flags WHERE tenant_id = @tenantId", [new { tenantId = survivingTenantId.Value }]
93+
);
94+
survivingTenantRowCount.Should().Be(2, "other tenants' overrides must not be touched");
95+
96+
TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "TenantDeleted");
97+
var tenantDeleted = TelemetryEventsCollectorSpy.CollectedEvents.Single(e => e.GetType().Name == "TenantDeleted");
98+
tenantDeleted.Properties["event.feature_flag_rows_removed"].Should().Be("2");
99+
}
100+
101+
private void InsertTenantFeatureFlagOverride(string flagKey, TenantId tenantId, string source)
102+
{
103+
Connection.Insert("feature_flags", [
104+
("id", FeatureFlagId.NewId().ToString()),
105+
("created_at", TimeProvider.GetUtcNow()),
106+
("modified_at", null),
107+
("flag_key", flagKey),
108+
("tenant_id", tenantId.Value),
109+
("user_id", null),
110+
("enabled_at", TimeProvider.GetUtcNow()),
111+
("disabled_at", null),
112+
("bucket_start", null),
113+
("bucket_end", null),
114+
("source", source),
115+
("scope", "Tenant")
116+
]
117+
);
118+
}
119+
54120
[Fact]
55121
public async Task DeleteTenant_WhenActiveSubscription_ShouldReturnBadRequest()
56122
{

application/account/Tests/Workers/FeatureFlagDefinitionReconcilerTests.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,105 @@ await act.Should().ThrowAsync<InvalidOperationException>()
333333
deletedAt.Should().NotBeNullOrEmpty("the reconciler must not clear DeletedAt; it must fail deployment instead");
334334
}
335335

336+
[Fact]
337+
public async Task Reconciler_WhenRowExistsForRemovedFlag_ShouldEmitOrphanedTelemetryEvent()
338+
{
339+
// Arrange
340+
Connection.Insert("feature_flags", [
341+
("id", FeatureFlagId.NewId().ToString()),
342+
("created_at", TimeProvider.GetUtcNow()),
343+
("modified_at", null),
344+
("deleted_at", null),
345+
("orphaned_at", null),
346+
("flag_key", "removed-feature"),
347+
("tenant_id", null),
348+
("user_id", null),
349+
("enabled_at", TimeProvider.GetUtcNow()),
350+
("disabled_at", null),
351+
("bucket_start", null),
352+
("bucket_end", null),
353+
("source", "Manual"),
354+
("scope", "Tenant")
355+
]
356+
);
357+
TelemetryEventsCollectorSpy.Reset();
358+
359+
// Act
360+
await RunReconcilerAsync();
361+
362+
// Assert
363+
TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagOrphanedByReconciler");
364+
}
365+
366+
[Fact]
367+
public async Task Reconciler_WhenOrphanedBaseRowKeyIsBackInDefinitions_ShouldEmitRestoredTelemetryEvent()
368+
{
369+
// Arrange — orphan the sso base row and a tenant override sharing the key so the restore
370+
// event reports a non-zero overrides_restored count.
371+
var orphanedAt = TimeProvider.GetUtcNow();
372+
var baseRowId = Connection.ExecuteScalar<string>(
373+
"SELECT id FROM feature_flags WHERE flag_key = 'sso' AND tenant_id IS NULL AND user_id IS NULL", []
374+
);
375+
Connection.Update("feature_flags", "id", baseRowId, [("orphaned_at", orphanedAt)]);
376+
Connection.Insert("feature_flags", [
377+
("id", FeatureFlagId.NewId().ToString()),
378+
("created_at", TimeProvider.GetUtcNow()),
379+
("modified_at", null),
380+
("deleted_at", null),
381+
("orphaned_at", orphanedAt),
382+
("flag_key", "sso"),
383+
("tenant_id", DatabaseSeeder.Tenant1.Id.Value),
384+
("user_id", null),
385+
("enabled_at", TimeProvider.GetUtcNow()),
386+
("disabled_at", null),
387+
("bucket_start", null),
388+
("bucket_end", null),
389+
("source", "Plan"),
390+
("scope", "Tenant")
391+
]
392+
);
393+
TelemetryEventsCollectorSpy.Reset();
394+
395+
// Act
396+
await RunReconcilerAsync();
397+
398+
// Assert
399+
TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "FeatureFlagRestoredByReconciler");
400+
var restored = TelemetryEventsCollectorSpy.CollectedEvents.Single(e => e.GetType().Name == "FeatureFlagRestoredByReconciler");
401+
restored.Properties["event.overrides_restored"].Should().Be("1");
402+
}
403+
404+
[Fact]
405+
public async Task Reconciler_WhenSsoTenantOverrideSourceDiffersFromDefinition_ShouldEmitSourceTransitionedTelemetryEvent()
406+
{
407+
// Arrange — sso is a PlanGatedTenantFlag (definition Source=Plan). Seed a Manual override
408+
// row so the reconciler's source-transition sweep removes it and emits the event.
409+
Connection.Insert("feature_flags", [
410+
("id", FeatureFlagId.NewId().ToString()),
411+
("created_at", TimeProvider.GetUtcNow()),
412+
("modified_at", null),
413+
("deleted_at", null),
414+
("orphaned_at", null),
415+
("flag_key", "sso"),
416+
("tenant_id", DatabaseSeeder.Tenant1.Id.Value),
417+
("user_id", null),
418+
("enabled_at", TimeProvider.GetUtcNow()),
419+
("disabled_at", null),
420+
("bucket_start", null),
421+
("bucket_end", null),
422+
("source", "Manual"),
423+
("scope", "Tenant")
424+
]
425+
);
426+
TelemetryEventsCollectorSpy.Reset();
427+
428+
// Act
429+
await RunReconcilerAsync();
430+
431+
// Assert
432+
TelemetryEventsCollectorSpy.CollectedEvents.Should().Contain(e => e.GetType().Name == "FeatureFlagSourceTransitionedByReconciler");
433+
}
434+
336435
private async Task RunReconcilerAsync()
337436
{
338437
using var scope = Provider.CreateScope();

0 commit comments

Comments
 (0)