Skip to content
Merged
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
68 changes: 64 additions & 4 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +30,7 @@ jobs:
HANGFIRE_PASSWORD: root
REDIS_HOST: localhost
REDIS_PORT: 6339

steps:
- uses: actions/checkout@v4
- name: Setup .NET
Expand All @@ -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
22 changes: 22 additions & 0 deletions Sunrise.API/Controllers/UserController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,28 @@ public async Task<IActionResult> 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<IActionResult> 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")]
Expand Down
39 changes: 39 additions & 0 deletions Sunrise.API/Serializable/Response/PlayHistorySnapshotsResponse.cs
Original file line number Diff line number Diff line change
@@ -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<DateTime, int> 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<PlayHistorySnapshotResponse> 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; }
}
Original file line number Diff line number Diff line change
@@ -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<ProblemDetails>();
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<ProblemDetails>();
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<PlayHistorySnapshotsResponse>();
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<PlayHistorySnapshotsResponse>();
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<PlayHistorySnapshotsResponse>();
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<PlayHistorySnapshotsResponse>();
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<PlayHistorySnapshotsResponse>();
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<PlayHistorySnapshotsResponse>();
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);
}
}
18 changes: 18 additions & 0 deletions Sunrise.Shared/Database/Repositories/ScoreRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,24 @@ public async Task<Result> MarkScoreAsDeleted(Score score)
return (scores, totalCount);
}

public async Task<Dictionary<DateTime, int>> 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<Score>, int)> GetScores(GameMode? mode = null, QueryOptions? options = null, int? startFromId = null, CancellationToken ct = default)
{
var scoresQuery = dbContext.Scores.FilterValidScores();
Expand Down
Loading