diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e243ed55..e3cf013a 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -5,9 +5,14 @@ concurrency: cancel-in-progress: true jobs: - build: - name: Build and Test + test: + name: Test Group ${{ matrix.group }} 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,6 +30,7 @@ jobs: HANGFIRE_PASSWORD: root REDIS_HOST: localhost REDIS_PORT: 6339 + steps: - uses: actions/checkout@v4 - name: Setup .NET @@ -43,5 +49,59 @@ jobs: 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[@]} + + echo "Found $TOTAL test files total" + + # 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 + echo " - $CLASS" + 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 "" + 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 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.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); + } +} 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();