|
1 | 1 | using Exceptionless.Core; |
2 | 2 | using Exceptionless.Core.Billing; |
3 | 3 | using Exceptionless.Core.Jobs; |
| 4 | +using Exceptionless.Core.Models; |
4 | 5 | using Exceptionless.Core.Repositories; |
5 | 6 | using Exceptionless.DateTimeExtensions; |
6 | 7 | using Exceptionless.Tests.Utility; |
@@ -169,4 +170,152 @@ public async Task CanDeleteOrphanedEventsByStack() |
169 | 170 | eventCount = await _eventRepository.CountAsync(o => o.IncludeSoftDeletes().ImmediateConsistency()); |
170 | 171 | Assert.Equal(5000, eventCount); |
171 | 172 | } |
| 173 | + |
| 174 | + [Fact] |
| 175 | + public async Task CanCleanupMultipleSoftDeletedOrganizations() |
| 176 | + { |
| 177 | + // Create more than the page size (5) of soft-deleted organizations to test pagination |
| 178 | + var orgs = new List<Organization>(); |
| 179 | + for (int i = 0; i < 12; i++) |
| 180 | + { |
| 181 | + var org = _organizationData.GenerateSampleOrganization(_billingManager, _plans); |
| 182 | + org.Id = ObjectId.GenerateNewId().ToString(); |
| 183 | + org.IsDeleted = true; |
| 184 | + orgs.Add(org); |
| 185 | + } |
| 186 | + await _organizationRepository.AddAsync(orgs, o => o.ImmediateConsistency()); |
| 187 | + |
| 188 | + // Create associated projects and events for a subset |
| 189 | + var project = _projectData.GenerateSampleProject(); |
| 190 | + project.OrganizationId = orgs[0].Id; |
| 191 | + await _projectRepository.AddAsync(project, o => o.ImmediateConsistency()); |
| 192 | + |
| 193 | + var stack = _stackData.GenerateStack(generateId: true, organizationId: orgs[0].Id, projectId: project.Id); |
| 194 | + await _stackRepository.AddAsync(stack, o => o.ImmediateConsistency()); |
| 195 | + |
| 196 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, orgs[0].Id, project.Id, stack.Id), o => o.ImmediateConsistency()); |
| 197 | + |
| 198 | + await _job.RunAsync(TestCancellationToken); |
| 199 | + |
| 200 | + // All soft-deleted orgs should be removed |
| 201 | + foreach (var org in orgs) |
| 202 | + Assert.Null(await _organizationRepository.GetByIdAsync(org.Id, o => o.IncludeSoftDeletes())); |
| 203 | + |
| 204 | + // Associated data should be gone too |
| 205 | + Assert.Null(await _projectRepository.GetByIdAsync(project.Id, o => o.IncludeSoftDeletes())); |
| 206 | + Assert.Null(await _stackRepository.GetByIdAsync(stack.Id, o => o.IncludeSoftDeletes())); |
| 207 | + var eventCount = await _eventRepository.CountAsync(o => o.IncludeSoftDeletes()); |
| 208 | + Assert.Equal(0, eventCount); |
| 209 | + } |
| 210 | + |
| 211 | + [Fact] |
| 212 | + public async Task CanEnforceRetentionForMultipleOrganizations() |
| 213 | + { |
| 214 | + // Retention enforcement uses GetBillingPlanByUpsellingRetentionPeriod which returns the next |
| 215 | + // plan with retention > org's retention. FreePlan (3d) → effective 30d, SmallPlan (30d) → effective 90d. |
| 216 | + var org1 = _organizationData.GenerateSampleOrganization(_billingManager, _plans); |
| 217 | + org1.Id = ObjectId.GenerateNewId().ToString(); |
| 218 | + _billingManager.ApplyBillingPlan(org1, _plans.SmallPlan); // effective retention: 90 days (next plan up is Large) |
| 219 | + org1.StripeCustomerId = "cust_test1"; |
| 220 | + org1.CardLast4 = "4242"; |
| 221 | + org1.SubscribeDate = DateTime.UtcNow; |
| 222 | + org1.BillingChangedByUserId = TestConstants.UserId; |
| 223 | + |
| 224 | + var org2 = _organizationData.GenerateSampleOrganization(_billingManager, _plans); |
| 225 | + org2.Id = ObjectId.GenerateNewId().ToString(); |
| 226 | + _billingManager.ApplyBillingPlan(org2, _plans.FreePlan); // effective retention: 30 days (next plan up is Small) |
| 227 | + await _organizationRepository.AddAsync(new[] { org1, org2 }, o => o.ImmediateConsistency()); |
| 228 | + |
| 229 | + var project1 = _projectData.GenerateProject(generateId: true, organizationId: org1.Id); |
| 230 | + var project2 = _projectData.GenerateProject(generateId: true, organizationId: org2.Id); |
| 231 | + await _projectRepository.AddAsync(new[] { project1, project2 }, o => o.ImmediateConsistency()); |
| 232 | + |
| 233 | + var stack1 = _stackData.GenerateStack(generateId: true, organizationId: org1.Id, projectId: project1.Id); |
| 234 | + var stack2 = _stackData.GenerateStack(generateId: true, organizationId: org2.Id, projectId: project2.Id); |
| 235 | + await _stackRepository.AddAsync(new[] { stack1, stack2 }, o => o.ImmediateConsistency()); |
| 236 | + |
| 237 | + // Events inside retention for both (2 days old) |
| 238 | + var recentStart = DateTimeOffset.UtcNow.AddDays(-2); |
| 239 | + var recentEnd = DateTimeOffset.UtcNow.AddDays(-1); |
| 240 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, org1.Id, project1.Id, stack1.Id, startDate: recentStart, endDate: recentEnd), o => o.ImmediateConsistency()); |
| 241 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, org2.Id, project2.Id, stack2.Id, startDate: recentStart, endDate: recentEnd), o => o.ImmediateConsistency()); |
| 242 | + |
| 243 | + // Events at 35 days old: outside org2's effective retention (30d) but inside org1's (90d) |
| 244 | + var olderStart = DateTimeOffset.UtcNow.AddDays(-37); |
| 245 | + var olderEnd = DateTimeOffset.UtcNow.AddDays(-33); |
| 246 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, org1.Id, project1.Id, stack1.Id, startDate: olderStart, endDate: olderEnd), o => o.ImmediateConsistency()); |
| 247 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, org2.Id, project2.Id, stack2.Id, startDate: olderStart, endDate: olderEnd), o => o.ImmediateConsistency()); |
| 248 | + |
| 249 | + await _job.RunAsync(TestCancellationToken); |
| 250 | + await RefreshDataAsync(); |
| 251 | + |
| 252 | + // org1 should keep all events (within 90 day effective retention) |
| 253 | + var org1Events = await _eventRepository.CountAsync(q => q.FilterExpression($"organization:{org1.Id}")); |
| 254 | + Assert.Equal(20, org1Events); |
| 255 | + |
| 256 | + // org2 should only keep recent events (older ones outside 30 day effective retention) |
| 257 | + var org2Events = await _eventRepository.CountAsync(q => q.FilterExpression($"organization:{org2.Id}")); |
| 258 | + Assert.Equal(10, org2Events); |
| 259 | + } |
| 260 | + |
| 261 | + [Fact] |
| 262 | + public async Task CanCleanupSoftDeletedStacksAcrossPages() |
| 263 | + { |
| 264 | + var organization = await _organizationRepository.AddAsync(_organizationData.GenerateSampleOrganization(_billingManager, _plans), o => o.ImmediateConsistency()); |
| 265 | + var project = await _projectRepository.AddAsync(_projectData.GenerateSampleProject(), o => o.ImmediateConsistency()); |
| 266 | + |
| 267 | + // Create more stacks than the page size (500) to test pagination |
| 268 | + var stacks = new List<Stack>(); |
| 269 | + for (int i = 0; i < 600; i++) |
| 270 | + { |
| 271 | + var stack = _stackData.GenerateStack(generateId: true, organizationId: organization.Id, projectId: project.Id); |
| 272 | + stack.IsDeleted = true; |
| 273 | + stacks.Add(stack); |
| 274 | + } |
| 275 | + await _stackRepository.AddAsync(stacks, o => o.ImmediateConsistency()); |
| 276 | + |
| 277 | + // Create a valid non-deleted stack with events |
| 278 | + var validStack = await _stackRepository.AddAsync(_stackData.GenerateStack(generateId: true, organizationId: organization.Id, projectId: project.Id), o => o.ImmediateConsistency()); |
| 279 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(5, organization.Id, project.Id, validStack.Id), o => o.ImmediateConsistency()); |
| 280 | + |
| 281 | + await _job.RunAsync(TestCancellationToken); |
| 282 | + await RefreshDataAsync(); |
| 283 | + |
| 284 | + // All soft-deleted stacks should be removed |
| 285 | + var remainingStacks = await _stackRepository.CountAsync(o => o.IncludeSoftDeletes()); |
| 286 | + Assert.Equal(1, remainingStacks); |
| 287 | + |
| 288 | + // Valid events should remain |
| 289 | + var eventCount = await _eventRepository.CountAsync(o => o.IncludeSoftDeletes()); |
| 290 | + Assert.Equal(5, eventCount); |
| 291 | + } |
| 292 | + |
| 293 | + [Fact] |
| 294 | + public async Task RespectsMaxRetentionDays() |
| 295 | + { |
| 296 | + // FreePlan has 3 day retention, but enforcement uses GetBillingPlanByUpsellingRetentionPeriod |
| 297 | + // which returns the next plan up (SmallPlan, 30 days). So effective retention = 30 days. |
| 298 | + var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); |
| 299 | + _billingManager.ApplyBillingPlan(organization, _plans.FreePlan); |
| 300 | + await _organizationRepository.AddAsync(organization, o => o.ImmediateConsistency()); |
| 301 | + |
| 302 | + var project = await _projectRepository.AddAsync(_projectData.GenerateSampleProject(), o => o.ImmediateConsistency()); |
| 303 | + var stack = await _stackRepository.AddAsync(_stackData.GenerateSampleStack(), o => o.ImmediateConsistency()); |
| 304 | + |
| 305 | + // Events outside effective retention (30 days) should be deleted |
| 306 | + var outsideRetentionStart = DateTimeOffset.UtcNow.SubtractDays(37); |
| 307 | + var outsideRetentionEnd = DateTimeOffset.UtcNow.SubtractDays(33); |
| 308 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, organization.Id, project.Id, stack.Id, startDate: outsideRetentionStart, endDate: outsideRetentionEnd), o => o.ImmediateConsistency()); |
| 309 | + |
| 310 | + // Events inside effective retention should be kept |
| 311 | + var insideRetentionStart = DateTimeOffset.UtcNow.SubtractDays(2); |
| 312 | + var insideRetentionEnd = DateTimeOffset.UtcNow.SubtractDays(1); |
| 313 | + await _eventRepository.AddAsync(_eventData.GenerateEvents(10, organization.Id, project.Id, stack.Id, startDate: insideRetentionStart, endDate: insideRetentionEnd), o => o.ImmediateConsistency()); |
| 314 | + |
| 315 | + await _job.RunAsync(TestCancellationToken); |
| 316 | + await RefreshDataAsync(); |
| 317 | + |
| 318 | + var eventCount = await _eventRepository.CountAsync(o => o.IncludeSoftDeletes()); |
| 319 | + Assert.Equal(10, eventCount); |
| 320 | + } |
172 | 321 | } |
0 commit comments