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