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
16 changes: 13 additions & 3 deletions Tests/E2E/fixtures/api-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,14 @@ export async function setupApiMocks(page: Page, options: ApiMockOptions = {}): P
return;
}

// ── Game status update (both apps call this on start/pause/resume) ────────
if (path.includes('/status/') && method === 'PATCH') {
// ── Game status: validate (POST /status/validate) ─────────────────────────
if (path.includes('/status/validate') && method === 'POST') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ isValid: true, errors: [] }) });
return;
}

// ── Game status: pause / resume / abandon / complete ──────────────────────
if (path.includes('/status/') && method === 'POST') {
await route.fulfill({ status: 204 });
return;
}
Expand Down Expand Up @@ -152,7 +158,11 @@ export async function setupApiMocks(page: Page, options: ApiMockOptions = {}): P

// ── Create game: POST /games/{difficulty} (difficulty is all alpha) ───────
if (/\/games\/[A-Za-z]+$/.test(path) && method === 'POST') {
await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(newGame) });
currentGame = newGame;
await route.fulfill({
status: 201,
headers: { Location: `/api/players/${TEST_ALIAS}/games/${newGame.id}` },
});
return;
}

Expand Down
16 changes: 9 additions & 7 deletions scripts/set-app-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ push_production() {
# -------------------------------------------------------------------------
set_key "$LABEL" "UseCosmosDb" "true"
set_key "$LABEL" "CosmosDb:DatabaseName" "sudoku"
set_key "$LABEL" "CosmosDb:ContainerName" "games"
set_key "$LABEL" "CosmosDb:DisableSslValidation" "false"
set_key "$LABEL" "CosmosDb:AutoCreateContainers" "false"
set_key "$LABEL" "CosmosDb:UseManagedIdentity" "true"
set_key "$LABEL" "CosmosDb:AccountEndpoint" "https://cosmos-sudoku-prod.documents.azure.com:443/"
set_key "$LABEL" "CosmosDb:ConnectionMode" "Direct"
Expand Down Expand Up @@ -107,12 +108,13 @@ push_development() {
# -------------------------------------------------------------------------
# Cosmos DB (same account as prod, separate container for dev data)
# -------------------------------------------------------------------------
set_key "$LABEL" "UseCosmosDb" "true"
set_key "$LABEL" "CosmosDb:DatabaseName" "sudoku-dev"
set_key "$LABEL" "CosmosDb:ContainerName" "games"
set_key "$LABEL" "CosmosDb:UseManagedIdentity" "true"
set_key "$LABEL" "CosmosDb:AccountEndpoint" "https://cosmos-sudoku-prod.documents.azure.com:443/"
set_key "$LABEL" "CosmosDb:ConnectionMode" "Direct"
set_key "$LABEL" "UseCosmosDb" "true"
set_key "$LABEL" "CosmosDb:DatabaseName" "sudoku"
set_key "$LABEL" "CosmosDb:DisableSslValidation" "true"
set_key "$LABEL" "CosmosDb:AutoCreateContainers" "true"
set_key "$LABEL" "CosmosDb:UseManagedIdentity" "false"
set_key "$LABEL" "CosmosDb:AccountEndpoint" "https://localhost:8081/"
set_key "$LABEL" "CosmosDb:ConnectionMode" "Gateway"

# -------------------------------------------------------------------------
# Azure Storage
Expand Down
17 changes: 9 additions & 8 deletions src/backend/Sudoku.Api/Controllers/BaseGameController.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using Microsoft.AspNetCore.Mvc;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Sudoku.Application.Common;
using Sudoku.Application.DTOs;
using Sudoku.Application.Interfaces;
using Sudoku.Application.Queries;

namespace Sudoku.Api.Controllers;

public abstract class BaseGameController(IGameApplicationService gameService) : ControllerBase
public abstract class BaseGameController(IMediator mediator) : ControllerBase
{
protected IGameApplicationService GameService => gameService;
protected IMediator Mediator => mediator;

protected async Task<(GameDto? game, ActionResult? error)> GetAuthorizedGameAsync(string alias, string gameId)
{
Expand All @@ -15,7 +17,7 @@ public abstract class BaseGameController(IGameApplicationService gameService) :
return (null, BadRequest("Player alias and game id cannot be null or empty."));
}

var gameResult = await GameService.GetGameAsync(gameId);
var gameResult = await mediator.Send(new GetGameQuery(gameId));
if (!gameResult.IsSuccess)
{
return (null, BadRequest(gameResult.Error));
Expand All @@ -29,14 +31,13 @@ public abstract class BaseGameController(IGameApplicationService gameService) :
return (gameResult.Value, null);
}

protected ActionResult HandleUnitResult(dynamic result)
protected ActionResult HandleUnitResult(Result result)
{
// result is expected to have IsSuccess and Error members.
if (!result.IsSuccess)
{
return BadRequest(result.Error);
}

return NoContent();
}
}
}
17 changes: 9 additions & 8 deletions src/backend/Sudoku.Api/Controllers/GameActionsController.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Sudoku.Api.Models;
using Sudoku.Application.Interfaces;
using Sudoku.Application.Commands;

namespace Sudoku.Api.Controllers
{
[Route("api/players/{alias}/games/{gameId}/actions")]
[ApiController]
public class GameActionsController(IGameApplicationService gameService) : BaseGameController(gameService)
public class GameActionsController(IMediator mediator) : BaseGameController(mediator)
{
/// <summary>
/// Updates a game (makes a move)
Expand All @@ -24,7 +25,7 @@ public async Task<ActionResult> MakeMoveAsync(string alias, string gameId, [From
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

var result = await GameService.MakeMoveAsync(gameId, move.Row, move.Column, move.Value, move.PlayDuration);
var result = await Mediator.Send(new MakeMoveCommand(gameId, move.Row, move.Column, move.Value, move.PlayDuration));
return HandleUnitResult(result);
}

Expand All @@ -40,10 +41,10 @@ public async Task<ActionResult> MakeMoveAsync(string alias, string gameId, [From
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> ResetGameAsync(string alias, string gameId)
{
var (game, error) = await GetAuthorizedGameAsync(alias, gameId);
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

var result = await GameService.ResetGameAsync(gameId);
var result = await Mediator.Send(new ResetGameCommand(gameId));
return HandleUnitResult(result);
}

Expand All @@ -59,10 +60,10 @@ public async Task<ActionResult> ResetGameAsync(string alias, string gameId)
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UndoMoveAsync(string alias, string gameId)
{
var (game, error) = await GetAuthorizedGameAsync(alias, gameId);
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

var result = await GameService.UndoLastMoveAsync(gameId);
var result = await Mediator.Send(new UndoLastMoveCommand(gameId));
return HandleUnitResult(result);
}
}
Expand Down
77 changes: 57 additions & 20 deletions src/backend/Sudoku.Api/Controllers/GameStatusController.cs
Original file line number Diff line number Diff line change
@@ -1,41 +1,78 @@
using Microsoft.AspNetCore.Mvc;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Sudoku.Application.Commands;
using Sudoku.Application.DTOs;
using Sudoku.Application.Interfaces;
using Sudoku.Application.Queries;

namespace Sudoku.Api.Controllers
{
[Route("api/players/{alias}/games/{gameId}/status")]
[ApiController]
public class GameStatusController(IGameApplicationService gameService) : BaseGameController(gameService)
public class GameStatusController(IMediator mediator) : BaseGameController(mediator)
{
/// <summary>
/// Updates the status of a game for the specified player.
/// Pauses an in-progress game.
/// </summary>
/// <param name="alias">The alias of the player associated with the game. Cannot be null or empty.</param>
/// <param name="gameId">The unique identifier of the game to update. Cannot be null or empty.</param>
/// <param name="gameStatus">The new status to set for the game.</param>
/// <returns>An <see cref="ActionResult"/> indicating the result of the operation. Returns 204 No Content if the update is
/// successful, 400 Bad Request if the input is invalid or the update fails, or 404 Not Found if the game does not
/// exist or does not belong to the specified player.</returns>
[HttpPatch("{gameStatus}")]
[HttpPost("pause")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateGameStatusAsync(string alias, string gameId, string gameStatus)
public async Task<ActionResult> PauseGameAsync(string alias, string gameId)
{
var (game, error) = await GetAuthorizedGameAsync(alias, gameId);
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

return HandleUnitResult(await Mediator.Send(new PauseGameCommand(gameId)));
}

/// <summary>
/// Resumes a paused game.
/// </summary>
[HttpPost("resume")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> ResumeGameAsync(string alias, string gameId)
{
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

return HandleUnitResult(await Mediator.Send(new ResumeGameCommand(gameId)));
}

/// <summary>
/// Abandons a game.
/// </summary>
[HttpPost("abandon")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> AbandonGameAsync(string alias, string gameId)
{
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

return HandleUnitResult(await Mediator.Send(new AbandonGameCommand(gameId)));
}

/// <summary>
/// Marks a game as complete.
/// </summary>
[HttpPost("complete")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> CompleteGameAsync(string alias, string gameId)
{
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

var result = await GameService.UpdateGameStatusAsync(gameId, gameStatus);
return HandleUnitResult(result);
return HandleUnitResult(await Mediator.Send(new CompleteGameCommand(gameId)));
}

/// <summary>
/// Validates a game to check if it's completed correctly
/// Validates a game to check if it is completed correctly.
/// </summary>
/// <param name="alias">The player's alias</param>
/// <param name="gameId">The game id</param>
/// <returns>Result of the validation</returns>
[HttpPost("validate")]
[ProducesResponseType(typeof(ValidationResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand All @@ -45,7 +82,7 @@ public async Task<ActionResult<ValidationResultDto>> ValidateGameAsync(string al
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

var result = await GameService.ValidateGameAsync(gameId);
var result = await Mediator.Send(new ValidateGameQuery(gameId));

if (!result.IsSuccess)
{
Expand Down
35 changes: 15 additions & 20 deletions src/backend/Sudoku.Api/Controllers/GamesController.cs
Original file line number Diff line number Diff line change
@@ -1,38 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Sudoku.Api.Models;
using Sudoku.Application.Commands;
using Sudoku.Application.DTOs;
using Sudoku.Application.Interfaces;
using Sudoku.Application.Queries;

namespace Sudoku.Api.Controllers;

[Route("api/players/{alias}/games")]
[ApiController]
public class GamesController(IGameApplicationService gameService) : BaseGameController(gameService)
public class GamesController(IMediator mediator) : BaseGameController(mediator)
{
/// <summary>
/// Creates a new game for the specified player with the given difficulty.
/// </summary>
/// <param name="alias">The alias of the player</param>
/// <param name="difficulty">The difficulty level of the game</param>
/// <returns>The created game</returns>
/// <returns>Location of the created game</returns>
[HttpPost("{difficulty}")]
[ProducesResponseType(typeof(GameDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<GameDto>> CreateGameAsync(string alias, string difficulty)
public async Task<ActionResult> CreateGameAsync(string alias, string difficulty)
{
if (string.IsNullOrWhiteSpace(alias) || string.IsNullOrWhiteSpace(difficulty))
{
return BadRequest("Player alias and difficulty cannot be null or empty.");
}

var result = await GameService.CreateGameAsync(alias, difficulty);
var result = await Mediator.Send(new CreateGameCommand(alias, difficulty));

if (!result.IsSuccess)
{
return BadRequest(result.Error);
}

return Created($"/api/players/{alias}/games/{result.Value.Id}", result.Value);
return Created($"/api/players/{alias}/games/{result.Value}", null);
Comment thread
xenobiasoft marked this conversation as resolved.
}

/// <summary>
Expand All @@ -50,14 +52,8 @@ public async Task<ActionResult> DeleteAllGamesAsync(string alias)
return BadRequest("Player alias cannot be null or empty.");
}

var result = await GameService.DeletePlayerGamesAsync(alias);

if (!result.IsSuccess)
{
return BadRequest(result.Error);
}

return NoContent();
var result = await Mediator.Send(new DeletePlayerGamesCommand(alias));
return HandleUnitResult(result);
}

/// <summary>
Expand All @@ -72,10 +68,10 @@ public async Task<ActionResult> DeleteAllGamesAsync(string alias)
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteGameAsync(string alias, string gameId)
{
var (game, error) = await GetAuthorizedGameAsync(alias, gameId);
var (_, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

var result = await GameService.DeleteGameAsync(gameId);
var result = await Mediator.Send(new DeleteGameCommand(gameId));
return HandleUnitResult(result);
}

Expand All @@ -95,7 +91,7 @@ public async Task<ActionResult<List<GameDto>>> GetAllGamesAsync(string alias)
return BadRequest("Player alias cannot be null or empty.");
}

var result = await GameService.GetPlayerGamesAsync(alias);
var result = await Mediator.Send(new GetPlayerGamesQuery(alias));

if (!result.IsSuccess)
{
Expand All @@ -120,7 +116,6 @@ public async Task<ActionResult<GameDto>> GetGameAsync(string alias, string gameI
var (game, error) = await GetAuthorizedGameAsync(alias, gameId);
if (error != null) return error;

// We already fetched the game in GetAuthorizedGameAsync, so return it directly.
return Ok(game);
}
}
Loading
Loading