From ee19c09c78fe80e6ee65b2875e13be444dc06575 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:12:06 +0200 Subject: [PATCH 1/4] feat: Add GetUserPlayHistoryGraphData --- Sunrise.API/Controllers/UserController.cs | 22 +++++++++++ .../Response/PlayHistorySnapshotsResponse.cs | 39 +++++++++++++++++++ ...tResponse.cs => StatsSnapshotsResponse.cs} | 0 .../Database/Repositories/ScoreRepository.cs | 18 +++++++++ 4 files changed, 79 insertions(+) create mode 100644 Sunrise.API/Serializable/Response/PlayHistorySnapshotsResponse.cs rename Sunrise.API/Serializable/Response/{StatsSnapshotResponse.cs => StatsSnapshotsResponse.cs} (100%) diff --git a/Sunrise.API/Controllers/UserController.cs b/Sunrise.API/Controllers/UserController.cs index 4835bfa3..d68c358b 100644 --- a/Sunrise.API/Controllers/UserController.cs +++ b/Sunrise.API/Controllers/UserController.cs @@ -283,6 +283,28 @@ public async Task GetUserGraphData([Range(1, int.MaxValue)] int u return Ok(new StatsSnapshotsResponse(snapshots)); } + + [HttpGet] + [Route("{userId:int}/play-history-graph")] + [EndpointDescription("Get user play history graph data")] + [ResponseCache(VaryByHeader = "Authorization", Duration = 300)] + [ProducesResponseType(typeof(ProblemDetailsResponseType), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(PlayHistorySnapshotsResponse), StatusCodes.Status200OK)] + public async Task GetUserPlayHistoryGraphData([Range(1, int.MaxValue)] int userId, CancellationToken ct = default) + { + var user = await database.Users.GetUser(userId, ct: ct); + + if (user == null) + return Problem(ApiErrorResponse.Detail.UserNotFound, statusCode: StatusCodes.Status404NotFound); + + if (user.IsRestricted()) + return Problem(ApiErrorResponse.Detail.UserIsRestricted, statusCode: StatusCodes.Status404NotFound); + + var playHistorySnapshots = await database.Scores.GetUserPlayHistoryScores(userId, ct); + + return Ok(new PlayHistorySnapshotsResponse(playHistorySnapshots)); + } + [HttpGet] [Route("{id:int}/scores")] [EndpointDescription("Get user scores")] diff --git a/Sunrise.API/Serializable/Response/PlayHistorySnapshotsResponse.cs b/Sunrise.API/Serializable/Response/PlayHistorySnapshotsResponse.cs new file mode 100644 index 00000000..ef32d4d6 --- /dev/null +++ b/Sunrise.API/Serializable/Response/PlayHistorySnapshotsResponse.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; +using Sunrise.Shared.Utils.Converters; + +namespace Sunrise.API.Serializable.Response; + +public class PlayHistorySnapshotsResponse +{ + public PlayHistorySnapshotsResponse(Dictionary snapshots) + { + Snapshots = snapshots.Select(x => new PlayHistorySnapshotResponse + { + PlayCount = x.Value, + SavedAt = x.Key + }).ToList(); + + TotalCount = snapshots.Count; + } + + [JsonConstructor] + public PlayHistorySnapshotsResponse() + { + } + + [JsonPropertyName("total_count")] + public int TotalCount { get; set; } + + [JsonPropertyName("snapshots")] + public List Snapshots { get; set; } +} + +public class PlayHistorySnapshotResponse +{ + [JsonPropertyName("play_count")] + public int PlayCount { get; set; } + + [JsonPropertyName("saved_at")] + [JsonConverter(typeof(DateTimeWithTimezoneConverter))] + public DateTime SavedAt { get; set; } +} \ No newline at end of file diff --git a/Sunrise.API/Serializable/Response/StatsSnapshotResponse.cs b/Sunrise.API/Serializable/Response/StatsSnapshotsResponse.cs similarity index 100% rename from Sunrise.API/Serializable/Response/StatsSnapshotResponse.cs rename to Sunrise.API/Serializable/Response/StatsSnapshotsResponse.cs diff --git a/Sunrise.Shared/Database/Repositories/ScoreRepository.cs b/Sunrise.Shared/Database/Repositories/ScoreRepository.cs index c0873324..8528c9a0 100644 --- a/Sunrise.Shared/Database/Repositories/ScoreRepository.cs +++ b/Sunrise.Shared/Database/Repositories/ScoreRepository.cs @@ -221,6 +221,24 @@ public async Task MarkScoreAsDeleted(Score score) return (scores, totalCount); } + public async Task> GetUserPlayHistoryScores(int userId, CancellationToken ct = default) + { + return await dbContext.Scores + .FilterValidScores() + .Where(s => s.UserId == userId) + .GroupBy(s => new + { + s.WhenPlayed.Year, + s.WhenPlayed.Month + }) + .Select(g => new + { + Date = new DateTime(g.Key.Year, g.Key.Month, 1, 0, 0, 0, DateTimeKind.Utc), + Count = g.Count() + }) + .ToDictionaryAsync(x => x.Date, x => x.Count, ct); + } + public async Task<(List, int)> GetScores(GameMode? mode = null, QueryOptions? options = null, int? startFromId = null, CancellationToken ct = default) { var scoresQuery = dbContext.Scores.FilterValidScores(); From 1903269acc1b3c4bc450585fd25f0f2e63f0eb7f Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:20:46 +0200 Subject: [PATCH 2/4] feat: add tests --- .../ApiUserGetUserPlayHistoryGraphTests.cs | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 Sunrise.Server.Tests/API/UserController/ApiUserGetUserPlayHistoryGraphTests.cs diff --git a/Sunrise.Server.Tests/API/UserController/ApiUserGetUserPlayHistoryGraphTests.cs b/Sunrise.Server.Tests/API/UserController/ApiUserGetUserPlayHistoryGraphTests.cs new file mode 100644 index 00000000..ef869b9e --- /dev/null +++ b/Sunrise.Server.Tests/API/UserController/ApiUserGetUserPlayHistoryGraphTests.cs @@ -0,0 +1,205 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Sunrise.API.Objects.Keys; +using Sunrise.API.Serializable.Response; +using Sunrise.Shared.Enums.Beatmaps; +using Sunrise.Tests.Abstracts; +using Sunrise.Tests.Extensions; +using Sunrise.Tests.Services.Mock; +using Sunrise.Tests.Utils; +using Sunrise.Tests; +using SubmissionStatus = Sunrise.Shared.Enums.Scores.SubmissionStatus; + +namespace Sunrise.Server.Tests.API.UserController; + +[Collection("Integration tests collection")] +public class ApiUserGetUserPlayHistoryGraphTests(IntegrationDatabaseFixture fixture) : ApiTest(fixture) +{ + private readonly MockService _mocker = new(); + + [Fact] + public async Task TestGetUserPlayHistoryGraphUserNotFound() + { + var client = App.CreateClient().UseClient("api"); + var userId = _mocker.GetRandomInteger(); + + var response = await client.GetAsync($"user/{userId}/play-history-graph"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var responseContent = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Detail.UserNotFound, responseContent?.Detail); + } + + [Theory] + [InlineData("-1")] + [InlineData("test")] + public async Task TestGetUserPlayHistoryGraphInvalidRoute(string userId) + { + var client = App.CreateClient().UseClient("api"); + + var response = await client.GetAsync($"user/{userId}/play-history-graph"); + + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphRestrictedUserNotFound() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + await Database.Users.Moderation.RestrictPlayer(user.Id, null, "Test"); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var responseContent = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.Contains(ApiErrorResponse.Detail.UserIsRestricted, responseContent?.Detail); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphReturnsEmptyWhenNoScores() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(data); + Assert.Equal(0, data.TotalCount); + + Assert.NotNull(data.Snapshots); + Assert.Empty(data.Snapshots); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphScoreLastSecondDecemberCountedAsDecember() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + var lastSecondDecemberUtc = new DateTime(2024, 12, 31, 23, 59, 59, DateTimeKind.Utc); + await AddValidScoreForUser(user.Id, lastSecondDecemberUtc, beatmapIdSeed: 1); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(data); + Assert.True(data.TotalCount >= 1, "Expected at least one snapshot (December 2024)"); + + var decemberSnapshot = data.Snapshots.FirstOrDefault(s => + s.SavedAt is { Year: 2024, Month: 12 }); + Assert.NotNull(decemberSnapshot); + Assert.True(decemberSnapshot.PlayCount >= 1, "Score played on last second of December must be counted in December"); + + var januarySnapshot = data.Snapshots.FirstOrDefault(s => + s.SavedAt is { Year: 2025, Month: 1 }); + Assert.True(januarySnapshot == null || januarySnapshot.PlayCount == 0, + "Score on last second of December must not be counted as January"); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphScoreFirstSecondJanuaryCountedAsJanuary() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + var firstSecondJanuaryUtc = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + await AddValidScoreForUser(user.Id, firstSecondJanuaryUtc, beatmapIdSeed: 2); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(data); + Assert.True(data.TotalCount >= 1, "Expected at least one snapshot (January 2025)"); + + var januarySnapshot = data.Snapshots.FirstOrDefault(s => + s.SavedAt is { Year: 2025, Month: 1 }); + Assert.NotNull(januarySnapshot); + Assert.True(januarySnapshot.PlayCount >= 1, "Score played on first second of January must be counted in January"); + + var decemberSnapshot = data.Snapshots.FirstOrDefault(s => + s.SavedAt is { Year: 2024, Month: 12 }); + Assert.True(decemberSnapshot == null || decemberSnapshot.PlayCount == 0, + "Score on first second of January must not be counted as December"); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphMultipleScoresDifferentMonths() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + await AddValidScoreForUser(user.Id, new DateTime(2024, 3, 15, 12, 0, 0, DateTimeKind.Utc), beatmapIdSeed: 10); + await AddValidScoreForUser(user.Id, new DateTime(2024, 6, 20, 12, 0, 0, DateTimeKind.Utc), beatmapIdSeed: 20); + await AddValidScoreForUser(user.Id, new DateTime(2024, 9, 1, 12, 0, 0, DateTimeKind.Utc), beatmapIdSeed: 30); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(data); + Assert.Equal(3, data.TotalCount); + + Assert.Equal(1, data.Snapshots.First(s => s.SavedAt is { Month: 3, Year: 2024 }).PlayCount); + Assert.Equal(1, data.Snapshots.First(s => s.SavedAt is { Month: 6, Year: 2024 }).PlayCount); + Assert.Equal(1, data.Snapshots.First(s => s.SavedAt is { Month: 9, Year: 2024 }).PlayCount); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphMultipleScoresSameMonthAggregated() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + await AddValidScoreForUser(user.Id, new DateTime(2024, 5, 1, 0, 0, 0, DateTimeKind.Utc), beatmapIdSeed: 40); + await AddValidScoreForUser(user.Id, new DateTime(2024, 5, 15, 12, 0, 0, DateTimeKind.Utc), beatmapIdSeed: 41); + await AddValidScoreForUser(user.Id, new DateTime(2024, 5, 31, 23, 59, 59, DateTimeKind.Utc), beatmapIdSeed: 42); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(data); + Assert.Equal(1, data.TotalCount); + + var maySnapshot = data.Snapshots.Single(s => s.SavedAt is { Month: 5, Year: 2024 }); + Assert.Equal(3, maySnapshot.PlayCount); + } + + [Fact] + public async Task TestGetUserPlayHistoryGraphReturnsCorrectStructure() + { + var client = App.CreateClient().UseClient("api"); + var user = await CreateTestUser(); + await AddValidScoreForUser(user.Id, new DateTime(2024, 7, 1, 0, 0, 0, DateTimeKind.Utc), beatmapIdSeed: 50); + + var response = await client.GetAsync($"user/{user.Id}/play-history-graph"); + + response.EnsureSuccessStatusCode(); + var data = await response.Content.ReadFromJsonAsyncWithAppConfig(); + Assert.NotNull(data); + Assert.True(data.TotalCount >= 1); + Assert.NotNull(data.Snapshots); + Assert.True(data.Snapshots.Count >= 1); + var snapshot = data.Snapshots.First(); + Assert.True(snapshot.PlayCount >= 1); + Assert.True(snapshot.SavedAt is { Year: >= 2024, Month: >= 1 }); + } + + private async Task AddValidScoreForUser(int userId, DateTime whenPlayedUtc, int beatmapIdSeed) + { + var score = _mocker.Score.GetBestScoreableRandomScore(); + score.UserId = userId; + score.WhenPlayed = whenPlayedUtc; + score.BeatmapId = Math.Abs(userId * 1000 + beatmapIdSeed); + score.ScoreHash = _mocker.GetRandomString(32); + score.BeatmapStatus = BeatmapStatus.Ranked; + score.SubmissionStatus = SubmissionStatus.Best; + score.IsScoreable = true; + score.IsPassed = true; + score.WhenPlayed = score.WhenPlayed.ToDatabasePrecision(); + score.ClientTime = score.ClientTime.ToDatabasePrecision(); + + await Database.Scores.AddScore(score); + } +} From f441ea92c3d5f8be1ac0e2d7c4bb81affe826ae4 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:37:00 +0200 Subject: [PATCH 3/4] feat: try to refactor tests workflow --- .github/workflows/dotnet.yml | 77 ++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e243ed55..ddc9668b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -6,8 +6,36 @@ concurrency: jobs: build: - name: Build and Test + name: Build runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + **/bin + **/obj + retention-days: 1 + + test: + name: Test Group ${{ matrix.group }} + needs: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + group: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + env: ASPNETCORE_ENVIRONMENT: Tests WEB_DOMAIN: sunrise.local @@ -25,12 +53,17 @@ jobs: HANGFIRE_PASSWORD: root REDIS_HOST: localhost REDIS_PORT: 6339 + steps: - uses: actions/checkout@v4 - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-output - name: Docker compose test environment run: docker compose -f docker-compose.tests.yml up -d - name: Wait for MySQL to be ready @@ -39,9 +72,39 @@ jobs: echo "Waiting for MySQL..." sleep 5 done - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Test - run: dotnet test -m:1 --no-build --verbosity normal --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" + - name: Run tests (Group ${{ matrix.group }}/16) + env: + TEST_GROUP: ${{ matrix.group }} + TEST_TOTAL_GROUPS: 16 + run: | + # Find all test class files + TEST_FILES=$(find . -path "*.Tests/*.cs" -name "*Tests.cs" | sort) + + # Convert to array + readarray -t FILES_ARRAY <<< "$TEST_FILES" + TOTAL=${#FILES_ARRAY[@]} + + # Build filter for this group's test classes + FILTER="" + for i in "${!FILES_ARRAY[@]}"; do + if [ $(( (i % TEST_TOTAL_GROUPS) + 1 )) -eq $TEST_GROUP ]; then + FILE="${FILES_ARRAY[$i]}" + # Extract class name from filename + CLASS=$(basename "$FILE" .cs) + if [ -n "$CLASS" ]; then + if [ -z "$FILTER" ]; then + FILTER="FullyQualifiedName~$CLASS" + else + FILTER="$FILTER|FullyQualifiedName~$CLASS" + fi + fi + fi + done + + if [ -z "$FILTER" ]; then + echo "No tests assigned to group $TEST_GROUP" + exit 0 + fi + + echo "Group $TEST_GROUP/$TEST_TOTAL_GROUPS - Running: $FILTER" + dotnet test --no-build --verbosity normal --filter "$FILTER" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" From 0452a1f95a44fa4daa73eefbc2844b60526b4d3e Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:44:14 +0200 Subject: [PATCH 4/4] feat: try to refactor tests workflow --- .github/workflows/dotnet.yml | 55 +++++++++++++++++------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index ddc9668b..e3cf013a 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,31 +5,8 @@ concurrency: cancel-in-progress: true jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: build-output - path: | - **/bin - **/obj - retention-days: 1 - test: name: Test Group ${{ matrix.group }} - needs: build runs-on: ubuntu-latest strategy: fail-fast: false @@ -60,10 +37,6 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: build-output - name: Docker compose test environment run: docker compose -f docker-compose.tests.yml up -d - name: Wait for MySQL to be ready @@ -72,6 +45,10 @@ jobs: echo "Waiting for MySQL..." sleep 5 done + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore - name: Run tests (Group ${{ matrix.group }}/16) env: TEST_GROUP: ${{ matrix.group }} @@ -84,6 +61,8 @@ jobs: readarray -t FILES_ARRAY <<< "$TEST_FILES" TOTAL=${#FILES_ARRAY[@]} + echo "Found $TOTAL test files total" + # Build filter for this group's test classes FILTER="" for i in "${!FILES_ARRAY[@]}"; do @@ -92,6 +71,7 @@ jobs: # Extract class name from filename CLASS=$(basename "$FILE" .cs) if [ -n "$CLASS" ]; then + echo " - $CLASS" if [ -z "$FILTER" ]; then FILTER="FullyQualifiedName~$CLASS" else @@ -106,5 +86,22 @@ jobs: exit 0 fi - echo "Group $TEST_GROUP/$TEST_TOTAL_GROUPS - Running: $FILTER" - dotnet test --no-build --verbosity normal --filter "$FILTER" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" + echo "" + echo "Group $TEST_GROUP/$TEST_TOTAL_GROUPS running with filter:" + echo "$FILTER" + echo "" + + FAILED=0 + + echo "=== Running Sunrise.Shared.Tests ===" + if ! dotnet test Sunrise.Shared.Tests/Sunrise.Shared.Tests.csproj --no-build --verbosity normal --filter "$FILTER" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"; then + FAILED=1 + fi + + echo "" + echo "=== Running Sunrise.Server.Tests ===" + if ! dotnet test Sunrise.Server.Tests/Sunrise.Server.Tests.csproj --no-build --verbosity normal --filter "$FILTER" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"; then + FAILED=1 + fi + + exit $FAILED