using Xunit; using System.Net; using AwesomeAssertions; using Aptabase.IntegrationTests.Clients; using Aptabase.Features.Ingestion.Buffer; using Aptabase.Data; using Dapper; namespace Aptabase.IntegrationTests; [Collection("Integration Tests")] public class IngestionTests { private readonly IntegrationTestsFixture _fixture; private readonly EventBackgroundWritter _eventWritter; public IngestionTests(IntegrationTestsFixture fixture) { _fixture = fixture; _eventWritter = _fixture.GetHostedService(); } [Fact] public async Task Can_Ingest_For_Known_App() { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); var code = await client.TrackEvent(DateTime.UtcNow.AddHours(-10), "Button Clicked", null); code.Should().Be(HttpStatusCode.OK); await _eventWritter.FlushEvents(); var count = await _fixture.UserA.CountEvents(app.Id, "24h"); count.Should().Be(1); } [Fact] public async Task Can_Ingest_Future_Events() { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); var code = await client.TrackEvent(DateTime.UtcNow.AddYears(10), "Button Clicked", null); code.Should().Be(HttpStatusCode.OK); await _eventWritter.FlushEvents(); var count = await _fixture.UserA.CountEvents(app.Id, "24h"); count.Should().Be(1); } [Fact] public async Task Can_Ingest_Multiple_Events() { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); var code = await client.TrackEvents([ (DateTime.UtcNow, "App Started", null), (DateTime.UtcNow, "Menu Opened", null), (DateTime.UtcNow, "Button Clicked", null), ]); code.Should().Be(HttpStatusCode.OK); await _eventWritter.FlushEvents(); var count = await _fixture.UserA.CountEvents(app.Id, "24h"); count.Should().Be(3); } [Fact] public async Task Should_drop_events_if_invalid() { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); var code = await client.TrackEvents([ (DateTime.UtcNow, "App Started", null), (DateTime.UtcNow, "Menu Opened", null), (DateTime.UtcNow, "Button Clicked", "INVALID PROPS"), ]); code.Should().Be(HttpStatusCode.OK); await _eventWritter.FlushEvents(); var count = await _fixture.UserA.CountEvents(app.Id, "24h"); count.Should().Be(2); } [Fact] public async Task Cant_Ingest_More_Than_25_Events_In_Batch() { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); var code = await client.TrackEvents(Enumerable.Range(1, 26).Select(i => (DateTime.UtcNow, "Button Clicked", (object?)null))); code.Should().Be(HttpStatusCode.BadRequest); await _eventWritter.FlushEvents(); var count = await _fixture.UserA.CountEvents(app.Id, "24h"); count.Should().Be(0); } [Fact] public async Task Cant_Ingest_Unknown_AppKey() { // Dropped, but answered with success so SDKs that retry every non-2xx response don't re-send it forever var client = new IngestionClient(_fixture.CreateClient(), "THIS-DOES-NOT-EXIST"); var code = await client.TrackEvent(DateTime.UtcNow, "Button Clicked", null); code.Should().Be(HttpStatusCode.OK); } [Fact] public async Task Drops_Events_For_Locked_Account_With_Success_Status() { // A dedicated owner, because locking the shared fixture users would affect other tests var owner = new AccountClient(_fixture.CreateClient()); await owner.CreateAccount("Locked Owner", $"locked.owner.{Guid.NewGuid()}@got.com"); var app = await owner.CreateApp(Guid.NewGuid().ToString()); var db = _fixture.GetService(); await SetOwnerLockReason(db, app.Id, 'O'); // SDKs that retry every non-2xx response must see success, or they re-send these events forever var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); (await client.TrackEvent(DateTime.UtcNow, "Button Clicked", null)).Should().Be(HttpStatusCode.OK); (await client.TrackEvents([ (DateTime.UtcNow, "App Started", null), (DateTime.UtcNow, "Menu Opened", null), ])).Should().Be(HttpStatusCode.OK); await _eventWritter.FlushEvents(); // Stats are hidden while the owner is locked, so unlock to check nothing was recorded await SetOwnerLockReason(db, app.Id, null); var count = await owner.CountEvents(app.Id, "24h"); count.Should().Be(0); } private static Task SetOwnerLockReason(IDbContext db, string appId, char? lockReason) { return db.Connection.ExecuteAsync( "UPDATE users SET lock_reason = @lockReason WHERE id = (SELECT owner_id FROM apps WHERE id = @appId)", new { appId, lockReason }); } [Theory] [InlineData("/api/v0/event")] [InlineData("/api/v0/events")] public async Task Rejects_Unknown_AppKey_Before_Parsing_Body(string path) { // A malformed body would be a 400 if it were parsed, so a 200 proves the app key check ran first var client = new IngestionClient(_fixture.CreateClient(), "THIS-DOES-NOT-EXIST"); var code = await client.PostRaw(path, "{ this is not json"); code.Should().Be(HttpStatusCode.OK); } public static IEnumerable ValidProps => [ ["{}"], ["{\"name\":\"Bob\"}"], ["{\"name\":\"Bob\", \"age\":20}"], [new { }], [new { age = 20 }], [new { name = "Bob", age = 20 }], [new { name = "Bob", valid = true }], [new { name = "Bob", surname = (object?)null }], [new { name = "Bob", age = 20, list = new int[]{1,2,3} }], ["{\"name\":\"Bob\", \"age\":20, \"list\": [1,2,3]}"], ]; [Theory, MemberData(nameof(ValidProps))] public async Task Cant_Ingest_With_Valid_Props(object props) { var appA = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), appA.AppKey); var code = await client.TrackEvent(DateTime.UtcNow, "Button Clicked", props); code.Should().Be(HttpStatusCode.OK); } public static IEnumerable InvalidProps => [ [1], ["Something"], ["[1,2,3]"], [""], [new string[]{"A", "B", "C"}], ]; [Theory, MemberData(nameof(InvalidProps))] public async Task Cant_Ingest_With_Invalid_Props(object props) { var appA = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), appA.AppKey); var code = await client.TrackEvent(DateTime.UtcNow, "Button Clicked", props); code.Should().Be(HttpStatusCode.BadRequest); } public static IEnumerable ValidSessionIds => [ ["8ee47a56-a457-4513-a65f-2b8c3065eb95"], ["0ef724ce-7d46-4bde-9e0f-69303ef3f2af"], ["1234-5678-9012-3456"], [IngestionClient.NewSessionId().ToString()], ]; [Theory, MemberData(nameof(ValidSessionIds))] public async Task Can_Ingest_Valid_SessionId(string sessionId) { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); client.SetSessionId(sessionId); var code = await client.TrackEvent(DateTime.UtcNow, "Button Clicked", null); code.Should().Be(HttpStatusCode.OK); } [Theory] [InlineData("")] [InlineData("1234567890123456789012345678901234567890")] [InlineData(null)] public async Task Cant_Ingest_Invalid_SessionId(string? sessionId) { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey); client.SetSessionId(sessionId); var code = await client.TrackEvent(DateTime.UtcNow, "Button Clicked", null); code.Should().Be(HttpStatusCode.BadRequest); } [Fact] public async Task Cant_Read_Stats_From_Other_Users() { var appA = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), appA.AppKey); await client.TrackEvent(DateTime.UtcNow, "Button Clicked", null); var responseA = await _fixture.UserA.GetKeyMetrics(appA.Id, "24h"); responseA.StatusCode.Should().Be(HttpStatusCode.OK); var responseB = await _fixture.UserB.GetKeyMetrics(appA.Id, "24h"); responseB.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] public async Task Check_Rate_Limiting() { var app = await _fixture.UserA.CreateApp(Guid.NewGuid().ToString()); var client = new IngestionClient(_fixture.CreateClient(), app.AppKey, "10.0.0.0"); for (var i = 1; i <= 22; i++) { var code1 = await client.TrackEvent(DateTime.UtcNow, "Button Clicked", null); if (i <= 20) code1.Should().Be(HttpStatusCode.OK); else code1.Should().Be(HttpStatusCode.TooManyRequests); } // A different IP should succeed because it uses a different IP var client2 = new IngestionClient(_fixture.CreateClient(), app.AppKey, "12.0.0.0"); var code2 = await client2.TrackEvent(DateTime.UtcNow, "Button Clicked", null); code2.Should().Be(HttpStatusCode.OK); await _eventWritter.FlushEvents(); var count = await _fixture.UserA.CountEvents(app.Id, "24h"); count.Should().Be(21); } }