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
35 changes: 23 additions & 12 deletions src/SpanishByExample.Api/Controllers/ExamplesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ namespace SpanishByExample.Api.Controllers;
/// <summary>
/// Handles requests for the example sentences.
/// </summary>
/// <param name="log">Injected logger.</param>
/// <param name="_log">Injected logger.</param>
/// <param name="examplesService">Injected Examples service.</param>
[Route("api/[controller]")]
[ApiController]
public class ExamplesController(ILogger<ExamplesController> log, IExamplesService examplesService) : ControllerBase
public class ExamplesController(ILogger<ExamplesController> _log, IExamplesService examplesService) : ControllerBase
{
private const int MAX_QUERY_LENGTH = 50;

/// <summary>
/// Searches for a given verb in the knowledge base.
/// </summary>
Expand All @@ -23,38 +25,47 @@ public class ExamplesController(ILogger<ExamplesController> log, IExamplesServic
[HttpGet]
public async Task<ActionResult<VocabularyEntry>> Get([FromQuery(Name = "q")] string query, CancellationToken cancellationToken)
{
log.LogDebug($"Query: {query}");
_log.LogDebug($"Query: {query}");

// TODO error handling, logging of exceptions
// TODO other checks eg not a word
if (string.IsNullOrWhiteSpace(query))
{
log.LogInformation("Empty query.");
return BadRequest("Query parameter 'q' is required.");
_log.LogDebug("Empty query.");
return BadRequest($"Query parameter 'q' is required.");
}

if (query.Length > MAX_QUERY_LENGTH)
{
_log.LogDebug("Query too long.");
return BadRequest($"Query too long.");
}

try
{
log.LogDebug($"Retrieving vocabulary entry for {query}.");
_log.LogDebug($"Retrieving vocabulary entry for {query}.");
var vocab = await examplesService.GetVocabularyEntryAsync(query, cancellationToken);

if (vocab == null)
{
log.LogDebug($"No vocabulary entry found for {query}.");
_log.LogDebug($"No vocabulary entry found for {query}.");
return NotFound();
}

log.LogDebug($"Returning found vocabulary entry found for {query}: ({nameof(vocab.VocabularyId)}: {vocab.VocabularyId}, {nameof(vocab.WordNorm)}: {vocab.WordNorm}");
_log.LogDebug($"Returning found vocabulary entry found for {query}: ({nameof(vocab.VocabularyId)}: {vocab.VocabularyId}, {nameof(vocab.WordNorm)}: {vocab.WordNorm}");
return Ok(vocab);
}
catch (BusinessException ex)
{
_log.LogError(ex, null);
return BadRequest(ex.Message);
}
catch (DatabaseException ex)
{
log.LogError(ex, null);
_log.LogError(ex, null);
return StatusCode(500);
}
catch (Exception ex)
{
log.LogError(ex, null);
_log.LogError(ex, null);
return StatusCode(500);
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/SpanishByExample.Api/Controllers/VocabularyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,10 @@ public async Task<ActionResult<VocabularyEntry>> Post([FromBody] VocabularyEntry
_log.LogError(ex, null);
return StatusCode(500, "Database error.");
}
catch (Exception ex)
{
_log.LogError(ex, null);
return StatusCode(500);
}
}
}
22 changes: 22 additions & 0 deletions src/SpanishByExample.Business/BusinessUtils.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using SpanishByExample.Core.Commands;
using SpanishByExample.Core.Entities;
using System.Text.RegularExpressions;

namespace SpanishByExample.Business;

Expand All @@ -8,6 +9,25 @@ namespace SpanishByExample.Business;
/// </summary>
public static class BusinessUtils
{
#region Private Fields

private static readonly Regex WORDNORM_REGEX = new(@"\w+(ar|er|ir)");

#endregion

#region Public Methods

/// <summary>
/// Checks if provided word is a Spanish verb in infinitive form.
/// </summary>
/// <param name="word">Word.</param>
/// <returns>Whether provided word is a Spanish verb in infinitive form.</returns>
public static bool IsInfinitiveVerb(string word)
{
return !string.IsNullOrWhiteSpace(word) && word.All(c => char.IsLower(c)) && WORDNORM_REGEX.IsMatch(word);
}


/// <summary>
/// Converts <c>CreateVocabularyCommand</c> to <c>VocabularyEntry</c>.
/// </summary>
Expand All @@ -29,4 +49,6 @@ public static VocabularyEntry ToVocabularyEntry(this CreateVocabularyCommand voc

return new VocabularyEntry(0, vocabularyCmd.RawWord, vocabularyCmd.EnglishTranslation, examples);
}

#endregion
}
8 changes: 6 additions & 2 deletions src/SpanishByExample.Business/ExamplesService.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using Microsoft.Extensions.Logging;

using SpanishByExample.Core.Services;
using SpanishByExample.Core.Entities;
using SpanishByExample.Core.Errors;
using SpanishByExample.Core.Services;

namespace SpanishByExample.Business
{
Expand All @@ -17,6 +17,10 @@ public class ExamplesService(ILogger<ExamplesService> _log, IDataAccessService _
{
_log.LogDebug($"{nameof(word)}: {word}");

// Check that word is in normal form eg comer
if (!BusinessUtils.IsInfinitiveVerb(word))
throw new BusinessException($"Supplied word is not a verb in infinitive form: {word}");

// NOTE: Currently a wrapper only.

var vocab = await _da.GetVocabularyEntryAsync(word, token);
Expand Down
6 changes: 1 addition & 5 deletions src/SpanishByExample.Business/VocabularyService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using SpanishByExample.Core.Commands;
using SpanishByExample.Core.Entities;
using SpanishByExample.Core.Services;
using System.Text.RegularExpressions;

namespace SpanishByExample.Business;

Expand All @@ -15,16 +14,13 @@ namespace SpanishByExample.Business;
/// <param name="_da">Injected Data Access service.</param>
public class VocabularyService(ILogger<VocabularyService> _log, IDataAccessService _da) : IVocabularyService
{
private static readonly Regex WORDNORM_REGEX = new(@"\w+(ar|er|ir)");

/// <inheritdoc/>
public async Task<VocabularyEntry> AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default)
{
_log.LogDebug($"Adding vocabulary entry for word: {vocabularyCmd.RawWord}");

// Check that word is in normal form eg comer
if (string.IsNullOrWhiteSpace(vocabularyCmd.RawWord)
|| !WORDNORM_REGEX.IsMatch(vocabularyCmd.RawWord))
if (!BusinessUtils.IsInfinitiveVerb(vocabularyCmd.RawWord))
throw new BusinessException($"Supplied word is not a verb in infinitive form: {vocabularyCmd.RawWord}");

// Check business rule: #examples > 0
Expand Down
2 changes: 2 additions & 0 deletions src/SpanishByExample.Core/Services/IExamplesService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using SpanishByExample.Core.Entities;
using SpanishByExample.Core.Errors;

namespace SpanishByExample.Core.Services;

Expand All @@ -13,6 +14,7 @@ public interface IExamplesService
/// <param name="word">Queried word.</param>
/// <param name="token">Cancellation token.</param>
/// <returns>The vocabulary entry associated with the queried word, if present.</returns>
/// <exception cref="BusinessException"/>
/// <exception cref="DatabaseException"/>
Task<VocabularyEntry?> GetVocabularyEntryAsync(string word, CancellationToken token = default);
}
4 changes: 1 addition & 3 deletions src/SpanishByExample.DataAccess/DataAccessService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,6 @@ public async Task<VocabularyEntry> AddVocabularyEntryAsync(string userId, Vocabu
{
_log.LogDebug($"{nameof(word)}: {word}");

// TODO security

const string query = @"
SELECT
VOCABULARYID,
Expand All @@ -132,7 +130,7 @@ FROM dbo.EXAMPLES ex
using var connection = new SqlConnection(_connectionString);

using var command = new SqlCommand(query, connection);
command.Parameters.AddWithValue("@wordNorm", word);
command.Parameters.AddWithValue("@wordNorm", word); // SECURITY NOTE: supplying input as SQL parameter prevents SQL injection

await connection.OpenAsync(token);

Expand Down
84 changes: 56 additions & 28 deletions tests/SpanishByExample.Tests/ExamplesControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
using Microsoft.AspNetCore.Mvc;
using SpanishByExample.Api.Controllers;
using SpanishByExample.Core.Services;
using SpanishByExample.Business;
using SpanishByExample.Core.Entities;
using Microsoft.Extensions.Logging.Abstractions;
using SpanishByExample.Core.Errors;

namespace SpanishByExample.Tests
{
Expand All @@ -14,25 +14,24 @@ namespace SpanishByExample.Tests
/// </summary>
public class ExamplesControllerTests
{
private readonly Mock<IDataAccessService> _daMock;
private readonly Mock<IExamplesService> _exServiceMock;
private readonly ExamplesController _controller;

public ExamplesControllerTests()
{
_daMock = new Mock<IDataAccessService>();
_exServiceMock = new Mock<IExamplesService>();

var log = NullLogger<ExamplesController>.Instance;
var log2 = NullLogger<ExamplesService>.Instance;
_controller = new ExamplesController(log, new ExamplesService(log2, _daMock.Object));
_controller = new ExamplesController(log, _exServiceMock.Object);
}

[Fact]
public async Task Get_Ok_Hit()
public async Task Get_Ok()
{
var query = "hablar";
var oracle = new VocabularyEntry(1, query, null, [ new() { ExampleId = 1, ExampleText = "Hablo español." } ]);

_daMock.Setup(da => da.GetVocabularyEntryAsync(query))
_exServiceMock.Setup(exService => exService.GetVocabularyEntryAsync(query))
.ReturnsAsync(oracle);

var result = await _controller.Get(query, CancellationToken.None);
Expand All @@ -41,34 +40,22 @@ public async Task Get_Ok_Hit()
var vocabularyEntry = ok.Value.Should().BeOfType<VocabularyEntry>().Subject;
vocabularyEntry.Should().BeEquivalentTo(oracle);

_daMock.Verify(da => da.GetVocabularyEntryAsync(query), Times.Once());
_exServiceMock.Verify(exService => exService.GetVocabularyEntryAsync(query), Times.Once());
}

[Fact]
public async Task Get_NotFound()
{
var query = "x";
var query = "manejar";

_daMock.Setup(da => da.GetVocabularyEntryAsync(query))
_exServiceMock.Setup(exService => exService.GetVocabularyEntryAsync(query))
.ReturnsAsync((VocabularyEntry?)null);

var result = await _controller.Get(query, CancellationToken.None);

var notFound = result.Result.Should().BeOfType<NotFoundResult>();
}

[Fact (Skip = "TODO")]
public async Task Get_Ok_NoHit()
{
// TODO test: no hit
var query = "sujetar";

var result = await _controller.Get(query, CancellationToken.None);

var ok = result.Result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(""); // Empty result set
}

[Theory]
[InlineData("")]
[InlineData(" ")]
Expand All @@ -80,15 +67,56 @@ public async Task Get_Error_EmptyInput(string query)
bad.Value.Should().Be("Query parameter 'q' is required.");
}

[Theory (Skip = "TODO")]
[InlineData("x")]
public async Task Get_Error_InvalidInput(string query)
[Fact]
public async Task Get_Error_InputTooLong()
{
// TODO test: invalid word
var query = "123456789012345678901234567890123456789012345678901";

var result = await _controller.Get(query, CancellationToken.None);

var bad = result.Result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().Be("Query too long.");
}

[Fact]
public async Task Get_Error_BusinessException()
{
var query = "hablar";

_exServiceMock.Setup(exService => exService.GetVocabularyEntryAsync(query))
.ThrowsAsync(new BusinessException(""));

var result = await _controller.Get(query, CancellationToken.None);

result.Result.Should().BeOfType<BadRequestObjectResult>();
}

[Fact]
public async Task Get_Error_DatabaseException()
{
var query = "hablar";

_exServiceMock.Setup(exService => exService.GetVocabularyEntryAsync(query))
.ThrowsAsync(new DatabaseException("", new Exception()));

var result = await _controller.Get(query, CancellationToken.None);

var statusCode = result.Result.Should().BeOfType<StatusCodeResult>().Subject;
statusCode.StatusCode.Should().Be(500);
}

[Fact]
public async Task Get_Error_Exception()
{
var query = "hablar";

_exServiceMock.Setup(exService => exService.GetVocabularyEntryAsync(query))
.ThrowsAsync(new Exception(""));

var result = await _controller.Get(query, CancellationToken.None);

var bad = result.Result.Should().BeOfType<OkObjectResult>().Subject;
bad.Value.Should().Be($"Query parameter is not a recognized word: {query}");
var statusCode = result.Result.Should().BeOfType<StatusCodeResult>().Subject;
statusCode.StatusCode.Should().Be(500);
}
}
}
Loading