diff --git a/Refresh.Database/GameDatabaseContext.Contests.cs b/Refresh.Database/GameDatabaseContext.Contests.cs index a7e98a59..8bd91600 100644 --- a/Refresh.Database/GameDatabaseContext.Contests.cs +++ b/Refresh.Database/GameDatabaseContext.Contests.cs @@ -8,15 +8,30 @@ namespace Refresh.Database; public partial class GameDatabaseContext // Contests { - public void CreateContest(GameContest contest) + public GameContest CreateContest(string contestId, ICreateContestInfo createInfo, GameUser organizer, GameLevel? templateLevel = null) { - if (this.GetContestById(contest.ContestId) != null) throw new InvalidOperationException("Contest already exists."); + if (this.GetContestById(contestId) != null) throw new InvalidOperationException("Contest already exists."); - this.Write(() => + GameContest contest = new() { - contest.CreationDate = this._time.Now; - this.GameContests.Add(contest); - }); + ContestId = contestId, + Organizer = organizer, + CreationDate = this._time.Now, + StartDate = createInfo.StartDate!.Value, + EndDate = createInfo.EndDate!.Value, + ContestTitle = createInfo.ContestTitle!, + BannerUrl = createInfo.BannerUrl ?? "", + ContestTag = createInfo.ContestTag ?? $"#{contestId}", + ContestSummary = createInfo.ContestSummary ?? "", + ContestDetails = createInfo.ContestDetails ?? "", + ContestTheme = createInfo.ContestTheme ?? "", + AllowedGames = createInfo.AllowedGames ?? [], + TemplateLevel = templateLevel + }; + + this.GameContests.Add(contest); + this.SaveChanges(); + return contest; } public void DeleteContest(GameContest contest) @@ -57,7 +72,7 @@ public IEnumerable GetAllContests() .FirstOrDefault(); } - public GameContest UpdateContest(ICreateContestInfo body, GameContest contest, GameUser? newOrganizer = null) + public GameContest UpdateContest(ICreateContestInfo body, GameContest contest, GameUser? newOrganizer = null, GameLevel? newTemplate = null) { this.Write(() => { @@ -82,8 +97,9 @@ public GameContest UpdateContest(ICreateContestInfo body, GameContest contest, G contest.ContestTheme = body.ContestTheme; if (body.AllowedGames != null) contest.AllowedGames = body.AllowedGames; - if (body.TemplateLevelId != null) - contest.TemplateLevel = this.GetLevelById((int)body.TemplateLevelId); + + if (newTemplate != null) + contest.TemplateLevel = newTemplate; }); return contest; @@ -92,10 +108,17 @@ public GameContest UpdateContest(ICreateContestInfo body, GameContest contest, G [Pure] public DatabaseList GetLevelsFromContest(GameContest contest, int count, int skip, GameUser? user, LevelFilterSettings levelFilterSettings) { - return new DatabaseList(((IQueryable)this.GetLevelsByGameVersion(levelFilterSettings.GameVersion)).FilterByLevelFilterSettings(user, levelFilterSettings) + IQueryable levels = this.GetLevelsByGameVersion(levelFilterSettings.GameVersion) + .FilterByLevelFilterSettings(user, levelFilterSettings) .Where(l => l.Title.Contains(contest.ContestTag)) - .Where(l => l.PublishDate >= contest.StartDate && l.PublishDate < contest.EndDate) - .AsEnumerable() // This shouldn't be a noticeable performance hit, since levels that aren't for the contest have already been filtered out - .Where(l => contest.AllowedGames.Contains(l.GameVersion)), skip, count); + .Where(l => l.PublishDate >= contest.StartDate && l.PublishDate < contest.EndDate); + + // Allow levels from all games if there are no allowed games specified + if (contest.AllowedGames.Count > 0) + { + levels = levels.Where(l => contest.AllowedGames.Contains(l.GameVersion)); + } + + return new(levels.ToArray(), skip, count); } } \ No newline at end of file diff --git a/Refresh.Database/Models/Contests/GameContest.cs b/Refresh.Database/Models/Contests/GameContest.cs index 1da89f9a..8ce31ec3 100644 --- a/Refresh.Database/Models/Contests/GameContest.cs +++ b/Refresh.Database/Models/Contests/GameContest.cs @@ -67,7 +67,16 @@ public partial class GameContest [CanBeNull] public string ContestTheme { get; set; } + /// + /// Specifies what games the submitted levels must be made in/published from. + /// If empty, all games will be allowed. + /// public IList AllowedGames { get; set; } = []; - [CanBeNull] public GameLevel TemplateLevel { get; set; } + #nullable restore + + /// + /// Optional template level, useful to see what the organizer expects. + /// + public GameLevel? TemplateLevel { get; set; } } \ No newline at end of file diff --git a/Refresh.Database/Query/ICreateContestInfo.cs b/Refresh.Database/Query/ICreateContestInfo.cs index 8219cf90..08233a38 100644 --- a/Refresh.Database/Query/ICreateContestInfo.cs +++ b/Refresh.Database/Query/ICreateContestInfo.cs @@ -4,7 +4,6 @@ namespace Refresh.Database.Query; public interface ICreateContestInfo { - string? OrganizerId { get; set; } DateTimeOffset? StartDate { get; set; } DateTimeOffset? EndDate { get; set; } string? ContestTag { get; set; } @@ -14,5 +13,4 @@ public interface ICreateContestInfo string? ContestDetails { get; set; } string? ContestTheme { get; set; } TokenGame[]? AllowedGames { get; set; } - int? TemplateLevelId { get; set; } } \ No newline at end of file diff --git a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminContestApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminContestApiEndpoints.cs index 2a16a0ef..c6569429 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminContestApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/Admin/AdminContestApiEndpoints.cs @@ -7,6 +7,7 @@ using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Contests; +using Refresh.Database.Models.Levels; using Refresh.Database.Models.Users; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; @@ -18,42 +19,39 @@ namespace Refresh.Interfaces.APIv3.Endpoints.Admin; public class AdminContestApiEndpoints : EndpointGroup { [ApiV3Endpoint("admin/contests/{id}", HttpMethods.Post), MinimumRole(GameUserRole.Admin)] - [DocSummary("Creates a contest.")] + [DocSummary("Creates a contest. You must at least include the contest's title, aswell as its start and end date.")] [DocError(typeof(ApiValidationError), ApiValidationError.ResourceExistsErrorWhen)] - [DocError(typeof(ApiValidationError), ApiValidationError.ObjectIdParseErrorWhen)] - [DocError(typeof(ApiNotFoundError), ApiNotFoundError.UserMissingErrorWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.ContestOrganizerIdParseErrorWhen)] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.ContestOrganizerMissingErrorWhen)] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.TemplateLevelMissingErrorWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.ContestDataMissingErrorWhen)] public ApiResponse CreateContest(RequestContext context, GameDatabaseContext database, ApiContestRequest body, string id, DataContext dataContext) { if (database.GetContestById(id) != null) return ApiValidationError.ResourceExistsError; - bool parsed = ObjectId.TryParse(body.OrganizerId, out ObjectId organizerId); - if (!parsed) - return ApiValidationError.ObjectIdParseError; + bool organizerParsed = ObjectId.TryParse(body.OrganizerId, out ObjectId organizerId); + if (!organizerParsed) + return ApiValidationError.ContestOrganizerIdParseError; GameUser? organizer = database.GetUserByObjectId(organizerId); if (organizer == null) - return ApiNotFoundError.UserMissingError; - - GameContest contest = new() + return ApiNotFoundError.ContestOrganizerMissingError; + + // Allow template level to either be unspecified or valid, but not an ID which is invalid + GameLevel? templateLevel = null; + if (body.TemplateLevelId != null) { - ContestId = id, - Organizer = organizer, - BannerUrl = body.BannerUrl, - ContestTitle = body.ContestTitle, - ContestSummary = body.ContestSummary, - ContestTag = body.ContestTag, - ContestDetails = body.ContestDetails, - StartDate = body.StartDate!.Value, - EndDate = body.EndDate!.Value, - ContestTheme = body.ContestTheme, - AllowedGames = body.AllowedGames, - TemplateLevel = body.TemplateLevelId != null ? database.GetLevelById((int)body.TemplateLevelId) : null, - }; - - database.CreateContest(contest); - + templateLevel = database.GetLevelById(body.TemplateLevelId.Value); + if (templateLevel == null) + return ApiNotFoundError.TemplateLevelMissingError; + } + + if (body.StartDate == null || body.EndDate == null || body.ContestTitle == null) + return ApiValidationError.ContestDataMissingError; + + GameContest contest = database.CreateContest(id, body, organizer, templateLevel); return ApiContestResponse.FromOld(contest, dataContext); } diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiNotFoundError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiNotFoundError.cs index 5c80f894..b8c6b437 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiNotFoundError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiNotFoundError.cs @@ -28,6 +28,12 @@ public class ApiNotFoundError : ApiError public const string ContestMissingErrorWhen = "The contest could not be found"; public static readonly ApiNotFoundError ContestMissingError = new(ContestMissingErrorWhen); + public const string ContestOrganizerMissingErrorWhen = "The contest organizer could not be found"; + public static readonly ApiNotFoundError ContestOrganizerMissingError = new(ContestOrganizerMissingErrorWhen); + + public const string TemplateLevelMissingErrorWhen = "The template level specified by ID could not be found"; + public static readonly ApiNotFoundError TemplateLevelMissingError = new(TemplateLevelMissingErrorWhen); + public const string VerifiedIpMissingErrorWhen = "The verified IP could not be found"; public static readonly ApiNotFoundError VerifiedIpMissingError = new(VerifiedIpMissingErrorWhen); diff --git a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs index f9812d10..e7371b47 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ApiTypes/Errors/ApiValidationError.cs @@ -64,6 +64,12 @@ public class ApiValidationError : ApiError public const string BadUserLookupIdTypeWhen = "The ID type used to specify the user is not supported"; public static readonly ApiValidationError BadUserLookupIdType = new(BadUserLookupIdTypeWhen); + + public const string ContestOrganizerIdParseErrorWhen = "The organizer's user ID could not be parsed by the server"; + public static readonly ApiValidationError ContestOrganizerIdParseError = new(ContestOrganizerIdParseErrorWhen); + + public const string ContestDataMissingErrorWhen = "The contest must at least have a title, aswell as a start and end date specified"; + public static readonly ApiValidationError ContestDataMissingError = new(ContestDataMissingErrorWhen); public ApiValidationError(string message) : base(message) {} -} \ No newline at end of file +} diff --git a/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs b/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs index 97aef0cb..e3dd0992 100644 --- a/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs +++ b/Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs @@ -6,6 +6,7 @@ using Refresh.Core.Types.Data; using Refresh.Database; using Refresh.Database.Models.Contests; +using Refresh.Database.Models.Levels; using Refresh.Database.Models.Users; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.ApiTypes.Errors; @@ -41,6 +42,9 @@ public ApiResponse GetContest(RequestContext context, GameDa [DocSummary("Allows an admin/organizer to update their contest details")] [DocError(typeof(ApiNotFoundError), ApiNotFoundError.ContestMissingErrorWhen)] [DocError(typeof(ApiAuthenticationError), ApiAuthenticationError.NoPermissionsForObjectWhen)] + [DocError(typeof(ApiValidationError), ApiValidationError.ContestOrganizerIdParseErrorWhen)] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.ContestOrganizerMissingErrorWhen)] + [DocError(typeof(ApiNotFoundError), ApiNotFoundError.TemplateLevelMissingErrorWhen)] public ApiResponse UpdateContest(RequestContext context, GameDatabaseContext database, string id, ApiContestRequest body, GameUser user, DataContext dataContext) { @@ -50,16 +54,29 @@ public ApiResponse UpdateContest(RequestContext context, Gam if (!Equals(user, contest.Organizer) && user.Role != GameUserRole.Admin) return ApiAuthenticationError.NoPermissionsForObject; - bool parsed = ObjectId.TryParse(body.OrganizerId, out ObjectId organizerId); - if (!parsed) - return ApiValidationError.ObjectIdParseError; - - GameUser? organizer = database.GetUserByObjectId(organizerId); - if (organizer == null) - return ApiNotFoundError.UserMissingError; - - database.UpdateContest(body, contest, organizer); - - return ApiContestResponse.FromOld(contest, dataContext); + GameUser? newOrganizer = null; + if (body.OrganizerId != null) + { + bool organizerParsed = ObjectId.TryParse(body.OrganizerId, out ObjectId organizerId); + if (!organizerParsed) + return ApiValidationError.ContestOrganizerIdParseError; + + GameUser? organizer = database.GetUserByObjectId(organizerId); + if (organizer == null) + return ApiNotFoundError.ContestOrganizerMissingError; + + newOrganizer = database.GetUserByObjectId(organizerId); + } + + GameLevel? newTemplate = null; + if (body.TemplateLevelId != null) + { + newTemplate = database.GetLevelById(body.TemplateLevelId.Value); + if (newTemplate == null) + return ApiNotFoundError.TemplateLevelMissingError; + } + + GameContest updatedContest = database.UpdateContest(body, contest, newOrganizer, newTemplate); + return ApiContestResponse.FromOld(updatedContest, dataContext); } } \ No newline at end of file diff --git a/RefreshTests.GameServer/Tests/Levels/ContestTests.cs b/RefreshTests.GameServer/Tests/Levels/ContestTests.cs index fb7fb657..c2422567 100644 --- a/RefreshTests.GameServer/Tests/Levels/ContestTests.cs +++ b/RefreshTests.GameServer/Tests/Levels/ContestTests.cs @@ -8,6 +8,7 @@ using Refresh.Interfaces.APIv3.Endpoints.ApiTypes; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Request; using Refresh.Interfaces.APIv3.Endpoints.DataTypes.Response; +using MongoDB.Bson; namespace RefreshTests.GameServer.Tests.Levels; @@ -22,27 +23,21 @@ public class ContestTests : GameServerTest private GameContest CreateContest(TestContext context, GameUser? organizer = null, string id = "ut", TokenGame[]? allowedGames = null) { organizer ??= context.CreateUser(); - allowedGames ??= this._allowedGames; GameLevel templateLevel = context.CreateLevel(organizer); - GameContest contest = new() + GameContest contest = context.Database.CreateContest(id, new ApiContestRequest { - ContestId = id, - Organizer = organizer, + OrganizerId = organizer.UserId.ToString(), BannerUrl = Banner, ContestTitle = "unit test", ContestSummary = "summary", ContestTag = "#ut", ContestDetails = "# unit test", - CreationDate = context.Time.Now, StartDate = context.Time.Now, EndDate = context.Time.Now + TimeSpan.FromMilliseconds(10), ContestTheme = "good level", - TemplateLevel = templateLevel, AllowedGames = allowedGames, - }; - - context.Database.CreateContest(contest); + }, organizer, templateLevel); return contest; } @@ -57,22 +52,19 @@ public void CanCreateContest() Assert.That(() => { // ReSharper disable once AccessToDisposedClosure - context.Database.CreateContest(new GameContest + context.Database.CreateContest("contest", new ApiContestRequest { - ContestId = "contest", - Organizer = organizer, + OrganizerId = organizer.UserId.ToString(), BannerUrl = Banner, ContestTitle = "The Contest Contest", ContestSummary = "a contest about contests", ContestTag = "#cc1", ContestDetails = "# test", - CreationDate = DateTimeOffset.FromUnixTimeMilliseconds(0), StartDate = DateTimeOffset.FromUnixTimeMilliseconds(1), EndDate = DateTimeOffset.FromUnixTimeMilliseconds(2), ContestTheme = "good level", - TemplateLevel = templateLevel, AllowedGames = this._allowedGames, - }); + }, organizer, templateLevel); }, Throws.Nothing); GameContest? contest = context.Database.GetContestById("contest"); @@ -81,6 +73,28 @@ public void CanCreateContest() Assert.That(contest.Organizer, Is.EqualTo(organizer)); } + [Test] + public void CanCreateContestWithLessData() + { + using TestContext context = this.GetServer(false); + GameUser organizer = context.CreateUser(); + + Assert.That(() => + { + // ReSharper disable once AccessToDisposedClosure + context.Database.CreateContest("minicontest", new ApiContestRequest + { + OrganizerId = organizer.UserId.ToString(), + ContestTitle = "The Lazy Contest", + StartDate = DateTimeOffset.FromUnixTimeMilliseconds(1), + EndDate = DateTimeOffset.FromUnixTimeMilliseconds(2), + }, organizer, null); + }, Throws.Nothing); + + GameContest? contest = context.Database.GetContestById("minicontest"); + Assert.That(contest, Is.Not.Null); + } + [Test] public void CanCreateContestFromApi() { @@ -112,6 +126,61 @@ public void CanCreateContestFromApi() Assert.That(createdContest, Is.Not.Null); Assert.That(createdContest!.Organizer, Is.EqualTo(organizer)); } + + [Test] + public void CannotCreateContestIfOrganizerIsInvalid() + { + using TestContext context = this.GetServer(); + GameUser admin = context.CreateUser(role: GameUserRole.Admin); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, admin); + + // Try uploading a contest with an invalid organizer UUID + ApiContestRequest request = new() + { + OrganizerId = "hi", + StartDate = context.Time.Now + TimeSpan.FromHours(1), + EndDate = context.Time.Now + TimeSpan.FromHours(2), + ContestTag = "#cbt2", + ContestTitle = "The You-Know-What Contest #2", + ContestSummary = "We're not done yet", + ContestDetails = "Wikipedia", + ContestTheme = "good level", + }; + + ApiResponse? response = client.PostData("/api/v3/admin/contests/cbt2", request, false, true); + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(BadRequest)); + + // Now try creating one with a valid UUID, but there is no user with this UUID + request.OrganizerId = ObjectId.GenerateNewId().ToString(); + response = client.PostData("/api/v3/admin/contests/cbt3", request, false, true); + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(NotFound)); + } + + [Test] + public void CannotCreateContestIfTemplateIsInvalid() + { + using TestContext context = this.GetServer(); + GameUser admin = context.CreateUser(role: GameUserRole.Admin); + + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, admin); + ApiResponse? response = client.PostData("/api/v3/admin/contests/oc1", new ApiContestRequest + { + OrganizerId = ObjectId.GenerateNewId().ToString(), + StartDate = context.Time.Now + TimeSpan.FromHours(1), + EndDate = context.Time.Now + TimeSpan.FromHours(2), + ContestTag = "#oc1", + ContestTitle = "Unoriginality Contest", + ContestSummary = "Be unoriginal, just like this contest!", + ContestDetails = "Submit bomb survivals", + ContestTheme = "unoriginal level", + TemplateLevelId = 7865432, + }, false, true); + + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(NotFound)); + } [Test] public void CanUpdateContestFromApi() @@ -138,7 +207,54 @@ public void CanUpdateContestFromApi() } [Test] - public void UpdateApiIsSecure() + public void CannotUpdateContestToInvalidOrganizer() + { + using TestContext context = this.GetServer(); + GameUser admin = context.CreateUser(role: GameUserRole.Admin); + GameContest contest = this.CreateContest(context); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, admin); + + // Try setting the organizer to an invalid UUID + ApiResponse? response = client.PatchData("/api/v3/contests/ut", new ApiContestRequest + { + OrganizerId = "hi", + ContestTag = "#ut2", + }, false, true); + + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(BadRequest)); + + // Now update it to have a valid UUID which doesn't exist + response = client.PatchData("/api/v3/contests/ut", new ApiContestRequest + { + OrganizerId = ObjectId.GenerateNewId().ToString(), + ContestTag = "#ut2", + }, false, true); + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(NotFound)); + } + + [Test] + public void CannotUpdateContestToInvalidTemplate() + { + using TestContext context = this.GetServer(); + GameUser admin = context.CreateUser(role: GameUserRole.Admin); + GameContest contest = this.CreateContest(context); + using HttpClient client = context.GetAuthenticatedClient(TokenType.Api, admin); + + // Try setting the organizer to an invalid UUID + ApiResponse? response = client.PatchData("/api/v3/contests/ut", new ApiContestRequest + { + TemplateLevelId = 98765443, + ContestTag = "#ut2", + }, false, true); + + Assert.That(response?.Error, Is.Not.Null); + Assert.That(response!.Error!.StatusCode, Is.EqualTo(NotFound)); + } + + [Test] + public void RandomUsersCannotUpdateContest() { using TestContext context = this.GetServer(); GameContest contest = this.CreateContest(context); @@ -190,6 +306,29 @@ public void LevelsShowUpInCategory() Assert.That(levels.Items.All(l => l.Title.Contains("#ut "))); } + [Test] + public void LevelsShowUpInGamelessCategory() + { + using TestContext context = this.GetServer(false); + GameUser user = context.CreateUser(); + + context.Time.TimestampMilliseconds = 500; + GameContest contest = this.CreateContest(context, null); + + // test levels from various games + context.CreateLevel(user, "#ut level 1", TokenGame.LittleBigPlanet2); + context.CreateLevel(user, "level #ut 2", TokenGame.LittleBigPlanet3); + context.CreateLevel(user, "#ut best level 3", TokenGame.LittleBigPlanetPSP); + context.CreateLevel(user, "#ut this is the best one!", TokenGame.BetaBuild); + + // test level without tag + context.CreateLevel(user, "level 6", TokenGame.LittleBigPlanet2); + + DatabaseList levels = context.Database.GetLevelsFromContest(contest, 10, 0, user, new(TokenGame.Website)); + Assert.That(levels.TotalItems, Is.EqualTo(4)); + Assert.That(levels.Items.All(l => l.Title.Contains("#ut "))); + } + [Test] public void NewestContestIsCorrectOne() {