From 3247cb864dad62d8d7d644a158bb92233f2b2ac9 Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Feb 2026 12:29:01 +0100 Subject: [PATCH 01/24] Issue #12: Added new use case UC-3 with functional requirements to the spec. --- docs/spec/SPEC.md | 63 ++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index 5d2aaa6..c133d9e 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. @@ -52,21 +52,20 @@ All Users: - **UC-1**: Search Vocabulary -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 +78,42 @@ 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**: + - The author is authenticated. + - Verb does not exist in the system. +- **Main Flow**: + 1. The author submits a verb and one or more usage examples. + 1. System validates and normalizes verb by using an external dictionary. + 1. 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 -- **Actor**: Authorized User (Bob) +- **Actors**: Author (authenticated user) - **Input**: - - Verb possibly in conjugated form or non-existent in database. - - At least one example sentence optionally with translation. + - Vocabulary entry + - One or more example sentences (optional translation) - **Preconditions**: - - Bob is authenticated. - - Bob is not banned. + - The author is authenticated. + - The same example sentences may already be associated with another user. - **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. Author selects an existing vocabulary entry. + 1. Author submits one or more usage examples. + 1. System persists the examples. - **Postconditions**: - - The verb together with the example(s) exist in the system. + - The example sentences are associated with the vocabulary entry. + - The author is recorded as the creator of the stored data. # Requirements @@ -106,11 +121,13 @@ 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) ## Non-Functional Requirements From 8f34a13fdd592474f981fd38e8d2ed3ba95cf9da Mon Sep 17 00:00:00 2001 From: dp Date: Wed, 4 Feb 2026 11:01:51 +0100 Subject: [PATCH 02/24] Issue #15, #13: Created interfaces, reorganized code. CODE BROKEN. --- README.md | 7 +++-- .../Controllers/ExamplesController.cs | 4 +-- .../Controllers/VocabularyController.cs | 27 +++++++++++++++++ .../Dtos/ExampleRequestDto.cs | 7 +++++ .../Dtos/VocabularyEntryRequestDto.cs | 7 +++++ src/SpanishByExample.Api/Program.cs | 2 +- .../ExamplesService.cs | 4 +-- .../Commands/CreateExampleCommand.cs | 9 ++++++ .../Commands/CreateVocabularyCommand.cs | 10 +++++++ .../{Model => Entities}/Example.cs | 2 +- .../{Model => Entities}/VocabularyEntry.cs | 2 +- .../DuplicateVocabularyEntryException.cs | 15 ++++++++++ .../Interfaces/IDataAccessService.cs | 18 ------------ .../Services/IDataAccessService.cs | 29 +++++++++++++++++++ .../IExamplesService.cs | 4 +-- .../Services/IVocabularyService.cs | 22 ++++++++++++++ .../DataAccessService.cs | 4 +-- .../ExamplesControllerTests.cs | 4 +-- 18 files changed, 143 insertions(+), 34 deletions(-) create mode 100644 src/SpanishByExample.Api/Controllers/VocabularyController.cs create mode 100644 src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs create mode 100644 src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs create mode 100644 src/SpanishByExample.Core/Commands/CreateExampleCommand.cs create mode 100644 src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs rename src/SpanishByExample.Core/{Model => Entities}/Example.cs (85%) rename src/SpanishByExample.Core/{Model => Entities}/VocabularyEntry.cs (89%) create mode 100644 src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs delete mode 100644 src/SpanishByExample.Core/Interfaces/IDataAccessService.cs create mode 100644 src/SpanishByExample.Core/Services/IDataAccessService.cs rename src/SpanishByExample.Core/{Interfaces => Services}/IExamplesService.cs (86%) create mode 100644 src/SpanishByExample.Core/Services/IVocabularyService.cs 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/src/SpanishByExample.Api/Controllers/ExamplesController.cs b/src/SpanishByExample.Api/Controllers/ExamplesController.cs index ee17d36..9321fa9 100644 --- a/src/SpanishByExample.Api/Controllers/ExamplesController.cs +++ b/src/SpanishByExample.Api/Controllers/ExamplesController.cs @@ -2,8 +2,8 @@ using Microsoft.AspNetCore.Http.HttpResults; 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; diff --git a/src/SpanishByExample.Api/Controllers/VocabularyController.cs b/src/SpanishByExample.Api/Controllers/VocabularyController.cs new file mode 100644 index 0000000..614cfed --- /dev/null +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SpanishByExample.Api.Dtos; +using SpanishByExample.Core.Entities; + +namespace SpanishByExample.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class VocabularyController : ControllerBase +{ + /// + /// Creates new vocabulary entry in the knowledge base. + /// + /// Vocabulary entry to be created. + /// Cancellation token + /// Created vocabulary entry. + [HttpPost] + public async Task> Post([FromBody] VocabularyEntryRequestDto vocabularyEntryDto, CancellationToken token) + { + // TODO Post + + // Call BL: Map DTO to Command + + return Ok(new VocabularyEntry { VocabularyId = 0, WordNorm = "comer" }); + } +} diff --git a/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs b/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs new file mode 100644 index 0000000..fe8d438 --- /dev/null +++ b/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs @@ -0,0 +1,7 @@ +namespace SpanishByExample.Api.Dtos; + +public class ExampleRequestDto +{ + public required string ExampleText { get; set; } + 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..eb942fc --- /dev/null +++ b/src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs @@ -0,0 +1,7 @@ +namespace SpanishByExample.Api.Dtos; + +public class VocabularyEntryRequestDto +{ + public required string RawWord { get; set; } + public required ExampleRequestDto[] Examples { get; set; } +} diff --git a/src/SpanishByExample.Api/Program.cs b/src/SpanishByExample.Api/Program.cs index 5a2a40f..e05e1d1 100644 --- a/src/SpanishByExample.Api/Program.cs +++ b/src/SpanishByExample.Api/Program.cs @@ -1,4 +1,4 @@ -using SpanishByExample.Core.Interfaces; +using SpanishByExample.Core.Services; using SpanishByExample.Business; using SpanishByExample.DataAccess; diff --git a/src/SpanishByExample.Business/ExamplesService.cs b/src/SpanishByExample.Business/ExamplesService.cs index d054ac5..925ab18 100644 --- a/src/SpanishByExample.Business/ExamplesService.cs +++ b/src/SpanishByExample.Business/ExamplesService.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging; -using SpanishByExample.Core.Interfaces; -using SpanishByExample.Core.Model; +using SpanishByExample.Core.Services; +using SpanishByExample.Core.Entities; namespace SpanishByExample.Business { diff --git a/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs b/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs new file mode 100644 index 0000000..50dde1b --- /dev/null +++ b/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs @@ -0,0 +1,9 @@ +namespace SpanishByExample.Core.Commands +{ + public class CreateExampleCommand + { + public required string ExampleText { get; init; } + + 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..e57ac88 --- /dev/null +++ b/src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs @@ -0,0 +1,10 @@ +using System.Collections.ObjectModel; + +namespace SpanishByExample.Core.Commands; + +public class CreateVocabularyCommand +{ + public required string RawWord { get; init; } + + public required ReadOnlyCollection Examples { get; init; } +} diff --git a/src/SpanishByExample.Core/Model/Example.cs b/src/SpanishByExample.Core/Entities/Example.cs similarity index 85% rename from src/SpanishByExample.Core/Model/Example.cs rename to src/SpanishByExample.Core/Entities/Example.cs index bae86c1..a467f27 100644 --- a/src/SpanishByExample.Core/Model/Example.cs +++ b/src/SpanishByExample.Core/Entities/Example.cs @@ -1,4 +1,4 @@ -namespace SpanishByExample.Core.Model; +namespace SpanishByExample.Core.Entities; /// /// An example for a word in the vocabulary. diff --git a/src/SpanishByExample.Core/Model/VocabularyEntry.cs b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs similarity index 89% rename from src/SpanishByExample.Core/Model/VocabularyEntry.cs rename to src/SpanishByExample.Core/Entities/VocabularyEntry.cs index 0376893..8b302a2 100644 --- a/src/SpanishByExample.Core/Model/VocabularyEntry.cs +++ b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs @@ -1,4 +1,4 @@ -namespace SpanishByExample.Core.Model; +namespace SpanishByExample.Core.Entities; /// /// A vocabulary entry for a specific word. diff --git a/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs b/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs new file mode 100644 index 0000000..c396060 --- /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 : Exception +{ + public DuplicateVocabularyEntryException(VocabularyEntry vocabularyEntry) + : base($"Duplicate vocabulary entry: {vocabularyEntry.WordNorm}.") + { + + } +} 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/Services/IDataAccessService.cs b/src/SpanishByExample.Core/Services/IDataAccessService.cs new file mode 100644 index 0000000..102ce38 --- /dev/null +++ b/src/SpanishByExample.Core/Services/IDataAccessService.cs @@ -0,0 +1,29 @@ +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(int 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..797009d --- /dev/null +++ b/src/SpanishByExample.Core/Services/IVocabularyService.cs @@ -0,0 +1,22 @@ +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(int UserId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default); +} diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index f39e127..b205849 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -2,8 +2,8 @@ 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; namespace SpanishByExample.DataAccess; diff --git a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs index ca90962..32cc0e2 100644 --- a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs +++ b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs @@ -3,9 +3,9 @@ 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 From f539c4d771931a975d186aecd2a9836d35564380 Mon Sep 17 00:00:00 2001 From: dp Date: Sat, 28 Feb 2026 03:06:20 +0100 Subject: [PATCH 03/24] Prepared and fixed code. --- docs/spec/SPEC.md | 6 ++- .../Controllers/VocabularyController.cs | 2 + .../VocabularyService.cs | 23 ++++++++++ .../DataAccessService.cs | 13 ++++++ .../VocabularyControllerTests.cs | 42 +++++++++++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/SpanishByExample.Business/VocabularyService.cs create mode 100644 tests/SpanishByExample.Tests/VocabularyControllerTests.cs diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index c133d9e..db3e889 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -85,10 +85,12 @@ In the following sub-sections, selected use cases are detailled. - Verb in infinitive or conjugated form - One or more example sentences (optional translation) - **Preconditions**: - - The author is authenticated. + - Author is authenticated. + - Author account is active. - Verb does not exist in the system. - **Main Flow**: - 1. The author submits a verb and one or more usage examples. + 1. Author submits a verb and one or more usage examples. + 1. System verifies that the author account is active. 1. System validates and normalizes verb by using an external dictionary. 1. If the verb is valid, the system stores the normalized verb and associated examples. - **Alternative Flow A**: diff --git a/src/SpanishByExample.Api/Controllers/VocabularyController.cs b/src/SpanishByExample.Api/Controllers/VocabularyController.cs index 614cfed..fd63528 100644 --- a/src/SpanishByExample.Api/Controllers/VocabularyController.cs +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -22,6 +22,8 @@ public async Task> Post([FromBody] VocabularyEntry // Call BL: Map DTO to Command + // If word already exists in DB, return 409 + return Ok(new VocabularyEntry { VocabularyId = 0, WordNorm = "comer" }); } } diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs new file mode 100644 index 0000000..a695b28 --- /dev/null +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -0,0 +1,23 @@ +using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Services; + +namespace SpanishByExample.Business; + +public class VocabularyService : IVocabularyService +{ + public Task AddVocabularyEntryAsync(int UserId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default) + { + // TODO + + // check that word is in normal form eg comer + + // convert Command to Entity for DA + + // call DA & receive newly created entry with IDs + + // return new entry + + throw new NotImplementedException(); + } +} diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index b205849..8a9da37 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -10,6 +10,19 @@ namespace SpanishByExample.DataAccess; /// public class DataAccessService(ILogger log, IConfiguration configuration) : IDataAccessService { + /// + public Task AddVocabularyEntryAsync(int UserId, VocabularyEntry vocabularyEntry, CancellationToken token = default) + { + // transaction: + // INSERT vocabulary and examples into DB + // SELECT new vocabulary and examples from view + // convert to entity + + // case Duplicate: handle SqlException on UNIQUE violation and throw DuplicateVocabularyEntryException + + throw new NotImplementedException(); + } + /// public async Task GetVocabularyEntryAsync(string word, CancellationToken token = default) { diff --git a/tests/SpanishByExample.Tests/VocabularyControllerTests.cs b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs new file mode 100644 index 0000000..c5787b6 --- /dev/null +++ b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SpanishByExample.Api.Controllers; +using SpanishByExample.Api.Dtos; +using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Services; + +namespace SpanishByExample.Tests; + +/// +/// Tests the VocabularyController. +/// +public class VocabularyControllerTests +{ + private readonly Mock _daMock; + private readonly VocabularyController _controller; + + public VocabularyControllerTests() + { + _daMock = new Mock(); + _controller = new VocabularyController(); + } + + [Fact] + public async Task Post_Ok() + { + // TODO just a sketch, finish test + + VocabularyEntryRequestDto vocabularyEntryDto = new() + { + RawWord = "hablar", + Examples = [] + }; + + var result = await _controller.Post(vocabularyEntryDto, CancellationToken.None); + + var ok = result.Result.Should().BeOfType().Subject; + var vocabularyEntry = ok.Value.Should().BeOfType().Subject; + vocabularyEntry.WordNorm.Should().Be("hablar"); + } +} From 0a59e903467bdd5dfb1aeef4f7b2f11b02b11443 Mon Sep 17 00:00:00 2001 From: dp Date: Mon, 2 Mar 2026 11:14:56 +0100 Subject: [PATCH 04/24] Issue #13: Added stored procedure that stores vocabulary entry with examples. --- database/schema/create_UDTT.sql | 10 ++++ ..._sp-usp_AddVocabularyEntryWithExamples.sql | 55 +++++++++++++++++++ database/schema/create_tables.sql | 7 ++- ...-sp-usp_AddVocabularyEntryWithExamples.sql | 23 ++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 database/schema/create_UDTT.sql create mode 100644 database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql create mode 100644 database/tests/test-sp-usp_AddVocabularyEntryWithExamples.sql 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_sp-usp_AddVocabularyEntryWithExamples.sql b/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql new file mode 100644 index 0000000..13acf14 --- /dev/null +++ b/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql @@ -0,0 +1,55 @@ +USE [spanish_ex] +GO + +/****** Object: StoredProcedure [dbo].[usp_AddVocabularyEntryWithExamples] Script Date: 02.03.2026 11:03:56 ******/ +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]) + OUTPUT inserted.EXAMPLEID + SELECT @vocId, EXAMPLE_TEXT, ENGLISH, @userId + FROM @examplesTT; + + COMMIT TRAN +END +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/tests/test-sp-usp_AddVocabularyEntryWithExamples.sql b/database/tests/test-sp-usp_AddVocabularyEntryWithExamples.sql new file mode 100644 index 0000000..4c5beda --- /dev/null +++ b/database/tests/test-sp-usp_AddVocabularyEntryWithExamples.sql @@ -0,0 +1,23 @@ +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 + + SELECT * + FROM VOCABULARY_T + JOIN EXAMPLES_T + ON VOCABULARY_T.VOCABULARYID = EXAMPLES_T.VOCABULARYID + + ROLLBACK \ No newline at end of file From b4f1e3b06b8dc19da723831dffcb9a257b5a4e20 Mon Sep 17 00:00:00 2001 From: dp Date: Mon, 2 Mar 2026 11:20:09 +0100 Subject: [PATCH 05/24] Added Migrations/ folder to .gitignore. --- src/.gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/.gitignore b/src/.gitignore index df00081..9d1101c 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,2 +1,5 @@ # dp -appsettings.Development.json \ No newline at end of file +appsettings.Development.json + +# dp: EF Core Migrations +SpanishByExample.Api/Migrations/ From b9e65c27c86f9bb45541e43f2799b78248972fb7 Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Mar 2026 04:43:12 +0100 Subject: [PATCH 06/24] refactoring. --- ...thExamples.sql => test-usp_AddVocabularyEntryWithExamples.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename database/tests/{test-sp-usp_AddVocabularyEntryWithExamples.sql => test-usp_AddVocabularyEntryWithExamples.sql} (100%) diff --git a/database/tests/test-sp-usp_AddVocabularyEntryWithExamples.sql b/database/tests/test-usp_AddVocabularyEntryWithExamples.sql similarity index 100% rename from database/tests/test-sp-usp_AddVocabularyEntryWithExamples.sql rename to database/tests/test-usp_AddVocabularyEntryWithExamples.sql From 80667ebb1799a68e973c2f16c768d09808d99d96 Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Mar 2026 04:47:47 +0100 Subject: [PATCH 07/24] Adapted output of stored procedure to actual needs. --- ..._sp-usp_AddVocabularyEntryWithExamples.sql | 20 +++++++++++++++++-- ...est-usp_AddVocabularyEntryWithExamples.sql | 5 ----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql b/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql index 13acf14..e0aa286 100644 --- a/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql +++ b/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql @@ -1,13 +1,14 @@ USE [spanish_ex] GO -/****** Object: StoredProcedure [dbo].[usp_AddVocabularyEntryWithExamples] Script Date: 02.03.2026 11:03:56 ******/ +/****** Object: StoredProcedure [dbo].[usp_AddVocabularyEntryWithExamples] Script Date: 03.03.2026 04:46:27 ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO + -- ============================================= -- Author: David Pasch -- Create date: 2026-03-02 @@ -44,11 +45,26 @@ BEGIN ,[EXAMPLE_TEXT] ,[ENGLISH] ,[USERID]) - OUTPUT inserted.EXAMPLEID SELECT @vocId, EXAMPLE_TEXT, ENGLISH, @userId FROM @examplesTT; COMMIT TRAN + + SELECT [VOCABULARYID] + ,[WORDNORM] + ,[ENGLISH] + ,[USERID] + FROM [dbo].[VOCABULARY_T] + WHERE VOCABULARYID = @vocId; + + SELECT [EXAMPLEID] + ,[VOCABULARYID] + ,[EXAMPLE_TEXT] + ,[ENGLISH] + ,[USERID] + 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 index 4c5beda..7124d9d 100644 --- a/database/tests/test-usp_AddVocabularyEntryWithExamples.sql +++ b/database/tests/test-usp_AddVocabularyEntryWithExamples.sql @@ -15,9 +15,4 @@ GO EXEC dbo.usp_AddVocabularyEntryWithExamples @wordNorm, @english, @examplesTT, @userId - SELECT * - FROM VOCABULARY_T - JOIN EXAMPLES_T - ON VOCABULARY_T.VOCABULARYID = EXAMPLES_T.VOCABULARYID - ROLLBACK \ No newline at end of file From 58c0deb3073e964d64d1c12198e35e950a476716 Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Mar 2026 04:48:26 +0100 Subject: [PATCH 08/24] Refactoring. --- ...Examples.sql => create_usp_AddVocabularyEntryWithExamples.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename database/schema/{create_sp-usp_AddVocabularyEntryWithExamples.sql => create_usp_AddVocabularyEntryWithExamples.sql} (100%) diff --git a/database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql b/database/schema/create_usp_AddVocabularyEntryWithExamples.sql similarity index 100% rename from database/schema/create_sp-usp_AddVocabularyEntryWithExamples.sql rename to database/schema/create_usp_AddVocabularyEntryWithExamples.sql From 7cdd5426d74afc472d9e8f6be39186f04fddee0d Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Mar 2026 05:22:29 +0100 Subject: [PATCH 09/24] Issue #13: Prepared code for implementing DAL. --- .../SpanishByExample.Api.csproj | 7 +++ .../VocabularyService.cs | 2 +- .../Services/IDataAccessService.cs | 2 +- .../Services/IVocabularyService.cs | 2 +- .../DataAccessService.cs | 2 +- .../DataAccessServiceTests.cs | 45 +++++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/SpanishByExample.Api/SpanishByExample.Api.csproj b/src/SpanishByExample.Api/SpanishByExample.Api.csproj index 26b25b1..4e1d2ce 100644 --- a/src/SpanishByExample.Api/SpanishByExample.Api.csproj +++ b/src/SpanishByExample.Api/SpanishByExample.Api.csproj @@ -6,6 +6,13 @@ enable + + + + + + + diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs index a695b28..a3305b3 100644 --- a/src/SpanishByExample.Business/VocabularyService.cs +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -6,7 +6,7 @@ namespace SpanishByExample.Business; public class VocabularyService : IVocabularyService { - public Task AddVocabularyEntryAsync(int UserId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default) + public Task AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default) { // TODO diff --git a/src/SpanishByExample.Core/Services/IDataAccessService.cs b/src/SpanishByExample.Core/Services/IDataAccessService.cs index 102ce38..9ec2682 100644 --- a/src/SpanishByExample.Core/Services/IDataAccessService.cs +++ b/src/SpanishByExample.Core/Services/IDataAccessService.cs @@ -25,5 +25,5 @@ public interface IDataAccessService /// Cancellation token. /// Stored vocabulary entry with the IDs set. /// - Task AddVocabularyEntryAsync(int UserId, VocabularyEntry vocabularyEntry, CancellationToken token = default); + Task AddVocabularyEntryAsync(string userId, VocabularyEntry vocabularyEntry, CancellationToken token = default); } diff --git a/src/SpanishByExample.Core/Services/IVocabularyService.cs b/src/SpanishByExample.Core/Services/IVocabularyService.cs index 797009d..2540bbf 100644 --- a/src/SpanishByExample.Core/Services/IVocabularyService.cs +++ b/src/SpanishByExample.Core/Services/IVocabularyService.cs @@ -18,5 +18,5 @@ public interface IVocabularyService /// Cancellation token. /// Stored vocabulary entry with the IDs set. /// - Task AddVocabularyEntryAsync(int UserId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default); + Task AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default); } diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index 8a9da37..8aa1720 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -11,7 +11,7 @@ namespace SpanishByExample.DataAccess; public class DataAccessService(ILogger log, IConfiguration configuration) : IDataAccessService { /// - public Task AddVocabularyEntryAsync(int UserId, VocabularyEntry vocabularyEntry, CancellationToken token = default) + public Task AddVocabularyEntryAsync(string UserId, VocabularyEntry vocabularyEntry, CancellationToken token = default) { // transaction: // INSERT vocabulary and examples into DB diff --git a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs index b5ae09b..6a47e04 100644 --- a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs +++ b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs @@ -1,10 +1,14 @@ using FluentAssertions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using SpanishByExample.Core.Entities; using SpanishByExample.DataAccess; namespace SpanishByExample.Tests; +/// +/// Tests the DataAccessService. +/// public class DataAccessServiceTests { private readonly DataAccessService _da; @@ -18,6 +22,8 @@ public DataAccessServiceTests() _da = new DataAccessService(log, config); } + #region GetVocabularyEntryAsync + //[Fact] [Fact(Skip = "Real test.")] public async Task GetVocabularyEntryAsync_Ok_Examples() @@ -66,4 +72,43 @@ public async Task GetVocabularyEntryAsync_Ok_Unknown() vocabularyEntry.Should().BeNull(); } + + #endregion + + #region AddVocabularyEntryAsync + + // TODO make theory to test null, non-null values + + [Fact] + public async Task AddVocabularyEntryAsync_Ok() + { + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + VocabularyEntry vocEntry = new() + { + VocabularyId = 0, + WordNorm = "comer", + EnglishTranslation = null, + Examples = new List() + { + new Example { + ExampleId = 0, + ExampleText = "Como carne.", + EnglishTranslation = null + } + } + + }; + + 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); + } + + #endregion } From 1e6506d8c5fc0fa2be5e0f092a5b3f950abf3d81 Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Mar 2026 08:38:24 +0100 Subject: [PATCH 10/24] Issue #13: Implemented DAL with unit tests. --- .../Controllers/VocabularyController.cs | 2 + .../VocabularyService.cs | 2 + .../DataAccessService.cs | 101 ++++++++++++++++-- .../DataAccessServiceTests.cs | 40 +++++-- 4 files changed, 131 insertions(+), 14 deletions(-) diff --git a/src/SpanishByExample.Api/Controllers/VocabularyController.cs b/src/SpanishByExample.Api/Controllers/VocabularyController.cs index fd63528..78432f6 100644 --- a/src/SpanishByExample.Api/Controllers/VocabularyController.cs +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -20,6 +20,8 @@ public async Task> Post([FromBody] VocabularyEntry { // TODO Post + // TODO input validation: #examples > 0 + // Call BL: Map DTO to Command // If word already exists in DB, return 409 diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs index a3305b3..f0b57da 100644 --- a/src/SpanishByExample.Business/VocabularyService.cs +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -12,6 +12,8 @@ public Task AddVocabularyEntryAsync(string userId, CreateVocabu // check that word is in normal form eg comer + // TODO IMPORTANT: check business rule: #examples > 0 + // convert Command to Entity for DA // call DA & receive newly created entry with IDs diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index 8aa1720..f1abdfd 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -4,6 +4,7 @@ using SpanishByExample.Core.Errors; using SpanishByExample.Core.Services; using SpanishByExample.Core.Entities; +using System.Data; namespace SpanishByExample.DataAccess; @@ -11,16 +12,100 @@ namespace SpanishByExample.DataAccess; public class DataAccessService(ILogger log, IConfiguration configuration) : IDataAccessService { /// - public Task AddVocabularyEntryAsync(string UserId, VocabularyEntry vocabularyEntry, CancellationToken token = default) + public async Task AddVocabularyEntryAsync(string userId, VocabularyEntry vocabularyEntry, CancellationToken token = default) { - // transaction: - // INSERT vocabulary and examples into DB - // SELECT new vocabulary and examples from view - // convert to entity + log.LogDebug($"{nameof(userId)}: {userId}, {nameof(vocabularyEntry)}: {vocabularyEntry.WordNorm}"); - // case Duplicate: handle SqlException on UNIQUE violation and throw DuplicateVocabularyEntryException + try + { + // TODO extension method + + var examplesDt = new DataTable(); + examplesDt.Columns.AddRange( + [ + new DataColumn("EXAMPLE_TEXT"), + new DataColumn("ENGLISH"), + ]); + foreach (var example in vocabularyEntry.Examples) + { + examplesDt.Rows.Add(example.ExampleText, example.EnglishTranslation); + } + ; + + var connectionString = configuration.GetConnectionString("SpanishByExampleDb") ?? throw new InvalidOperationException("Connection string is missing."); + 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); + 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 false + // NOTE: For debugging only. + using var transaction = connection.BeginTransaction(); + command.Transaction = transaction; +#endif + + using var reader = await command.ExecuteReaderAsync(token); + + VocabularyEntry newVocEntry = null!; + while (await reader.ReadAsync(token)) + { + if (newVocEntry != null) + { + throw new InvalidOperationException("Database returns more than one vocabulary entry."); + } + newVocEntry = new() + { + VocabularyId = reader.GetInt32(0), + WordNorm = reader.GetString(1), + EnglishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2), + }; + } - throw new NotImplementedException(); + if (!await reader.NextResultAsync(token)) + { + throw new InvalidOperationException("Database fails to return newly created examples for vocabulary entry."); + } + + newVocEntry.Examples = new List(); + while (await reader.ReadAsync(token)) + { + newVocEntry.Examples.Add(new Example + { + ExampleId = reader.GetInt32(0), + ExampleText = reader.GetString(1), + EnglishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2), + }); + } + +#if false + // NOTE: For debugging only. + await reader.CloseAsync(); + transaction.Rollback(); +#endif + + return newVocEntry ?? 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); + } + catch (SqlException ex) + { + log.LogDebug(ex, $"Error: {ex.Message}"); + throw new DatabaseException(ex.Message, ex); + } } /// @@ -43,7 +128,7 @@ FROM dbo.EXAMPLES ex "; try { - var connectionString = configuration.GetConnectionString("SpanishByExampleDb"); + var connectionString = configuration.GetConnectionString("SpanishByExampleDb") ?? throw new InvalidOperationException("Connection string is missing."); using var connection = new SqlConnection(connectionString); using var command = new SqlCommand(query, connection); diff --git a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs index 6a47e04..892936e 100644 --- a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs +++ b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; using SpanishByExample.DataAccess; namespace SpanishByExample.Tests; @@ -77,23 +78,23 @@ public async Task GetVocabularyEntryAsync_Ok_Unknown() #region AddVocabularyEntryAsync - // TODO make theory to test null, non-null values - - [Fact] - public async Task AddVocabularyEntryAsync_Ok() + [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"; VocabularyEntry vocEntry = new() { VocabularyId = 0, WordNorm = "comer", - EnglishTranslation = null, + EnglishTranslation = vocEnglish, Examples = new List() { new Example { ExampleId = 0, ExampleText = "Como carne.", - EnglishTranslation = null + EnglishTranslation = exEnglish } } @@ -110,5 +111,32 @@ public async Task AddVocabularyEntryAsync_Ok() newVocEntry.Examples[0].EnglishTranslation.Should().Be(vocEntry.Examples[0].EnglishTranslation); } + [Fact(Skip = "Real test.")] + public async Task AddVocabularyEntryAsync_Error_DuplicateVocabularyEntryException() + { + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; + VocabularyEntry vocEntry = new() + { + VocabularyId = 0, + WordNorm = "hablar", + EnglishTranslation = null, + Examples = new List() + { + 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 } From de88402281d04b6899e446f5ca8333c2ec844004 Mon Sep 17 00:00:00 2001 From: dp Date: Tue, 3 Mar 2026 09:15:30 +0100 Subject: [PATCH 11/24] Issue #13: Refactoring: Added extension method. --- .../DataAccessService.cs | 15 +-------- .../DataAccessUtils.cs | 32 +++++++++++++++++++ .../DataAccessServiceTests.cs | 2 ++ 3 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 src/SpanishByExample.DataAccess/DataAccessUtils.cs diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index f1abdfd..e5c3b44 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -18,20 +18,6 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu try { - // TODO extension method - - var examplesDt = new DataTable(); - examplesDt.Columns.AddRange( - [ - new DataColumn("EXAMPLE_TEXT"), - new DataColumn("ENGLISH"), - ]); - foreach (var example in vocabularyEntry.Examples) - { - examplesDt.Rows.Add(example.ExampleText, example.EnglishTranslation); - } - ; - var connectionString = configuration.GetConnectionString("SpanishByExampleDb") ?? throw new InvalidOperationException("Connection string is missing."); using var connection = new SqlConnection(connectionString); @@ -39,6 +25,7 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu 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", diff --git a/src/SpanishByExample.DataAccess/DataAccessUtils.cs b/src/SpanishByExample.DataAccess/DataAccessUtils.cs new file mode 100644 index 0000000..60ea780 --- /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 IList 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 892936e..2fb320d 100644 --- a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs +++ b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs @@ -78,6 +78,7 @@ public async Task GetVocabularyEntryAsync_Ok_Unknown() #region AddVocabularyEntryAsync + //[Theory] [Theory(Skip = "Real test.")] [InlineData(null, null)] [InlineData("to eat", "I eat meat.")] @@ -111,6 +112,7 @@ public async Task AddVocabularyEntryAsync_Ok(string? vocEnglish, string? exEngli newVocEntry.Examples[0].EnglishTranslation.Should().Be(vocEntry.Examples[0].EnglishTranslation); } + //[Fact] [Fact(Skip = "Real test.")] public async Task AddVocabularyEntryAsync_Error_DuplicateVocabularyEntryException() { From 908f6a26fda7dccdf0979851824be31d2049970a Mon Sep 17 00:00:00 2001 From: dp Date: Wed, 4 Mar 2026 08:46:12 +0100 Subject: [PATCH 12/24] Issue #13: Refactoring. --- .../DuplicateVocabularyEntryException.cs | 6 +-- .../Services/IDataAccessService.cs | 1 + .../DataAccessService.cs | 44 ++++++++++++------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs b/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs index c396060..2179980 100644 --- a/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs +++ b/src/SpanishByExample.Core/Errors/DuplicateVocabularyEntryException.cs @@ -5,10 +5,10 @@ namespace SpanishByExample.Core.Errors; /// /// Represents an error caused by trying to create a duplicate vocabulary entry. /// -public class DuplicateVocabularyEntryException : Exception +public class DuplicateVocabularyEntryException : DatabaseException { - public DuplicateVocabularyEntryException(VocabularyEntry vocabularyEntry) - : base($"Duplicate vocabulary entry: {vocabularyEntry.WordNorm}.") + public DuplicateVocabularyEntryException(VocabularyEntry vocabularyEntry, Exception innerException) + : base($"Duplicate vocabulary entry: {vocabularyEntry.WordNorm}.", innerException) { } diff --git a/src/SpanishByExample.Core/Services/IDataAccessService.cs b/src/SpanishByExample.Core/Services/IDataAccessService.cs index 9ec2682..5b95e9e 100644 --- a/src/SpanishByExample.Core/Services/IDataAccessService.cs +++ b/src/SpanishByExample.Core/Services/IDataAccessService.cs @@ -25,5 +25,6 @@ public interface IDataAccessService /// Cancellation token. /// Stored vocabulary entry with the IDs set. /// + /// Task AddVocabularyEntryAsync(string userId, VocabularyEntry vocabularyEntry, CancellationToken token = default); } diff --git a/src/SpanishByExample.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index e5c3b44..6053ecd 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -1,4 +1,6 @@ -using Microsoft.Data.SqlClient; +//#define DEBUG_ROLLBACK + +using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using SpanishByExample.Core.Errors; @@ -8,18 +10,29 @@ 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}"); + _log.LogDebug($"{nameof(userId)}: {userId}, {nameof(vocabularyEntry)}: {vocabularyEntry.WordNorm}"); try { - var connectionString = configuration.GetConnectionString("SpanishByExampleDb") ?? throw new InvalidOperationException("Connection string is missing."); - using var connection = new SqlConnection(connectionString); + using var connection = new SqlConnection(_connectionString); using var command = new SqlCommand("dbo.usp_AddVocabularyEntryWithExamples", connection); command.CommandType = CommandType.StoredProcedure; @@ -36,7 +49,7 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu connection.Open(); // TODO make prettier -#if false +#if DEBUG_ROLLBACK // NOTE: For debugging only. using var transaction = connection.BeginTransaction(); command.Transaction = transaction; @@ -75,7 +88,7 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu }); } -#if false +#if DEBUG_ROLLBACK // NOTE: For debugging only. await reader.CloseAsync(); transaction.Rollback(); @@ -85,12 +98,12 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu } catch (SqlException ex) when (ex.Number == 2627 || ex.Number == 2601) { - log.LogDebug(ex, $"Error: Duplicate entry: {vocabularyEntry.WordNorm}"); - throw new DuplicateVocabularyEntryException(vocabularyEntry); + _log.LogDebug(ex, $"Error: Duplicate entry: {vocabularyEntry.WordNorm}"); + throw new DuplicateVocabularyEntryException(vocabularyEntry, ex); } catch (SqlException ex) { - log.LogDebug(ex, $"Error: {ex.Message}"); + _log.LogDebug(ex, $"Error: {ex.Message}"); throw new DatabaseException(ex.Message, ex); } } @@ -98,7 +111,7 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu /// public async Task GetVocabularyEntryAsync(string word, CancellationToken token = default) { - log.LogDebug($"{nameof(word)}: {word}"); + _log.LogDebug($"{nameof(word)}: {word}"); // TODO security @@ -115,8 +128,7 @@ FROM dbo.EXAMPLES ex "; try { - var connectionString = configuration.GetConnectionString("SpanishByExampleDb") ?? throw new InvalidOperationException("Connection string is missing."); - using var connection = new SqlConnection(connectionString); + using var connection = new SqlConnection(_connectionString); using var command = new SqlCommand(query, connection); command.Parameters.AddWithValue("@wordNorm", word); @@ -154,8 +166,10 @@ FROM dbo.EXAMPLES ex } 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 } From 2de1c2a6fbb5be7075d7ee17c0c80d961a6283a9 Mon Sep 17 00:00:00 2001 From: dp Date: Wed, 4 Mar 2026 10:07:31 +0100 Subject: [PATCH 13/24] Issue #13: Prepared VocabularyService with unit test. --- .../ExamplesService.cs | 19 ++-- .../VocabularyService.cs | 12 ++- .../Commands/CreateExampleCommand.cs | 21 ++-- .../Commands/CreateVocabularyCommand.cs | 18 +++- .../Services/IVocabularyService.cs | 3 +- .../VocabularyServiceTests.cs | 102 ++++++++++++++++++ 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 tests/SpanishByExample.Tests/VocabularyServiceTests.cs diff --git a/src/SpanishByExample.Business/ExamplesService.cs b/src/SpanishByExample.Business/ExamplesService.cs index 925ab18..30d5093 100644 --- a/src/SpanishByExample.Business/ExamplesService.cs +++ b/src/SpanishByExample.Business/ExamplesService.cs @@ -5,33 +5,36 @@ 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 index f0b57da..c529e0c 100644 --- a/src/SpanishByExample.Business/VocabularyService.cs +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -1,11 +1,19 @@ -using SpanishByExample.Core.Commands; +using Microsoft.Extensions.Logging; +using SpanishByExample.Core.Commands; using SpanishByExample.Core.Entities; using SpanishByExample.Core.Services; namespace SpanishByExample.Business; -public class VocabularyService : IVocabularyService +/// +/// 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 { + /// public Task AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default) { // TODO diff --git a/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs b/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs index 50dde1b..e349fc3 100644 --- a/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs +++ b/src/SpanishByExample.Core/Commands/CreateExampleCommand.cs @@ -1,9 +1,18 @@ -namespace SpanishByExample.Core.Commands +namespace SpanishByExample.Core.Commands; + +/// +/// Command to create a new example in the knowledge base. +/// NOTE: It's an immutable object. +/// +public class CreateExampleCommand { - public class CreateExampleCommand - { - public required string ExampleText { get; init; } + /// + /// The example text. NOTE: Must be non-empty. + /// + public required string ExampleText { get; init; } - public string? EnglishTranslation { 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 index e57ac88..ef4ab47 100644 --- a/src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs +++ b/src/SpanishByExample.Core/Commands/CreateVocabularyCommand.cs @@ -2,9 +2,25 @@ 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; } - public required ReadOnlyCollection Examples { 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/Services/IVocabularyService.cs b/src/SpanishByExample.Core/Services/IVocabularyService.cs index 2540bbf..1c4c154 100644 --- a/src/SpanishByExample.Core/Services/IVocabularyService.cs +++ b/src/SpanishByExample.Core/Services/IVocabularyService.cs @@ -13,10 +13,11 @@ public interface IVocabularyService /// /// Adds a new vocabulary entry to the database. /// - /// ID of user who creates the vocabulary entry. + /// 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/tests/SpanishByExample.Tests/VocabularyServiceTests.cs b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs new file mode 100644 index 0000000..3016e23 --- /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.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 createVocCmd = new CreateVocabularyCommand() + { + RawWord = "comer", + EnglishTranslation = vocEnglish, + CreateExampleCommands = new List() + { + new() + { + ExampleText = "Como carne.", + EnglishTranslation = exEnglish + } + } + .AsReadOnly() + }; + + var businessVocEntry = new VocabularyEntry() + { + VocabularyId = 0, + WordNorm = createVocCmd.RawWord, + EnglishTranslation = vocEnglish, + Examples = + [ + new() + { + ExampleId = 0, + ExampleText = createVocCmd.CreateExampleCommands[0].ExampleText, + EnglishTranslation = exEnglish + } + ] + }; + + VocabularyEntry daVocEntry = new() + { + VocabularyId = 1, + WordNorm = businessVocEntry.WordNorm, + EnglishTranslation = vocEnglish, + Examples = + [ + new() + { + ExampleId = 11, + ExampleText = businessVocEntry.Examples[0].ExampleText, + EnglishTranslation = exEnglish + } + ] + }; + _daMock.Setup(da => da.AddVocabularyEntryAsync(userId, businessVocEntry)) + .ReturnsAsync(daVocEntry); + + + var newVocEntry = await _vocabularyService.AddVocabularyEntryAsync(userId, createVocCmd); + + newVocEntry.Should().BeEquivalentTo(daVocEntry); + _daMock.Verify(da => da.AddVocabularyEntryAsync(userId, businessVocEntry), Times.Once()); + } + + #endregion +} From 8661005e5000f96b75f424a023815f2dc3068667 Mon Sep 17 00:00:00 2001 From: dp Date: Wed, 4 Mar 2026 10:55:37 +0100 Subject: [PATCH 14/24] Issue #13: Implemented VocabularyService. --- .../VocabularyService.cs | 44 +++++++++++++++---- .../Services/IVocabularyService.cs | 1 + .../VocabularyServiceTests.cs | 28 +++--------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs index c529e0c..e7ac8f4 100644 --- a/src/SpanishByExample.Business/VocabularyService.cs +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -2,6 +2,7 @@ using SpanishByExample.Core.Commands; using SpanishByExample.Core.Entities; using SpanishByExample.Core.Services; +using System.Text.RegularExpressions; namespace SpanishByExample.Business; @@ -13,21 +14,46 @@ namespace SpanishByExample.Business; /// Injected Data Access service. public class VocabularyService(ILogger _log, IDataAccessService _da) : IVocabularyService { + private static readonly Regex WORDNORM_REGEX = new(@"\w+(ar|er|ir)"); + /// - public Task AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default) + public async Task AddVocabularyEntryAsync(string userId, CreateVocabularyCommand vocabularyCmd, CancellationToken token = default) { - // TODO - - // check that word is in normal form eg comer + _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 ArgumentException($"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 ArgumentException($"No examples were provided for word: {vocabularyCmd.RawWord}"); + + // TODO refactor: extension method - // TODO IMPORTANT: check business rule: #examples > 0 + var vocEntry = new VocabularyEntry() + { + VocabularyId = 0, + WordNorm = vocabularyCmd.RawWord, + EnglishTranslation = vocabularyCmd.EnglishTranslation, + Examples = [] + }; - // convert Command to Entity for DA + foreach ( var exampleCommand in vocabularyCmd.CreateExampleCommands ) + { + vocEntry.Examples.Add(new() + { + ExampleId = 0, + ExampleText = vocabularyCmd.CreateExampleCommands[0].ExampleText, + EnglishTranslation = vocabularyCmd.CreateExampleCommands[0].EnglishTranslation + }); + } - // call DA & receive newly created entry with IDs + var newVocEntry = await _da.AddVocabularyEntryAsync(userId, vocEntry, token); - // return new entry + _log.LogDebug($"Vocabulary entry successfully created: {newVocEntry.VocabularyId}"); - throw new NotImplementedException(); + return newVocEntry; } } diff --git a/src/SpanishByExample.Core/Services/IVocabularyService.cs b/src/SpanishByExample.Core/Services/IVocabularyService.cs index 1c4c154..140316f 100644 --- a/src/SpanishByExample.Core/Services/IVocabularyService.cs +++ b/src/SpanishByExample.Core/Services/IVocabularyService.cs @@ -17,6 +17,7 @@ public interface IVocabularyService /// 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/tests/SpanishByExample.Tests/VocabularyServiceTests.cs b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs index 3016e23..6cb13a1 100644 --- a/tests/SpanishByExample.Tests/VocabularyServiceTests.cs +++ b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs @@ -42,7 +42,7 @@ public async Task AddVocabularyEntryAsync_Ok() var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; - var createVocCmd = new CreateVocabularyCommand() + var vocabularyCmd = new CreateVocabularyCommand() { RawWord = "comer", EnglishTranslation = vocEnglish, @@ -57,45 +57,29 @@ public async Task AddVocabularyEntryAsync_Ok() .AsReadOnly() }; - var businessVocEntry = new VocabularyEntry() - { - VocabularyId = 0, - WordNorm = createVocCmd.RawWord, - EnglishTranslation = vocEnglish, - Examples = - [ - new() - { - ExampleId = 0, - ExampleText = createVocCmd.CreateExampleCommands[0].ExampleText, - EnglishTranslation = exEnglish - } - ] - }; - VocabularyEntry daVocEntry = new() { VocabularyId = 1, - WordNorm = businessVocEntry.WordNorm, + WordNorm = "comer", EnglishTranslation = vocEnglish, Examples = [ new() { ExampleId = 11, - ExampleText = businessVocEntry.Examples[0].ExampleText, + ExampleText = vocabularyCmd.CreateExampleCommands[0].ExampleText, EnglishTranslation = exEnglish } ] }; - _daMock.Setup(da => da.AddVocabularyEntryAsync(userId, businessVocEntry)) + _daMock.Setup(da => da.AddVocabularyEntryAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(daVocEntry); - var newVocEntry = await _vocabularyService.AddVocabularyEntryAsync(userId, createVocCmd); + var newVocEntry = await _vocabularyService.AddVocabularyEntryAsync(userId, vocabularyCmd); newVocEntry.Should().BeEquivalentTo(daVocEntry); - _daMock.Verify(da => da.AddVocabularyEntryAsync(userId, businessVocEntry), Times.Once()); + _daMock.Verify(da => da.AddVocabularyEntryAsync(userId, It.Is(v => v.VocabularyId == 0 && v.WordNorm == vocabularyCmd.RawWord)), Times.Once()); } #endregion From d2c2aa328c826eef66680ab32631067e8ccff411 Mon Sep 17 00:00:00 2001 From: dp Date: Wed, 4 Mar 2026 11:44:41 +0100 Subject: [PATCH 15/24] Refactoring. --- .../BusinessUtils.cs | 39 +++++++++++++++++++ .../VocabularyService.cs | 20 +--------- 2 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 src/SpanishByExample.Business/BusinessUtils.cs diff --git a/src/SpanishByExample.Business/BusinessUtils.cs b/src/SpanishByExample.Business/BusinessUtils.cs new file mode 100644 index 0000000..2af6992 --- /dev/null +++ b/src/SpanishByExample.Business/BusinessUtils.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging.Abstractions; +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) + { + var vocEntry = new VocabularyEntry() + { + VocabularyId = 0, + WordNorm = vocabularyCmd.RawWord, + EnglishTranslation = vocabularyCmd.EnglishTranslation, + Examples = [] + }; + + foreach (var exampleCommand in vocabularyCmd.CreateExampleCommands) + { + vocEntry.Examples.Add(new() + { + ExampleId = 0, + ExampleText = exampleCommand.ExampleText, + EnglishTranslation = exampleCommand.EnglishTranslation + }); + } + + return vocEntry; + } +} diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs index e7ac8f4..f9079ff 100644 --- a/src/SpanishByExample.Business/VocabularyService.cs +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -30,25 +30,7 @@ public async Task AddVocabularyEntryAsync(string userId, Create if (vocabularyCmd.CreateExampleCommands == null || vocabularyCmd.CreateExampleCommands.Count == 0) throw new ArgumentException($"No examples were provided for word: {vocabularyCmd.RawWord}"); - // TODO refactor: extension method - - var vocEntry = new VocabularyEntry() - { - VocabularyId = 0, - WordNorm = vocabularyCmd.RawWord, - EnglishTranslation = vocabularyCmd.EnglishTranslation, - Examples = [] - }; - - foreach ( var exampleCommand in vocabularyCmd.CreateExampleCommands ) - { - vocEntry.Examples.Add(new() - { - ExampleId = 0, - ExampleText = vocabularyCmd.CreateExampleCommands[0].ExampleText, - EnglishTranslation = vocabularyCmd.CreateExampleCommands[0].EnglishTranslation - }); - } + var vocEntry = vocabularyCmd.ToVocabularyEntry(); var newVocEntry = await _da.AddVocabularyEntryAsync(userId, vocEntry, token); From 9b0f2b04a5049ffbe5b3f68af6c398e31f106377 Mon Sep 17 00:00:00 2001 From: dp Date: Wed, 4 Mar 2026 18:39:28 +0100 Subject: [PATCH 16/24] Added test for VocabularyService in the case of error. --- .../VocabularyService.cs | 5 +++-- .../Errors/BusinessException.cs | 12 ++++++++++++ .../Errors/MissingExamplesException.cs | 12 ++++++++++++ .../Services/IVocabularyService.cs | 3 ++- .../VocabularyServiceTests.cs | 17 +++++++++++++++++ 5 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/SpanishByExample.Core/Errors/BusinessException.cs create mode 100644 src/SpanishByExample.Core/Errors/MissingExamplesException.cs diff --git a/src/SpanishByExample.Business/VocabularyService.cs b/src/SpanishByExample.Business/VocabularyService.cs index f9079ff..aec7002 100644 --- a/src/SpanishByExample.Business/VocabularyService.cs +++ b/src/SpanishByExample.Business/VocabularyService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using SpanishByExample.Core.Errors; using SpanishByExample.Core.Commands; using SpanishByExample.Core.Entities; using SpanishByExample.Core.Services; @@ -24,11 +25,11 @@ public async Task AddVocabularyEntryAsync(string userId, Create // Check that word is in normal form eg comer if (string.IsNullOrWhiteSpace(vocabularyCmd.RawWord) || !WORDNORM_REGEX.IsMatch(vocabularyCmd.RawWord)) - throw new ArgumentException($"Supplied word is not a verb in infinitive form: {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 ArgumentException($"No examples were provided for word: {vocabularyCmd.RawWord}"); + throw new MissingExamplesException($"No examples were provided for word: {vocabularyCmd.RawWord}"); var vocEntry = vocabularyCmd.ToVocabularyEntry(); 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/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/Services/IVocabularyService.cs b/src/SpanishByExample.Core/Services/IVocabularyService.cs index 140316f..233a427 100644 --- a/src/SpanishByExample.Core/Services/IVocabularyService.cs +++ b/src/SpanishByExample.Core/Services/IVocabularyService.cs @@ -17,7 +17,8 @@ public interface IVocabularyService /// 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/tests/SpanishByExample.Tests/VocabularyServiceTests.cs b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs index 6cb13a1..e547010 100644 --- a/tests/SpanishByExample.Tests/VocabularyServiceTests.cs +++ b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs @@ -4,6 +4,7 @@ using SpanishByExample.Business; using SpanishByExample.Core.Commands; using SpanishByExample.Core.Entities; +using SpanishByExample.Core.Errors; using SpanishByExample.Core.Services; namespace SpanishByExample.Tests; @@ -82,5 +83,21 @@ public async Task AddVocabularyEntryAsync_Ok() _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 } From c1dd481da3e354a77d0d0ac258d79e1b20612a12 Mon Sep 17 00:00:00 2001 From: dp Date: Thu, 5 Mar 2026 09:05:49 +0100 Subject: [PATCH 17/24] Issue #18: Fix: appsettings.json. --- src/SpanishByExample.Api/Program.cs | 5 ++--- src/SpanishByExample.Api/appsettings.json | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/SpanishByExample.Api/Program.cs b/src/SpanishByExample.Api/Program.cs index 4483edc..a4a320f 100644 --- a/src/SpanishByExample.Api/Program.cs +++ b/src/SpanishByExample.Api/Program.cs @@ -23,7 +23,7 @@ RegisterAuthentication(builder); -RegisterServices(builder); +RegisterCustomServices(builder); var app = builder.Build(); @@ -50,8 +50,7 @@ #region Private Methods -// TODO ren to RegisterCustomServices -static void RegisterServices(WebApplicationBuilder builder) +static void RegisterCustomServices(WebApplicationBuilder builder) { 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": { From 4353aa5bb3b4be181e8b515b45e1887c321d2195 Mon Sep 17 00:00:00 2001 From: dp Date: Thu, 5 Mar 2026 10:15:36 +0100 Subject: [PATCH 18/24] Issue #13: Implemented the VocabularyController. --- .../Controllers/ExamplesController.cs | 5 +- .../Controllers/VocabularyController.cs | 66 ++++++++++++++++--- .../Dtos/ExampleRequestDto.cs | 11 +++- .../Dtos/VocabularyEntryRequestDto.cs | 17 ++++- src/SpanishByExample.Api/Program.cs | 1 + .../Entities/VocabularyEntry.cs | 2 + 6 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/SpanishByExample.Api/Controllers/ExamplesController.cs b/src/SpanishByExample.Api/Controllers/ExamplesController.cs index 9321fa9..8401263 100644 --- a/src/SpanishByExample.Api/Controllers/ExamplesController.cs +++ b/src/SpanishByExample.Api/Controllers/ExamplesController.cs @@ -1,6 +1,4 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using SpanishByExample.Core.Errors; using SpanishByExample.Core.Services; using SpanishByExample.Core.Entities; @@ -10,6 +8,7 @@ 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 index 78432f6..e036442 100644 --- a/src/SpanishByExample.Api/Controllers/VocabularyController.cs +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -1,31 +1,77 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SpanishByExample.Api.Dtos; +using SpanishByExample.Core.Commands; 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 : ControllerBase +public class VocabularyController(ILogger _log, IVocabularyService _vocabularyService) : ControllerBase { /// /// Creates new vocabulary entry in the knowledge base. /// - /// Vocabulary entry to be created. - /// Cancellation token + /// Vocabulary entry to be created. + /// Cancellation token /// Created vocabulary entry. + [Authorize] [HttpPost] - public async Task> Post([FromBody] VocabularyEntryRequestDto vocabularyEntryDto, CancellationToken token) + public async Task> Post([FromBody] VocabularyEntryRequestDto vocDto, CancellationToken cancellationToken) { - // TODO Post + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)!; - // TODO input validation: #examples > 0 + _log.LogDebug($"Request for creating vocabulary entry ({vocDto.RawWord}) by user {userId}."); - // Call BL: Map DTO to Command + try + { + // TODO refactor: make extension method - // If word already exists in DB, return 409 + 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 Ok(new VocabularyEntry { VocabularyId = 0, WordNorm = "comer" }); + 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 index fe8d438..7b40381 100644 --- a/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs +++ b/src/SpanishByExample.Api/Dtos/ExampleRequestDto.cs @@ -1,7 +1,14 @@ -namespace SpanishByExample.Api.Dtos; +using System.ComponentModel.DataAnnotations; +namespace SpanishByExample.Api.Dtos; + +/// +/// DTO for creating a new usage example for a vocabulary entry. +/// public class ExampleRequestDto { - public required string ExampleText { get; set; } + [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 index eb942fc..b39a196 100644 --- a/src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs +++ b/src/SpanishByExample.Api/Dtos/VocabularyEntryRequestDto.cs @@ -1,7 +1,18 @@ -namespace SpanishByExample.Api.Dtos; +using System.ComponentModel.DataAnnotations; +namespace SpanishByExample.Api.Dtos; + +/// +/// DTO for creating a new vocabulary entry. +/// public class VocabularyEntryRequestDto { - public required string RawWord { get; set; } - public required ExampleRequestDto[] Examples { get; set; } + [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 a4a320f..b9825f1 100644 --- a/src/SpanishByExample.Api/Program.cs +++ b/src/SpanishByExample.Api/Program.cs @@ -53,6 +53,7 @@ static void RegisterCustomServices(WebApplicationBuilder builder) { builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); } diff --git a/src/SpanishByExample.Core/Entities/VocabularyEntry.cs b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs index 8b302a2..93e0a94 100644 --- a/src/SpanishByExample.Core/Entities/VocabularyEntry.cs +++ b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs @@ -1,5 +1,7 @@ namespace SpanishByExample.Core.Entities; +// TODO make readonly + /// /// A vocabulary entry for a specific word. /// Stores translations and available usage examples. From 627260bcb0490e8682ba60e7b4b25f396c2b200c Mon Sep 17 00:00:00 2001 From: dp Date: Thu, 5 Mar 2026 10:26:15 +0100 Subject: [PATCH 19/24] Refactoring. --- src/SpanishByExample.Api/ApiUtils.cs | 31 +++++++++++++++++++ .../Controllers/VocabularyController.cs | 13 +------- 2 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 src/SpanishByExample.Api/ApiUtils.cs 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/VocabularyController.cs b/src/SpanishByExample.Api/Controllers/VocabularyController.cs index e036442..fc64219 100644 --- a/src/SpanishByExample.Api/Controllers/VocabularyController.cs +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -34,18 +34,7 @@ public async Task> Post([FromBody] VocabularyEntry try { - // TODO refactor: make extension method - - CreateVocabularyCommand vocabularyCommand = new() - { - RawWord = vocDto.RawWord, - EnglishTranslation = vocDto.EnglishTranslation, - CreateExampleCommands = vocDto.Examples.Select(exDto => new CreateExampleCommand() - { - ExampleText = exDto.ExampleText, - EnglishTranslation = exDto.EnglishTranslation - }).ToList().AsReadOnly(), - }; + var vocabularyCommand = vocDto.ToCommand(); var newVocEntry = await _vocabularyService.AddVocabularyEntryAsync(userId, vocabularyCommand, cancellationToken); From cbefb699e4a461fa11cfb5f3aff9b46b17bf65c6 Mon Sep 17 00:00:00 2001 From: dp Date: Fri, 6 Mar 2026 10:04:45 +0100 Subject: [PATCH 20/24] Issue #13: Implemented unit test for VocabularyController. --- .../Controllers/VocabularyController.cs | 1 - .../VocabularyControllerTests.cs | 75 ++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/SpanishByExample.Api/Controllers/VocabularyController.cs b/src/SpanishByExample.Api/Controllers/VocabularyController.cs index fc64219..ac453ac 100644 --- a/src/SpanishByExample.Api/Controllers/VocabularyController.cs +++ b/src/SpanishByExample.Api/Controllers/VocabularyController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SpanishByExample.Api.Dtos; -using SpanishByExample.Core.Commands; using SpanishByExample.Core.Entities; using SpanishByExample.Core.Errors; using SpanishByExample.Core.Services; diff --git a/tests/SpanishByExample.Tests/VocabularyControllerTests.cs b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs index c5787b6..e33da5d 100644 --- a/tests/SpanishByExample.Tests/VocabularyControllerTests.cs +++ b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs @@ -1,10 +1,14 @@ 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; @@ -13,30 +17,83 @@ namespace SpanishByExample.Tests; /// public class VocabularyControllerTests { - private readonly Mock _daMock; + private readonly Mock _vocServiceMock; private readonly VocabularyController _controller; public VocabularyControllerTests() { - _daMock = new Mock(); - _controller = new VocabularyController(); + _vocServiceMock = new Mock(); + var log = NullLogger.Instance; + _controller = new VocabularyController(log, _vocServiceMock.Object); } [Fact] public async Task Post_Ok() { - // TODO just a sketch, finish test + var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; - VocabularyEntryRequestDto vocabularyEntryDto = new() + VocabularyEntryRequestDto vocDto = new() { - RawWord = "hablar", - Examples = [] + RawWord = "comer", + EnglishTranslation = "to eat", + Examples = + [ + new() + { + ExampleText = "Como carne.", + EnglishTranslation = "I eat meat." + } + ] }; - var result = await _controller.Post(vocabularyEntryDto, CancellationToken.None); + _controller.ControllerContext = CreateAuthenticatedContext(userId); + + VocabularyEntry newVocEntry = new() + { + VocabularyId = 1, + WordNorm = vocDto.RawWord, + EnglishTranslation = vocDto.EnglishTranslation, + Examples = + [ + new() + { + 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.WordNorm.Should().Be("hablar"); + 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 } From 784eecda7d7502ea19471fd2e7ce9210c3a4f6df Mon Sep 17 00:00:00 2001 From: dp Date: Fri, 6 Mar 2026 10:05:19 +0100 Subject: [PATCH 21/24] Spec: Added use case for user registration. --- docs/spec/SPEC.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/spec/SPEC.md b/docs/spec/SPEC.md index db3e889..d30f066 100644 --- a/docs/spec/SPEC.md +++ b/docs/spec/SPEC.md @@ -50,6 +50,7 @@ The different user roles are engaged in the following use cases. All Users: - **UC-1**: Search Vocabulary +- **UC-99**: Register New User Author: @@ -90,9 +91,9 @@ In the following sub-sections, selected use cases are detailled. - Verb does not exist in the system. - **Main Flow**: 1. Author submits a verb and one or more usage examples. - 1. System verifies that the author account is active. - 1. System validates and normalizes verb by using an external dictionary. - 1. If the verb is valid, the system stores the normalized verb and associated 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**: @@ -111,13 +112,33 @@ In the following sub-sections, selected use cases are detailled. - The same example sentences may already be associated with another user. - **Main Flow**: 1. Author selects an existing vocabulary entry. - 1. Author submits one or more usage examples. - 1. System persists the examples. + 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. +## UC-99: Register New User + +- **Actors**: Anonymous, Author, Admin +- **Input**: + - Username + - Email + - Password +- **Preconditions**: + - The user is not authenticated. + - Username is not yet registered. +- **Main Flow**: + 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**: + - A new user account exists in the system. + - The user can authenticate using the provided credentials. + + # Requirements @@ -130,6 +151,9 @@ In the following sub-sections, selected use cases are detailled. - **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 From b7e140e7b67307cf87b59271fdc6bfff98b6a701 Mon Sep 17 00:00:00 2001 From: dp Date: Fri, 6 Mar 2026 11:26:35 +0100 Subject: [PATCH 22/24] database schema. --- .../schema/create_usp_AddVocabularyEntryWithExamples.sql | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/database/schema/create_usp_AddVocabularyEntryWithExamples.sql b/database/schema/create_usp_AddVocabularyEntryWithExamples.sql index e0aa286..1179c03 100644 --- a/database/schema/create_usp_AddVocabularyEntryWithExamples.sql +++ b/database/schema/create_usp_AddVocabularyEntryWithExamples.sql @@ -1,7 +1,7 @@ USE [spanish_ex] GO -/****** Object: StoredProcedure [dbo].[usp_AddVocabularyEntryWithExamples] Script Date: 03.03.2026 04:46:27 ******/ +/****** Object: StoredProcedure [dbo].[usp_AddVocabularyEntryWithExamples] Script Date: 06.03.2026 10:14:22 ******/ SET ANSI_NULLS ON GO @@ -9,6 +9,7 @@ SET QUOTED_IDENTIFIER ON GO + -- ============================================= -- Author: David Pasch -- Create date: 2026-03-02 @@ -53,15 +54,12 @@ BEGIN SELECT [VOCABULARYID] ,[WORDNORM] ,[ENGLISH] - ,[USERID] FROM [dbo].[VOCABULARY_T] WHERE VOCABULARYID = @vocId; SELECT [EXAMPLEID] - ,[VOCABULARYID] ,[EXAMPLE_TEXT] ,[ENGLISH] - ,[USERID] FROM [dbo].[EXAMPLES_T] WHERE VOCABULARYID = @vocId; From 6bf1b139111aa16f6fb1ea3e4fe940b697e0d5fa Mon Sep 17 00:00:00 2001 From: dp Date: Fri, 6 Mar 2026 11:27:46 +0100 Subject: [PATCH 23/24] Refactoring: made VocabularyEntry and Example immutable. --- .../BusinessUtils.cs | 15 ++----- src/SpanishByExample.Core/Entities/Example.cs | 6 +-- .../Entities/VocabularyEntry.cs | 21 ++++++--- .../DataAccessService.cs | 44 +++++++++---------- .../DataAccessUtils.cs | 2 +- .../DataAccessServiceTests.cs | 43 ++++++++---------- .../ExamplesControllerTests.cs | 7 +-- .../VocabularyControllerTests.cs | 28 ++++++------ .../VocabularyServiceTests.cs | 29 ++++++------ 9 files changed, 92 insertions(+), 103 deletions(-) diff --git a/src/SpanishByExample.Business/BusinessUtils.cs b/src/SpanishByExample.Business/BusinessUtils.cs index 2af6992..0c24e19 100644 --- a/src/SpanishByExample.Business/BusinessUtils.cs +++ b/src/SpanishByExample.Business/BusinessUtils.cs @@ -1,5 +1,4 @@ -using Microsoft.Extensions.Logging.Abstractions; -using SpanishByExample.Core.Commands; +using SpanishByExample.Core.Commands; using SpanishByExample.Core.Entities; namespace SpanishByExample.Business; @@ -16,17 +15,11 @@ public static class BusinessUtils /// VocabularyEntry populated with data from command. public static VocabularyEntry ToVocabularyEntry(this CreateVocabularyCommand vocabularyCmd) { - var vocEntry = new VocabularyEntry() - { - VocabularyId = 0, - WordNorm = vocabularyCmd.RawWord, - EnglishTranslation = vocabularyCmd.EnglishTranslation, - Examples = [] - }; + List examples = []; foreach (var exampleCommand in vocabularyCmd.CreateExampleCommands) { - vocEntry.Examples.Add(new() + examples.Add(new() { ExampleId = 0, ExampleText = exampleCommand.ExampleText, @@ -34,6 +27,6 @@ public static VocabularyEntry ToVocabularyEntry(this CreateVocabularyCommand voc }); } - return vocEntry; + return new VocabularyEntry(0, vocabularyCmd.RawWord, vocabularyCmd.EnglishTranslation, examples); } } diff --git a/src/SpanishByExample.Core/Entities/Example.cs b/src/SpanishByExample.Core/Entities/Example.cs index a467f27..8d93cd9 100644 --- a/src/SpanishByExample.Core/Entities/Example.cs +++ b/src/SpanishByExample.Core/Entities/Example.cs @@ -5,9 +5,9 @@ /// public class Example { - public required int ExampleId { get; set; } + public required int ExampleId { get; init; } - public required string ExampleText { get; set; } + public required string ExampleText { get; init; } - public string? EnglishTranslation { get; set; } + public string? EnglishTranslation { get; init; } } diff --git a/src/SpanishByExample.Core/Entities/VocabularyEntry.cs b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs index 93e0a94..ccf66e9 100644 --- a/src/SpanishByExample.Core/Entities/VocabularyEntry.cs +++ b/src/SpanishByExample.Core/Entities/VocabularyEntry.cs @@ -1,18 +1,27 @@ namespace SpanishByExample.Core.Entities; -// TODO make readonly - /// /// A vocabulary entry for a specific word. /// Stores translations and available usage examples. +/// NOTE: Immutable object. /// public class VocabularyEntry { - public required int VocabularyId { get; set; } + public int VocabularyId { get; } - public required string WordNorm { get; set; } + public string WordNorm { get; } - public string? EnglishTranslation { get; set; } + public string? EnglishTranslation { get; } - public IList Examples { get; set; } = []; + 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.DataAccess/DataAccessService.cs b/src/SpanishByExample.DataAccess/DataAccessService.cs index 6053ecd..35d6e83 100644 --- a/src/SpanishByExample.DataAccess/DataAccessService.cs +++ b/src/SpanishByExample.DataAccess/DataAccessService.cs @@ -57,19 +57,20 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu using var reader = await command.ExecuteReaderAsync(token); - VocabularyEntry newVocEntry = null!; + int vocabularyId = -1; + string wordNorm = null!; + string? englishTranslation = null; + List examples; + while (await reader.ReadAsync(token)) { - if (newVocEntry != null) + if (vocabularyId != -1) { throw new InvalidOperationException("Database returns more than one vocabulary entry."); } - newVocEntry = new() - { - VocabularyId = reader.GetInt32(0), - WordNorm = reader.GetString(1), - EnglishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2), - }; + vocabularyId = reader.GetInt32(0); + wordNorm = reader.GetString(1); + englishTranslation = await reader.IsDBNullAsync(2, token) ? null : reader.GetString(2); } if (!await reader.NextResultAsync(token)) @@ -77,10 +78,10 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu throw new InvalidOperationException("Database fails to return newly created examples for vocabulary entry."); } - newVocEntry.Examples = new List(); + examples = new List(); while (await reader.ReadAsync(token)) { - newVocEntry.Examples.Add(new Example + examples.Add(new Example { ExampleId = reader.GetInt32(0), ExampleText = reader.GetString(1), @@ -94,7 +95,7 @@ public async Task AddVocabularyEntryAsync(string userId, Vocabu transaction.Rollback(); #endif - return newVocEntry ?? throw new InvalidOperationException("Database fails to return newly created vocabulary entry."); + 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) { @@ -137,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), @@ -162,7 +162,7 @@ FROM dbo.EXAMPLES ex }); } - return vocabEntry; + return vocabularyId != -1 ? new VocabularyEntry(vocabularyId, wordNorm, englishTranslation, examples) : null; } catch (SqlException ex) { diff --git a/src/SpanishByExample.DataAccess/DataAccessUtils.cs b/src/SpanishByExample.DataAccess/DataAccessUtils.cs index 60ea780..647e1e9 100644 --- a/src/SpanishByExample.DataAccess/DataAccessUtils.cs +++ b/src/SpanishByExample.DataAccess/DataAccessUtils.cs @@ -13,7 +13,7 @@ public static class DataAccessUtils /// /// Examples. /// Table containing the example data. - public static DataTable ToDataTable(this IList examples) + public static DataTable ToDataTable(this IReadOnlyList examples) { var examplesDt = new DataTable(); examplesDt.Columns.AddRange( diff --git a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs index 2fb320d..f5101b6 100644 --- a/tests/SpanishByExample.Tests/DataAccessServiceTests.cs +++ b/tests/SpanishByExample.Tests/DataAccessServiceTests.cs @@ -85,21 +85,18 @@ public async Task GetVocabularyEntryAsync_Ok_Unknown() public async Task AddVocabularyEntryAsync_Ok(string? vocEnglish, string? exEnglish) { var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; - VocabularyEntry vocEntry = new() - { - VocabularyId = 0, - WordNorm = "comer", - EnglishTranslation = vocEnglish, - Examples = new List() - { + var vocEntry = new VocabularyEntry( + 0, + "comer", + vocEnglish, + [ new Example { ExampleId = 0, ExampleText = "Como carne.", EnglishTranslation = exEnglish } - } - - }; + ] + ); var newVocEntry = await _da.AddVocabularyEntryAsync(userId, vocEntry); @@ -117,21 +114,19 @@ public async Task AddVocabularyEntryAsync_Ok(string? vocEnglish, string? exEngli public async Task AddVocabularyEntryAsync_Error_DuplicateVocabularyEntryException() { var userId = "f401d78e-3dda-4bce-9f84-55ee158c57f9"; - VocabularyEntry vocEntry = new() - { - VocabularyId = 0, - WordNorm = "hablar", - EnglishTranslation = null, - Examples = new List() - { - new Example { - ExampleId = 0, - ExampleText = "Hablo mucho.", - EnglishTranslation = null - } - } - }; + var vocEntry = new VocabularyEntry( + 0, + "hablar", + null, + [ + new Example { + ExampleId = 0, + ExampleText = "Hablo mucho.", + EnglishTranslation = null + } + ] + ); var act = async () => await _da.AddVocabularyEntryAsync(userId, vocEntry); diff --git a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs index 32cc0e2..a80dc2c 100644 --- a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs +++ b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs @@ -1,6 +1,5 @@ using FluentAssertions; using Moq; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using SpanishByExample.Api.Controllers; using SpanishByExample.Core.Services; @@ -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, []); _daMock.Setup(da => da.GetVocabularyEntryAsync(query)) .ReturnsAsync(oracle); diff --git a/tests/SpanishByExample.Tests/VocabularyControllerTests.cs b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs index e33da5d..1e26391 100644 --- a/tests/SpanishByExample.Tests/VocabularyControllerTests.cs +++ b/tests/SpanishByExample.Tests/VocabularyControllerTests.cs @@ -48,21 +48,19 @@ public async Task Post_Ok() _controller.ControllerContext = CreateAuthenticatedContext(userId); - VocabularyEntry newVocEntry = new() - { - VocabularyId = 1, - WordNorm = vocDto.RawWord, - EnglishTranslation = vocDto.EnglishTranslation, - Examples = - [ - new() - { - ExampleId = 1, - ExampleText = vocDto.Examples[0].ExampleText, - EnglishTranslation = vocDto.Examples[0].EnglishTranslation, - } - ] - }; + 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); diff --git a/tests/SpanishByExample.Tests/VocabularyServiceTests.cs b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs index e547010..02c6041 100644 --- a/tests/SpanishByExample.Tests/VocabularyServiceTests.cs +++ b/tests/SpanishByExample.Tests/VocabularyServiceTests.cs @@ -58,21 +58,20 @@ public async Task AddVocabularyEntryAsync_Ok() .AsReadOnly() }; - VocabularyEntry daVocEntry = new() - { - VocabularyId = 1, - WordNorm = "comer", - EnglishTranslation = vocEnglish, - Examples = - [ - new() - { - ExampleId = 11, - ExampleText = vocabularyCmd.CreateExampleCommands[0].ExampleText, - EnglishTranslation = exEnglish - } - ] - }; + + 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); From 5b3c9f90ed0dfada65c240e782905d567deeff97 Mon Sep 17 00:00:00 2001 From: dp Date: Fri, 6 Mar 2026 11:39:44 +0100 Subject: [PATCH 24/24] Refactoring: made VocabularyEntry and Example immutable. --- tests/SpanishByExample.Tests/ExamplesControllerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs index a80dc2c..9a28215 100644 --- a/tests/SpanishByExample.Tests/ExamplesControllerTests.cs +++ b/tests/SpanishByExample.Tests/ExamplesControllerTests.cs @@ -30,7 +30,7 @@ public ExamplesControllerTests() public async Task Get_Ok_Hit() { var query = "hablar"; - var oracle = new VocabularyEntry(1, query, null, []); + var oracle = new VocabularyEntry(1, query, null, [ new() { ExampleId = 1, ExampleText = "Hablo español." } ]); _daMock.Setup(da => da.GetVocabularyEntryAsync(query)) .ReturnsAsync(oracle);