diff --git a/README.md b/README.md index eba83a0..b9a5580 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,13 @@ It consists of the following projects: SpanishByExample.sln │ ├─ SpanishByExample.Core -│ ├─ Model -│ ├─ Interfaces +│ ├─ Commands +│ ├─ Entities +│ ├─ Services │ └─ Errors ├─ SpanishByExample.Api │ ├─ Controllers -│ └─ DTO +│ └─ Dtos ├─ SpanishByExample.Business ├─ SpanishByExample.DataAccess └─ SpanishByExample.Tests diff --git a/database/schema/create_UDTT.sql b/database/schema/create_UDTT.sql new file mode 100644 index 0000000..12c8a65 --- /dev/null +++ b/database/schema/create_UDTT.sql @@ -0,0 +1,10 @@ +USE [spanish_ex] +GO + +CREATE TYPE [dbo].[Examples_TT] AS TABLE( + [EXAMPLE_TEXT] [nvarchar](max) NOT NULL, + [ENGLISH] [nvarchar](max) NULL +) +GO + + diff --git a/database/schema/create_tables.sql b/database/schema/create_tables.sql index 8cb4db7..efb0362 100644 --- a/database/schema/create_tables.sql +++ b/database/schema/create_tables.sql @@ -17,10 +17,12 @@ CREATE TABLE VOCABULARY_T ( VOCABULARYID INT IDENTITY(1,1) NOT NULL, WORDNORM NVARCHAR(100) NOT NULL, ENGLISH NVARCHAR(MAX), + USERID NVARCHAR(450) NOT NULL, -- dbo.AspNetUsers.Id CREATEDAT DATETIME2 NOT NULL CONSTRAINT DF_VOCABULARY_CREATEDAT DEFAULT SYSUTCDATETIME(), CONSTRAINT PK_VOCABULARY PRIMARY KEY(VOCABULARYID), - CONSTRAINT UQ_VOCABULARY_WORDNORM UNIQUE(WORDNORM) + CONSTRAINT UQ_VOCABULARY_WORDNORM UNIQUE(WORDNORM), + CONSTRAINT FK_VOCABULARY_AspNetUsers FOREIGN KEY (USERID) REFERENCES dbo.AspNetUsers(Id) ON DELETE NO ACTION ); GO @@ -30,12 +32,15 @@ CREATE TABLE EXAMPLES_T ( VOCABULARYID INT NOT NULL, EXAMPLE_TEXT NVARCHAR(MAX) NOT NULL, ENGLISH NVARCHAR(MAX), + USERID NVARCHAR(450) NOT NULL, -- dbo.AspNetUsers.Id CREATEDAT DATETIME2 NOT NULL ); ALTER TABLE dbo.EXAMPLES_T ADD CONSTRAINT PK_EXAMPLES PRIMARY KEY(EXAMPLEID); ALTER TABLE dbo.EXAMPLES_T ADD CONSTRAINT FK_EXAMPLES_VOCABULARY FOREIGN KEY(VOCABULARYID) REFERENCES dbo.VOCABULARY_T(VOCABULARYID) ON DELETE CASCADE; ALTER TABLE dbo.EXAMPLES_T ADD CONSTRAINT DF_EXAMPLES_CREATEDAT DEFAULT SYSUTCDATETIME() FOR CREATEDAT; +-- NOTE: Keep examples even if user got deleted. +ALTER TABLE dbo.EXAMPLES_T ADD CONSTRAINT FK_EXAMPLES_AspNetUsers FOREIGN KEY (USERID) REFERENCES dbo.AspNetUsers(Id) ON DELETE NO ACTION; GO diff --git a/database/schema/create_usp_AddVocabularyEntryWithExamples.sql b/database/schema/create_usp_AddVocabularyEntryWithExamples.sql new file mode 100644 index 0000000..1179c03 --- /dev/null +++ b/database/schema/create_usp_AddVocabularyEntryWithExamples.sql @@ -0,0 +1,69 @@ +USE [spanish_ex] +GO + +/****** Object: StoredProcedure [dbo].[usp_AddVocabularyEntryWithExamples] Script Date: 06.03.2026 10:14:22 ******/ +SET ANSI_NULLS ON +GO + +SET QUOTED_IDENTIFIER ON +GO + + + +-- ============================================= +-- Author: David Pasch +-- Create date: 2026-03-02 +-- Description: Adds a new vocabulary entry with examples. +-- ============================================= +CREATE PROCEDURE [dbo].[usp_AddVocabularyEntryWithExamples] + @wordNorm NVARCHAR(100), + @english NVARCHAR(MAX) = NULL, + @examplesTT dbo.Examples_TT READONLY, + @userId NVARCHAR(450) +AS +BEGIN + -- SET NOCOUNT ON added to prevent extra result sets from + -- interfering with SELECT statements. + SET NOCOUNT ON; + + SET XACT_ABORT ON; + + BEGIN TRAN + + INSERT INTO [dbo].[VOCABULARY_T] + ([WORDNORM] + ,[ENGLISH] + ,[USERID]) + VALUES + (@wordNorm + ,@english + ,@userId); + + DECLARE @vocId INT = SCOPE_IDENTITY(); + + INSERT INTO [dbo].[EXAMPLES_T] + ([VOCABULARYID] + ,[EXAMPLE_TEXT] + ,[ENGLISH] + ,[USERID]) + SELECT @vocId, EXAMPLE_TEXT, ENGLISH, @userId + FROM @examplesTT; + + COMMIT TRAN + + SELECT [VOCABULARYID] + ,[WORDNORM] + ,[ENGLISH] + FROM [dbo].[VOCABULARY_T] + WHERE VOCABULARYID = @vocId; + + SELECT [EXAMPLEID] + ,[EXAMPLE_TEXT] + ,[ENGLISH] + FROM [dbo].[EXAMPLES_T] + WHERE VOCABULARYID = @vocId; + +END +GO + + diff --git a/database/tests/test-usp_AddVocabularyEntryWithExamples.sql b/database/tests/test-usp_AddVocabularyEntryWithExamples.sql new file mode 100644 index 0000000..7124d9d --- /dev/null +++ b/database/tests/test-usp_AddVocabularyEntryWithExamples.sql @@ -0,0 +1,18 @@ +USE spanish_ex; +GO + + DECLARE @wordNorm NVARCHAR(100) = N'comer', + @english NVARCHAR(MAX) = NULL, + @examplesTT dbo.Examples_TT, + @userId NVARCHAR(450) = N'f401d78e-3dda-4bce-9f84-55ee158c57f9'; + + INSERT INTO @examplesTT(EXAMPLE_TEXT, ENGLISH) + VALUES + (N'Como carne.', NULL), + (N'No como queso.', N'I don''t eat cheese.'); + + BEGIN TRAN + + EXEC dbo.usp_AddVocabularyEntryWithExamples @wordNorm, @english, @examplesTT, @userId + + ROLLBACK \ No newline at end of file diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 5d2aaa6..d30f066 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -37,7 +37,7 @@ The reason why we limit ourselves to verbs is that verbs are generally considere The system distinguishes between the following user roles: - *Anonymous*: Any unauthenticated user. -- *Authorized User*: An authenticated user. +- *Author*: An authenticated user who edits the vocabulary. - *Admin*: A system administrator. @@ -50,23 +50,23 @@ The different user roles are engaged in the following use cases. All Users: - **UC-1**: Search Vocabulary +- **UC-99**: Register New User -Authorized User: +Author: -- UC-02 Browse One's Own Vocabulary Entries -- UC-03 Create New Vocabulary Entry -- UC-04 Edit One's Own Vocabulary Entry -- UC-05 Delete One's Own Vocabulary Entry +- UC-2: Browse One's Own Vocabulary Entries +- **UC-3**: Create New Vocabulary Entry +- **UC-4**: Add Example to Existing Vocabulary Entry +- UC-5: Delete One's Own Vocabulary Entry In the following sub-sections, selected use cases are detailled. -## UC-1 Search Vocabulary +## UC-1: Search Vocabulary - -- **Actors**: Anonymous, Authorized User, Admin +- **Actors**: Anonymous, Author, Admin - **Input**: - Verb possibly in conjugated form or non-existent in database. - **Preconditions**: None. @@ -79,26 +79,64 @@ In the following sub-sections, selected use cases are detailled. - No data is modified. -## UC-2 Browse One's Own Vocabulary Entries +## UC-3: Create New Vocabulary Entry +- **Actors**: Author (authenticated user) +- **Input**: + - Verb in infinitive or conjugated form + - One or more example sentences (optional translation) +- **Preconditions**: + - Author is authenticated. + - Author account is active. + - Verb does not exist in the system. +- **Main Flow**: + 1. Author submits a verb and one or more usage examples. + 2. System verifies that the author account is active. + 3. System validates and normalizes verb by using an external dictionary. + 4. If the verb is valid, the system stores the normalized verb and associated examples. +- **Alternative Flow A**: + - If the verb is not recognized by the external dictionary, the system rejects the request. +- **Postconditions**: + - The normalized verb and its example sentences are stored in the database. + - The author is recorded as the creator of the stored data. -## UC-3 Create New Example Sentence(s) +## UC-4: Add Example to Existing Vocabulary Entry + +- **Actors**: Author (authenticated user) +- **Input**: + - Vocabulary entry + - One or more example sentences (optional translation) +- **Preconditions**: + - The author is authenticated. + - The same example sentences may already be associated with another user. +- **Main Flow**: + 1. Author selects an existing vocabulary entry. + 2. Author submits one or more usage examples. + 3. System persists the examples. +- **Postconditions**: + - The example sentences are associated with the vocabulary entry. + - The author is recorded as the creator of the stored data. -- **Actor**: Authorized User (Bob) +## UC-99: Register New User + +- **Actors**: Anonymous, Author, Admin - **Input**: - - Verb possibly in conjugated form or non-existent in database. - - At least one example sentence optionally with translation. + - Username + - Email + - Password - **Preconditions**: - - Bob is authenticated. - - Bob is not banned. + - The user is not authenticated. + - Username is not yet registered. - **Main Flow**: - 1. Bob submits a verb and at least one example sentence. - 1. If the verb does not yet exist in the database, the system verifies, normalizes, and translates it and creates a new vocabulary entry. - 1. The provided example sentence(s) and translation(s) are associated with the vocabulary entry and Bob as the author. + 1. User registers by providing username, email, password. + 2. System validates input. + 3. System checks that username is unique. + 4. System creates a new user account. - **Postconditions**: - - The verb together with the example(s) exist in the system. + - A new user account exists in the system. + - The user can authenticate using the provided credentials. # Requirements @@ -106,11 +144,16 @@ In the following sub-sections, selected use cases are detailled. ## Functional Requirements - - **FR-1**: The system shall allow any user to search for example sentences associated with a given Spanish verb. (Related use cases: UC-1) -- **FR-2**: The system shall accept both infinitive and conjugated verb forms as search input. (Related use cases: UC-1) +- **FR-2**: The system shall accept both infinitive and conjugated verb forms as search input. (Related use cases: UC-1, UC-3) - **FR-3**: The system returns the queried verb in its infinitive form together with an English translation. (Related use cases: UC-1) - **FR-4**: The system returns example sentences with their translation, where available. (Related use cases: UC-1) +- **FR-5**: The system shall enable an author to create a new vocabulary entry with one or more usage examples, optionally with translation. (Related use cases: UC-3) +- **FR-6**: The system shall enable an author to add one or more usage examples, optionally with translation, to an existing vocabulary entry. (Related use cases: UC-4) +- **FR-7**: The system shall record the author who has created a new vocabulary entry or example. (Related use cases: UC-3, UC-4) +- **FR-99**: The system shall allow any user to register a new account. (Related use cases: UC-99) +- **FR-100**: The system shall store passwords securely using hashing. (Related use cases: UC-99) +- **FR-101**: The system shall prevent duplicate usernames or emails. (Related use cases: UC-99) ## Non-Functional Requirements diff --git a/src/SpanishByExample.Api/ApiUtils.cs b/src/SpanishByExample.Api/ApiUtils.cs new file mode 100644 index 0000000..cf96d7b --- /dev/null +++ b/src/SpanishByExample.Api/ApiUtils.cs @@ -0,0 +1,31 @@ +using SpanishByExample.Api.Dtos; +using SpanishByExample.Core.Commands; + +namespace SpanishByExample.Api; + +/// +/// Utilities for Api. +/// +public static class ApiUtils +{ + /// + /// Converts VocabularyEntryRequestDto to CreateVocabularyCommand. + /// + /// DTO to create vocabulary entry. + /// CreateVocabularyCommand populated with data from DTO. + public static CreateVocabularyCommand ToCommand(this VocabularyEntryRequestDto vocDto) + { + CreateVocabularyCommand vocabularyCommand = new() + { + RawWord = vocDto.RawWord, + EnglishTranslation = vocDto.EnglishTranslation, + CreateExampleCommands = vocDto.Examples.Select(exDto => new CreateExampleCommand() + { + ExampleText = exDto.ExampleText, + EnglishTranslation = exDto.EnglishTranslation + }).ToList().AsReadOnly(), + }; + + return vocabularyCommand; + } +} diff --git a/src/SpanishByExample.Api/Controllers/ExamplesController.cs b/src/SpanishByExample.Api/Controllers/ExamplesController.cs index ee17d36..8401263 100644 --- a/src/SpanishByExample.Api/Controllers/ExamplesController.cs +++ b/src/SpanishByExample.Api/Controllers/ExamplesController.cs @@ -1,15 +1,14 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using SpanishByExample.Core.Errors; -using SpanishByExample.Core.Interfaces; -using SpanishByExample.Core.Model; +using SpanishByExample.Core.Services; +using SpanishByExample.Core.Entities; namespace SpanishByExample.Api.Controllers; /// /// Handles requests for the example sentences. /// +/// Injected logger. /// Injected Examples service. [Route("api/[controller]")] [ApiController] diff --git a/src/SpanishByExample.Api/Controllers/VocabularyController.cs b/src/SpanishByExample.Api/Controllers/VocabularyController.cs new file mode 100644 index 0000000..ac453ac --- /dev/null +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SpanishByExample.Api.Dtos; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; +using SpanishByExample.Core.Services; +using System.Security.Claims; + +namespace SpanishByExample.Api.Controllers; + +/// +/// Handles requests for the management of vocabulary entries. +/// +/// Injected logger. +/// Injected vocabulary service. +[Route("api/[controller]")] +[ApiController] +public class VocabularyController(ILogger _log, IVocabularyService _vocabularyService) : ControllerBase +{ + /// + /// Creates new vocabulary entry in the knowledge base. + /// + /// Vocabulary entry to be created. + /// Cancellation token + /// Created vocabulary entry. + [Authorize] + [HttpPost] + public async Task> Post([FromBody] VocabularyEntryRequestDto vocDto, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; + + _log.LogDebug($"Request for creating vocabulary entry ({vocDto.RawWord}) by user {userId}."); + + try + { + var vocabularyCommand = vocDto.ToCommand(); + + var newVocEntry = await _vocabularyService.AddVocabularyEntryAsync(userId, vocabularyCommand, cancellationToken); + + _log.LogDebug($"Vocabulary entry successfully created: {newVocEntry.VocabularyId}"); + return Ok(newVocEntry); + } + catch (MissingExamplesException ex) + { + _log.LogError(ex, null); + return BadRequest(ex.Message); + } + catch (BusinessException ex) + { + _log.LogError(ex, null); + return BadRequest(ex.Message); + } + catch (DuplicateVocabularyEntryException ex) + { + _log.LogError(ex, null); + return Conflict(ex.Message); + + } + catch (DatabaseException ex) + { + _log.LogError(ex, null); + return StatusCode(500, "Database error."); + } + } +} diff --git a/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs b/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs new file mode 100644 index 0000000..7b40381 --- /dev/null +++ b/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpanishByExample.Api.Dtos; + +/// +/// DTO for creating a new usage example for a vocabulary entry. +/// +public class ExampleRequestDto +{ + [Required] + public string ExampleText { get; set; } = null!; + + public string? EnglishTranslation { get; set; } +} diff --git a/src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs b/src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs new file mode 100644 index 0000000..b39a196 --- /dev/null +++ b/src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpanishByExample.Api.Dtos; + +/// +/// DTO for creating a new vocabulary entry. +/// +public class VocabularyEntryRequestDto +{ + [Required] + public string RawWord { get; set; } = null!; + + public string? EnglishTranslation { get; set; } + + [Required] + [MinLength(1)] + public IList Examples { get; set; } = null!; +} diff --git a/src/SpanishByExample.Api/Program.cs b/src/SpanishByExample.Api/Program.cs index 4be7de3..b9825f1 100644 --- a/src/SpanishByExample.Api/Program.cs +++ b/src/SpanishByExample.Api/Program.cs @@ -6,7 +6,7 @@ using Microsoft.IdentityModel.Tokens; using SpanishByExample.Api.Authentication; using SpanishByExample.Business; -using SpanishByExample.Core.Interfaces; +using SpanishByExample.Core.Services; using SpanishByExample.DataAccess; using System.Text; @@ -23,7 +23,7 @@ RegisterAuthentication(builder); -RegisterServices(builder); +RegisterCustomServices(builder); var app = builder.Build(); @@ -50,10 +50,10 @@ #region Private Methods -// TODO ren to RegisterCustomServices -static void RegisterServices(WebApplicationBuilder builder) +static void RegisterCustomServices(WebApplicationBuilder builder) { builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); } diff --git a/src/SpanishByExample.Api/appsettings.json b/src/SpanishByExample.Api/appsettings.json index 7f1d394..1c00c57 100644 --- a/src/SpanishByExample.Api/appsettings.json +++ b/src/SpanishByExample.Api/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "DefaultConnectionString": "" + "SpanishByExampleDb": "" }, "Logging": { "LogLevel": { diff --git a/src/SpanishByExample.Business/BusinessUtils.cs b/src/SpanishByExample.Business/BusinessUtils.cs new file mode 100644 index 0000000..0c24e19 --- /dev/null +++ b/src/SpanishByExample.Business/BusinessUtils.cs @@ -0,0 +1,32 @@ +using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Entities; + +namespace SpanishByExample.Business; + +/// +/// Utilities for BL. +/// +public static class BusinessUtils +{ + /// + /// Converts CreateVocabularyCommand to VocabularyEntry. + /// + /// Command to create vocabulary entry. + /// VocabularyEntry populated with data from command. + public static VocabularyEntry ToVocabularyEntry(this CreateVocabularyCommand vocabularyCmd) + { + List examples = []; + + foreach (var exampleCommand in vocabularyCmd.CreateExampleCommands) + { + examples.Add(new() + { + ExampleId = 0, + ExampleText = exampleCommand.ExampleText, + EnglishTranslation = exampleCommand.EnglishTranslation + }); + } + + return new VocabularyEntry(0, vocabularyCmd.RawWord, vocabularyCmd.EnglishTranslation, examples); + } +} diff --git a/src/SpanishByExample.Business/ExamplesService.cs b/src/SpanishByExample.Business/ExamplesService.cs index d054ac5..30d5093 100644 --- a/src/SpanishByExample.Business/ExamplesService.cs +++ b/src/SpanishByExample.Business/ExamplesService.cs @@ -1,37 +1,40 @@ using Microsoft.Extensions.Logging; -using SpanishByExample.Core.Interfaces; -using SpanishByExample.Core.Model; +using SpanishByExample.Core.Services; +using SpanishByExample.Core.Entities; namespace SpanishByExample.Business { - /// - /// Injected Data Access service. - public class ExamplesService(ILogger log, IDataAccessService da) : IExamplesService + /// + /// Manages usage examples for known words. + /// + /// Injected logger. + /// Injected Data Access service. + public class ExamplesService(ILogger _log, IDataAccessService _da) : IExamplesService { /// public async Task GetVocabularyEntryAsync(string word, CancellationToken token = default) { - log.LogDebug($"{nameof(word)}: {word}"); + _log.LogDebug($"{nameof(word)}: {word}"); // NOTE: Currently a wrapper only. - var vocab = await da.GetVocabularyEntryAsync(word, token); + var vocab = await _da.GetVocabularyEntryAsync(word, token); if (vocab == null) { - log.LogDebug($"No vocabulary entry found in database for word: {word}"); + _log.LogDebug($"No vocabulary entry found in database for word: {word}"); // TODO call ponsApi // - normalize verb // - try again in database // - if success then return vocab else return null - log.LogDebug($"Unkown word in external dictionary: {word}"); + _log.LogDebug($"Unkown word in external dictionary: {word}"); return null; } - log.LogDebug($"Got vocabulary entry for word: {word}"); + _log.LogDebug($"Got vocabulary entry for word: {word}"); return vocab; } } diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs new file mode 100644 index 0000000..aec7002 --- /dev/null +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using SpanishByExample.Core.Errors; +using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Services; +using System.Text.RegularExpressions; + +namespace SpanishByExample.Business; + +/// +/// Manages the vocabulary. +/// Handles, for example, creation of vocabulary entries or normalization of words. +/// +/// Injected logger. +/// Injected Data Access service. +public class VocabularyService(ILogger _log, IDataAccessService _da) : IVocabularyService +{ + private static readonly Regex WORDNORM_REGEX = new(@"\w+(ar|er|ir)"); + + /// + public async Task 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)) + throw new BusinessException($"Supplied word is not a verb in infinitive form: {vocabularyCmd.RawWord}"); + + // Check business rule: #examples > 0 + if (vocabularyCmd.CreateExampleCommands == null || vocabularyCmd.CreateExampleCommands.Count == 0) + throw new MissingExamplesException($"No examples were provided for word: {vocabularyCmd.RawWord}"); + + var vocEntry = vocabularyCmd.ToVocabularyEntry(); + + var newVocEntry = await _da.AddVocabularyEntryAsync(userId, vocEntry, token); + + _log.LogDebug($"Vocabulary entry successfully created: {newVocEntry.VocabularyId}"); + + return newVocEntry; + } +} diff --git a/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs b/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs new file mode 100644 index 0000000..e349fc3 --- /dev/null +++ b/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs @@ -0,0 +1,18 @@ +namespace SpanishByExample.Core.Commands; + +/// +/// Command to create a new example in the knowledge base. +/// NOTE: It's an immutable object. +/// +public class CreateExampleCommand +{ + /// + /// The example text. NOTE: Must be non-empty. + /// + public required string ExampleText { get; init; } + + /// + /// An optional english translation. + /// + public string? EnglishTranslation { get; init; } +} \ No newline at end of file diff --git a/src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs b/src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs new file mode 100644 index 0000000..ef4ab47 --- /dev/null +++ b/src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs @@ -0,0 +1,26 @@ +using System.Collections.ObjectModel; + +namespace SpanishByExample.Core.Commands; + +/// +/// Command to create a new vocabulary entry in the knowledge base. +/// NOTE: It's an immutable object. +/// +public class CreateVocabularyCommand +{ + /// + /// Word to create the vocabulary entry for. + /// NOTE: For now, it must be a verb in infinitive form. + /// + public required string RawWord { get; init; } + + /// + /// An optional english translation of the word. + /// + public string? EnglishTranslation { get; init; } + + /// + /// A non-empty collection of examples that illustrate the use of the word. + /// + public required ReadOnlyCollection CreateExampleCommands { get; init; } +} diff --git a/src/SpanishByExample.Core/Entities/Example.cs b/src/SpanishByExample.Core/Entities/Example.cs new file mode 100644 index 0000000..8d93cd9 --- /dev/null +++ b/src/SpanishByExample.Core/Entities/Example.cs @@ -0,0 +1,13 @@ +namespace SpanishByExample.Core.Entities; + +/// +/// An example for a word in the vocabulary. +/// +public class Example +{ + public required int ExampleId { get; init; } + + public required string ExampleText { get; init; } + + public string? EnglishTranslation { get; init; } +} diff --git a/src/SpanishByExample.Core/Entities/VocabularyEntry.cs b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs new file mode 100644 index 0000000..ccf66e9 --- /dev/null +++ b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs @@ -0,0 +1,27 @@ +namespace SpanishByExample.Core.Entities; + +/// +/// A vocabulary entry for a specific word. +/// Stores translations and available usage examples. +/// NOTE: Immutable object. +/// +public class VocabularyEntry +{ + public int VocabularyId { get; } + + public string WordNorm { get; } + + public string? EnglishTranslation { get; } + + public IReadOnlyList Examples { get; } + + // NOTE: Constructor initialization ensures no partial object. + public VocabularyEntry(int vocabularyId, string wordNorm, string? englishTranslation, IReadOnlyList examples) + { + VocabularyId = vocabularyId; + WordNorm = wordNorm; + EnglishTranslation = englishTranslation; + Examples = examples; + } +} + diff --git a/src/SpanishByExample.Core/Errors/BusinessException.cs b/src/SpanishByExample.Core/Errors/BusinessException.cs new file mode 100644 index 0000000..4be8d97 --- /dev/null +++ b/src/SpanishByExample.Core/Errors/BusinessException.cs @@ -0,0 +1,12 @@ +namespace SpanishByExample.Core.Errors; + +/// +/// Represents violation of a business rule. +/// +public class BusinessException : Exception +{ + public BusinessException(string msg) + : base(msg) + { + } +} \ No newline at end of file diff --git a/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs b/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs new file mode 100644 index 0000000..2179980 --- /dev/null +++ b/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs @@ -0,0 +1,15 @@ +using SpanishByExample.Core.Entities; + +namespace SpanishByExample.Core.Errors; + +/// +/// Represents an error caused by trying to create a duplicate vocabulary entry. +/// +public class DuplicateVocabularyEntryException : DatabaseException +{ + public DuplicateVocabularyEntryException(VocabularyEntry vocabularyEntry, Exception innerException) + : base($"Duplicate vocabulary entry: {vocabularyEntry.WordNorm}.", innerException) + { + + } +} diff --git a/src/SpanishByExample.Core/Errors/MissingExamplesException.cs b/src/SpanishByExample.Core/Errors/MissingExamplesException.cs new file mode 100644 index 0000000..693732d --- /dev/null +++ b/src/SpanishByExample.Core/Errors/MissingExamplesException.cs @@ -0,0 +1,12 @@ +namespace SpanishByExample.Core.Errors; + +/// +/// There are no usage examples for a vocabulary entry. +/// +public class MissingExamplesException : BusinessException +{ + public MissingExamplesException(string message) + : base(message) + { + } +} \ No newline at end of file diff --git a/src/SpanishByExample.Core/Interfaces/IDataAccessService.cs b/src/SpanishByExample.Core/Interfaces/IDataAccessService.cs deleted file mode 100644 index 7da0d71..0000000 --- a/src/SpanishByExample.Core/Interfaces/IDataAccessService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using SpanishByExample.Core.Model; - -namespace SpanishByExample.Core.Interfaces; - -/// -/// Handles the communication with the database. -/// -public interface IDataAccessService -{ - /// - /// Retrieves usage examples for a provided word from the database. - /// - /// Queried word. - /// Cancellation token. - /// The vocabulary entry in the database associated with the queried word, if present. - /// - Task GetVocabularyEntryAsync(string word, CancellationToken token = default); -} diff --git a/src/SpanishByExample.Core/Model/Example.cs b/src/SpanishByExample.Core/Model/Example.cs deleted file mode 100644 index bae86c1..0000000 --- a/src/SpanishByExample.Core/Model/Example.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace SpanishByExample.Core.Model; - -/// -/// An example for a word in the vocabulary. -/// -public class Example -{ - public required int ExampleId { get; set; } - - public required string ExampleText { get; set; } - - public string? EnglishTranslation { get; set; } -} diff --git a/src/SpanishByExample.Core/Model/VocabularyEntry.cs b/src/SpanishByExample.Core/Model/VocabularyEntry.cs deleted file mode 100644 index 0376893..0000000 --- a/src/SpanishByExample.Core/Model/VocabularyEntry.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SpanishByExample.Core.Model; - -/// -/// A vocabulary entry for a specific word. -/// Stores translations and available usage examples. -/// -public class VocabularyEntry -{ - public required int VocabularyId { get; set; } - - public required string WordNorm { get; set; } - - public string? EnglishTranslation { get; set; } - - public IList Examples { get; set; } = []; -} diff --git a/src/SpanishByExample.Core/Services/IDataAccessService.cs b/src/SpanishByExample.Core/Services/IDataAccessService.cs new file mode 100644 index 0000000..5b95e9e --- /dev/null +++ b/src/SpanishByExample.Core/Services/IDataAccessService.cs @@ -0,0 +1,30 @@ +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; + +namespace SpanishByExample.Core.Services; + +/// +/// Handles the communication with the database. +/// +public interface IDataAccessService +{ + /// + /// Retrieves usage examples for a provided word from the database. + /// + /// Queried word. + /// Cancellation token. + /// The vocabulary entry in the database associated with the queried word, if present. + /// + Task GetVocabularyEntryAsync(string word, CancellationToken token = default); + + /// + /// Adds a new vocabulary entry to the database. + /// + /// ID of user who creates the vocabulary entry. + /// A new vocabulary entry. + /// Cancellation token. + /// Stored vocabulary entry with the IDs set. + /// + /// + Task AddVocabularyEntryAsync(string userId, VocabularyEntry vocabularyEntry, CancellationToken token = default); +} diff --git a/src/SpanishByExample.Core/Interfaces/IExamplesService.cs b/src/SpanishByExample.Core/Services/IExamplesService.cs similarity index 86% rename from src/SpanishByExample.Core/Interfaces/IExamplesService.cs rename to src/SpanishByExample.Core/Services/IExamplesService.cs index 0bfb7b8..3905658 100644 --- a/src/SpanishByExample.Core/Interfaces/IExamplesService.cs +++ b/src/SpanishByExample.Core/Services/IExamplesService.cs @@ -1,6 +1,6 @@ -using SpanishByExample.Core.Model; +using SpanishByExample.Core.Entities; -namespace SpanishByExample.Core.Interfaces; +namespace SpanishByExample.Core.Services; /// /// Manages usage examples for known words. diff --git a/src/SpanishByExample.Core/Services/IVocabularyService.cs b/src/SpanishByExample.Core/Services/IVocabularyService.cs new file mode 100644 index 0000000..233a427 --- /dev/null +++ b/src/SpanishByExample.Core/Services/IVocabularyService.cs @@ -0,0 +1,25 @@ +using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; + +namespace SpanishByExample.Core.Services; + +/// +/// Manages the vocabulary. +/// Handles, for example, creation of vocabulary entries or normalization of words. +/// +public interface IVocabularyService +{ + /// + /// Adds a new vocabulary entry to the database. + /// + /// ID of user who creates the vocabulary entry. + /// A command to create a new vocabulary entry. + /// Cancellation token. + /// Stored vocabulary entry with the IDs set. + /// + /// + /// + /// + Task AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default); +} diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index f39e127..35d6e83 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -1,19 +1,118 @@ -using Microsoft.Data.SqlClient; +//#define DEBUG_ROLLBACK + +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using SpanishByExample.Core.Errors; -using SpanishByExample.Core.Interfaces; -using SpanishByExample.Core.Model; +using SpanishByExample.Core.Services; +using SpanishByExample.Core.Entities; +using System.Data; namespace SpanishByExample.DataAccess; -/// -public class DataAccessService(ILogger log, IConfiguration configuration) : IDataAccessService +/// +/// Handles the communication with the database. +/// +/// Injected logger. +/// Injected configuration. +public class DataAccessService(ILogger _log, IConfiguration _configuration) : IDataAccessService { + #region Private fields + + private readonly string _connectionString = _configuration.GetConnectionString("SpanishByExampleDb") ?? throw new InvalidOperationException("Connection string is missing."); + + #endregion + + #region IDataAccessService + + /// + public async Task AddVocabularyEntryAsync(string userId, VocabularyEntry vocabularyEntry, CancellationToken token = default) + { + _log.LogDebug($"{nameof(userId)}: {userId}, {nameof(vocabularyEntry)}: {vocabularyEntry.WordNorm}"); + + try + { + using var connection = new SqlConnection(_connectionString); + + using var command = new SqlCommand("dbo.usp_AddVocabularyEntryWithExamples", connection); + command.CommandType = CommandType.StoredProcedure; + command.Parameters.AddWithValue("@wordNorm", vocabularyEntry.WordNorm); + command.Parameters.AddWithValue("@english", vocabularyEntry.EnglishTranslation); + var examplesDt = vocabularyEntry.Examples.ToDataTable(); + command.Parameters.Add(new SqlParameter("@examplesTT", examplesDt) + { + TypeName = "dbo.Examples_TT", + SqlDbType = SqlDbType.Structured, + }); + command.Parameters.AddWithValue("@userId", userId); + + connection.Open(); + + // TODO make prettier +#if DEBUG_ROLLBACK + // NOTE: For debugging only. + using var transaction = connection.BeginTransaction(); + command.Transaction = transaction; +#endif + + using var reader = await command.ExecuteReaderAsync(token); + + int vocabularyId = -1; + string wordNorm = null!; + string? englishTranslation = null; + List examples; + + while (await reader.ReadAsync(token)) + { + if (vocabularyId != -1) + { + throw new InvalidOperationException("Database returns more than one vocabulary entry."); + } + vocabularyId = reader.GetInt32(0); + wordNorm = reader.GetString(1); + englishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2); + } + + if (!await reader.NextResultAsync(token)) + { + throw new InvalidOperationException("Database fails to return newly created examples for vocabulary entry."); + } + + examples = new List(); + while (await reader.ReadAsync(token)) + { + examples.Add(new Example + { + ExampleId = reader.GetInt32(0), + ExampleText = reader.GetString(1), + EnglishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2), + }); + } + +#if DEBUG_ROLLBACK + // NOTE: For debugging only. + await reader.CloseAsync(); + transaction.Rollback(); +#endif + + return vocabularyId != -1 ? new VocabularyEntry(vocabularyId, wordNorm, englishTranslation, examples) : throw new InvalidOperationException("Database fails to return newly created vocabulary entry."); + } + catch (SqlException ex) when (ex.Number == 2627 || ex.Number == 2601) + { + _log.LogDebug(ex, $"Error: Duplicate entry: {vocabularyEntry.WordNorm}"); + throw new DuplicateVocabularyEntryException(vocabularyEntry, ex); + } + catch (SqlException ex) + { + _log.LogDebug(ex, $"Error: {ex.Message}"); + throw new DatabaseException(ex.Message, ex); + } + } + /// public async Task GetVocabularyEntryAsync(string word, CancellationToken token = default) { - log.LogDebug($"{nameof(word)}: {word}"); + _log.LogDebug($"{nameof(word)}: {word}"); // TODO security @@ -30,8 +129,7 @@ FROM dbo.EXAMPLES ex "; try { - var connectionString = configuration.GetConnectionString("SpanishByExampleDb"); - using var connection = new SqlConnection(connectionString); + using var connection = new SqlConnection(_connectionString); using var command = new SqlCommand(query, connection); command.Parameters.AddWithValue("@wordNorm", word); @@ -40,24 +138,23 @@ FROM dbo.EXAMPLES ex using var reader = await command.ExecuteReaderAsync(token); - VocabularyEntry? vocabEntry = null; // case: unknown word: remains null because of empty result set + int vocabularyId = -1; // case: unknown word: remains -1 because of empty result set + string wordNorm = null!; + string? englishTranslation = null; + List examples = []; while (await reader.ReadAsync(token)) { - if (vocabEntry == null) - { - vocabEntry = new VocabularyEntry - { - VocabularyId = reader.GetInt32(0), - WordNorm = reader.GetString(1), - EnglishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2), - }; + if (vocabularyId == -1) { + vocabularyId = reader.GetInt32(0); + wordNorm = reader.GetString(1); + englishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2); } if (await reader.IsDBNullAsync(3, token)) // case: no examples break; - vocabEntry.Examples.Add(new Example + examples.Add(new Example { ExampleId = reader.GetInt32(3), ExampleText = reader.GetString(4), @@ -65,12 +162,14 @@ FROM dbo.EXAMPLES ex }); } - return vocabEntry; + return vocabularyId != -1 ? new VocabularyEntry(vocabularyId, wordNorm, englishTranslation, examples) : null; } catch (SqlException ex) { - log.LogDebug(ex, $"Error: {ex.Message}"); + _log.LogDebug(ex, $"Error: {ex.Message}"); throw new DatabaseException($"Failed to retrieve examples for word: {word}", ex); } } + + #endregion } diff --git a/src/SpanishByExample.DataAccess/DataAccessUtils.cs b/src/SpanishByExample.DataAccess/DataAccessUtils.cs new file mode 100644 index 0000000..647e1e9 --- /dev/null +++ b/src/SpanishByExample.DataAccess/DataAccessUtils.cs @@ -0,0 +1,32 @@ +using SpanishByExample.Core.Entities; +using System.Data; + +namespace SpanishByExample.DataAccess; + +/// +/// Utilities for DAL. +/// +public static class DataAccessUtils +{ + /// + /// Converts Example collection to DataTable. + /// + /// Examples. + /// Table containing the example data. + public static DataTable ToDataTable(this IReadOnlyList examples) + { + var examplesDt = new DataTable(); + examplesDt.Columns.AddRange( + [ + new DataColumn("EXAMPLE_TEXT"), + new DataColumn("ENGLISH"), + ]); + + foreach (var example in examples) + { + examplesDt.Rows.Add(example.ExampleText, example.EnglishTranslation); + } + + return examplesDt; + } +} diff --git a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs index b5ae09b..f5101b6 100644 --- a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs +++ b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs @@ -1,10 +1,15 @@ using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; using SpanishByExample.DataAccess; namespace SpanishByExample.Tests; +/// +/// Tests the DataAccessService. +/// public class DataAccessServiceTests { private readonly DataAccessService _da; @@ -18,6 +23,8 @@ public DataAccessServiceTests() _da = new DataAccessService(log, config); } + #region GetVocabularyEntryAsync + //[Fact] [Fact(Skip = "Real test.")] public async Task GetVocabularyEntryAsync_Ok_Examples() @@ -66,4 +73,67 @@ public async Task GetVocabularyEntryAsync_Ok_Unknown() vocabularyEntry.Should().BeNull(); } + + #endregion + + #region AddVocabularyEntryAsync + + //[Theory] + [Theory(Skip = "Real test.")] + [InlineData(null, null)] + [InlineData("to eat", "I eat meat.")] + public async Task AddVocabularyEntryAsync_Ok(string? vocEnglish, string? exEnglish) + { + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + var vocEntry = new VocabularyEntry( + 0, + "comer", + vocEnglish, + [ + new Example { + ExampleId = 0, + ExampleText = "Como carne.", + EnglishTranslation = exEnglish + } + ] + ); + + var newVocEntry = await _da.AddVocabularyEntryAsync(userId, vocEntry); + + newVocEntry.VocabularyId.Should().BeGreaterThan(0, "Ids in database start with 1."); + newVocEntry.WordNorm.Should().Be(vocEntry.WordNorm); + newVocEntry.EnglishTranslation.Should().Be(vocEntry.EnglishTranslation); + newVocEntry.Examples.Count.Should().Be(vocEntry.Examples.Count); + newVocEntry.Examples[0].ExampleId.Should().BeGreaterThan(0, "Ids in database start with 1."); + newVocEntry.Examples[0].ExampleText.Should().Be(vocEntry.Examples[0].ExampleText); + newVocEntry.Examples[0].EnglishTranslation.Should().Be(vocEntry.Examples[0].EnglishTranslation); + } + + //[Fact] + [Fact(Skip = "Real test.")] + public async Task AddVocabularyEntryAsync_Error_DuplicateVocabularyEntryException() + { + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + + var vocEntry = new VocabularyEntry( + 0, + "hablar", + null, + [ + new Example { + ExampleId = 0, + ExampleText = "Hablo mucho.", + EnglishTranslation = null + } + ] + ); + + var act = async () => await _da.AddVocabularyEntryAsync(userId, vocEntry); + + await act.Should().ThrowAsync().WithMessage("Duplicate vocabulary entry: hablar."); + } + + // TODO other tests ... + + #endregion } diff --git a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs index ca90962..9a28215 100644 --- a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs +++ b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs @@ -1,11 +1,10 @@ using FluentAssertions; using Moq; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using SpanishByExample.Api.Controllers; -using SpanishByExample.Core.Interfaces; +using SpanishByExample.Core.Services; using SpanishByExample.Business; -using SpanishByExample.Core.Model; +using SpanishByExample.Core.Entities; using Microsoft.Extensions.Logging.Abstractions; namespace SpanishByExample.Tests @@ -31,11 +30,7 @@ public ExamplesControllerTests() public async Task Get_Ok_Hit() { var query = "hablar"; - var oracle = new VocabularyEntry - { - VocabularyId = 1, - WordNorm = query - }; + var oracle = new VocabularyEntry(1, query, null, [ new() { ExampleId = 1, ExampleText = "Hablo español." } ]); _daMock.Setup(da => da.GetVocabularyEntryAsync(query)) .ReturnsAsync(oracle); diff --git a/tests/SpanishByExample.Tests/VocabularyControllerTests.cs b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs new file mode 100644 index 0000000..1e26391 --- /dev/null +++ b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SpanishByExample.Api.Controllers; +using SpanishByExample.Api.Dtos; +using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Services; +using System.Security.Claims; + +namespace SpanishByExample.Tests; + +/// +/// Tests the VocabularyController. +/// +public class VocabularyControllerTests +{ + private readonly Mock _vocServiceMock; + private readonly VocabularyController _controller; + + public VocabularyControllerTests() + { + _vocServiceMock = new Mock(); + var log = NullLogger.Instance; + _controller = new VocabularyController(log, _vocServiceMock.Object); + } + + [Fact] + public async Task Post_Ok() + { + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + + VocabularyEntryRequestDto vocDto = new() + { + RawWord = "comer", + EnglishTranslation = "to eat", + Examples = + [ + new() + { + ExampleText = "Como carne.", + EnglishTranslation = "I eat meat." + } + ] + }; + + _controller.ControllerContext = CreateAuthenticatedContext(userId); + + var newVocEntry = new VocabularyEntry( + 1, + vocDto.RawWord, + vocDto.EnglishTranslation, + [ + new Example { + ExampleId = 1, + ExampleText = vocDto.Examples[0].ExampleText, + EnglishTranslation = vocDto.Examples[0].EnglishTranslation + } + ] + ); + + _vocServiceMock.Setup(vocService => vocService.AddVocabularyEntryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(newVocEntry); + + + var result = await _controller.Post(vocDto, CancellationToken.None); + + + var ok = result.Result.Should().BeOfType().Subject; + var vocabularyEntry = ok.Value.Should().BeOfType().Subject; + vocabularyEntry.Should().BeEquivalentTo(newVocEntry); + } + + #region Helpers + + /// + /// Fakes authenticated user for unit test. + /// + /// User ID. + /// Controller context. + private static ControllerContext CreateAuthenticatedContext(string userId) + { + var user = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId)], + "TestAuth")); + + return new ControllerContext + { + HttpContext = new DefaultHttpContext { User = user } + }; + } + + #endregion +} diff --git a/tests/SpanishByExample.Tests/VocabularyServiceTests.cs b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs new file mode 100644 index 0000000..02c6041 --- /dev/null +++ b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs @@ -0,0 +1,102 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SpanishByExample.Business; +using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; +using SpanishByExample.Core.Services; + +namespace SpanishByExample.Tests; + +/// +/// Tests the VocabularyService. +/// +public class VocabularyServiceTests +{ + #region Private Fields + + private readonly Mock _daMock; + private readonly VocabularyService _vocabularyService; + + #endregion + + #region Constructors + + public VocabularyServiceTests() + { + _daMock = new Mock(); + + var log = NullLogger.Instance; + _vocabularyService = new VocabularyService(log, _daMock.Object); + } + + #endregion + + #region Test Methods + + [Fact] + public async Task AddVocabularyEntryAsync_Ok() + { + string? vocEnglish = null; + string? exEnglish = null; + + + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + var vocabularyCmd = new CreateVocabularyCommand() + { + RawWord = "comer", + EnglishTranslation = vocEnglish, + CreateExampleCommands = new List() + { + new() + { + ExampleText = "Como carne.", + EnglishTranslation = exEnglish + } + } + .AsReadOnly() + }; + + + var daVocEntry = new VocabularyEntry( + 1, + "comer", + vocEnglish, + [ + new Example { + ExampleId = 11, + ExampleText = vocabularyCmd.CreateExampleCommands[0].ExampleText, + EnglishTranslation = exEnglish + } + ] + ); + + _daMock.Setup(da => da.AddVocabularyEntryAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(daVocEntry); + + + var newVocEntry = await _vocabularyService.AddVocabularyEntryAsync(userId, vocabularyCmd); + + newVocEntry.Should().BeEquivalentTo(daVocEntry); + _daMock.Verify(da => da.AddVocabularyEntryAsync(userId, It.Is(v => v.VocabularyId == 0 && v.WordNorm == vocabularyCmd.RawWord)), Times.Once()); + } + + [Fact] + public async Task AddVocabularyEntryAsync_Error_MissingExamplesException() + { + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + var vocabularyCmd = new CreateVocabularyCommand() + { + RawWord = "comer", + EnglishTranslation = null, + CreateExampleCommands = [] + }; + + var act = async () => await _vocabularyService.AddVocabularyEntryAsync(userId, vocabularyCmd); + + await act.Should().ThrowAsync().WithMessage($"No examples were provided for word: {vocabularyCmd.RawWord}"); + } + + #endregion +}