Refactor and update test projects, remove obsolete tests, and upgrade dependencies
- Deleted obsolete test files for SchedulerAuditService and SchedulerMongoSessionFactory. - Removed unused TestDataFactory class. - Updated project files for Mongo.Tests to remove references to deleted files. - Upgraded BouncyCastle.Cryptography package to version 2.6.2 across multiple projects. - Replaced Microsoft.Extensions.Http.Polly with Microsoft.Extensions.Http.Resilience in Zastava.Webhook project. - Updated NetEscapades.Configuration.Yaml package to version 3.1.0 in Configuration library. - Upgraded Pkcs11Interop package to version 5.1.2 in Cryptography libraries. - Refactored Argon2idPasswordHasher to use BouncyCastle for hashing instead of Konscious. - Updated JsonSchema.Net package to version 7.3.2 in Microservice project. - Updated global.json to use .NET SDK version 10.0.101.
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
<PackageReference Include="Cronos" Version="0.10.0" />
|
||||
<PackageReference Include="Cronos" Version="0.9.0" />
|
||||
<PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
global using System.Text.Json;
|
||||
global using System.Text.Json.Nodes;
|
||||
global using Microsoft.Extensions.Logging.Abstractions;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Mongo2Go;
|
||||
global using MongoDB.Bson;
|
||||
global using MongoDB.Driver;
|
||||
global using StellaOps.Scheduler.Models;
|
||||
global using StellaOps.Scheduler.Storage.Postgres.Repositories.Internal;
|
||||
global using StellaOps.Scheduler.Storage.Postgres.Repositories.Migrations;
|
||||
global using StellaOps.Scheduler.Storage.Postgres.Repositories.Options;
|
||||
global using Xunit;
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Integration;
|
||||
|
||||
public sealed class GraphJobStoreTests
|
||||
{
|
||||
private static readonly DateTimeOffset OccurredAt = new(2025, 11, 4, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_SucceedsWhenExpectedStatusMatches()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new GraphJobRepository(harness.Context);
|
||||
var store = new MongoGraphJobStore(repository);
|
||||
|
||||
var initial = CreateBuildJob();
|
||||
await store.AddAsync(initial, CancellationToken.None);
|
||||
|
||||
var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts);
|
||||
var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1);
|
||||
|
||||
var updateResult = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
|
||||
|
||||
Assert.True(updateResult.Updated);
|
||||
var persisted = await store.GetBuildJobAsync(initial.TenantId, initial.Id, CancellationToken.None);
|
||||
Assert.NotNull(persisted);
|
||||
Assert.Equal(GraphJobStatus.Completed, persisted!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ReturnsExistingWhenExpectedStatusMismatch()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new GraphJobRepository(harness.Context);
|
||||
var store = new MongoGraphJobStore(repository);
|
||||
|
||||
var initial = CreateBuildJob();
|
||||
await store.AddAsync(initial, CancellationToken.None);
|
||||
|
||||
var running = GraphJobStateMachine.EnsureTransition(initial, GraphJobStatus.Running, OccurredAt, attempts: initial.Attempts);
|
||||
var completed = GraphJobStateMachine.EnsureTransition(running, GraphJobStatus.Completed, OccurredAt, attempts: running.Attempts + 1);
|
||||
|
||||
await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
|
||||
|
||||
var result = await store.UpdateAsync(completed, GraphJobStatus.Pending, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Updated);
|
||||
Assert.Equal(GraphJobStatus.Completed, result.Job.Status);
|
||||
}
|
||||
|
||||
private static GraphBuildJob CreateBuildJob()
|
||||
{
|
||||
var digest = "sha256:" + new string('b', 64);
|
||||
return new GraphBuildJob(
|
||||
id: "gbj_store_test",
|
||||
tenantId: "tenant-store",
|
||||
sbomId: "sbom-alpha",
|
||||
sbomVersionId: "sbom-alpha-v1",
|
||||
sbomDigest: digest,
|
||||
status: GraphJobStatus.Pending,
|
||||
trigger: GraphBuildJobTrigger.SbomVersion,
|
||||
createdAt: OccurredAt,
|
||||
metadata: null);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Integration;
|
||||
|
||||
public sealed class SchedulerMongoRoundTripTests : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
private readonly SchedulerMongoContext _context;
|
||||
|
||||
public SchedulerMongoRoundTripTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
|
||||
var options = new SchedulerMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = $"scheduler_roundtrip_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
_context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
|
||||
var migrations = new ISchedulerMongoMigration[]
|
||||
{
|
||||
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
|
||||
new EnsureSchedulerIndexesMigration()
|
||||
};
|
||||
var runner = new SchedulerMongoMigrationRunner(_context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
|
||||
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SamplesRoundTripThroughMongoWithoutLosingCanonicalShape()
|
||||
{
|
||||
var samplesRoot = LocateSamplesRoot();
|
||||
|
||||
var scheduleJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "schedule.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
scheduleJson,
|
||||
_context.Options.SchedulesCollection,
|
||||
CanonicalJsonSerializer.Deserialize<Schedule>,
|
||||
schedule => schedule.Id);
|
||||
|
||||
var runJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "run.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
runJson,
|
||||
_context.Options.RunsCollection,
|
||||
CanonicalJsonSerializer.Deserialize<Run>,
|
||||
run => run.Id);
|
||||
|
||||
var impactJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "impact-set.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
impactJson,
|
||||
_context.Options.ImpactSnapshotsCollection,
|
||||
CanonicalJsonSerializer.Deserialize<ImpactSet>,
|
||||
_ => null);
|
||||
|
||||
var auditJson = await File.ReadAllTextAsync(Path.Combine(samplesRoot, "audit.json"), CancellationToken.None);
|
||||
await AssertRoundTripAsync(
|
||||
auditJson,
|
||||
_context.Options.AuditCollection,
|
||||
CanonicalJsonSerializer.Deserialize<AuditRecord>,
|
||||
audit => audit.Id);
|
||||
}
|
||||
|
||||
private async Task AssertRoundTripAsync<TModel>(
|
||||
string json,
|
||||
string collectionName,
|
||||
Func<string, TModel> deserialize,
|
||||
Func<TModel, string?> resolveId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deserialize);
|
||||
ArgumentNullException.ThrowIfNull(resolveId);
|
||||
|
||||
var model = deserialize(json);
|
||||
var canonical = CanonicalJsonSerializer.Serialize(model);
|
||||
|
||||
var document = BsonDocument.Parse(canonical);
|
||||
var identifier = resolveId(model);
|
||||
if (!string.IsNullOrEmpty(identifier))
|
||||
{
|
||||
document["_id"] = identifier;
|
||||
}
|
||||
|
||||
var collection = _context.Database.GetCollection<BsonDocument>(collectionName);
|
||||
await collection.InsertOneAsync(document, cancellationToken: CancellationToken.None);
|
||||
|
||||
var filter = identifier is null ? Builders<BsonDocument>.Filter.Empty : Builders<BsonDocument>.Filter.Eq("_id", identifier);
|
||||
var stored = await collection.Find(filter).FirstOrDefaultAsync();
|
||||
Assert.NotNull(stored);
|
||||
|
||||
var sanitized = stored!.DeepClone().AsBsonDocument;
|
||||
sanitized.Remove("_id");
|
||||
|
||||
var storedJson = sanitized.ToJson();
|
||||
|
||||
var parsedExpected = JsonNode.Parse(canonical) ?? throw new InvalidOperationException("Canonical node null.");
|
||||
var parsedActual = JsonNode.Parse(storedJson) ?? throw new InvalidOperationException("Stored node null.");
|
||||
Assert.True(JsonNode.DeepEquals(parsedExpected, parsedActual), "Document changed shape after Mongo round-trip.");
|
||||
}
|
||||
|
||||
private static string LocateSamplesRoot()
|
||||
{
|
||||
var current = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var candidate = Path.Combine(current, "samples", "api", "scheduler");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
var parent = Path.GetDirectoryName(current.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (string.Equals(parent, current, StringComparison.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Unable to locate samples/api/scheduler in repository tree.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Migrations;
|
||||
|
||||
public sealed class SchedulerMongoMigrationTests : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public SchedulerMongoMigrationTests()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_CreatesCollectionsAndIndexes()
|
||||
{
|
||||
var options = new SchedulerMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = $"scheduler_tests_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
var context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
|
||||
var migrations = new ISchedulerMongoMigration[]
|
||||
{
|
||||
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
|
||||
new EnsureSchedulerIndexesMigration()
|
||||
};
|
||||
|
||||
var runner = new SchedulerMongoMigrationRunner(context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
|
||||
await runner.RunAsync(CancellationToken.None);
|
||||
|
||||
var cursor = await context.Database.ListCollectionNamesAsync(cancellationToken: CancellationToken.None);
|
||||
var collections = await cursor.ToListAsync();
|
||||
|
||||
Assert.Contains(options.SchedulesCollection, collections);
|
||||
Assert.Contains(options.RunsCollection, collections);
|
||||
Assert.Contains(options.ImpactSnapshotsCollection, collections);
|
||||
Assert.Contains(options.AuditCollection, collections);
|
||||
Assert.Contains(options.LocksCollection, collections);
|
||||
Assert.Contains(options.MigrationsCollection, collections);
|
||||
|
||||
await AssertScheduleIndexesAsync(context, options);
|
||||
await AssertRunIndexesAsync(context, options);
|
||||
await AssertImpactSnapshotIndexesAsync(context, options);
|
||||
await AssertAuditIndexesAsync(context, options);
|
||||
await AssertLockIndexesAsync(context, options);
|
||||
}
|
||||
|
||||
private static async Task AssertScheduleIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.SchedulesCollection));
|
||||
Assert.Contains("tenant_enabled", names);
|
||||
Assert.Contains("cron_timezone", names);
|
||||
}
|
||||
|
||||
private static async Task AssertRunIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var collection = context.Database.GetCollection<BsonDocument>(options.RunsCollection);
|
||||
var indexes = await ListIndexesAsync(collection);
|
||||
|
||||
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "tenant_createdAt_desc", StringComparison.Ordinal));
|
||||
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "state_lookup", StringComparison.Ordinal));
|
||||
Assert.Contains(indexes, doc => string.Equals(doc["name"].AsString, "schedule_createdAt_desc", StringComparison.Ordinal));
|
||||
|
||||
var ttl = indexes.FirstOrDefault(doc => doc.TryGetValue("name", out var name) && name == "finishedAt_ttl");
|
||||
Assert.NotNull(ttl);
|
||||
Assert.Equal(options.CompletedRunRetention.TotalSeconds, ttl!["expireAfterSeconds"].ToDouble());
|
||||
}
|
||||
|
||||
private static async Task AssertImpactSnapshotIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.ImpactSnapshotsCollection));
|
||||
Assert.Contains("selector_tenant_scope", names);
|
||||
Assert.Contains("snapshotId_unique", names);
|
||||
}
|
||||
|
||||
private static async Task AssertAuditIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.AuditCollection));
|
||||
Assert.Contains("tenant_occurredAt_desc", names);
|
||||
Assert.Contains("correlation_lookup", names);
|
||||
}
|
||||
|
||||
private static async Task AssertLockIndexesAsync(SchedulerMongoContext context, SchedulerMongoOptions options)
|
||||
{
|
||||
var names = await ListIndexNamesAsync(context.Database.GetCollection<BsonDocument>(options.LocksCollection));
|
||||
Assert.Contains("tenant_resource_unique", names);
|
||||
Assert.Contains("expiresAt_ttl", names);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> ListIndexNamesAsync(IMongoCollection<BsonDocument> collection)
|
||||
{
|
||||
var documents = await ListIndexesAsync(collection);
|
||||
return documents.Select(doc => doc["name"].AsString).ToArray();
|
||||
}
|
||||
|
||||
private static async Task<List<BsonDocument>> ListIndexesAsync(IMongoCollection<BsonDocument> collection)
|
||||
{
|
||||
using var cursor = await collection.Indexes.ListAsync();
|
||||
return await cursor.ToListAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
|
||||
|
||||
public sealed class AuditRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InsertAndListAsync_ReturnsTenantScopedEntries()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new AuditRepository(harness.Context);
|
||||
|
||||
var record1 = TestDataFactory.CreateAuditRecord("tenant-alpha", "1");
|
||||
var record2 = TestDataFactory.CreateAuditRecord("tenant-alpha", "2");
|
||||
var otherTenant = TestDataFactory.CreateAuditRecord("tenant-beta", "3");
|
||||
|
||||
await repository.InsertAsync(record1);
|
||||
await repository.InsertAsync(record2);
|
||||
await repository.InsertAsync(otherTenant);
|
||||
|
||||
var results = await repository.ListAsync("tenant-alpha");
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.DoesNotContain(results, record => record.TenantId == "tenant-beta");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_AppliesFilters()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new AuditRepository(harness.Context);
|
||||
|
||||
var older = TestDataFactory.CreateAuditRecord(
|
||||
"tenant-alpha",
|
||||
"old",
|
||||
occurredAt: DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
scheduleId: "sch-a");
|
||||
var newer = TestDataFactory.CreateAuditRecord(
|
||||
"tenant-alpha",
|
||||
"new",
|
||||
occurredAt: DateTimeOffset.UtcNow,
|
||||
scheduleId: "sch-a");
|
||||
|
||||
await repository.InsertAsync(older);
|
||||
await repository.InsertAsync(newer);
|
||||
|
||||
var options = new AuditQueryOptions
|
||||
{
|
||||
Since = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
ScheduleId = "sch-a",
|
||||
Limit = 5
|
||||
};
|
||||
|
||||
var results = await repository.ListAsync("tenant-alpha", options);
|
||||
Assert.Single(results);
|
||||
Assert.Equal("audit_new", results.Single().Id);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
|
||||
|
||||
public sealed class ImpactSnapshotRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAndGetAsync_RoundTripsSnapshot()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ImpactSnapshotRepository(harness.Context);
|
||||
|
||||
var snapshot = TestDataFactory.CreateImpactSet("tenant-alpha", "impact-1", DateTimeOffset.UtcNow.AddMinutes(-5));
|
||||
await repository.UpsertAsync(snapshot, cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await repository.GetBySnapshotIdAsync("impact-1", cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(snapshot.SnapshotId, stored!.SnapshotId);
|
||||
Assert.Equal(snapshot.Images[0].ImageDigest, stored.Images[0].ImageDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLatestBySelectorAsync_ReturnsMostRecent()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ImpactSnapshotRepository(harness.Context);
|
||||
|
||||
var selectorTenant = "tenant-alpha";
|
||||
var first = TestDataFactory.CreateImpactSet(selectorTenant, "impact-old", DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
var latest = TestDataFactory.CreateImpactSet(selectorTenant, "impact-new", DateTimeOffset.UtcNow);
|
||||
|
||||
await repository.UpsertAsync(first);
|
||||
await repository.UpsertAsync(latest);
|
||||
|
||||
var resolved = await repository.GetLatestBySelectorAsync(latest.Selector);
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal("impact-new", resolved!.SnapshotId);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
|
||||
|
||||
public sealed class RunRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InsertAndGetAsync_RoundTripsRun()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new RunRepository(harness.Context);
|
||||
|
||||
var run = TestDataFactory.CreateRun("run_1", "tenant-alpha", RunState.Planning);
|
||||
await repository.InsertAsync(run, cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await repository.GetAsync(run.TenantId, run.Id, cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(run.State, stored!.State);
|
||||
Assert.Equal(run.Trigger, stored.Trigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_ChangesStateAndStats()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new RunRepository(harness.Context);
|
||||
|
||||
var run = TestDataFactory.CreateRun("run_update", "tenant-alpha", RunState.Planning);
|
||||
await repository.InsertAsync(run);
|
||||
|
||||
var updated = run with
|
||||
{
|
||||
State = RunState.Completed,
|
||||
FinishedAt = DateTimeOffset.UtcNow,
|
||||
Stats = new RunStats(candidates: 10, deduped: 10, queued: 10, completed: 10, deltas: 2)
|
||||
};
|
||||
|
||||
var result = await repository.UpdateAsync(updated);
|
||||
Assert.True(result);
|
||||
|
||||
var stored = await repository.GetAsync(updated.TenantId, updated.Id);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(RunState.Completed, stored!.State);
|
||||
Assert.Equal(10, stored.Stats.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_FiltersByStateAndSchedule()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new RunRepository(harness.Context);
|
||||
|
||||
var run1 = TestDataFactory.CreateRun("run_state_1", "tenant-alpha", RunState.Planning, scheduleId: "sch_a");
|
||||
var run2 = TestDataFactory.CreateRun("run_state_2", "tenant-alpha", RunState.Running, scheduleId: "sch_a");
|
||||
var run3 = TestDataFactory.CreateRun("run_state_3", "tenant-alpha", RunState.Completed, scheduleId: "sch_b");
|
||||
|
||||
await repository.InsertAsync(run1);
|
||||
await repository.InsertAsync(run2);
|
||||
await repository.InsertAsync(run3);
|
||||
|
||||
var options = new RunQueryOptions
|
||||
{
|
||||
ScheduleId = "sch_a",
|
||||
States = new[] { RunState.Running }.ToImmutableArray(),
|
||||
Limit = 10
|
||||
};
|
||||
|
||||
var results = await repository.ListAsync("tenant-alpha", options);
|
||||
Assert.Single(results);
|
||||
Assert.Equal("run_state_2", results.Single().Id);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Repositories;
|
||||
|
||||
public sealed class ScheduleRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertAsync_PersistsScheduleWithCanonicalShape()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ScheduleRepository(harness.Context);
|
||||
|
||||
var schedule = TestDataFactory.CreateSchedule("sch_unit_1", "tenant-alpha");
|
||||
await repository.UpsertAsync(schedule, cancellationToken: CancellationToken.None);
|
||||
|
||||
var stored = await repository.GetAsync(schedule.TenantId, schedule.Id, cancellationToken: CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(schedule.Id, stored!.Id);
|
||||
Assert.Equal(schedule.Name, stored.Name);
|
||||
Assert.Equal(schedule.Selection.Scope, stored.Selection.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ExcludesDisabledAndDeletedByDefault()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ScheduleRepository(harness.Context);
|
||||
var tenantId = "tenant-alpha";
|
||||
|
||||
var enabled = TestDataFactory.CreateSchedule("sch_enabled", tenantId, enabled: true, name: "Enabled");
|
||||
var disabled = TestDataFactory.CreateSchedule("sch_disabled", tenantId, enabled: false, name: "Disabled");
|
||||
|
||||
await repository.UpsertAsync(enabled);
|
||||
await repository.UpsertAsync(disabled);
|
||||
await repository.SoftDeleteAsync(tenantId, enabled.Id, "svc_scheduler", DateTimeOffset.UtcNow);
|
||||
|
||||
var results = await repository.ListAsync(tenantId);
|
||||
Assert.Empty(results);
|
||||
|
||||
var includeDisabled = await repository.ListAsync(
|
||||
tenantId,
|
||||
new ScheduleQueryOptions { IncludeDisabled = true, IncludeDeleted = true });
|
||||
|
||||
Assert.Equal(2, includeDisabled.Count);
|
||||
Assert.Contains(includeDisabled, schedule => schedule.Id == enabled.Id);
|
||||
Assert.Contains(includeDisabled, schedule => schedule.Id == disabled.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftDeleteAsync_SetsMetadataAndExcludesFromQueries()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var repository = new ScheduleRepository(harness.Context);
|
||||
|
||||
var schedule = TestDataFactory.CreateSchedule("sch_delete", "tenant-beta");
|
||||
await repository.UpsertAsync(schedule);
|
||||
|
||||
var deletedAt = DateTimeOffset.UtcNow;
|
||||
var deleted = await repository.SoftDeleteAsync(schedule.TenantId, schedule.Id, "svc_delete", deletedAt);
|
||||
Assert.True(deleted);
|
||||
|
||||
var retrieved = await repository.GetAsync(schedule.TenantId, schedule.Id);
|
||||
Assert.Null(retrieved);
|
||||
|
||||
var includeDeleted = await repository.ListAsync(
|
||||
schedule.TenantId,
|
||||
new ScheduleQueryOptions { IncludeDeleted = true, IncludeDisabled = true });
|
||||
|
||||
Assert.Single(includeDeleted);
|
||||
Assert.Equal("sch_delete", includeDeleted[0].Id);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests;
|
||||
|
||||
internal sealed class SchedulerMongoTestHarness : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public SchedulerMongoTestHarness()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(additionalMongodArguments: "--quiet");
|
||||
var options = new SchedulerMongoOptions
|
||||
{
|
||||
ConnectionString = _runner.ConnectionString,
|
||||
Database = $"scheduler_tests_{Guid.NewGuid():N}"
|
||||
};
|
||||
|
||||
Context = new SchedulerMongoContext(Microsoft.Extensions.Options.Options.Create(options), NullLogger<SchedulerMongoContext>.Instance);
|
||||
var migrations = new ISchedulerMongoMigration[]
|
||||
{
|
||||
new EnsureSchedulerCollectionsMigration(NullLogger<EnsureSchedulerCollectionsMigration>.Instance),
|
||||
new EnsureSchedulerIndexesMigration()
|
||||
};
|
||||
var runner = new SchedulerMongoMigrationRunner(Context, migrations, NullLogger<SchedulerMongoMigrationRunner>.Instance);
|
||||
runner.RunAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public SchedulerMongoContext Context { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Services;
|
||||
|
||||
public sealed class RunSummaryServiceTests : IDisposable
|
||||
{
|
||||
private readonly SchedulerMongoTestHarness _harness;
|
||||
private readonly RunSummaryRepository _repository;
|
||||
private readonly StubTimeProvider _timeProvider;
|
||||
private readonly RunSummaryService _service;
|
||||
|
||||
public RunSummaryServiceTests()
|
||||
{
|
||||
_harness = new SchedulerMongoTestHarness();
|
||||
_repository = new RunSummaryRepository(_harness.Context);
|
||||
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T10:00:00Z"));
|
||||
_service = new RunSummaryService(_repository, _timeProvider, NullLogger<RunSummaryService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectAsync_FirstRunCreatesProjection()
|
||||
{
|
||||
var run = TestDataFactory.CreateRun("run-1", "tenant-alpha", RunState.Planning, "sch-alpha");
|
||||
|
||||
var projection = await _service.ProjectAsync(run, CancellationToken.None);
|
||||
|
||||
Assert.Equal("tenant-alpha", projection.TenantId);
|
||||
Assert.Equal("sch-alpha", projection.ScheduleId);
|
||||
Assert.NotNull(projection.LastRun);
|
||||
Assert.Equal(RunState.Planning, projection.LastRun!.State);
|
||||
Assert.Equal(1, projection.Counters.Total);
|
||||
Assert.Equal(1, projection.Counters.Planning);
|
||||
Assert.Equal(0, projection.Counters.Completed);
|
||||
Assert.Single(projection.Recent);
|
||||
Assert.Equal(run.Id, projection.Recent[0].RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectAsync_UpdateRunReplacesExistingEntry()
|
||||
{
|
||||
var createdAt = DateTimeOffset.Parse("2025-10-26T09:55:00Z");
|
||||
var run = TestDataFactory.CreateRun(
|
||||
"run-update",
|
||||
"tenant-alpha",
|
||||
RunState.Planning,
|
||||
"sch-alpha",
|
||||
createdAt: createdAt,
|
||||
startedAt: createdAt.AddMinutes(1));
|
||||
await _service.ProjectAsync(run, CancellationToken.None);
|
||||
|
||||
var updated = run with
|
||||
{
|
||||
State = RunState.Completed,
|
||||
StartedAt = run.StartedAt,
|
||||
FinishedAt = run.CreatedAt.AddMinutes(5),
|
||||
Stats = new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 10, deltas: 2, newCriticals: 1)
|
||||
};
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var projection = await _service.ProjectAsync(updated, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(projection.LastRun);
|
||||
Assert.Equal(RunState.Completed, projection.LastRun!.State);
|
||||
Assert.Equal(1, projection.Counters.Completed);
|
||||
Assert.Equal(0, projection.Counters.Planning);
|
||||
Assert.Single(projection.Recent);
|
||||
Assert.Equal(updated.Stats.Completed, projection.LastRun!.Stats.Completed);
|
||||
Assert.True(projection.UpdatedAt > run.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProjectAsync_TrimsRecentEntriesBeyondLimit()
|
||||
{
|
||||
var baseTime = DateTimeOffset.Parse("2025-10-26T00:00:00Z");
|
||||
|
||||
for (var i = 0; i < 25; i++)
|
||||
{
|
||||
var run = TestDataFactory.CreateRun(
|
||||
$"run-{i}",
|
||||
"tenant-alpha",
|
||||
RunState.Completed,
|
||||
"sch-alpha",
|
||||
stats: new RunStats(candidates: 5, deduped: 4, queued: 3, completed: 5, deltas: 1),
|
||||
createdAt: baseTime.AddMinutes(i));
|
||||
|
||||
await _service.ProjectAsync(run, CancellationToken.None);
|
||||
}
|
||||
|
||||
var projections = await _service.ListAsync("tenant-alpha", CancellationToken.None);
|
||||
Assert.Single(projections);
|
||||
var projection = projections[0];
|
||||
Assert.Equal(20, projection.Recent.Length);
|
||||
Assert.Equal(20, projection.Counters.Total);
|
||||
Assert.Equal("run-24", projection.Recent[0].RunId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_harness.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public StubTimeProvider(DateTimeOffset initial)
|
||||
=> _utcNow = initial;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta);
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scheduler.Models;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories.Services;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Services;
|
||||
|
||||
public sealed class SchedulerAuditServiceTests : IDisposable
|
||||
{
|
||||
private readonly SchedulerMongoTestHarness _harness;
|
||||
private readonly AuditRepository _repository;
|
||||
private readonly StubTimeProvider _timeProvider;
|
||||
private readonly SchedulerAuditService _service;
|
||||
|
||||
public SchedulerAuditServiceTests()
|
||||
{
|
||||
_harness = new SchedulerMongoTestHarness();
|
||||
_repository = new AuditRepository(_harness.Context);
|
||||
_timeProvider = new StubTimeProvider(DateTimeOffset.Parse("2025-10-26T11:30:00Z"));
|
||||
_service = new SchedulerAuditService(_repository, _timeProvider, NullLogger<SchedulerAuditService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PersistsRecordWithGeneratedId()
|
||||
{
|
||||
var auditEvent = new SchedulerAuditEvent(
|
||||
TenantId: "tenant-alpha",
|
||||
Category: "scheduler",
|
||||
Action: "create",
|
||||
Actor: new AuditActor("user_admin", "Admin", "user"),
|
||||
ScheduleId: "sch-alpha",
|
||||
CorrelationId: "corr-1",
|
||||
Metadata: new Dictionary<string, string>
|
||||
{
|
||||
["Reason"] = "initial",
|
||||
},
|
||||
Message: "created schedule");
|
||||
|
||||
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
|
||||
|
||||
Assert.StartsWith("audit_", record.Id, StringComparison.Ordinal);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), record.OccurredAt);
|
||||
|
||||
var stored = await _repository.ListAsync("tenant-alpha", new AuditQueryOptions { ScheduleId = "sch-alpha" }, session: null, CancellationToken.None);
|
||||
Assert.Single(stored);
|
||||
Assert.Equal(record.Id, stored[0].Id);
|
||||
Assert.Equal("created schedule", stored[0].Message);
|
||||
Assert.Contains(stored[0].Metadata, pair => pair.Key == "reason" && pair.Value == "initial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_HonoursProvidedAuditId()
|
||||
{
|
||||
var auditEvent = new SchedulerAuditEvent(
|
||||
TenantId: "tenant-alpha",
|
||||
Category: "scheduler",
|
||||
Action: "update",
|
||||
Actor: new AuditActor("user_admin", "Admin", "user"),
|
||||
ScheduleId: "sch-alpha",
|
||||
AuditId: "audit_custom_1",
|
||||
OccurredAt: DateTimeOffset.Parse("2025-10-26T12:00:00Z"));
|
||||
|
||||
var record = await _service.WriteAsync(auditEvent, CancellationToken.None);
|
||||
Assert.Equal("audit_custom_1", record.Id);
|
||||
Assert.Equal(DateTimeOffset.Parse("2025-10-26T12:00:00Z"), record.OccurredAt);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_harness.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StubTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public StubTimeProvider(DateTimeOffset initial)
|
||||
=> _utcNow = initial;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System.Threading;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scheduler.Storage.Postgres.Repositories.Sessions;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests.Sessions;
|
||||
|
||||
public sealed class SchedulerMongoSessionFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartSessionAsync_UsesCausalConsistencyByDefault()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var factory = new SchedulerMongoSessionFactory(harness.Context);
|
||||
|
||||
using var session = await factory.StartSessionAsync(cancellationToken: CancellationToken.None);
|
||||
Assert.True(session.Options.CausalConsistency.GetValueOrDefault());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartSessionAsync_AllowsOverridingOptions()
|
||||
{
|
||||
using var harness = new SchedulerMongoTestHarness();
|
||||
var factory = new SchedulerMongoSessionFactory(harness.Context);
|
||||
|
||||
var options = new SchedulerMongoSessionOptions
|
||||
{
|
||||
CausalConsistency = false,
|
||||
ReadPreference = ReadPreference.PrimaryPreferred
|
||||
};
|
||||
|
||||
using var session = await factory.StartSessionAsync(options);
|
||||
Assert.False(session.Options.CausalConsistency.GetValueOrDefault(true));
|
||||
Assert.Equal(ReadPreference.PrimaryPreferred, session.Options.DefaultTransactionOptions?.ReadPreference);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="../../samples/api/scheduler/*.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,98 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scheduler.Storage.Postgres.Repositories.Tests;
|
||||
|
||||
internal static class TestDataFactory
|
||||
{
|
||||
public static Schedule CreateSchedule(
|
||||
string id,
|
||||
string tenantId,
|
||||
bool enabled = true,
|
||||
string name = "Nightly Prod")
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new Schedule(
|
||||
id,
|
||||
tenantId,
|
||||
name,
|
||||
enabled,
|
||||
"0 2 * * *",
|
||||
"UTC",
|
||||
ScheduleMode.AnalysisOnly,
|
||||
new Selector(SelectorScope.AllImages, tenantId),
|
||||
ScheduleOnlyIf.Default,
|
||||
ScheduleNotify.Default,
|
||||
ScheduleLimits.Default,
|
||||
now,
|
||||
"svc_scheduler",
|
||||
now,
|
||||
"svc_scheduler",
|
||||
ImmutableArray<string>.Empty,
|
||||
SchedulerSchemaVersions.Schedule);
|
||||
}
|
||||
|
||||
public static Run CreateRun(
|
||||
string id,
|
||||
string tenantId,
|
||||
RunState state,
|
||||
string? scheduleId = null,
|
||||
RunTrigger trigger = RunTrigger.Manual,
|
||||
RunStats? stats = null,
|
||||
DateTimeOffset? createdAt = null,
|
||||
DateTimeOffset? startedAt = null)
|
||||
{
|
||||
var resolvedStats = stats ?? new RunStats(candidates: 10, deduped: 8, queued: 5, completed: 0, deltas: 2);
|
||||
var created = createdAt ?? DateTimeOffset.UtcNow;
|
||||
return new Run(
|
||||
id,
|
||||
tenantId,
|
||||
trigger,
|
||||
state,
|
||||
resolvedStats,
|
||||
created,
|
||||
scheduleId: scheduleId,
|
||||
reason: new RunReason(manualReason: "test"),
|
||||
startedAt: startedAt ?? created);
|
||||
}
|
||||
|
||||
public static ImpactSet CreateImpactSet(string tenantId, string snapshotId, DateTimeOffset? generatedAt = null, bool usageOnly = true)
|
||||
{
|
||||
var selector = new Selector(SelectorScope.AllImages, tenantId);
|
||||
var image = new ImpactImage(
|
||||
"sha256:" + Guid.NewGuid().ToString("N"),
|
||||
"registry",
|
||||
"repo/app",
|
||||
namespaces: new[] { "team-a" },
|
||||
tags: new[] { "prod" },
|
||||
usedByEntrypoint: true);
|
||||
|
||||
return new ImpactSet(
|
||||
selector,
|
||||
new[] { image },
|
||||
usageOnly: usageOnly,
|
||||
generatedAt ?? DateTimeOffset.UtcNow,
|
||||
total: 1,
|
||||
snapshotId: snapshotId,
|
||||
schemaVersion: SchedulerSchemaVersions.ImpactSet);
|
||||
}
|
||||
|
||||
public static AuditRecord CreateAuditRecord(
|
||||
string tenantId,
|
||||
string idSuffix,
|
||||
DateTimeOffset? occurredAt = null,
|
||||
string? scheduleId = null,
|
||||
string? category = null,
|
||||
string? action = null)
|
||||
{
|
||||
return new AuditRecord(
|
||||
$"audit_{idSuffix}",
|
||||
tenantId,
|
||||
category ?? "scheduler",
|
||||
action ?? "create",
|
||||
occurredAt ?? DateTimeOffset.UtcNow,
|
||||
new AuditActor("user_admin", "Admin", "user"),
|
||||
scheduleId: scheduleId ?? $"sch_{idSuffix}",
|
||||
message: "created");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user