Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions API.IntegrationTests/Tests/ControlStatChangesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using OpenShock.API.IntegrationTests.Helpers;
using OpenShock.Common.Constants;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.AutomationTokens;
using OpenShock.Common.Utils;

namespace OpenShock.API.IntegrationTests.Tests;

public sealed class ControlStatChangesTests
{
private const string Password = "SecurePassword123#";
private const string Endpoint = "/1/integrations/control-stats/changes";

[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }

[Test]
public async Task WithoutControlStatsReadToken_Returns403()
{
var body = TestHelper.JsonContent(new { userIds = new[] { Guid.NewGuid() } });

using var anonymous = WebApplicationFactory.CreateClient();
await Assert.That((await anonymous.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden);

using var wrongType = await CreateTokenClient([AutomationTokenType.Turnstile, AutomationTokenType.RateLimit]);
await Assert.That((await wrongType.PostAsync(Endpoint, body)).StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}

[Test]
public async Task InitialSync_ReportsEveryRequestedUser_WithReceivedAndGivenTotals()
{
var owner = await CreateUser("chgowner");
var controller = await CreateUser("chgctrl");
var idle = await CreateUser("chgidle");
var notRequested = await CreateUser("chgother");
var shockerId = await CreateShocker(owner, "Changes");
var at = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);

await Upsert(
Delta(shockerId, controller, at, count: 2, intensity: 40),
Delta(shockerId, notRequested, at, count: 7));

using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
using var doc = await Post(client, new { userIds = new[] { owner, controller, idle } });

var users = doc.RootElement.GetProperty("users").EnumerateArray()
.ToDictionary(x => x.GetProperty("userId").GetGuid());
await Assert.That(users.Keys).IsEquivalentTo(new[] { owner, controller, idle });

// Lifetime totals, so the not requested controller's activity counts towards the owner as well
var received = SingleShock(users[owner].GetProperty("received"));
await Assert.That(received.GetProperty("count").GetInt64()).IsEqualTo(9);
await Assert.That(users[owner].GetProperty("given").GetArrayLength()).IsEqualTo(0);

var given = SingleShock(users[controller].GetProperty("given"));
await Assert.That(given.GetProperty("count").GetInt64()).IsEqualTo(2);
await Assert.That(given.GetProperty("averageIntensity").GetDouble()).IsEqualTo(40).Within(0.001);

await Assert.That(users[idle].GetProperty("received").GetArrayLength()).IsEqualTo(0);
await Assert.That(users[idle].GetProperty("given").GetArrayLength()).IsEqualTo(0);
}

[Test]
public async Task Since_OnlyReportsUsersWithRecentActivity()
{
var owner = await CreateUser("chgsince");
var quiet = await CreateUser("chgquiet");
var shockerId = await CreateShocker(owner, "Since");
var quietShockerId = await CreateShocker(quiet, "Quiet");

// Old activity only, outside of any lookback
var old = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
await Upsert(Delta(shockerId, owner, old, count: 3), Delta(quietShockerId, quiet, old, count: 1));

using var client = await CreateTokenClient([AutomationTokenType.ControlStatsRead]);
var userIds = new[] { owner, quiet };

DateTime cursor;
using (var initial = await Post(client, new { userIds }))
cursor = initial.RootElement.GetProperty("cursor").GetDateTime();

using (var unchanged = await Post(client, new { since = cursor, userIds }))
await Assert.That(unchanged.RootElement.GetProperty("users").GetArrayLength()).IsEqualTo(0);

await Upsert(Delta(shockerId, owner, DateTime.UtcNow, count: 1));

using var changed = await Post(client, new { since = cursor, userIds });
var user = changed.RootElement.GetProperty("users").EnumerateArray().Single();
await Assert.That(user.GetProperty("userId").GetGuid()).IsEqualTo(owner);
await Assert.That(SingleShock(user.GetProperty("received")).GetProperty("count").GetInt64()).IsEqualTo(4);
await Assert.That(SingleShock(user.GetProperty("given")).GetProperty("count").GetInt64()).IsEqualTo(4);
}

private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) =>
new(shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000,
IntensityMs: count * 1000 * intensity, intensity);

private static JsonElement SingleShock(JsonElement totals) => totals.EnumerateArray()
.Single(x => x.GetProperty("type").GetString() == "Shock" && !x.GetProperty("live").GetBoolean());

private async Task Upsert(params ControlStatDelta[] deltas)
{
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<OpenShockContext>().UpsertControlStatsAsync(deltas);
}

private static async Task<JsonDocument> Post(HttpClient client, object body)
{
var response = await client.PostAsync(Endpoint, TestHelper.JsonContent(body));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
}

private Task<Guid> CreateUser(string prefix) => TestHelper.CreateUserInDb(WebApplicationFactory,
TestHelper.UniqueUsername(prefix), TestHelper.UniqueEmail(prefix), Password);

private async Task<HttpClient> CreateTokenClient(List<AutomationTokenType> types)
{
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var secret = IAutomationTokenService.GenerateSecret();
db.AutomationTokens.Add(new AutomationToken
{
Id = Guid.CreateVersion7(),
Name = "control-stats-test",
TokenHash = HashingUtils.HashToken(secret),
Types = types
});
await db.SaveChangesAsync();

var client = WebApplicationFactory.CreateClient();
client.DefaultRequestHeaders.Add(AuthConstants.AutomationTokenHeaderName, secret);
return client;
}

private async Task<Guid> CreateShocker(Guid ownerId, string name)
{
var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, ownerId);

await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var shockerId = Guid.CreateVersion7();
db.Shockers.Add(new Shocker
{
Id = shockerId,
Name = name,
Model = ShockerModelType.CaiXianlin,
RfId = 1234,
DeviceId = deviceId
});
await db.SaveChangesAsync();
return shockerId;
}
}
223 changes: 223 additions & 0 deletions API.IntegrationTests/Tests/ControlStatsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using System.Net;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OpenShock.API.IntegrationTests.Helpers;
using OpenShock.Common.OpenShockDb;

namespace OpenShock.API.IntegrationTests.Tests;

public sealed class ControlStatsTests
{
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }

[Test]
public async Task SendControl_RecordsStats_ThatSurviveLogDeletion()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("stats"),
TestHelper.UniqueEmail("stats"), "SecurePassword123#");
var shockerId = await CreateShocker(user.Id, "Stats Shocker");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);

foreach (var (type, intensity, duration) in new[] { ("Shock", 50, 1000), ("Shock", 100, 2000), ("Vibrate", 30, 500), ("Stop", 1, 300) })
{
var response = await client.PostAsync("/2/shockers/control", TestHelper.JsonContent(new
{
shocks = new[] { new { id = shockerId, type, intensity, duration } }
}));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}

// The rollup must not depend on the logs, which get trimmed
await using (var scope = WebApplicationFactory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
await db.ShockerControlLogs.Where(x => x.ShockerId == shockerId).ExecuteDeleteAsync();
}

using var doc = await GetStats(client);
var received = doc.RootElement.GetProperty("received");

var totals = received.GetProperty("totals");
await Assert.That(totals.GetArrayLength()).IsEqualTo(2); // Stop is not counted

var shock = FindTotals(totals, "Shock", live: false);
await Assert.That(shock.GetProperty("count").GetInt64()).IsEqualTo(2);
await Assert.That(shock.GetProperty("durationMs").GetInt64()).IsEqualTo(3000);
await Assert.That(shock.GetProperty("averageIntensity").GetDouble()).IsEqualTo((50.0 * 1000 + 100.0 * 2000) / 3000).Within(0.001);
await Assert.That(shock.GetProperty("maxIntensity").GetInt32()).IsEqualTo(100);

var vibrate = FindTotals(totals, "Vibrate", live: false);
await Assert.That(vibrate.GetProperty("count").GetInt64()).IsEqualTo(1);

var shocker = received.GetProperty("shockers").EnumerateArray().Single();
await Assert.That(shocker.GetProperty("shockerId").GetGuid()).IsEqualTo(shockerId);
await Assert.That(shocker.GetProperty("shockerName").GetString()).IsEqualTo("Stats Shocker");

var controller = received.GetProperty("controllers").EnumerateArray().Single();
await Assert.That(controller.GetProperty("userId").GetGuid()).IsEqualTo(user.Id);
await Assert.That(controller.GetProperty("user").GetProperty("name").GetString()).IsEqualTo(user.Username);

await Assert.That(received.GetProperty("days").GetArrayLength()).IsEqualTo(1);

// Controlling your own shocker counts as given too
var given = doc.RootElement.GetProperty("given");
await Assert.That(FindTotals(given.GetProperty("totals"), "Shock", live: false).GetProperty("count").GetInt64()).IsEqualTo(2);
await Assert.That(given.GetProperty("owners").EnumerateArray().Single().GetProperty("userId").GetGuid()).IsEqualTo(user.Id);
}

[Test]
public async Task DeletingShockers_KeepsTheirStats_WithoutUniqueConflicts()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statsdel"),
TestHelper.UniqueEmail("statsdel"), "SecurePassword123#");
var shockerA = await CreateShocker(user.Id, "A");
var shockerB = await CreateShocker(user.Id, "B");
var bucket = new DateTime(2026, 3, 1, 12, 15, 0, DateTimeKind.Utc);

await using (var scope = WebApplicationFactory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

// Identical keys apart from the shocker, so after SET NULL they would collide on a full unique index
await db.UpsertControlStatsAsync([
Delta(shockerA, user.Id, bucket, count: 2),
Delta(shockerB, user.Id, bucket, count: 3)
]);

await db.Shockers.Where(x => x.Id == shockerA).ExecuteDeleteAsync();
await db.Shockers.Where(x => x.Id == shockerB).ExecuteDeleteAsync();

await Assert.That(await db.ControlStats.CountAsync(x => x.OwnerUserId == user.Id && x.ShockerId == null)).IsEqualTo(2);
}

using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
using var doc = await GetStats(client);

var deleted = doc.RootElement.GetProperty("received").GetProperty("shockers").EnumerateArray().Single();
await Assert.That(deleted.GetProperty("shockerId").ValueKind).IsEqualTo(JsonValueKind.Null);
await Assert.That(FindTotals(deleted.GetProperty("stats"), "Shock", live: false).GetProperty("count").GetInt64()).IsEqualTo(5);
}

[Test]
public async Task Upsert_MergesDuplicateKeysAndAccumulates()
{
var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, TestHelper.UniqueUsername("statsmerge"),
TestHelper.UniqueEmail("statsmerge"), "SecurePassword123#");
var shockerId = await CreateShocker(userId, "Merge");
var bucket = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);

await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

// Same hour, same key twice in one batch, plus a guest (null controller) bucket
await db.UpsertControlStatsAsync([
Delta(shockerId, userId, bucket.AddMinutes(5), count: 1, intensity: 20),
Delta(shockerId, userId, bucket.AddMinutes(50), count: 1, intensity: 80),
Delta(shockerId, null, bucket, count: 4)
]);
await db.UpsertControlStatsAsync([Delta(shockerId, userId, bucket, count: 1, intensity: 10)]);
await db.UpsertControlStatsAsync([Delta(shockerId, null, bucket, count: 1)]);

var rows = await db.ControlStats.AsNoTracking().Where(x => x.ShockerId == shockerId).ToArrayAsync();
await Assert.That(rows.Length).IsEqualTo(2);

var own = rows.Single(x => x.ControllerUserId == userId);
await Assert.That(own.OwnerUserId).IsEqualTo(userId);
await Assert.That(own.Bucket).IsEqualTo(bucket);
await Assert.That(own.Count).IsEqualTo(3);
await Assert.That(own.DurationMs).IsEqualTo(3000);
await Assert.That(own.MaxIntensity).IsEqualTo((byte)80);

await Assert.That(rows.Single(x => x.ControllerUserId == null).Count).IsEqualTo(5);
}

[Test]
public async Task Stats_GroupsDaysInRequestedTimeZone_AndFiltersRange()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statstz"),
TestHelper.UniqueEmail("statstz"), "SecurePassword123#");
var shockerId = await CreateShocker(user.Id, "Tz");

await using (var scope = WebApplicationFactory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
await db.UpsertControlStatsAsync([
// 23:00 UTC on Jan 1 is already Jan 2 in Berlin (UTC+1)
Delta(shockerId, user.Id, new DateTime(2026, 1, 1, 23, 0, 0, DateTimeKind.Utc), count: 1),
Delta(shockerId, user.Id, new DateTime(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc), count: 1)
]);
}

using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);

using (var utc = await GetStats(client))
await Assert.That(Days(utc)).IsEquivalentTo(new[] { "2026-01-01", "2026-01-05" });

using (var berlin = await GetStats(client, "?tz=Europe/Berlin"))
await Assert.That(Days(berlin)).IsEquivalentTo(new[] { "2026-01-02", "2026-01-05" });

using (var ranged = await GetStats(client, "?tz=Europe/Berlin&from=2026-01-02&to=2026-01-02"))
await Assert.That(Days(ranged)).IsEquivalentTo(new[] { "2026-01-02" });
}

[Test]
public async Task Stats_InvalidTimeZoneOrRange_Returns400()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, TestHelper.UniqueUsername("statsbad"),
TestHelper.UniqueEmail("statsbad"), "SecurePassword123#");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);

var badTz = await client.GetAsync("/1/users/self/stats?tz=Not/AZone");
await Assert.That(badTz.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);

var badRange = await client.GetAsync("/1/users/self/stats?from=2026-02-01&to=2026-01-01");
await Assert.That(badRange.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}

[Test]
public async Task Stats_Unauthenticated_Returns401()
{
using var client = WebApplicationFactory.CreateClient();
var response = await client.GetAsync("/1/users/self/stats");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}

private static ControlStatDelta Delta(Guid shockerId, Guid? controllerId, DateTime at, long count, byte intensity = 50) =>
new(shockerId, controllerId, at, ControlType.Shock, Live: false, count, DurationMs: count * 1000,
IntensityMs: count * 1000 * intensity, intensity);

private async Task<Guid> CreateShocker(Guid ownerId, string name)
{
var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, ownerId);

await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var shockerId = Guid.CreateVersion7();
db.Shockers.Add(new Shocker
{
Id = shockerId,
Name = name,
Model = ShockerModelType.CaiXianlin,
RfId = 1234,
DeviceId = deviceId
});
await db.SaveChangesAsync();
return shockerId;
}

private static async Task<JsonDocument> GetStats(HttpClient client, string query = "")
{
var response = await client.GetAsync("/1/users/self/stats" + query);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
return JsonDocument.Parse(await response.Content.ReadAsStringAsync());
}

private static JsonElement FindTotals(JsonElement totals, string type, bool live) => totals.EnumerateArray()
.Single(x => x.GetProperty("type").GetString() == type && x.GetProperty("live").GetBoolean() == live);

private static string[] Days(JsonDocument doc) => doc.RootElement.GetProperty("received").GetProperty("days")
.EnumerateArray().Select(x => x.GetProperty("date").GetString()!).ToArray();
}
Loading
Loading