diff --git a/tests/TarninaHub.Tests/Architecture/NoObjectValuedDictionariesTests.cs b/tests/TarninaHub.Tests/Architecture/NoObjectValuedDictionariesTests.cs
new file mode 100644
index 0000000..bc75bcb
--- /dev/null
+++ b/tests/TarninaHub.Tests/Architecture/NoObjectValuedDictionariesTests.cs
@@ -0,0 +1,60 @@
+using System.Text.RegularExpressions;
+
+namespace TarninaHub.Tests.Architecture;
+
+///
+/// Guarantees that data contracts never expose object-valued dictionaries.
+/// Such dictionaries break CBOR deserialization of SurrealDB rows and force
+/// lossy conversions, so only string-valued maps are allowed.
+///
+public class NoObjectValuedDictionariesTests
+{
+ private static readonly Regex Forbidden = new(@"Dictionary();
+
+ foreach (var directory in new[]
+ {
+ Path.Combine(root, "backend", "Application", "Dtos"),
+ Path.Combine(root, "backend", "Application", "Abstractions"),
+ Path.Combine(root, "backend", "Domain"),
+ })
+ {
+ files.AddRange(Directory.GetFiles(directory, "*.cs", SearchOption.AllDirectories));
+ }
+
+ files.AddRange(Directory.GetFiles(
+ Path.Combine(root, "backend", "Infrastructure", "Persistence"),
+ "*Row.cs",
+ SearchOption.AllDirectories));
+
+ var offenders = files
+ .Where(file => Forbidden.IsMatch(File.ReadAllText(file)))
+ .Select(file => Path.GetRelativePath(root, file))
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .ToList();
+
+ Assert.True(
+ offenders.Count == 0,
+ "Object-valued dictionaries are forbidden in data contracts. " +
+ "Use string-valued maps instead:\n" + string.Join(Environment.NewLine, offenders));
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+
+ while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "TarninaHub.slnx")))
+ {
+ directory = directory.Parent;
+ }
+
+ Assert.NotNull(directory);
+
+ return directory!.FullName;
+ }
+}
diff --git a/tests/TarninaHub.Tests/Controllers/ApplicationsControllerTests.cs b/tests/TarninaHub.Tests/Controllers/ApplicationsControllerTests.cs
index 021ff9b..c8f4fcc 100644
--- a/tests/TarninaHub.Tests/Controllers/ApplicationsControllerTests.cs
+++ b/tests/TarninaHub.Tests/Controllers/ApplicationsControllerTests.cs
@@ -49,7 +49,7 @@ public class ApplicationsControllerTests
"application:new",
"grant_call:x",
"idea:y",
- new Dictionary(),
+ new Dictionary(),
"draft",
DateTimeOffset.UtcNow);
var service = new StubApplicationService(
@@ -62,26 +62,4 @@ public class ApplicationsControllerTests
Assert.IsType(result.Result);
}
-
- private sealed class StubApplicationService : IApplicationService
- {
- private readonly CreateApplicationResult _createResult;
-
- public StubApplicationService(CreateApplicationResult createResult)
- {
- _createResult = createResult;
- }
-
- public Task CreateAsync(
- string userId,
- CreateApplicationRequest request,
- CancellationToken cancellationToken) => Task.FromResult(_createResult);
-
- public Task GetAsync(string applicationId, CancellationToken cancellationToken) =>
- Task.FromResult(_createResult.Application);
-
- public Task> ListForAuthorAsync(
- string authorId,
- CancellationToken cancellationToken) => Task.FromResult>([]);
- }
}
diff --git a/tests/TarninaHub.Tests/Controllers/IdeasControllerTests.cs b/tests/TarninaHub.Tests/Controllers/IdeasControllerTests.cs
new file mode 100644
index 0000000..6edd74e
--- /dev/null
+++ b/tests/TarninaHub.Tests/Controllers/IdeasControllerTests.cs
@@ -0,0 +1,54 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+using TarninaHub.Controllers;
+using TarninaHub.Tests.Stubs;
+
+namespace TarninaHub.Tests.Controllers;
+
+public class IdeasControllerTests
+{
+ private static IdeasController CreateController(IIdeaService ideaService, INotificationService notifications)
+ {
+ var controller = new IdeasController(
+ ideaService,
+ new StubIdeaAssistant(),
+ notifications);
+ var identity = new ClaimsIdentity(
+ [new Claim("sub", "user:tester"), new Claim("role", "user")],
+ authenticationType: "test");
+
+ controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(identity) },
+ };
+
+ return controller;
+ }
+
+ [Fact]
+ public async Task Create_NotifiesStaffAndReturnsCreated()
+ {
+ var ideaService = new StubIdeaService { Idea = SampleIdea() };
+ var notifications = new StubNotificationService();
+ var controller = CreateController(ideaService, notifications);
+
+ var result = await controller.Create(
+ new CreateIdeaRequest("Nowy pomysł", "Opis nowego pomysłu mieszkańca.", null),
+ CancellationToken.None);
+
+ Assert.IsType(result.Result);
+ Assert.Equal(1, notifications.StaffNotifications);
+ }
+
+ private static IdeaResponse SampleIdea() => new(
+ "idea:new",
+ "Nowy pomysł",
+ "Opis nowego pomysłu mieszkańca.",
+ "user:tester",
+ "draft",
+ null,
+ DateTimeOffset.UtcNow);
+}
diff --git a/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs b/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs
index ad38b8a..51a1d84 100644
--- a/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs
+++ b/tests/TarninaHub.Tests/Controllers/InnovationsControllerTests.cs
@@ -5,6 +5,7 @@ using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Controllers;
using TarninaHub.Tests.Matchmaking;
+using TarninaHub.Tests.Stubs;
namespace TarninaHub.Tests.Controllers;
@@ -12,7 +13,10 @@ public class InnovationsControllerTests
{
private static InnovationsController CreateController(IInnovationService service)
{
- var controller = new InnovationsController(service, new StubMatchmakingService());
+ var controller = new InnovationsController(
+ service,
+ new StubMatchmakingService(),
+ new StubTestFeedbackService());
var identity = new ClaimsIdentity(
[new Claim("sub", "user:tester"), new Claim("role", "admin")],
authenticationType: "test");
@@ -55,37 +59,4 @@ public class InnovationsControllerTests
Assert.IsType(result.Result);
}
-
- private sealed class StubInnovationService : IInnovationService
- {
- public Task> ListAsync(
- string? challengeId,
- string? stage,
- string? query,
- bool onlyPublished,
- CancellationToken cancellationToken) => Task.FromResult>([]);
-
- public Task GetAsync(string innovationId, CancellationToken cancellationToken) =>
- Task.FromResult(null);
-
- public Task CreateAsync(
- InnovationRequest request,
- CancellationToken cancellationToken) => Task.FromResult(new InnovationResponse(
- "innovation:new",
- request.Title,
- request.Summary ?? string.Empty,
- request.Description ?? string.Empty,
- request.ChallengeId,
- request.Stage,
- request.Cost,
- request.Contact,
- request.Media ?? [],
- request.Published,
- DateTimeOffset.UtcNow));
-
- public Task UpdateAsync(
- string innovationId,
- InnovationRequest request,
- CancellationToken cancellationToken) => Task.FromResult(null);
- }
}
diff --git a/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs b/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs
index 2e6f908..5b1bbb2 100644
--- a/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs
+++ b/tests/TarninaHub.Tests/Controllers/SubmissionsControllerTests.cs
@@ -5,6 +5,7 @@ using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Controllers;
using TarninaHub.Tests.Matchmaking;
+using TarninaHub.Tests.Stubs;
namespace TarninaHub.Tests.Controllers;
@@ -12,7 +13,10 @@ public class SubmissionsControllerTests
{
private static SubmissionsController CreateController(ISubmissionService service, string role = "operator")
{
- var controller = new SubmissionsController(service, new StubMatchmakingService());
+ var controller = new SubmissionsController(
+ service,
+ new StubMatchmakingService(),
+ new StubNotificationService());
var identity = new ClaimsIdentity(
[new Claim("sub", "user:tester"), new Claim("role", role)],
authenticationType: "test");
@@ -81,40 +85,4 @@ public class SubmissionsControllerTests
Assert.IsType(result.Result);
}
-
- private sealed class StubSubmissionService : ISubmissionService
- {
- public SubmissionResponse? Current { get; set; }
-
- public SubmissionResponse? UpdateResult { get; set; }
-
- public string? LastRequestedStatus { get; private set; }
-
- public Task CreateAsync(
- string authorId,
- CreateSubmissionRequest request,
- CancellationToken cancellationToken) => Task.FromResult(SampleSubmission("new"));
-
- public Task> ListAsync(
- string viewerId,
- bool isStaff,
- string? status,
- int page,
- int pageSize,
- CancellationToken cancellationToken) => Task.FromResult(
- new PagedResponse([], page, pageSize, 0));
-
- public Task GetAsync(string submissionId, CancellationToken cancellationToken) =>
- Task.FromResult(Current);
-
- public Task UpdateStatusAsync(
- string submissionId,
- string newStatus,
- CancellationToken cancellationToken)
- {
- LastRequestedStatus = newStatus;
-
- return Task.FromResult(UpdateResult);
- }
- }
}
diff --git a/tests/TarninaHub.Tests/Domain/PartnershipWorkflowTests.cs b/tests/TarninaHub.Tests/Domain/PartnershipWorkflowTests.cs
new file mode 100644
index 0000000..fb8fb02
--- /dev/null
+++ b/tests/TarninaHub.Tests/Domain/PartnershipWorkflowTests.cs
@@ -0,0 +1,25 @@
+using TarninaHub.Domain.Enums;
+using TarninaHub.Domain.Rules;
+
+namespace TarninaHub.Tests.Domain;
+
+public class PartnershipWorkflowTests
+{
+ [Theory]
+ [InlineData(PartnershipStatus.Proposed, PartnershipStatus.Active, true)]
+ [InlineData(PartnershipStatus.Proposed, PartnershipStatus.Closed, true)]
+ [InlineData(PartnershipStatus.Active, PartnershipStatus.Closed, true)]
+ [InlineData(PartnershipStatus.Closed, PartnershipStatus.Active, false)]
+ [InlineData(PartnershipStatus.Active, PartnershipStatus.Proposed, false)]
+ public void CanTransition_EnforcesLifecycle(PartnershipStatus from, PartnershipStatus to, bool expected)
+ {
+ Assert.Equal(expected, PartnershipWorkflow.CanTransition(from, to));
+ }
+
+ [Fact]
+ public void Closed_IsTerminal()
+ {
+ Assert.True(PartnershipWorkflow.IsTerminal(PartnershipStatus.Closed));
+ Assert.False(PartnershipWorkflow.IsTerminal(PartnershipStatus.Proposed));
+ }
+}
diff --git a/tests/TarninaHub.Tests/Infrastructure/MiddlemanAssistantTests.cs b/tests/TarninaHub.Tests/Infrastructure/MiddlemanAssistantTests.cs
new file mode 100644
index 0000000..1cdfd52
--- /dev/null
+++ b/tests/TarninaHub.Tests/Infrastructure/MiddlemanAssistantTests.cs
@@ -0,0 +1,62 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+using TarninaHub.Application.Matchmaking;
+using TarninaHub.Infrastructure.Assist;
+using TarninaHub.Infrastructure.Llm;
+using TarninaHub.Tests.Stubs;
+
+namespace TarninaHub.Tests.Infrastructure;
+
+public class MiddlemanAssistantTests
+{
+ private static InnovationResponse SampleInnovation() => new(
+ "innovation:klub",
+ "Klub sąsiedzki",
+ "Miejsce spotkań i edukacji cyfrowej",
+ "Opis",
+ "social_challenge:seniorzy",
+ "pilot",
+ "5000 PLN",
+ null,
+ [],
+ true,
+ DateTimeOffset.UtcNow);
+
+ private static MiddlemanAssistant CreateAssistant(InnovationResponse? innovation) =>
+ new(
+ new StubInnovationLookup(innovation),
+ new NullLlmProvider(),
+ new InMemoryLlmRequestLimiter(30));
+
+ [Fact]
+ public async Task AdaptAsync_ReturnsNullWhenInnovationMissing()
+ {
+ var assistant = CreateAssistant(null);
+
+ var result = await assistant.AdaptAsync(
+ new MiddlemanRequest("innovation:missing", null, null),
+ CancellationToken.None);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task AdaptAsync_BuildsOfflineServiceForm()
+ {
+ var assistant = CreateAssistant(SampleInnovation());
+
+ var result = await assistant.AdaptAsync(
+ new MiddlemanRequest(
+ "innovation:klub",
+ new InstitutionProfile("gmina", "usługi opiekuńcze", ["seniorzy"]),
+ "opieka dzienna"),
+ CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.False(result!.FromLlm);
+ Assert.Contains("Klub sąsiedzki", result.ServiceForm);
+ Assert.Contains("seniorzy", result.ServiceForm);
+ Assert.NotEmpty(result.RequiredResources);
+ Assert.NotEmpty(result.Rationale);
+ }
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubApplicationService.cs b/tests/TarninaHub.Tests/Stubs/StubApplicationService.cs
new file mode 100644
index 0000000..19a2ab1
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubApplicationService.cs
@@ -0,0 +1,27 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// Configurable IApplicationService stub for controller tests.
+public sealed class StubApplicationService : IApplicationService
+{
+ private readonly CreateApplicationResult _createResult;
+
+ public StubApplicationService(CreateApplicationResult createResult)
+ {
+ _createResult = createResult;
+ }
+
+ public Task CreateAsync(
+ string userId,
+ CreateApplicationRequest request,
+ CancellationToken cancellationToken) => Task.FromResult(_createResult);
+
+ public Task GetAsync(string applicationId, CancellationToken cancellationToken) =>
+ Task.FromResult(_createResult.Application);
+
+ public Task> ListForAuthorAsync(
+ string authorId,
+ CancellationToken cancellationToken) => Task.FromResult>([]);
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubIdeaAssistant.cs b/tests/TarninaHub.Tests/Stubs/StubIdeaAssistant.cs
new file mode 100644
index 0000000..df50115
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubIdeaAssistant.cs
@@ -0,0 +1,14 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// No-op IIdeaAssistant stub for controller tests.
+public sealed class StubIdeaAssistant : IIdeaAssistant
+{
+ public Task AssistAsync(
+ string ideaId,
+ IdeaAssistRequest request,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(new IdeaAssistResponse([], "opis", false));
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubIdeaService.cs b/tests/TarninaHub.Tests/Stubs/StubIdeaService.cs
index f20dc62..b3cd3ff 100644
--- a/tests/TarninaHub.Tests/Stubs/StubIdeaService.cs
+++ b/tests/TarninaHub.Tests/Stubs/StubIdeaService.cs
@@ -17,6 +17,9 @@ public sealed class StubIdeaService : IIdeaService
public Task> ListForAuthorAsync(string authorId, CancellationToken cancellationToken) =>
Task.FromResult>(Idea is null ? [] : [Idea]);
+ public Task> ListDistinctAuthorIdsAsync(CancellationToken cancellationToken) =>
+ Task.FromResult>(Idea is null ? [] : [Idea.AuthorId]);
+
public Task UpdateAsync(string ideaId, UpdateIdeaRequest request, CancellationToken cancellationToken) =>
Task.FromResult(Idea);
diff --git a/tests/TarninaHub.Tests/Stubs/StubInnovationLookup.cs b/tests/TarninaHub.Tests/Stubs/StubInnovationLookup.cs
new file mode 100644
index 0000000..af28ab6
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubInnovationLookup.cs
@@ -0,0 +1,34 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// Innovation lookup stub returning a fixed (or null) innovation.
+public sealed class StubInnovationLookup : IInnovationService
+{
+ private readonly InnovationResponse? _innovation;
+
+ public StubInnovationLookup(InnovationResponse? innovation)
+ {
+ _innovation = innovation;
+ }
+
+ public Task> ListAsync(
+ string? challengeId,
+ string? stage,
+ string? query,
+ bool onlyPublished,
+ CancellationToken cancellationToken) => Task.FromResult>([]);
+
+ public Task GetAsync(string innovationId, CancellationToken cancellationToken) =>
+ Task.FromResult(_innovation);
+
+ public Task CreateAsync(
+ InnovationRequest request,
+ CancellationToken cancellationToken) => throw new NotSupportedException();
+
+ public Task UpdateAsync(
+ string innovationId,
+ InnovationRequest request,
+ CancellationToken cancellationToken) => throw new NotSupportedException();
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubInnovationService.cs b/tests/TarninaHub.Tests/Stubs/StubInnovationService.cs
new file mode 100644
index 0000000..d94ed10
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubInnovationService.cs
@@ -0,0 +1,38 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// Configurable IInnovationService stub for controller tests.
+public sealed class StubInnovationService : IInnovationService
+{
+ public Task> ListAsync(
+ string? challengeId,
+ string? stage,
+ string? query,
+ bool onlyPublished,
+ CancellationToken cancellationToken) => Task.FromResult>([]);
+
+ public Task GetAsync(string innovationId, CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
+ public Task CreateAsync(
+ InnovationRequest request,
+ CancellationToken cancellationToken) => Task.FromResult(new InnovationResponse(
+ "innovation:new",
+ request.Title,
+ request.Summary ?? string.Empty,
+ request.Description ?? string.Empty,
+ request.ChallengeId,
+ request.Stage,
+ request.Cost,
+ request.Contact,
+ request.Media ?? [],
+ request.Published,
+ DateTimeOffset.UtcNow));
+
+ public Task UpdateAsync(
+ string innovationId,
+ InnovationRequest request,
+ CancellationToken cancellationToken) => Task.FromResult(null);
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubNotificationService.cs b/tests/TarninaHub.Tests/Stubs/StubNotificationService.cs
new file mode 100644
index 0000000..298a4f1
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubNotificationService.cs
@@ -0,0 +1,40 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// No-op INotificationService stub for controller tests.
+public sealed class StubNotificationService : INotificationService
+{
+ public int StaffNotifications { get; private set; }
+
+ public int UserNotifications { get; private set; }
+
+ public Task NotifyStaffAsync(
+ string type,
+ IReadOnlyDictionary payload,
+ CancellationToken cancellationToken)
+ {
+ StaffNotifications++;
+ return Task.CompletedTask;
+ }
+
+ public Task NotifyUserAsync(
+ string userId,
+ string type,
+ IReadOnlyDictionary payload,
+ CancellationToken cancellationToken)
+ {
+ UserNotifications++;
+ return Task.CompletedTask;
+ }
+
+ public Task> ListForUserAsync(
+ string userId,
+ CancellationToken cancellationToken) => Task.FromResult>([]);
+
+ public Task MarkReadAsync(
+ string notificationId,
+ string userId,
+ CancellationToken cancellationToken) => Task.FromResult(null);
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubSubmissionService.cs b/tests/TarninaHub.Tests/Stubs/StubSubmissionService.cs
new file mode 100644
index 0000000..265c58a
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubSubmissionService.cs
@@ -0,0 +1,41 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// Configurable ISubmissionService stub for controller tests.
+public sealed class StubSubmissionService : ISubmissionService
+{
+ public SubmissionResponse? Current { get; set; }
+
+ public SubmissionResponse? UpdateResult { get; set; }
+
+ public string? LastRequestedStatus { get; private set; }
+
+ public Task CreateAsync(
+ string authorId,
+ CreateSubmissionRequest request,
+ CancellationToken cancellationToken) => Task.FromResult(Current ?? throw new InvalidOperationException());
+
+ public Task> ListAsync(
+ string viewerId,
+ bool isStaff,
+ string? status,
+ int page,
+ int pageSize,
+ CancellationToken cancellationToken) => Task.FromResult(
+ new PagedResponse([], page, pageSize, 0));
+
+ public Task GetAsync(string submissionId, CancellationToken cancellationToken) =>
+ Task.FromResult(Current);
+
+ public Task UpdateStatusAsync(
+ string submissionId,
+ string newStatus,
+ CancellationToken cancellationToken)
+ {
+ LastRequestedStatus = newStatus;
+
+ return Task.FromResult(UpdateResult);
+ }
+}
diff --git a/tests/TarninaHub.Tests/Stubs/StubTestFeedbackService.cs b/tests/TarninaHub.Tests/Stubs/StubTestFeedbackService.cs
new file mode 100644
index 0000000..456b1a4
--- /dev/null
+++ b/tests/TarninaHub.Tests/Stubs/StubTestFeedbackService.cs
@@ -0,0 +1,18 @@
+using TarninaHub.Application.Abstractions;
+using TarninaHub.Application.Dtos;
+
+namespace TarninaHub.Tests.Stubs;
+
+/// No-op ITestFeedbackService stub for controller tests.
+public sealed class StubTestFeedbackService : ITestFeedbackService
+{
+ public Task CreateAsync(
+ string innovationId,
+ string authorId,
+ CreateTestFeedbackRequest request,
+ CancellationToken cancellationToken) => Task.FromResult(null);
+
+ public Task> ListForInnovationAsync(
+ string innovationId,
+ CancellationToken cancellationToken) => Task.FromResult>([]);
+}