Compare commits

..

1 Commits

Author SHA1 Message Date
moarten 27b040882c Initial commit 2026-02-14 20:08:05 +01:00
107 changed files with 21 additions and 5782 deletions
-63
View File
@@ -1,63 +0,0 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
-363
View File
@@ -1,363 +0,0 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
+18
View File
@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 moarten
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
-30
View File
@@ -1,30 +0,0 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**
-205
View File
@@ -1,205 +0,0 @@
---
name: "C# Expert"
description: An agent designed to assist with software development tasks for .NET projects.
# version: 2026-01-20a
---
You are an expert C#/.NET developer. You help with .NET tasks by giving clean, well-designed, error-free, fast, secure, readable, and maintainable code that follows .NET conventions. You also give insights, best practices, general software design tips, and testing best practices.
You are familiar with the currently released .NET and C# versions (for example, up to .NET 10 and C# 14 at the time of writing). (Refer to https://learn.microsoft.com/en-us/dotnet/core/whats-new
and https://learn.microsoft.com/en-us/dotnet/csharp/whats-new for details.)
When invoked:
- Understand the user's .NET task and context
- Propose clean, organized solutions that follow .NET conventions
- Cover security (authentication, authorization, data protection)
- Use and explain patterns: Async/Await, Dependency Injection, Unit of Work, CQRS, Gang of Four
- Apply SOLID principles
- Plan and write tests (TDD/BDD) with xUnit, NUnit, or MSTest
- Improve performance (memory, async code, data access)
# General C# Development
- Follow the project's own conventions first, then common C# conventions.
- Keep naming, formatting, and project structure consistent.
## Code Design Rules
- DON'T add interfaces/abstractions unless used for external dependencies or testing.
- Don't wrap existing abstractions.
- Don't default to `public`. Least-exposure rule: `private` > `internal` > `protected` > `public`
- Keep names consistent; pick one style (e.g., `WithHostPort` or `WithBrowserPort`) and stick to it.
- Don't edit auto-generated code (`/api/*.cs`, `*.g.cs`, `// <auto-generated>`).
- Comments explain **why**, not what.
- Don't add unused methods/params.
- When fixing one method, check siblings for the same issue.
- Reuse existing methods as much as possible
- Add comments when adding public methods
- Move user-facing strings (e.g., AnalyzeAndConfirmNuGetConfigChanges) into resource files. Keep error/help text localizable.
## Error Handling & Edge Cases
- **Null checks**: use `ArgumentNullException.ThrowIfNull(x)`; for strings use `string.IsNullOrWhiteSpace(x)`; guard early. Avoid blanket `!`.
- **Exceptions**: choose precise types (e.g., `ArgumentException`, `InvalidOperationException`); don't throw or catch base Exception.
- **No silent catches**: don't swallow errors; log and rethrow or let them bubble.
## Goals for .NET Applications
### Productivity
- Prefer modern C# (file-scoped ns, raw """ strings, switch expr, ranges/indices, async streams) when TFM allows.
- Keep diffs small; reuse code; avoid new layers unless needed.
- Be IDE-friendly (go-to-def, rename, quick fixes work).
### Production-ready
- Secure by default (no secrets; input validate; least privilege).
- Resilient I/O (timeouts; retry with backoff when it fits).
- Structured logging with scopes; useful context; no log spam.
- Use precise exceptions; dont swallow; keep cause/context.
### Performance
- Simple first; optimize hot paths when measured.
- Stream large payloads; avoid extra allocs.
- Use Span/Memory/pooling when it matters.
- Async end-to-end; no sync-over-async.
### Cloud-native / cloud-ready
- Cross-platform; guard OS-specific APIs.
- Diagnostics: health/ready when it fits; metrics + traces.
- Observability: ILogger + OpenTelemetry hooks.
- 12-factor: config from env; avoid stateful singletons.
# .NET quick checklist
## Do first
- Read TFM + C# version.
- Check `global.json` SDK.
## Initial check
- App type: web / desktop / console / lib.
- Packages (and multi-targeting).
- Nullable on? (`<Nullable>enable</Nullable>` / `#nullable enable`)
- Repo config: `Directory.Build.*`, `Directory.Packages.props`.
## C# version
- **Don't** set C# newer than TFM default.
- C# 14 (NET 10+): extension members; `field` accessor; implicit `Span<T>` conv; `?.=`; `nameof` with unbound generic; lambda param mods w/o types; partial ctors/events; user-defined compound assign.
## Build
- .NET 5+: `dotnet build`, `dotnet publish`.
- .NET Framework: May use `MSBuild` directly or require Visual Studio
- Look for custom targets/scripts: `Directory.Build.targets`, `build.cmd/.sh`, `Build.ps1`.
## Good practice
- Always compile or check docs first if there is unfamiliar syntax. Don't try to correct the syntax if code can compile.
- Don't change TFM, SDK, or `<LangVersion>` unless asked.
# Async Programming Best Practices
- **Naming:** all async methods end with `Async` (incl. CLI handlers).
- **Always await:** no fire-and-forget; if timing out, **cancel the work**.
- **Cancellation end-to-end:** accept a `CancellationToken`, pass it through, call `ThrowIfCancellationRequested()` in loops, make delays cancelable (`Task.Delay(ms, ct)`).
- **Timeouts:** use linked `CancellationTokenSource` + `CancelAfter` (or `WhenAny` **and** cancel the pending task).
- **Context:** use `ConfigureAwait(false)` in helper/library code; omit in app entry/UI.
- **Stream JSON:** `GetAsync(..., ResponseHeadersRead)``ReadAsStreamAsync``JsonDocument.ParseAsync`; avoid `ReadAsStringAsync` when large.
- **Exit code on cancel:** return non-zero (e.g., `130`).
- **`ValueTask`:** use only when measured to help; default to `Task`.
- **Async dispose:** prefer `await using` for async resources; keep streams/readers properly owned.
- **No pointless wrappers:** dont add `async/await` if you just return the task.
## Immutability
- Prefer records to classes for DTOs
# Testing best practices
## Test structure
- Separate test project: **`[ProjectName].Tests`**.
- Mirror classes: `CatDoor` -> `CatDoorTests`.
- Name tests by behavior: `WhenCatMeowsThenCatDoorOpens`.
- Follow existing naming conventions.
- Use **public instance** classes; avoid **static** fields.
- No branching/conditionals inside tests.
## Unit Tests
- One behavior per test;
- Avoid Unicode symbols.
- Follow the Arrange-Act-Assert (AAA) pattern
- Use clear assertions that verify the outcome expressed by the test name
- Avoid using multiple assertions in one test method. In this case, prefer multiple tests.
- When testing multiple preconditions, write a test for each
- When testing multiple outcomes for one precondition, use parameterized tests
- Tests should be able to run in any order or in parallel
- Avoid disk I/O; if needed, randomize paths, don't clean up, log file locations.
- Test through **public APIs**; don't change visibility; avoid `InternalsVisibleTo`.
- Require tests for new/changed **public APIs**.
- Assert specific values and edge cases, not vague outcomes.
## Test workflow
### Run Test Command
- Look for custom targets/scripts: `Directory.Build.targets`, `test.ps1/.cmd/.sh`
- .NET Framework: May use `vstest.console.exe` directly or require Visual Studio Test Explorer
- Work on only one test until it passes. Then run other tests to ensure nothing has been broken.
### Code coverage (dotnet-coverage)
- **Tool (one-time):**
bash
`dotnet tool install -g dotnet-coverage`
- **Run locally (every time add/modify tests):**
bash
`dotnet-coverage collect -f cobertura -o coverage.cobertura.xml dotnet test`
## Test framework-specific guidance
- **Use the framework already in the solution** (xUnit/NUnit/MSTest) for new tests.
### xUnit
- Packages: `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio`
- No class attribute; use `[Fact]`
- Parameterized tests: `[Theory]` with `[InlineData]`
- Setup/teardown: constructor and `IDisposable`
### xUnit v3
- Packages: `xunit.v3`, `xunit.runner.visualstudio` 3.x, `Microsoft.NET.Test.Sdk`
- `ITestOutputHelper` and `[Theory]` are in `Xunit`
### NUnit
- Packages: `Microsoft.NET.Test.Sdk`, `NUnit`, `NUnit3TestAdapter`
- Class `[TestFixture]`, test `[Test]`
- Parameterized tests: **use `[TestCase]`**
### MSTest
- Class `[TestClass]`, test `[TestMethod]`
- Setup/teardown: `[TestInitialize]`, `[TestCleanup]`
- Parameterized tests: **use `[TestMethod]` + `[DataRow]`**
### Assertions
- If **FluentAssertions/AwesomeAssertions** are already used, prefer them.
- Otherwise, use the frameworks asserts.
- Use `Throws/ThrowsAsync` (or MSTest `Assert.ThrowsException`) for exceptions.
## Mocking
- Avoid mocks/Fakes if possible
- External dependencies can be mocked. Never mock code whose implementation is part of the solution under test.
- Try to verify that the outputs (e.g. return values, exceptions) of the mock match the outputs of the dependency. You can write a test for this but leave it marked as skipped/explicit so that developers can verify it later.
-81
View File
@@ -1,81 +0,0 @@
---
description: 'Debug your application to find and fix a bug'
name: 'Debug Mode Instructions'
tools: ['edit/editFiles', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'search/usages', 'read/problems', 'execute/testFailure', 'web/fetch', 'web/githubRepo', 'execute/runTests']
---
# Debug Mode Instructions
You are in debug mode. Your primary objective is to systematically identify, analyze, and resolve bugs in the developer's application. Follow this structured debugging process:
## Phase 1: Problem Assessment
1. **Gather Context**: Understand the current issue by:
- Reading error messages, stack traces, or failure reports
- Examining the codebase structure and recent changes
- Identifying the expected vs actual behavior
- Reviewing relevant test files and their failures
2. **Reproduce the Bug**: Before making any changes:
- Run the application or tests to confirm the issue
- Document the exact steps to reproduce the problem
- Capture error outputs, logs, or unexpected behaviors
- Provide a clear bug report to the developer with:
- Steps to reproduce
- Expected behavior
- Actual behavior
- Error messages/stack traces
- Environment details
## Phase 2: Investigation
3. **Root Cause Analysis**:
- Trace the code execution path leading to the bug
- Examine variable states, data flows, and control logic
- Check for common issues: null references, off-by-one errors, race conditions, incorrect assumptions
- Use search and usages tools to understand how affected components interact
- Review git history for recent changes that might have introduced the bug
4. **Hypothesis Formation**:
- Form specific hypotheses about what's causing the issue
- Prioritize hypotheses based on likelihood and impact
- Plan verification steps for each hypothesis
## Phase 3: Resolution
5. **Implement Fix**:
- Make targeted, minimal changes to address the root cause
- Ensure changes follow existing code patterns and conventions
- Add defensive programming practices where appropriate
- Consider edge cases and potential side effects
6. **Verification**:
- Run tests to verify the fix resolves the issue
- Execute the original reproduction steps to confirm resolution
- Run broader test suites to ensure no regressions
- Test edge cases related to the fix
## Phase 4: Quality Assurance
7. **Code Quality**:
- Review the fix for code quality and maintainability
- Add or update tests to prevent regression
- Update documentation if necessary
- Consider if similar bugs might exist elsewhere in the codebase
8. **Final Report**:
- Summarize what was fixed and how
- Explain the root cause
- Document any preventive measures taken
- Suggest improvements to prevent similar issues
## Debugging Guidelines
- **Be Systematic**: Follow the phases methodically, don't jump to solutions
- **Document Everything**: Keep detailed records of findings and attempts
- **Think Incrementally**: Make small, testable changes rather than large refactors
- **Consider Context**: Understand the broader system impact of changes
- **Communicate Clearly**: Provide regular updates on progress and findings
- **Stay Focused**: Address the specific bug without unnecessary changes
- **Test Thoroughly**: Verify fixes work in various scenarios and environments
Remember: Always reproduce and understand the bug before attempting to fix it. A well-understood problem is half solved.
-25
View File
@@ -1,25 +0,0 @@
---
description: "Provide expert .NET software engineering guidance using modern software design patterns."
name: "Expert .NET software engineer mode instructions"
tools: ["changes", "codebase", "edit/editFiles", "extensions", "fetch", "findTestFiles", "githubRepo", "new", "openSimpleBrowser", "problems", "runCommands", "runNotebooks", "runTasks", "runTests", "search", "searchResults", "terminalLastCommand", "terminalSelection", "testFailure", "usages", "vscodeAPI", "microsoft.docs.mcp"]
---
# Expert .NET software engineer mode instructions
You are in expert software engineer mode. Your task is to provide expert software engineering guidance using modern software design patterns as if you were a leader in the field.
You will provide:
- insights, best practices and recommendations for .NET software engineering as if you were Anders Hejlsberg, the original architect of C# and a key figure in the development of .NET as well as Mads Torgersen, the lead designer of C#.
- general software engineering guidance and best-practices, clean code and modern software design, as if you were Robert C. Martin (Uncle Bob), a renowned software engineer and author of "Clean Code" and "The Clean Coder".
- DevOps and CI/CD best practices, as if you were Jez Humble, co-author of "Continuous Delivery" and "The DevOps Handbook".
- Testing and test automation best practices, as if you were Kent Beck, the creator of Extreme Programming (XP) and a pioneer in Test-Driven Development (TDD).
For .NET-specific guidance, focus on the following areas:
- **Design Patterns**: Use and explain modern design patterns such as Async/Await, Dependency Injection, Repository Pattern, Unit of Work, CQRS, Event Sourcing and of course the Gang of Four patterns.
- **SOLID Principles**: Emphasize the importance of SOLID principles in software design, ensuring that code is maintainable, scalable, and testable.
- **Testing**: Advocate for Test-Driven Development (TDD) and Behavior-Driven Development (BDD) practices, using frameworks like xUnit, NUnit, or MSTest.
- **Performance**: Provide insights on performance optimization techniques, including memory management, asynchronous programming, and efficient data access patterns.
- **Security**: Highlight best practices for securing .NET applications, including authentication, authorization, and data protection.
-20
View File
@@ -1,20 +0,0 @@
---
description: "Work with PostgreSQL databases using the PostgreSQL extension."
name: "PostgreSQL Database Administrator"
tools: ["codebase", "edit/editFiles", "githubRepo", "extensions", "runCommands", "database", "pgsql_bulkLoadCsv", "pgsql_connect", "pgsql_describeCsv", "pgsql_disconnect", "pgsql_listDatabases", "pgsql_listServers", "pgsql_modifyDatabase", "pgsql_open_script", "pgsql_query", "pgsql_visualizeSchema"]
---
# PostgreSQL Database Administrator
Before running any tools, use #extensions to ensure that `ms-ossdata.vscode-pgsql` is installed and enabled. This extension provides the necessary tools to interact with PostgreSQL databases. If it is not installed, ask the user to install it before continuing.
You are a PostgreSQL Database Administrator (DBA) with expertise in managing and maintaining PostgreSQL database systems. You can perform tasks such as:
- Creating and managing databases
- Writing and optimizing SQL queries
- Performing database backups and restores
- Monitoring database performance
- Implementing security measures
You have access to various tools that allow you to interact with databases, execute queries, and manage database configurations. **Always** use the tools to inspect the database, do not look into the codebase.
-162
View File
@@ -1,162 +0,0 @@
---
name: 'SE: Security'
description: 'Security-focused code review specialist with OWASP Top 10, Zero Trust, LLM security, and enterprise security standards'
model: GPT-5
tools: ['codebase', 'edit/editFiles', 'search', 'problems']
---
# Security Reviewer
Prevent production security failures through comprehensive security review.
## Your Mission
Review code for security vulnerabilities with focus on OWASP Top 10, Zero Trust principles, and AI/ML security (LLM and ML specific threats).
## Step 0: Create Targeted Review Plan
**Analyze what you're reviewing:**
1. **Code type?**
- Web API → OWASP Top 10
- AI/LLM integration → OWASP LLM Top 10
- ML model code → OWASP ML Security
- Authentication → Access control, crypto
2. **Risk level?**
- High: Payment, auth, AI models, admin
- Medium: User data, external APIs
- Low: UI components, utilities
3. **Business constraints?**
- Performance critical → Prioritize performance checks
- Security sensitive → Deep security review
- Rapid prototype → Critical security only
### Create Review Plan:
Select 3-5 most relevant check categories based on context.
## Step 1: OWASP Top 10 Security Review
**A01 - Broken Access Control:**
```python
# VULNERABILITY
@app.route('/user/<user_id>/profile')
def get_profile(user_id):
return User.get(user_id).to_json()
# SECURE
@app.route('/user/<user_id>/profile')
@require_auth
def get_profile(user_id):
if not current_user.can_access_user(user_id):
abort(403)
return User.get(user_id).to_json()
```
**A02 - Cryptographic Failures:**
```python
# VULNERABILITY
password_hash = hashlib.md5(password.encode()).hexdigest()
# SECURE
from werkzeug.security import generate_password_hash
password_hash = generate_password_hash(password, method='scrypt')
```
**A03 - Injection Attacks:**
```python
# VULNERABILITY
query = f"SELECT * FROM users WHERE id = {user_id}"
# SECURE
query = "SELECT * FROM users WHERE id = %s"
cursor.execute(query, (user_id,))
```
## Step 1.5: OWASP LLM Top 10 (AI Systems)
**LLM01 - Prompt Injection:**
```python
# VULNERABILITY
prompt = f"Summarize: {user_input}"
return llm.complete(prompt)
# SECURE
sanitized = sanitize_input(user_input)
prompt = f"""Task: Summarize only.
Content: {sanitized}
Response:"""
return llm.complete(prompt, max_tokens=500)
```
**LLM06 - Information Disclosure:**
```python
# VULNERABILITY
response = llm.complete(f"Context: {sensitive_data}")
# SECURE
sanitized_context = remove_pii(context)
response = llm.complete(f"Context: {sanitized_context}")
filtered = filter_sensitive_output(response)
return filtered
```
## Step 2: Zero Trust Implementation
**Never Trust, Always Verify:**
```python
# VULNERABILITY
def internal_api(data):
return process(data)
# ZERO TRUST
def internal_api(data, auth_token):
if not verify_service_token(auth_token):
raise UnauthorizedError()
if not validate_request(data):
raise ValidationError()
return process(data)
```
## Step 3: Reliability
**External Calls:**
```python
# VULNERABILITY
response = requests.get(api_url)
# SECURE
for attempt in range(3):
try:
response = requests.get(api_url, timeout=30, verify=True)
if response.status_code == 200:
break
except requests.RequestException as e:
logger.warning(f'Attempt {attempt + 1} failed: {e}')
time.sleep(2 ** attempt)
```
## Document Creation
### After Every Review, CREATE:
**Code Review Report** - Save to `docs/code-review/[date]-[component]-review.md`
- Include specific code examples and fixes
- Tag priority levels
- Document security findings
### Report Format:
```markdown
# Code Review: [Component]
**Ready for Production**: [Yes/No]
**Critical Issues**: [count]
## Priority 1 (Must Fix) ⛔
- [specific issue with fix]
## Recommended Changes
[code examples]
```
Remember: Goal is enterprise-grade code that is secure, maintainable, and compliant.
-86
View File
@@ -1,86 +0,0 @@
---
description: 'Orchestrates comprehensive test generation using Research-Plan-Implement pipeline. Use when asked to generate tests, write unit tests, improve test coverage, or add tests.'
name: 'Polyglot Test Generator'
---
# Test Generator Agent
You coordinate test generation using the Research-Plan-Implement (RPI) pipeline. You are polyglot - you work with any programming language.
## Pipeline Overview
1. **Research** - Understand the codebase structure, testing patterns, and what needs testing
2. **Plan** - Create a phased test implementation plan
3. **Implement** - Execute the plan phase by phase, with verification
## Workflow
### Step 1: Clarify the Request
First, understand what the user wants:
- What scope? (entire project, specific files, specific classes)
- Any priority areas?
- Any testing framework preferences?
If the request is clear (e.g., "generate tests for this project"), proceed directly.
### Step 2: Research Phase
Call the `polyglot-test-researcher` subagent to analyze the codebase:
```
runSubagent({
agent: "polyglot-test-researcher",
prompt: "Research the codebase at [PATH] for test generation. Identify: project structure, existing tests, source files to test, testing framework, build/test commands."
})
```
The researcher will create `.testagent/research.md` with findings.
### Step 3: Planning Phase
Call the `polyglot-test-planner` subagent to create the test plan:
```
runSubagent({
agent: "polyglot-test-planner",
prompt: "Create a test implementation plan based on the research at .testagent/research.md. Create phased approach with specific files and test cases."
})
```
The planner will create `.testagent/plan.md` with phases.
### Step 4: Implementation Phase
Read the plan and execute each phase by calling the `polyglot-test-implementer` subagent:
```
runSubagent({
agent: "polyglot-test-implementer",
prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Ensure tests compile and pass."
})
```
Call the implementer ONCE PER PHASE, sequentially. Wait for each phase to complete before starting the next.
### Step 5: Report Results
After all phases are complete:
- Summarize tests created
- Report any failures or issues
- Suggest next steps if needed
## State Management
All state is stored in `.testagent/` folder in the workspace:
- `.testagent/research.md` - Research findings
- `.testagent/plan.md` - Implementation plan
- `.testagent/status.md` - Progress tracking (optional)
## Important Rules
1. **Sequential phases** - Always complete one phase before starting the next
2. **Polyglot** - Detect the language and use appropriate patterns
3. **Verify** - Each phase should result in compiling, passing tests
4. **Don't skip** - If a phase fails, report it rather than skipping
-124
View File
@@ -1,124 +0,0 @@
# Copilot Instructions for Lutra
Use these standards when generating or modifying code in this repository.
## Project overview
- Target framework: .NET 10 (`net10.0`)
- Architecture: Clean Architecture
- Solution layers:
- `Lutra.Domain`
- `Lutra.Application`
- `Lutra.Infrastructure.Sql`
- `Lutra.API`
- `Lutra.AppHost`
- `Lutra.Infrastructure.Migrator`
- `Lutra.Application.UnitTests` — xUnit unit tests for Application handlers
- integration tests for API behavior (`Lutra.API.IntegrationTests`)
- Database: PostgreSQL via EF Core
- Orchestration: .NET Aspire
- Messaging pattern: `Cortex.Mediator` for CQRS handling
## General standards
- Prefer small, focused changes.
- Follow existing naming, formatting, and folder conventions.
- Keep dependencies pointing inward:
- Domain depends on nothing
- Application depends on Domain
- Infrastructure depends on Application and Domain
- API depends on Application and Infrastructure
- AppHost and Migrator are host projects only
- Use nullable reference types correctly and keep implicit usings compatible with the project style.
## Clean Architecture guidance
Use Jason Taylor’s CleanArchitecture project as a reference for intent and structure, but adapt to this codebase’s actual implementation.
Where the reference template uses MediatR, this project uses `Cortex.Mediator`.
The established test stack is **xUnit** with **FluentAssertions** — both are already in use and should be used for all new tests.
Add FluentValidation, AutoMapper, Shouldly, Moq, or Respawn only if the repository already uses them or the change clearly requires them.
## Domain layer rules
- Keep entities simple and focused on business state.
- Use `BaseEntity` as the shared base type when appropriate.
- Preserve the current entity style:
- `Id` as `Guid`
- audit fields like `CreatedAt`, `ModifiedAt`, `DeletedAt`
- `IsDeleted` as a derived property
- Keep validation and persistence concerns out of Domain unless they are intrinsic business rules.
## Application layer rules
- Put use cases in feature folders.
- Follow the existing CQRS split:
- `FeatureName.cs` as the partial entry point
- `FeatureName.Query.cs` or `FeatureName.Command.cs`
- `FeatureName.Handler.cs`
- `FeatureName.Response.cs`
- Use `Cortex.Mediator` request and handler interfaces.
- Keep handlers focused on orchestration and application logic.
- Use `ILutraDbContext` rather than referencing the concrete DbContext directly when possible.
- Shared model types (DTOs, enums) that are not use-case-specific live in `Models/<FeatureArea>/` (e.g., `Models/Verspakketten/`, `Models/Supermarkten/`).
## Infrastructure rules
- Put EF Core implementation, migrations, and database wiring in `Lutra.Infrastructure.Sql`.
- Keep the DbContext implementation aligned with `ILutraDbContext`.
- If package versions must be pinned for runtime compatibility, pin them explicitly in the infrastructure project.
- Be cautious with `PrivateAssets="all"` on design-time packages because they do not flow dependency constraints to downstream projects.
## API layer rules
- Keep controllers thin.
- Controllers should delegate to Application use cases through `IMediator`.
- Keep `Program.cs` focused on DI and middleware setup.
## Aspire and migration rules
- Keep `Lutra.AppHost` responsible for orchestration only.
- Preserve the persistent PostgreSQL container setup unless a change explicitly requires otherwise.
- Keep the migrator as a one-shot project that applies migrations on startup.
- Ensure migration-related changes do not break runtime assembly/version consistency.
## Naming and code style
- Use English for general code names (variables, method names, namespaces, folders) and use Dutch only for domain-specific enums, types, and files that reflect business concepts (e.g., `Verspakketten`, `Supermarkt`, `Beoordeling`).
- Keep class and file names consistent with the feature name.
- Use sealed types where the project already does.
- Prefer explicit `required` members where the current codebase uses them.
- Always use **file-scoped namespaces** (`namespace Foo.Bar;`), never block-scoped (`namespace Foo.Bar { }`).
- Preserve current indentation and brace style in each file.
## Testing guidance
- If tests exist, update or add tests alongside behavior changes.
- Follow the reference template's intent for test coverage:
- unit tests for business logic (`Lutra.Application.UnitTests`)
- integration tests for API behavior (`Lutra.API.IntegrationTests`)
- Test framework: **xUnit** (`xunit` v2 + `xunit.runner.visualstudio`).
- Assertions: **FluentAssertions**.
- Integration tests use **SQLite** (via `Microsoft.EntityFrameworkCore.Sqlite`) as the in-process test database -- not PostgreSQL. Use `LutraApiFactory` and `IntegrationTestBase` as the base infrastructure.
- Keep tests readable and focused on behavior.
## When making changes
- Read the relevant file before editing.
- Make the smallest change that solves the problem.
- Reuse existing patterns from the repo instead of inventing new ones.
- Run or request a build/test verification after changes when appropriate.
- Avoid broad refactors unless the user asks for them.
## Available agents
Specialist agents for common tasks are in `.github/agents/`. Invoke them when the task matches:
| Agent file | When to use |
|---|---|
| `csharp-expert.agent.md` | C# code quality, async patterns, LINQ, nullability, records, performance |
| `dotnet-expert.agent.md` | .NET architecture, CQRS, DI, SOLID, general engineering guidance |
| `security-reviewer.agent.md` | API security review, OWASP Top 10, access control, injection risks |
| `debug.agent.md` | Diagnosing and fixing bugs systematically |
| `postgresql-dba.agent.md` | PostgreSQL queries, schema, migrations, performance |
| `test-generator.agent.md` | Generating unit, integration, or functional tests for any layer |
-13
View File
@@ -1,13 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/contentModel.xml
/projectSettingsUpdater.xml
/modules.xml
/.idea.Lutra.iml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
@@ -1,157 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Lutra.API.IntegrationTests.Infrastructure;
using Lutra.Application.Supermarkten;
using Lutra.Domain.Entities;
namespace Lutra.API.IntegrationTests.Controllers;
public class SupermarktenControllerTests(LutraApiFactory factory)
: IntegrationTestBase(factory)
{
// ── GET /api/supermarkten ─────────────────────────────────────────────────
[Fact]
public async Task Get_ReturnsOk_WithEmptyList_WhenNoDataExists()
{
var response = await Client.GetAsync("/api/supermarkten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
body.Should().NotBeNull();
body!.Supermarkten.Should().BeEmpty();
}
[Fact]
public async Task Get_ReturnsSeededSupermarkt()
{
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Albert Heijn",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/supermarkten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
body!.Supermarkten.Should().HaveCount(1);
body.Supermarkten.First().Naam.Should().Be("Albert Heijn");
}
[Fact]
public async Task Get_ReturnsAllSeededSupermarkten()
{
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Albert Heijn",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Jumbo",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/supermarkten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
body!.Supermarkten.Should().HaveCount(3);
}
// ── GET /api/supermarkten — pagination ────────────────────────────────────
[Fact]
public async Task Get_Pagination_ReturnsCorrectPage()
{
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Albert Heijn",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Jumbo",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/supermarkten?skip=1&take=1");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
body!.Supermarkten.Should().HaveCount(1);
// Handler orders by Naam ascending, so skip=1 skips "Albert Heijn" and returns "Jumbo".
body.Supermarkten.First().Naam.Should().Be("Jumbo");
}
[Fact]
public async Task Get_Pagination_ReturnsEmptyList_WhenSkipExceedsTotalCount()
{
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Albert Heijn",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/supermarkten?skip=10&take=50");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
body!.Supermarkten.Should().BeEmpty();
}
// ── GET /api/supermarkten — sorting ───────────────────────────────────────
[Fact]
public async Task Get_ReturnsSupermarkten_OrderedByNaamAscending()
{
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Albert Heijn",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/supermarkten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
var namen = body!.Supermarkten.Select(s => s.Naam).ToList();
namen.Should().BeInAscendingOrder();
}
// ── Soft-delete behaviour ─────────────────────────────────────────────────
[Fact]
public async Task Get_DoesNotReturn_SoftDeletedSupermarkt()
{
await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Verwijderd",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow,
DeletedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/supermarkten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetSupermarkten.Response>();
body!.Supermarkten.Should().BeEmpty();
}
}
@@ -1,324 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Lutra.API.IntegrationTests.Infrastructure;
using Lutra.API.Requests;
using Lutra.Application.Verspakketten;
using Lutra.Domain.Entities;
namespace Lutra.API.IntegrationTests.Controllers;
public class VerspakkettenControllerTests(LutraApiFactory factory)
: IntegrationTestBase(factory)
{
// ── GET /api/verspakketten ────────────────────────────────────────────────
[Fact]
public async Task Get_ReturnsOk_WithEmptyList_WhenNoDataExists()
{
var response = await Client.GetAsync("/api/verspakketten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetVerspakketten.Response>();
body.Should().NotBeNull();
body!.Verspakketten.Should().BeEmpty();
}
[Fact]
public async Task Get_ReturnsSeededVerspakket()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var verspakket = new Verspakket
{
Id = Guid.NewGuid(), Naam = "Lente Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
};
verspakket.AddFoto(new VerspakketFoto
{
Id = Guid.NewGuid(),
Data = [1, 2, 3],
IsMainImage = true,
VerspakketId = verspakket.Id,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
});
verspakket.AddFoto(new VerspakketFoto
{
Id = Guid.NewGuid(),
Data = [4, 5, 6],
IsMainImage = false,
VerspakketId = verspakket.Id,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
});
await SeedAsync(verspakket);
var response = await Client.GetAsync("/api/verspakketten");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetVerspakketten.Response>();
body!.Verspakketten.Should().HaveCount(1);
body.Verspakketten.First().Naam.Should().Be("Lente Pakket");
body.Verspakketten.First().Foto.Should().NotBeNull();
body.Verspakketten.First().Foto!.IsMainImage.Should().BeTrue();
}
// ── GET /api/verspakketten/{id} ───────────────────────────────────────────
[Fact]
public async Task GetById_ReturnsNotFound_WhenVerspakketDoesNotExist()
{
var response = await Client.GetAsync($"/api/verspakketten/{Guid.NewGuid()}");
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task GetById_ReturnsVerspakket_WhenItExists()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Jumbo",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var verspakket = await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Zomer Pakket", AantalPersonen = 4,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync($"/api/verspakketten/{verspakket.Id}");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetVerspakket.Response>();
body!.Verspakket.Should().NotBeNull();
body.Verspakket!.Naam.Should().Be("Zomer Pakket");
}
// ── POST /api/verspakketten ───────────────────────────────────────────────
[Fact]
public async Task Post_CreatesVerspakket_AndReturns201()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var request = new CreateVerspakketRequest("Herfst Pakket", 1499, 3, supermarkt.Id);
var response = await Client.PostAsJsonAsync("/api/verspakketten", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<CreateVerspakket.Response>();
body!.Id.Should().NotBeEmpty();
response.Headers.Location.Should().NotBeNull();
}
[Fact]
public async Task Post_CreatesVerspakket_WithBeoordeling_AndReturns201()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Picnic",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var command = new CreateVerspakket.Command(
"Herfst Pakket",
1499,
3,
supermarkt.Id,
new Lutra.Application.Models.Verspakketten.Beoordeling
{
CijferSmaak = 9,
CijferBereiden = 8,
Aanbevolen = true,
Tekst = "Heel goed"
});
var response = await Client.PostAsJsonAsync("/api/verspakketten", command);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<CreateVerspakket.Response>();
body!.Id.Should().NotBeEmpty();
var created = await Client.GetFromJsonAsync<GetVerspakket.Response>($"/api/verspakketten/{body.Id}");
created!.Verspakket.Beoordelingen.Should().ContainSingle();
created.Verspakket.Beoordelingen!.Single().CijferSmaak.Should().Be(9);
}
[Fact]
public async Task Post_ReturnsBadRequest_WhenSupermarktDoesNotExist()
{
var request = new CreateVerspakketRequest("Winter Pakket", 999, 2, Guid.NewGuid());
var response = await Client.PostAsJsonAsync("/api/verspakketten", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── PUT /api/verspakketten/{id} ───────────────────────────────────────────
[Fact]
public async Task Update_ReturnsNoContent_WhenVerspakketExists()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var verspakket = await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Oud Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var request = new UpdateVerspakketRequest("Nieuw Pakket", 1999, 3, supermarkt.Id);
var response = await Client.PutAsJsonAsync($"/api/verspakketten/{verspakket.Id}", request);
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
[Fact]
public async Task Update_ReturnsNotFound_WhenVerspakketDoesNotExist()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var request = new UpdateVerspakketRequest("Pakket", 999, 2, supermarkt.Id);
var response = await Client.PutAsJsonAsync($"/api/verspakketten/{Guid.NewGuid()}", request);
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Update_ReturnsBadRequest_WhenSupermarktDoesNotExist()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var verspakket = await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var request = new UpdateVerspakketRequest("Pakket", 999, 2, Guid.NewGuid());
var response = await Client.PutAsJsonAsync($"/api/verspakketten/{verspakket.Id}", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── GET /api/verspakketten — pagination & sorting ─────────────────────────
[Fact]
public async Task Get_Pagination_ReturnsCorrectPage()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Aardappel Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Broccoli Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Courgette Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/verspakketten?skip=1&take=1");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetVerspakketten.Response>();
body!.Verspakketten.Should().HaveCount(1);
body.Verspakketten.First().Naam.Should().Be("Broccoli Pakket");
}
[Fact]
public async Task Get_SortDescending_ReturnsItemsInReverseOrder()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "AH",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Aardappel Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Zomerpakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var response = await Client.GetAsync("/api/verspakketten?sortDirection=Descending");
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<GetVerspakketten.Response>();
body!.Verspakketten.First().Naam.Should().Be("Zomerpakket");
body.Verspakketten.Last().Naam.Should().Be("Aardappel Pakket");
}
// ── POST /api/verspakketten/{id}/beoordelingen ────────────────────────────
[Fact]
public async Task AddBeoordeling_ReturnsBadRequest_WhenVerspakketDoesNotExist()
{
var command = new AddBeoordeling.Command(Guid.NewGuid(), 8, 7, true, "Heerlijk!");
var response = await Client.PostAsJsonAsync($"/api/verspakketten/{command.VerspakketId}/beoordelingen", command);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AddBeoordeling_Returns201_WhenVerspakketExists()
{
var supermarkt = await SeedAsync(new Supermarkt
{
Id = Guid.NewGuid(), Naam = "Lidl",
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var verspakket = await SeedAsync(new Verspakket
{
Id = Guid.NewGuid(), Naam = "Basis Pakket", AantalPersonen = 2,
SupermarktId = supermarkt.Id,
CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow
});
var command = new AddBeoordeling.Command(verspakket.Id, 8, 7, true, "Heerlijk!");
var response = await Client.PostAsJsonAsync($"/api/verspakketten/{verspakket.Id}/beoordelingen", command);
response.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await response.Content.ReadFromJsonAsync<AddBeoordeling.Response>();
body!.Id.Should().NotBeEmpty();
}
}
@@ -1,51 +0,0 @@
using Lutra.Application.Interfaces;
using Lutra.Infrastructure.Sql;
using Microsoft.Extensions.DependencyInjection;
namespace Lutra.API.IntegrationTests.Infrastructure;
/// <summary>
/// Base class for integration tests. Provides a shared factory and helper methods
/// to seed and reset the database between tests.
/// </summary>
public abstract class IntegrationTestBase : IClassFixture<LutraApiFactory>, IAsyncLifetime
{
protected readonly LutraApiFactory Factory;
protected readonly HttpClient Client;
protected IntegrationTestBase(LutraApiFactory factory)
{
Factory = factory;
Client = factory.CreateClient();
}
/// <summary>Seed data or perform setup before each test.</summary>
public virtual ValueTask InitializeAsync()
{
Factory.EnsureSchemaCreated();
return ValueTask.CompletedTask;
}
/// <summary>Reset database state after each test.</summary>
public async ValueTask DisposeAsync()
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ILutraDbContext>() as LutraDbContext;
if (db is not null)
{
db.Beoordelingen.RemoveRange(db.Beoordelingen);
db.Verspaketten.RemoveRange(db.Verspaketten);
db.Supermarkten.RemoveRange(db.Supermarkten);
await db.SaveChangesAsync(CancellationToken.None);
}
}
protected async ValueTask<T> SeedAsync<T>(T entity) where T : class
{
using var scope = Factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ILutraDbContext>() as LutraDbContext;
db!.Set<T>().Add(entity);
await db.SaveChangesAsync(CancellationToken.None);
return entity;
}
}
@@ -1,69 +0,0 @@
using Lutra.Application.Interfaces;
using Lutra.Infrastructure.Sql;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Lutra.API.IntegrationTests.Infrastructure;
/// <summary>
/// Custom WebApplicationFactory that replaces the PostgreSQL database with SQLite in-memory
/// so that integration tests can run without a live database server.
/// A single SqliteConnection is kept open for the lifetime of the factory so that
/// all DI scopes share the same in-memory database.
/// </summary>
public class LutraApiFactory : WebApplicationFactory<Program>
{
// Opened immediately so it is ready when ConfigureWebHost runs.
private readonly SqliteConnection _connection = new("Data Source=:memory:");
private bool _schemaCreated;
public LutraApiFactory()
{
_connection.Open();
}
/// <summary>Ensures the SQLite schema is created. Call once before the first test.</summary>
public void EnsureSchemaCreated()
{
if (_schemaCreated) return;
using var scope = Services.CreateScope();
var db = (LutraDbContext)scope.ServiceProvider.GetRequiredService<ILutraDbContext>();
db.Database.EnsureCreated();
_schemaCreated = true;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// EF Core 10 stores provider configuration in IDbContextOptionsConfiguration<T>
// descriptors (one per AddDbContext call). All four registration types must be
// removed so neither Npgsql options nor its provider services survive into the
// SQLite registration.
services.RemoveAll<ILutraDbContext>();
services.RemoveAll<LutraDbContext>();
services.RemoveAll<DbContextOptions<LutraDbContext>>();
services.RemoveAll<DbContextOptions>();
services.RemoveAll(typeof(IDbContextOptionsConfiguration<LutraDbContext>));
// Register SQLite using the shared open connection.
services.AddDbContext<ILutraDbContext, LutraDbContext>(options =>
options.UseSqlite(_connection));
});
builder.UseEnvironment("Testing");
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
_connection.Dispose();
}
}
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.API\Lutra.API.csproj" />
<ProjectReference Include="..\Lutra.Infrastructure\Lutra.Infrastructure.Sql.csproj" />
</ItemGroup>
</Project>
@@ -1,22 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace Lutra.API.Controllers;
/// <summary>
/// Exposes a lightweight health check for the API.
/// </summary>
[ApiController]
[Route("health")]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
public class HealthController : ControllerBase
{
/// <summary>
/// Returns the API health status.
/// </summary>
[HttpGet]
public IActionResult Index()
{
return Ok();
}
}
@@ -1,27 +0,0 @@
using Cortex.Mediator;
using Lutra.Application.Supermarkten;
using Microsoft.AspNetCore.Mvc;
namespace Lutra.API.Controllers;
/// <summary>
/// Provides endpoints for Supermarkt-related operations.
/// </summary>
[ApiController]
[Route("api/supermarkten")]
[Produces("application/json")]
public class SupermarktenController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Gets a page of supermarkten.
/// </summary>
/// <param name="skip">The number of items to skip.</param>
/// <param name="take">The maximum number of items to return.</param>
/// <returns>The requested supermarkt page.</returns>
[HttpGet]
[ProducesResponseType(typeof(GetSupermarkten.Response), StatusCodes.Status200OK)]
public async Task<GetSupermarkten.Response> Get(int skip = 0, int take = 50)
{
return await mediator.SendQueryAsync<GetSupermarkten.Query, GetSupermarkten.Response>(new GetSupermarkten.Query(skip, take));
}
}
@@ -1,160 +0,0 @@
using Cortex.Mediator;
using Lutra.API.Requests;
using Lutra.Application.Verspakketten;
using Microsoft.AspNetCore.Mvc;
namespace Lutra.API.Controllers;
/// <summary>
/// Provides access to verspakket resources.
/// </summary>
[ApiController]
[Route("api/verspakketten")]
[Produces("application/json")]
public class VerspakkettenController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Gets a page of verspakketten.
/// </summary>
/// <param name="skip">The number of items to skip. Default: 0.</param>
/// <param name="take">The maximum number of items to return. Default: 50.</param>
/// <param name="sortField">The field to sort by: Naam, PrijsInCenten, AverageCijferSmaak, or AverageCijferBereiden. Default: Naam.</param>
/// <param name="sortDirection">The sort direction: Ascending or Descending. Default: Ascending.</param>
/// <returns>The requested verspakket page.</returns>
[HttpGet]
[ProducesResponseType(typeof(GetVerspakketten.Response), StatusCodes.Status200OK)]
public async Task<GetVerspakketten.Response> Get(
int skip = 0,
int take = 50,
VerspakketSortField sortField = VerspakketSortField.Naam,
SortDirection sortDirection = SortDirection.Ascending)
{
return await mediator.SendQueryAsync<GetVerspakketten.Query, GetVerspakketten.Response>(
new GetVerspakketten.Query(skip, take, sortField, sortDirection));
}
/// <summary>
/// Gets a specific verspakket by ID.
/// </summary>
/// <param name="id">The verspakket ID.</param>
/// <returns>Returns 200 OK with the verspakket when found, or 404 Not Found when not found.</returns>
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(GetVerspakket.Response), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GetVerspakket.Response?>> GetById(Guid id)
{
var result = await mediator.SendQueryAsync<GetVerspakket.Query, GetVerspakket.Response?>(new GetVerspakket.Query(id));
if (result?.Verspakket == null)
{
return NotFound();
}
return Ok(result);
}
/// <summary>
/// Creates a new verspakket.
/// </summary>
/// <param name="request">The verspakket values to create.</param>
/// <returns>Returns 201 Created with the created verspakket identifier.</returns>
[HttpPost]
[ProducesResponseType(typeof(CreateVerspakket.Response), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CreateVerspakket.Response>> Post([FromBody] CreateVerspakketRequest request)
{
try
{
var beoordeling = request.Beoordeling is null
? null
: new Application.Models.Verspakketten.Beoordeling
{
CijferSmaak = request.Beoordeling.CijferSmaak,
CijferBereiden = request.Beoordeling.CijferBereiden,
Aanbevolen = request.Beoordeling.Aanbevolen,
Tekst = request.Beoordeling.Tekst
};
var fotos = request.Fotos?
.Select(f => new Application.Models.Verspakketten.VerspakketFoto(f.Base64Data, f.IsMainImage))
.ToList();
var command = new CreateVerspakket.Command(
request.Naam,
request.PrijsInCenten,
request.AantalPersonen,
request.SupermarktId,
beoordeling,
fotos);
var result = await mediator.SendCommandAsync<CreateVerspakket.Command, CreateVerspakket.Response>(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Updates an existing verspakket with the provided values.
/// </summary>
/// <param name="id">The verspakket identifier.</param>
/// <param name="request">The updated verspakket values.</param>
/// <returns>
/// Returns 204 No Content when the update succeeds.
/// Returns 404 Not Found when the specified verspakket does not exist.
/// </returns>
[HttpPut("{id:guid}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVerspakketRequest request)
{
try
{
var fotos = request.Fotos?
.Select(f => new Application.Models.Verspakketten.VerspakketFoto(f.Base64Data, f.IsMainImage))
.ToList();
var command = new UpdateVerspakket.Command(id, request.Naam, request.PrijsInCenten, request.AantalPersonen, request.SupermarktId, fotos);
await mediator.SendCommandAsync<UpdateVerspakket.Command, UpdateVerspakket.Response>(command);
return NoContent();
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (InvalidOperationException ex) when (ex.Message.StartsWith($"Verspakket with id '{id}'"))
{
return NotFound();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Adds a beoordeling to an existing verspakket.
/// </summary>
/// <param name="id">The verspakket ID.</param>
/// <param name="request">The beoordeling values to add.</param>
/// <returns>Returns 201 Created with the created beoordeling identifier.</returns>
[HttpPost("{id:guid}/beoordelingen")]
[ProducesResponseType(typeof(AddBeoordeling.Response), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<AddBeoordeling.Response>> AddBeoordeling(Guid id, [FromBody] AddBeoordelingRequest request)
{
try
{
var command = new AddBeoordeling.Command(id, request.CijferSmaak, request.CijferBereiden, request.Aanbevolen, request.Tekst);
var result = await mediator.SendCommandAsync<AddBeoordeling.Command, AddBeoordeling.Response>(command);
return CreatedAtAction(nameof(GetById), new { id }, result);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}
-30
View File
@@ -1,30 +0,0 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Lutra.API/Lutra.API.csproj", "Lutra.API/"]
RUN dotnet restore "./Lutra.API/Lutra.API.csproj"
COPY . .
WORKDIR "/src/Lutra.API"
RUN dotnet build "./Lutra.API.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Lutra.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Lutra.API.dll"]
-29
View File
@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<UserSecretsId>c9bd54b0-d347-4e93-bcea-ed5e98a71d5c</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cortex.Mediator" Version="3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.4" />
<ProjectReference Include="..\Lutra.Infrastructure\Lutra.Infrastructure.Sql.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.Application\Lutra.Application.csproj" />
</ItemGroup>
</Project>
-6
View File
@@ -1,6 +0,0 @@
@Lutra.API_HostAddress = http://localhost:5037
GET {{Lutra.API_HostAddress}}
Accept: application/json
###
-66
View File
@@ -1,66 +0,0 @@
using Cortex.Mediator.DependencyInjection;
using Lutra.Application.Verspakketten;
using Lutra.Application.Interfaces;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
namespace Lutra.API
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowLocalDevelopment", policy =>
policy.SetIsOriginAllowed(origin =>
{
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri))
{
return false;
}
return uri.Host is "localhost" or "127.0.0.1" or "[::1]";
})
.AllowAnyHeader()
.AllowAnyMethod());
});
builder.Services.AddCortexMediator(
handlerAssemblyMarkerTypes: [typeof(Program), typeof(GetVerspakketten)],
options => options.AddDefaultBehaviors()
);
builder.Services.AddDbContext<ILutraDbContext, LutraDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("LutraDb")));
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription();
}
app.UseHttpsRedirection();
app.UseCors("AllowLocalDevelopment");
app.UseAuthorization();
app.MapControllers();
app.Run();
}
}
}
@@ -1,46 +0,0 @@
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5037"
},
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7013;http://localhost:5037"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:50177/",
"sslPort": 44385
}
}
}
@@ -1,12 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.API.Requests;
/// <summary>
/// Represents the data required to add a beoordeling to a verspakket.
/// </summary>
public sealed record AddBeoordelingRequest(
[Range(1, 10)] int CijferSmaak,
[Range(1, 10)] int CijferBereiden,
bool Aanbevolen,
[MaxLength(1024)] string? Tekst);
@@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.API.Requests;
/// <summary>
/// Represents the data required to create a verspakket.
/// </summary>
public sealed record CreateVerspakketRequest(
[Required, MaxLength(50)] string Naam,
[Range(0, int.MaxValue)] int? PrijsInCenten,
[Range(1, 10)] int AantalPersonen,
[Required] Guid SupermarktId,
AddBeoordelingRequest? Beoordeling = null,
IReadOnlyList<VerspakketFotoRequest>? Fotos = null);
@@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.API.Requests;
/// <summary>
/// Represents the data required to update a verspakket.
/// </summary>
public sealed record UpdateVerspakketRequest(
[Required, MaxLength(50)] string Naam,
[Range(0, int.MaxValue)] int PrijsInCenten,
[Range(1, 10)] int AantalPersonen,
[Required] Guid SupermarktId,
IReadOnlyList<VerspakketFotoRequest>? Fotos = null);
@@ -1,10 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.API.Requests;
/// <summary>
/// Represents a foto in a request, encoded as base64.
/// </summary>
public sealed record VerspakketFotoRequest(
[Required] string Base64Data,
bool IsMainImage);
@@ -1,11 +0,0 @@
{
"ConnectionStrings": {
"LutraDb": "Host=localhost;Database=lutra;Username=postgres;Password=<set-locally>"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"LutraDb": ""
},
"AllowedHosts": "*"
}
-19
View File
@@ -1,19 +0,0 @@
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithDataVolume()
.WithLifetime(ContainerLifetime.Persistent);
var db = postgres
.AddDatabase("LutraDb");
var migrator = builder.AddProject<Projects.Lutra_Infrastructure_Migrator>("dbmigrator")
.WithReference(db)
.WaitFor(db);
var apiService = builder.AddProject<Projects.Lutra_API>("apiservice")
.WithHttpHealthCheck("/health")
.WithReference(db)
.WaitForCompletion(migrator);
builder.Build().Run();
-23
View File
@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<Sdk Name="Aspire.AppHost.Sdk" Version="9.5.0" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>cb3bf793-12ac-4e3d-9aeb-c2e3cb36a9fe</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.3" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.API\Lutra.API.csproj" />
<ProjectReference Include="..\Lutra.Infrastructure.Migrator\Lutra.Infrastructure.Migrator.csproj" />
</ItemGroup>
</Project>
@@ -1,29 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17126;http://localhost:15299",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21049",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22271"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15299",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19176",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20117"
}
}
}
}
@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Moq.EntityFrameworkCore" Version="10.0.0.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.Application\Lutra.Application.csproj" />
<ProjectReference Include="..\Lutra.Domain\Lutra.Domain.csproj" />
</ItemGroup>
</Project>
@@ -1,72 +0,0 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Verspakketten;
using Microsoft.EntityFrameworkCore;
using Moq;
using Moq.EntityFrameworkCore;
namespace Lutra.Application.UnitTests.Verspakketten;
public class AddBeoordelingHandlerTests
{
private readonly Mock<ILutraDbContext> _contextMock;
private readonly AddBeoordeling.Handler _handler;
public AddBeoordelingHandlerTests()
{
_contextMock = new Mock<ILutraDbContext>();
_handler = new AddBeoordeling.Handler(_contextMock.Object);
}
[Fact]
public async Task Handle_VerspakketExists_AddsBeoordeling()
{
var verspakketId = Guid.NewGuid();
var verspakketten = new List<Domain.Entities.Verspakket>
{
new() { Id = verspakketId, Naam = "Test", AantalPersonen = 2, SupermarktId = Guid.NewGuid(), CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(verspakketten);
_contextMock.Setup(c => c.Beoordelingen).ReturnsDbSet(new List<Domain.Entities.Beoordeling>());
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new AddBeoordeling.Command(verspakketId, 8, 7, true, "Lekker!");
var result = await _handler.Handle(command, CancellationToken.None);
result.Id.Should().NotBeEmpty();
_contextMock.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_VerspakketNotFound_ThrowsInvalidOperationException()
{
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
var command = new AddBeoordeling.Command(Guid.NewGuid(), 8, 7, true, null);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*was not found*");
}
[Fact]
public async Task Handle_VerspakketDeleted_ThrowsInvalidOperationException()
{
var verspakketId = Guid.NewGuid();
var verspakketten = new List<Domain.Entities.Verspakket>
{
new() { Id = verspakketId, Naam = "Deleted", AantalPersonen = 2, SupermarktId = Guid.NewGuid(), CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow, DeletedAt = DateTime.UtcNow }
};
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(verspakketten);
var command = new AddBeoordeling.Command(verspakketId, 8, 7, true, null);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
}
@@ -1,123 +0,0 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
namespace Lutra.Application.UnitTests.Verspakketten;
public class CreateVerspakketHandlerTests
{
private readonly Mock<ILutraDbContext> _contextMock;
private readonly CreateVerspakket.Handler _handler;
public CreateVerspakketHandlerTests()
{
_contextMock = new Mock<ILutraDbContext>();
_handler = new CreateVerspakket.Handler(_contextMock.Object);
}
[Fact]
public async Task Handle_SupermarktExists_CreatesVerspakket()
{
var supermarktId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "AH", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, supermarktId, null);
var result = await _handler.Handle(command, CancellationToken.None);
result.Id.Should().NotBeEmpty();
_contextMock.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_SupermarktNotFound_ThrowsInvalidOperationException()
{
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(new List<Domain.Entities.Supermarkt>());
var command = new CreateVerspakket.Command("Lente Pakket", 1299, 2, Guid.NewGuid(), null);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*was not found*");
}
[Fact]
public async Task Handle_CreatesVerspakketWithCorrectProperties()
{
var supermarktId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "Jumbo", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
Domain.Entities.Verspakket? savedVerspakket = null;
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => savedVerspakket = v);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command("Zomer Pakket", 999, 4, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
savedVerspakket.Should().NotBeNull();
savedVerspakket!.Naam.Should().Be("Zomer Pakket");
savedVerspakket.PrijsInCenten.Should().Be(999);
savedVerspakket.AantalPersonen.Should().Be(4);
savedVerspakket.SupermarktId.Should().Be(supermarktId);
}
[Fact]
public async Task Handle_WithBeoordeling_CreatesVerspakketWithBeoordeling()
{
var supermarktId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "Jumbo", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
Domain.Entities.Verspakket? savedVerspakket = null;
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => savedVerspakket = v);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
var command = new CreateVerspakket.Command(
"Zomer Pakket",
999,
4,
supermarktId,
new Beoordeling
{
CijferSmaak = 8,
CijferBereiden = 7,
Aanbevolen = true,
Tekst = "Lekker"
});
await _handler.Handle(command, CancellationToken.None);
savedVerspakket.Should().NotBeNull();
savedVerspakket!.Beoordelingen.Should().ContainSingle();
var beoordeling = savedVerspakket.Beoordelingen.Single();
beoordeling.CijferSmaak.Should().Be(8);
beoordeling.CijferBereiden.Should().Be(7);
beoordeling.Aanbevolen.Should().BeTrue();
beoordeling.Tekst.Should().Be("Lekker");
}
}
@@ -1,99 +0,0 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
namespace Lutra.Application.UnitTests.Verspakketten;
public class CreateVerspakketWithFotosHandlerTests
{
private readonly Mock<ILutraDbContext> _contextMock;
private readonly CreateVerspakket.Handler _handler;
// 1x1 white PNG as base64
private const string ValidBase64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==";
public CreateVerspakketWithFotosHandlerTests()
{
_contextMock = new Mock<ILutraDbContext>();
_handler = new CreateVerspakket.Handler(_contextMock.Object);
}
private void SetupContext(Guid supermarktId)
{
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "AH", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
}
[Fact]
public async Task Handle_WithFotos_CreatesVerspakketWithFotos()
{
var supermarktId = Guid.NewGuid();
SetupContext(supermarktId);
Domain.Entities.Verspakket? saved = null;
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => saved = v);
var fotos = new List<VerspakketFoto>
{
new(ValidBase64Png, IsMainImage: true),
new(ValidBase64Png, IsMainImage: false)
};
var command = new CreateVerspakket.Command("Lente Pakket", 999, 2, supermarktId, null, fotos);
await _handler.Handle(command, CancellationToken.None);
saved.Should().NotBeNull();
saved!.Fotos.Should().HaveCount(2);
saved.Fotos.Count(f => f.IsMainImage).Should().Be(1);
}
[Fact]
public async Task Handle_WithoutFotos_CreatesVerspakketWithNoFotos()
{
var supermarktId = Guid.NewGuid();
SetupContext(supermarktId);
Domain.Entities.Verspakket? saved = null;
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => saved = v);
var command = new CreateVerspakket.Command("Herfst Pakket", 799, 2, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
saved.Should().NotBeNull();
saved!.Fotos.Should().BeEmpty();
}
[Fact]
public async Task Handle_FotoBase64Decoded_StoresCorrectBytes()
{
var supermarktId = Guid.NewGuid();
SetupContext(supermarktId);
Domain.Entities.Verspakket? saved = null;
_contextMock
.Setup(c => c.Verspaketten.AddAsync(It.IsAny<Domain.Entities.Verspakket>(), It.IsAny<CancellationToken>()))
.Callback<Domain.Entities.Verspakket, CancellationToken>((v, _) => saved = v);
var fotos = new List<VerspakketFoto> { new(ValidBase64Png, IsMainImage: false) };
var command = new CreateVerspakket.Command("Pakket", null, 1, supermarktId, null, fotos);
await _handler.Handle(command, CancellationToken.None);
var foto = saved!.Fotos.Single();
foto.Data.Should().BeEquivalentTo(Convert.FromBase64String(ValidBase64Png));
}
}
@@ -1,141 +0,0 @@
using FluentAssertions;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Lutra.Application.Verspakketten;
using Moq;
using Moq.EntityFrameworkCore;
namespace Lutra.Application.UnitTests.Verspakketten;
public class UpdateVerspakketHandlerTests
{
private readonly Mock<ILutraDbContext> _contextMock;
private readonly UpdateVerspakket.Handler _handler;
private const string ValidBase64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==";
public UpdateVerspakketHandlerTests()
{
_contextMock = new Mock<ILutraDbContext>();
_handler = new UpdateVerspakket.Handler(_contextMock.Object);
}
private (Guid verspakketId, Guid supermarktId) SetupContext(
List<Domain.Entities.VerspakketFoto>? existingFotos = null)
{
var supermarktId = Guid.NewGuid();
var verspakketId = Guid.NewGuid();
var supermarkten = new List<Domain.Entities.Supermarkt>
{
new() { Id = supermarktId, Naam = "AH", CreatedAt = DateTime.UtcNow, ModifiedAt = DateTime.UtcNow }
};
var verspakket = new Domain.Entities.Verspakket
{
Id = verspakketId,
Naam = "Oud Pakket",
PrijsInCenten = 500,
AantalPersonen = 2,
SupermarktId = supermarktId,
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
};
foreach (var foto in existingFotos ?? [])
verspakket.AddFoto(foto);
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(supermarkten);
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket> { verspakket });
_contextMock.Setup(c => c.VerspakketFotos).ReturnsDbSet(existingFotos ?? []);
_contextMock.Setup(c => c.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
return (verspakketId, supermarktId);
}
[Fact]
public async Task Handle_WithoutFotos_UpdatesFieldsOnly()
{
var (verspakketId, supermarktId) = SetupContext();
var command = new UpdateVerspakket.Command(verspakketId, "Nieuw Pakket", 1200, 4, supermarktId);
var result = await _handler.Handle(command, CancellationToken.None);
result.Should().NotBeNull();
_contextMock.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_WithFotos_ReplacesFotos()
{
var oldFotoId = Guid.NewGuid();
var existingFotos = new List<Domain.Entities.VerspakketFoto>
{
new()
{
Id = oldFotoId,
Data = [0x00],
IsMainImage = true,
VerspakketId = Guid.NewGuid(),
CreatedAt = DateTime.UtcNow,
ModifiedAt = DateTime.UtcNow
}
};
var (verspakketId, supermarktId) = SetupContext(existingFotos);
var newFotos = new List<VerspakketFoto>
{
new(ValidBase64Png, IsMainImage: true),
new(ValidBase64Png, IsMainImage: false)
};
var command = new UpdateVerspakket.Command(verspakketId, "Pakket", 999, 2, supermarktId, newFotos);
await _handler.Handle(command, CancellationToken.None);
_contextMock.Verify(c => c.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_NullFotos_DoesNotTouchFotos()
{
var (verspakketId, supermarktId) = SetupContext();
var command = new UpdateVerspakket.Command(verspakketId, "Pakket", 800, 3, supermarktId, null);
await _handler.Handle(command, CancellationToken.None);
// VerspakketFotos.RemoveRange should NOT be called when Fotos is null
_contextMock.Verify(
c => c.VerspakketFotos.RemoveRange(It.IsAny<IEnumerable<Domain.Entities.VerspakketFoto>>()),
Times.Never);
}
[Fact]
public async Task Handle_VerspakketNotFound_ThrowsInvalidOperationException()
{
var supermarktId = Guid.NewGuid();
_contextMock.Setup(c => c.Verspaketten).ReturnsDbSet(new List<Domain.Entities.Verspakket>());
_contextMock.Setup(c => c.Supermarkten).ReturnsDbSet(new List<Domain.Entities.Supermarkt>());
var command = new UpdateVerspakket.Command(Guid.NewGuid(), "Pakket", 800, 2, supermarktId);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>().WithMessage("*was not found*");
}
[Fact]
public async Task Handle_InvalidAantalPersonen_ThrowsArgumentException()
{
var (verspakketId, supermarktId) = SetupContext();
var command = new UpdateVerspakket.Command(verspakketId, "Pakket", 800, 0, supermarktId);
var act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ArgumentException>();
}
}
@@ -1,17 +0,0 @@
using Lutra.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Interfaces;
public interface ILutraDbContext
{
DbSet<Supermarkt> Supermarkten { get; }
DbSet<Beoordeling> Beoordelingen { get; }
DbSet<VerspakketFoto> VerspakketFotos { get; }
DbSet<Verspakket> Verspaketten { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cortex.Mediator" Version="3.1.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.Domain\Lutra.Domain.csproj" />
</ItemGroup>
</Project>
@@ -1,8 +0,0 @@
namespace Lutra.Application.Models.Supermarkten;
public record Supermarkt
{
public required Guid Id { get; init; }
public required string Naam { get; init; }
}
@@ -1,12 +0,0 @@
namespace Lutra.Application.Models.Verspakketten;
public record Beoordeling
{
public required int CijferSmaak { get; init; }
public required int CijferBereiden { get; init; }
public required bool Aanbevolen { get; init; }
public string? Tekst { get; init; }
}
@@ -1,24 +0,0 @@
using Lutra.Application.Models.Supermarkten;
namespace Lutra.Application.Models.Verspakketten;
public record Verspakket
{
public required Guid Id { get; init; }
public required string Naam { get; init; }
public int? PrijsInCenten { get; init; }
public int AantalPersonen { get; init; }
public double? AverageCijferSmaak { get; init; }
public double? AverageCijferBereiden { get; init; }
public Beoordeling[]? Beoordelingen { get; init; }
public VerspakketFotoResponse[]? Fotos { get; init; }
public Supermarkt? Supermarkt { get; init; }
}
@@ -1,9 +0,0 @@
namespace Lutra.Application.Models.Verspakketten;
/// <summary>
/// Represents a foto to associate with a verspakket.
/// </summary>
public sealed record VerspakketFoto(
/// <summary>Base64-encoded image data.</summary>
string Base64Data,
bool IsMainImage);
@@ -1,7 +0,0 @@
namespace Lutra.Application.Models.Verspakketten;
public sealed record VerspakketFotoResponse(
Guid Id,
/// <summary>Base64-encoded image data.</summary>
string Base64Data,
bool IsMainImage);
@@ -1,22 +0,0 @@
using Lutra.Application.Models.Supermarkten;
namespace Lutra.Application.Models.Verspakketten;
public record VerspakketSummary
{
public required Guid Id { get; init; }
public required string Naam { get; init; }
public int? PrijsInCenten { get; init; }
public int AantalPersonen { get; init; }
public double? AverageCijferSmaak { get; init; }
public double? AverageCijferBereiden { get; init; }
public VerspakketFotoResponse? Foto { get; init; }
public Supermarkt? Supermarkt { get; init; }
}
@@ -1,30 +0,0 @@
using Cortex.Mediator.Queries;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Supermarkten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
{
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
{
var supermarkten = await context.Supermarkten
.AsNoTracking()
.Where(w => w.DeletedAt == null)
.OrderBy(s => s.Naam)
.Skip(request.Skip)
.Take(request.Take)
.Select(s => new Supermarkt
{
Id = s.Id,
Naam = s.Naam
})
.ToListAsync(cancellationToken);
return new Response { Supermarkten = supermarkten };
}
}
}
@@ -1,8 +0,0 @@
using Cortex.Mediator.Queries;
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten
{
public record Query(int Skip, int Take) : IQuery<Response>;
}
@@ -1,11 +0,0 @@
using Lutra.Application.Models.Supermarkten;
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten
{
public sealed record Response
{
public required IEnumerable<Supermarkt> Supermarkten { get; init; }
}
}
@@ -1,3 +0,0 @@
namespace Lutra.Application.Supermarkten;
public sealed partial class GetSupermarkten { }
@@ -1,13 +0,0 @@
using Cortex.Mediator.Commands;
namespace Lutra.Application.Verspakketten;
public sealed partial class AddBeoordeling
{
public sealed record Command(
Guid VerspakketId,
int CijferSmaak,
int CijferBereiden,
bool Aanbevolen,
string? Tekst) : ICommand<Response>;
}
@@ -1,41 +0,0 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
public sealed partial class AddBeoordeling
{
public sealed class Handler(ILutraDbContext context) : ICommandHandler<Command, Response>
{
public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
{
var verspakketExists = await context.Verspaketten
.AsNoTracking()
.AnyAsync(v => v.Id == request.VerspakketId && v.DeletedAt == null, cancellationToken);
if (!verspakketExists)
{
throw new InvalidOperationException($"Verspakket with id '{request.VerspakketId}' was not found.");
}
var now = DateTime.UtcNow;
var beoordeling = new Domain.Entities.Beoordeling
{
Id = Guid.NewGuid(),
CijferSmaak = request.CijferSmaak,
CijferBereiden = request.CijferBereiden,
Aanbevolen = request.Aanbevolen,
Tekst = request.Tekst,
VerspakketId = request.VerspakketId,
CreatedAt = now,
ModifiedAt = now
};
await context.Beoordelingen.AddAsync(beoordeling, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return new Response { Id = beoordeling.Id };
}
}
}
@@ -1,9 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class AddBeoordeling
{
public sealed record Response
{
public required Guid Id { get; init; }
}
}
@@ -1,3 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class AddBeoordeling { }
@@ -1,15 +0,0 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten;
public sealed partial class CreateVerspakket
{
public sealed record Command(
string Naam,
int? PrijsInCenten,
int AantalPersonen,
Guid SupermarktId,
Beoordeling? Beoordeling,
IReadOnlyList<VerspakketFoto>? Fotos = null) : ICommand<Response>;
}
@@ -1,72 +0,0 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
public sealed partial class CreateVerspakket
{
public sealed class Handler(ILutraDbContext context) : ICommandHandler<Command, Response>
{
public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
{
var supermarktExists = await context.Supermarkten
.AsNoTracking()
.AnyAsync(s => s.Id == request.SupermarktId, cancellationToken);
if (!supermarktExists)
{
throw new InvalidOperationException($"Supermarkt with id '{request.SupermarktId}' was not found.");
}
var now = DateTime.UtcNow;
var verspakket = new Domain.Entities.Verspakket
{
Id = Guid.NewGuid(),
Naam = request.Naam,
PrijsInCenten = request.PrijsInCenten,
AantalPersonen = request.AantalPersonen,
SupermarktId = request.SupermarktId,
CreatedAt = now,
ModifiedAt = now
};
if (request.Beoordeling is not null)
{
verspakket.AddBeoordeling(new Domain.Entities.Beoordeling
{
Id = Guid.NewGuid(),
CijferSmaak = request.Beoordeling.CijferSmaak,
CijferBereiden = request.Beoordeling.CijferBereiden,
Aanbevolen = request.Beoordeling.Aanbevolen,
Tekst = request.Beoordeling.Tekst,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
if (request.Fotos is { Count: > 0 })
{
foreach (var foto in request.Fotos)
{
verspakket.AddFoto(new Domain.Entities.VerspakketFoto
{
Id = Guid.NewGuid(),
Data = Convert.FromBase64String(foto.Base64Data),
IsMainImage = foto.IsMainImage,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
}
await context.Verspaketten.AddAsync(verspakket, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return new Response { Id = verspakket.Id };
}
}
}
@@ -1,9 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class CreateVerspakket
{
public sealed record Response
{
public required Guid Id { get; init; }
}
}
@@ -1,3 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class CreateVerspakket { }
@@ -1,52 +0,0 @@
using Cortex.Mediator.Queries;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Supermarkten;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response?>
{
public async Task<Response?> Handle(Query request, CancellationToken cancellationToken)
{
var verspakket = await context.Verspaketten
.AsNoTracking()
.Where(v => v.Id == request.Id)
.Select(v => new Verspakket
{
Id = v.Id,
Naam = v.Naam,
PrijsInCenten = v.PrijsInCenten,
AantalPersonen = v.AantalPersonen,
AverageCijferSmaak = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferSmaak) : null,
AverageCijferBereiden = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferBereiden) : null,
Beoordelingen = v.Beoordelingen
.Select(b => new Beoordeling
{
CijferSmaak = b.CijferSmaak,
CijferBereiden = b.CijferBereiden,
Aanbevolen = b.Aanbevolen,
Tekst = b.Tekst
})
.ToArray(),
Supermarkt = new Supermarkt
{
Id = v.Supermarkt.Id,
Naam = v.Supermarkt.Naam
},
Fotos = v.Fotos
.Select(f => new VerspakketFotoResponse(
f.Id,
Convert.ToBase64String(f.Data),
f.IsMainImage))
.ToArray()
})
.SingleOrDefaultAsync(cancellationToken);
return verspakket is null ? null : new Response { Verspakket = verspakket };
}
}
}
@@ -1,8 +0,0 @@
using Cortex.Mediator.Queries;
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket
{
public record Query(Guid Id) : IQuery<Response?>;
}
@@ -1,11 +0,0 @@
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket
{
public sealed record Response
{
public required Verspakket Verspakket { get; init; }
}
}
@@ -1,3 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakket { }
@@ -1,69 +0,0 @@
using Cortex.Mediator.Queries;
using Lutra.Application.Interfaces;
using Lutra.Application.Models.Supermarkten;
using Lutra.Application.Models.Verspakketten;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten
{
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
{
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
{
var query = context.Verspaketten
.Where(w => w.DeletedAt == null)
.AsNoTracking();
// Apply sort before pagination so the database handles ordering efficiently.
IOrderedQueryable<Domain.Entities.Verspakket> sorted = request.SortField switch
{
VerspakketSortField.AverageCijferSmaak =>
request.SortDirection == SortDirection.Ascending
? query.OrderBy(v => v.Beoordelingen.Average(b => (double)b.CijferSmaak))
: query.OrderByDescending(v => v.Beoordelingen.Average(b => (double)b.CijferSmaak)),
VerspakketSortField.AverageCijferBereiden =>
request.SortDirection == SortDirection.Ascending
? query.OrderBy(v => v.Beoordelingen.Average(b => (double)b.CijferBereiden))
: query.OrderByDescending(v => v.Beoordelingen.Average(b => (double)b.CijferBereiden)),
VerspakketSortField.PrijsInCenten =>
request.SortDirection == SortDirection.Ascending
? query.OrderBy(v => v.PrijsInCenten)
: query.OrderByDescending(v => v.PrijsInCenten),
VerspakketSortField.Naam or _ =>
request.SortDirection == SortDirection.Ascending
? query.OrderBy(v => v.Naam)
: query.OrderByDescending(v => v.Naam),
};
var verspakketten = await sorted
.Skip(request.Skip)
.Take(request.Take)
.Select(v => new VerspakketSummary
{
Id = v.Id,
Naam = v.Naam,
PrijsInCenten = v.PrijsInCenten,
AantalPersonen = v.AantalPersonen,
AverageCijferSmaak = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferSmaak) : null,
AverageCijferBereiden = v.Beoordelingen.Any() ? v.Beoordelingen.Average(b => (double)b.CijferBereiden) : null,
Supermarkt = new Supermarkt
{
Id = v.Supermarkt.Id,
Naam = v.Supermarkt.Naam
},
Foto = v.Fotos
.Where(f => f.IsMainImage)
.Select(f => new VerspakketFotoResponse(
f.Id,
Convert.ToBase64String(f.Data),
f.IsMainImage))
.SingleOrDefault()
})
.ToListAsync(cancellationToken);
return new Response { Verspakketten = verspakketten };
}
}
}
@@ -1,12 +0,0 @@
using Cortex.Mediator.Queries;
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten
{
public record Query(
int Skip,
int Take,
VerspakketSortField SortField = VerspakketSortField.Naam,
SortDirection SortDirection = SortDirection.Ascending) : IQuery<Response>;
}
@@ -1,11 +0,0 @@
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten
{
public sealed record Response
{
public required IEnumerable<VerspakketSummary> Verspakketten { get; init; }
}
}
@@ -1,3 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class GetVerspakketten { }
@@ -1,7 +0,0 @@
namespace Lutra.Application.Verspakketten;
public enum SortDirection
{
Ascending,
Descending
}
@@ -1,18 +0,0 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Models.Verspakketten;
namespace Lutra.Application.Verspakketten;
public sealed partial class UpdateVerspakket
{
/// <summary>
/// Updates an existing verspakket.
/// </summary>
public sealed record Command(
Guid Id,
string Naam,
int PrijsInCenten,
int AantalPersonen,
Guid SupermarktId,
IReadOnlyList<VerspakketFoto>? Fotos = null) : ICommand<Response>;
}
@@ -1,89 +0,0 @@
using Cortex.Mediator.Commands;
using Lutra.Application.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Application.Verspakketten;
public sealed partial class UpdateVerspakket
{
/// <summary>
/// Handles update requests for verspakketten.
/// </summary>
public sealed class Handler(ILutraDbContext context) : ICommandHandler<Command, Response>
{
/// <summary>
/// Updates an existing verspakket.
/// </summary>
/// <param name="request">The update command.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An empty response.</returns>
public async Task<Response> Handle(Command request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Naam))
throw new ArgumentException("Naam mag niet leeg zijn.", nameof(request.Naam));
if (request.Naam.Length > 50)
throw new ArgumentException("Naam mag maximaal 50 tekens bevatten.", nameof(request.Naam));
if (request.PrijsInCenten < 0)
throw new ArgumentException("PrijsInCenten mag niet negatief zijn.", nameof(request.PrijsInCenten));
if (request.AantalPersonen is < 1 or > 10)
throw new ArgumentException("AantalPersonen moet tussen 1 en 10 liggen.", nameof(request.AantalPersonen));
var verspakket = await context.Verspaketten
.Include(v => v.Fotos)
.FirstOrDefaultAsync(v => v.Id == request.Id && v.DeletedAt == null, cancellationToken);
if (verspakket is null)
{
throw new InvalidOperationException($"Verspakket with id '{request.Id}' was not found.");
}
var supermarktExists = await context.Supermarkten
.AsNoTracking()
.AnyAsync(s => s.Id == request.SupermarktId && s.DeletedAt == null, cancellationToken);
if (!supermarktExists)
{
throw new InvalidOperationException($"Supermarkt with id '{request.SupermarktId}' was not found.");
}
verspakket.Naam = request.Naam;
verspakket.PrijsInCenten = request.PrijsInCenten;
verspakket.AantalPersonen = request.AantalPersonen;
verspakket.SupermarktId = request.SupermarktId;
verspakket.ModifiedAt = DateTime.UtcNow;
if (request.Fotos is not null)
{
// Replace all existing fotos
foreach (var existing in verspakket.Fotos.ToList())
verspakket.RemoveFoto(existing.Id);
context.VerspakketFotos.RemoveRange(
await context.VerspakketFotos
.Where(f => f.VerspakketId == request.Id)
.ToListAsync(cancellationToken));
var now = DateTime.UtcNow;
foreach (var foto in request.Fotos)
{
verspakket.AddFoto(new Domain.Entities.VerspakketFoto
{
Id = Guid.NewGuid(),
Data = Convert.FromBase64String(foto.Base64Data),
IsMainImage = foto.IsMainImage,
VerspakketId = verspakket.Id,
CreatedAt = now,
ModifiedAt = now
});
}
}
await context.SaveChangesAsync(cancellationToken);
return new Response();
}
}
}
@@ -1,9 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class UpdateVerspakket
{
/// <summary>
/// Represents the result of an update verspakket operation.
/// </summary>
public sealed record Response;
}
@@ -1,3 +0,0 @@
namespace Lutra.Application.Verspakketten;
public sealed partial class UpdateVerspakket;
@@ -1,9 +0,0 @@
namespace Lutra.Application.Verspakketten;
public enum VerspakketSortField
{
Naam,
PrijsInCenten,
AverageCijferSmaak,
AverageCijferBereiden
}
-14
View File
@@ -1,14 +0,0 @@
namespace Lutra.Domain.Entities;
public abstract class BaseEntity
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ModifiedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public bool IsDeleted => DeletedAt.HasValue;
}
@@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.Domain.Entities;
public class Beoordeling : BaseEntity
{
[Range(1, 10)]
public required int CijferSmaak { get; set; }
[Range(1, 10)]
public required int CijferBereiden { get; set; }
public required bool Aanbevolen { get; set; }
[MaxLength(1024)]
public string? Tekst { get; set; }
public required Guid VerspakketId { get; set; }
public virtual Verspakket Verspakket { get; set; } = null!;
}
@@ -1,9 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.Domain.Entities;
public class Supermarkt : BaseEntity
{
[MaxLength(50)]
public required string Naam { get; set; }
}
-56
View File
@@ -1,56 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Lutra.Domain.Entities;
public class Verspakket : BaseEntity
{
private readonly List<Beoordeling> _beoordelingen = [];
private readonly List<VerspakketFoto> _fotos = [];
[MaxLength(50)]
public required string Naam { get; set; }
[Range(0, int.MaxValue)]
public int? PrijsInCenten { get; set; }
[Range(1, 10)]
public required int AantalPersonen { get; set; }
public required Guid SupermarktId { get; set; }
public virtual Supermarkt Supermarkt { get; set; } = null!;
public IReadOnlyCollection<Beoordeling> Beoordelingen => _beoordelingen.AsReadOnly();
public IReadOnlyCollection<VerspakketFoto> Fotos => _fotos.AsReadOnly();
public void AddBeoordeling(Beoordeling beoordeling)
{
_beoordelingen.Add(beoordeling);
}
public void AddFoto(VerspakketFoto foto)
{
_fotos.Add(foto);
}
public bool RemoveBeoordeling(Guid id)
{
var beoordeling = _beoordelingen.Find(b => b.Id == id);
if (beoordeling is null)
return false;
_beoordelingen.Remove(beoordeling);
return true;
}
public bool RemoveFoto(Guid id)
{
var foto = _fotos.Find(f => f.Id == id);
if (foto is null)
return false;
_fotos.Remove(foto);
return true;
}
}
@@ -1,12 +0,0 @@
namespace Lutra.Domain.Entities;
public class VerspakketFoto : BaseEntity
{
public required byte[] Data { get; set; }
public required bool IsMainImage { get; set; }
public required Guid VerspakketId { get; set; }
public virtual Verspakket Verspakket { get; set; } = null!;
}
-9
View File
@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.Infrastructure\Lutra.Infrastructure.Sql.csproj" />
</ItemGroup>
</Project>
@@ -1,40 +0,0 @@
using Lutra.Domain.Entities;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<LutraDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("LutraDb")));
using var host = builder.Build();
using var scope = host.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LutraDbContext>();
await dbContext.Database.MigrateAsync();
if (!await dbContext.Supermarkten.AnyAsync())
{
var createdAt = DateTime.UtcNow;
dbContext.Supermarkten.AddRange(
CreateSupermarkt("Albert Heijn", createdAt),
CreateSupermarkt("Jumbo", createdAt),
CreateSupermarkt("Poiesz", createdAt),
CreateSupermarkt("Lidl", createdAt));
await dbContext.SaveChangesAsync();
}
static Supermarkt CreateSupermarkt(string naam, DateTime createdAt)
{
return new Supermarkt
{
Id = Guid.NewGuid(),
Naam = naam,
CreatedAt = createdAt,
ModifiedAt = createdAt
};
}
@@ -1,27 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lutra.Application\Lutra.Application.csproj" />
</ItemGroup>
</Project>
@@ -1,76 +0,0 @@
using Lutra.Application.Interfaces;
using Lutra.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Lutra.Infrastructure.Sql;
public class LutraDbContext : DbContext, ILutraDbContext
{
public LutraDbContext(DbContextOptions<LutraDbContext> options)
: base(options)
{
}
public DbSet<Supermarkt> Supermarkten => Set<Supermarkt>();
public DbSet<Beoordeling> Beoordelingen => Set<Beoordeling>();
public DbSet<VerspakketFoto> VerspakketFotos => Set<VerspakketFoto>();
public DbSet<Verspakket> Verspaketten => Set<Verspakket>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Global soft-delete filter: exclude logically deleted entities from all queries.
modelBuilder.Entity<Beoordeling>().HasQueryFilter(b => !b.IsDeleted);
modelBuilder.Entity<VerspakketFoto>().HasQueryFilter(f => !f.IsDeleted);
modelBuilder.Entity<Supermarkt>().HasQueryFilter(s => !s.IsDeleted);
modelBuilder.Entity<Verspakket>(b =>
{
b.HasQueryFilter(v => !v.IsDeleted);
b.HasMany(v => v.Beoordelingen)
.WithOne()
.HasForeignKey(beo => beo.VerspakketId)
.IsRequired();
b.HasMany(v => v.Fotos)
.WithOne()
.HasForeignKey(foto => foto.VerspakketId)
.IsRequired();
b.ToTable(t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
t.HasCheckConstraint("CK_Verspaketten_PrijsInCenten", "\"PrijsInCenten\" IS NULL OR \"PrijsInCenten\" >= 0");
});
});
}
/// <summary>
/// Populates audit fields on tracked entities before persisting.
/// </summary>
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
foreach (var entry in ChangeTracker.Entries<BaseEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.ModifiedAt = now;
break;
case EntityState.Modified:
entry.Entity.ModifiedAt = now;
break;
}
}
return base.SaveChangesAsync(cancellationToken);
}
}
@@ -1,58 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
namespace Lutra.Infrastructure.Sql;
/// <summary>
/// Design-time factory used by EF Core tools (dotnet ef migrations) so that
/// no startup project or live database connection is required when running migrations.
/// Reads ConnectionStrings:LutraDb from the API project's appsettings.json.
/// </summary>
internal sealed class LutraDbContextFactory : IDesignTimeDbContextFactory<LutraDbContext>
{
private const string ConnectionStringName = "LutraDb";
private const string ConnectionStringEnvironmentVariableName = "ConnectionStrings__LutraDb";
public LutraDbContext CreateDbContext(string[] args)
{
var apiProjectPath = GetApiProjectPath();
var configuration = new ConfigurationBuilder()
.SetBasePath(apiProjectPath)
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString(ConnectionStringName);
if (string.IsNullOrWhiteSpace(connectionString) || connectionString.Contains("<set-locally>", StringComparison.Ordinal))
{
throw new InvalidOperationException($"ConnectionStrings:{ConnectionStringName} is not configured. Set it in appsettings.Development.json or via {ConnectionStringEnvironmentVariableName}.");
}
var options = new DbContextOptionsBuilder<LutraDbContext>()
.UseNpgsql(connectionString)
.Options;
return new LutraDbContext(options);
}
private static string GetApiProjectPath()
{
var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory());
while (currentDirectory is not null)
{
var apiProjectPath = Path.Combine(currentDirectory.FullName, "Lutra.API");
if (Directory.Exists(apiProjectPath))
{
return apiProjectPath;
}
currentDirectory = currentDirectory.Parent;
}
throw new InvalidOperationException("Could not locate the Lutra.API project directory.");
}
}
@@ -1,149 +0,0 @@
// <auto-generated />
using System;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
[DbContext(typeof(LutraDbContext))]
[Migration("20260326190730_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid?>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen");
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Beoordelingen")
.HasForeignKey("VerspakketId");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
.WithMany()
.HasForeignKey("SupermarktId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,100 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Supermarkten",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Naam = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ModifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Supermarkten", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Verspaketten",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Naam = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
AantalPersonen = table.Column<int>(type: "integer", nullable: false),
SupermarktId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ModifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Verspaketten", x => x.Id);
table.ForeignKey(
name: "FK_Verspaketten_Supermarkten_SupermarktId",
column: x => x.SupermarktId,
principalTable: "Supermarkten",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Beoordelingen",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CijferSmaak = table.Column<int>(type: "integer", nullable: false),
CijferBereiden = table.Column<int>(type: "integer", nullable: false),
Aanbevolen = table.Column<bool>(type: "boolean", nullable: false),
Tekst = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
VerspakketId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ModifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Beoordelingen", x => x.Id);
table.ForeignKey(
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
column: x => x.VerspakketId,
principalTable: "Verspaketten",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Beoordelingen_VerspakketId",
table: "Beoordelingen",
column: "VerspakketId");
migrationBuilder.CreateIndex(
name: "IX_Verspaketten_SupermarktId",
table: "Verspaketten",
column: "SupermarktId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Beoordelingen");
migrationBuilder.DropTable(
name: "Verspaketten");
migrationBuilder.DropTable(
name: "Supermarkten");
}
}
}
@@ -1,151 +0,0 @@
// <auto-generated />
using System;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
[DbContext(typeof(LutraDbContext))]
[Migration("20260409194456_MakeVerspakketAggregateRoot")]
partial class MakeVerspakketAggregateRoot
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Beoordelingen")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
.WithMany()
.HasForeignKey("SupermarktId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,60 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class MakeVerspakketAggregateRoot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
table: "Beoordelingen");
migrationBuilder.AlterColumn<Guid>(
name: "VerspakketId",
table: "Beoordelingen",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
table: "Beoordelingen",
column: "VerspakketId",
principalTable: "Verspaketten",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
table: "Beoordelingen");
migrationBuilder.AlterColumn<Guid>(
name: "VerspakketId",
table: "Beoordelingen",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddForeignKey(
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
table: "Beoordelingen",
column: "VerspakketId",
principalTable: "Verspaketten",
principalColumn: "Id");
}
}
}
@@ -1,154 +0,0 @@
// <auto-generated />
using System;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
[DbContext(typeof(LutraDbContext))]
[Migration("20260414193437_AddPrijsInCentenToVerspakket")]
partial class AddPrijsInCentenToVerspakket
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("PrijsInCenten")
.HasColumnType("integer");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Beoordelingen")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
.WithMany()
.HasForeignKey("SupermarktId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class AddPrijsInCentenToVerspakket : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PrijsInCenten",
table: "Verspaketten",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PrijsInCenten",
table: "Verspaketten");
}
}
}
@@ -1,157 +0,0 @@
// <auto-generated />
using System;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
[DbContext(typeof(LutraDbContext))]
[Migration("20260418173428_AddAantalPersonenCheckConstraint")]
partial class AddAantalPersonenCheckConstraint
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("PrijsInCenten")
.HasColumnType("integer");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten", t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
});
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Beoordelingen")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
.WithMany()
.HasForeignKey("SupermarktId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class AddAantalPersonenCheckConstraint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddCheckConstraint(
name: "CK_Verspaketten_AantalPersonen",
table: "Verspaketten",
sql: "\"AantalPersonen\" BETWEEN 1 AND 10");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "CK_Verspaketten_AantalPersonen",
table: "Verspaketten");
}
}
}
@@ -1,159 +0,0 @@
// <auto-generated />
using System;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
[DbContext(typeof(LutraDbContext))]
[Migration("20260418174149_AddPrijsInCentenCheckConstraint")]
partial class AddPrijsInCentenCheckConstraint
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("PrijsInCenten")
.HasColumnType("integer");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten", t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
t.HasCheckConstraint("CK_Verspaketten_PrijsInCenten", "\"PrijsInCenten\" IS NULL OR \"PrijsInCenten\" >= 0");
});
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Beoordelingen")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
.WithMany()
.HasForeignKey("SupermarktId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,27 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class AddPrijsInCentenCheckConstraint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddCheckConstraint(
name: "CK_Verspaketten_PrijsInCenten",
table: "Verspaketten",
sql: "\"PrijsInCenten\" IS NULL OR \"PrijsInCenten\" >= 0");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "CK_Verspaketten_PrijsInCenten",
table: "Verspaketten");
}
}
}
@@ -1,48 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
/// <inheritdoc />
public partial class AddVerspakketFotos : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VerspakketFotos",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ModifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Data = table.Column<byte[]>(type: "bytea", nullable: false),
VerspakketId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VerspakketFotos", x => x.Id);
table.ForeignKey(
name: "FK_VerspakketFotos_Verspaketten_VerspakketId",
column: x => x.VerspakketId,
principalTable: "Verspaketten",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VerspakketFotos_VerspakketId",
table: "VerspakketFotos",
column: "VerspakketId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VerspakketFotos");
}
}
}
@@ -1,201 +0,0 @@
// <auto-generated />
using System;
using Lutra.Infrastructure.Sql;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lutra.Infrastructure.Sql.Migrations
{
[DbContext(typeof(LutraDbContext))]
[Migration("20260425121000_AddIsMainImageToVerspakketFoto")]
partial class AddIsMainImageToVerspakketFoto
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Aanbevolen")
.HasColumnType("boolean");
b.Property<int>("CijferBereiden")
.HasColumnType("integer");
b.Property<int>("CijferSmaak")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Tekst")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("Beoordelingen", (string)null);
});
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.ToTable("Supermarkten");
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AantalPersonen")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Naam")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("PrijsInCenten")
.HasColumnType("integer");
b.Property<Guid>("SupermarktId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("SupermarktId");
b.ToTable("Verspaketten", t =>
{
t.HasCheckConstraint("CK_Verspaketten_AantalPersonen", "\"AantalPersonen\" BETWEEN 1 AND 10");
t.HasCheckConstraint("CK_Verspaketten_PrijsInCenten", "\"PrijsInCenten\" IS NULL OR \"PrijsInCenten\" >= 0");
});
});
modelBuilder.Entity("Lutra.Domain.Entities.VerspakketFoto", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("Data")
.IsRequired()
.HasColumnType("bytea");
b.Property<bool>("IsMainImage")
.HasColumnType("boolean");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("VerspakketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("VerspakketId");
b.ToTable("VerspakketFotos");
});
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Beoordelingen")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
.WithMany()
.HasForeignKey("SupermarktId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Supermarkt");
});
modelBuilder.Entity("Lutra.Domain.Entities.VerspakketFoto", b =>
{
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
.WithMany("Fotos")
.HasForeignKey("VerspakketId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
{
b.Navigation("Beoordelingen");
b.Navigation("Fotos");
});
#pragma warning restore 612, 618
}
}
}

Some files were not shown because too many files have changed in this diff Show More