diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b2a669 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Build with the repository root as context. +.git +**/bin +**/obj +**/.vs +**/.idea +**/TestResults +**/*.user +**/*.suo +**/*.log +**/node_modules + +# Runtime configuration and credentials must be supplied outside the image. +**/appsettings*.json +**/secrets.json +**/.env +**/.env.* +**/*.pfx +**/*.pem +**/*.key + +# Local receipts, database files, and backups are not application assets. +**/receipts +**/*.db +**/*.db-* +**/*.mdf +**/*.ldf +**/*.bak diff --git a/.gitea/workflows/build-moneymap.yml b/.gitea/workflows/build-moneymap.yml index 4b5c35a..a461afa 100644 --- a/.gitea/workflows/build-moneymap.yml +++ b/.gitea/workflows/build-moneymap.yml @@ -7,7 +7,8 @@ on: - "MoneyMap/**" - "MoneyMap.Core/**" - "MoneyMap.Mcp/**" - - "Dockerfile" + - "MoneyMap.sln" + - ".dockerignore" - ".gitea/workflows/build-moneymap.yml" workflow_dispatch: {} @@ -21,7 +22,7 @@ jobs: run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin - name: Build image - run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} MoneyMap/ + run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} . - name: Push image run: | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a4ccf99..9cb0994 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1801,6 +1801,34 @@ var importer = new TransactionImporter(mockDb.Object, mockCardResolver.Object); ## Deployment +### Docker image build (.NET 10) + +Run from the repository root (not `MoneyMap/`): + +```bash +docker build -f MoneyMap/Dockerfile -t moneymap:local . +``` + +The Dockerfile uses the .NET 10 SDK and ASP.NET Core 10 runtime. The root +context is required to copy `MoneyMap.sln` and the shared `MoneyMap.Core` +project. The root `.dockerignore` excludes local build output, receipts, +database files, and all `appsettings*.json`/credential files. Supply runtime +configuration separately via environment variables (for example +`ConnectionStrings__MoneyMapDb`, `OPENAI_API_KEY`) or a mounted +configuration file; credentials are not baked into the image. Building does +not start the application or apply migrations. + +### Category relationship mapping + +`Transaction.Category` and `CategoryMapping.Category` remain string category +names. The corresponding collections on `Category` are `[NotMapped]` so EF +cannot infer nonexistent `CategoryId` shadow foreign keys. The real +`RecurringPlace.CategoryId` and parent/child category relationships remain +mapped. This is a model correction only, with no database schema change or +migration required. SQL Server model/query regression tests compile both +receipt duplicate-check queries without connecting to a database. + + ### Database Migration ```bash # Create migration diff --git a/MoneyMap.Core/Models/Category.cs b/MoneyMap.Core/Models/Category.cs index 96e17f0..9b0a41d 100644 --- a/MoneyMap.Core/Models/Category.cs +++ b/MoneyMap.Core/Models/Category.cs @@ -25,7 +25,11 @@ public class Category public ICollection Children { get; set; } = new List(); public ICollection RecurringPlaces { get; set; } = new List(); + // These entities store category names, not Category foreign keys. Mapping these + // collections would make EF invent CategoryId columns absent from the schema. + [NotMapped] public ICollection CategoryMappings { get; set; } = new List(); + [NotMapped] public ICollection Transactions { get; set; } = new List(); [NotMapped] diff --git a/MoneyMap.Tests/Data/CategoryModelTests.cs b/MoneyMap.Tests/Data/CategoryModelTests.cs new file mode 100644 index 0000000..0c3c04b --- /dev/null +++ b/MoneyMap.Tests/Data/CategoryModelTests.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Tests.Data; + +public class CategoryModelTests +{ + // Model/query compilation only: never opens a connection or reads application configuration. + private static MoneyMapContext CreateContext() => new( + new DbContextOptionsBuilder() + .UseSqlServer("Server=localhost;Database=ModelOnly;Integrated Security=true;TrustServerCertificate=true") + .Options); + + [Theory] + [InlineData(typeof(Transaction))] + [InlineData(typeof(CategoryMapping))] + public void StringCategoryEntities_DoNotHaveCategoryForeignKeys(Type entityType) + { + using var context = CreateContext(); + var entity = context.Model.FindEntityType(entityType)!; + + Assert.Null(entity.FindProperty("CategoryId")); + Assert.DoesNotContain(entity.GetProperties(), p => p.IsShadowProperty()); + Assert.Equal(typeof(string), entity.FindProperty("Category")!.ClrType); + Assert.DoesNotContain(entity.GetForeignKeys(), fk => fk.PrincipalEntityType.ClrType == typeof(Category)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReceiptDuplicateQueries_DoNotSelectCategoryId(bool matchHash) + { + using var context = CreateContext(); + var query = context.Receipts.Include(r => r.Transaction); + var sql = (matchHash + ? query.Where(r => r.FileHashSha256 == "test-hash") + : query.Where(r => r.FileName == "receipt.png" && r.FileSizeBytes == 8)) + .ToQueryString(); + + Assert.Contains("LEFT JOIN [Transactions]", sql); + Assert.Contains("[Category]", sql); + Assert.DoesNotContain("[CategoryId]", sql); + } + + [Fact] + public void CategoryMappingQuery_DoesNotSelectCategoryId() + { + using var context = CreateContext(); + var sql = context.CategoryMappings.ToQueryString(); + Assert.Contains("[Category]", sql); + Assert.DoesNotContain("[CategoryId]", sql); + } + + [Fact] + public void IntendedCategoryRelationships_ArePreserved() + { + using var context = CreateContext(); + var recurringPlace = context.Model.FindEntityType(typeof(RecurringPlace))!; + Assert.Contains(recurringPlace.GetForeignKeys(), fk => + fk.PrincipalEntityType.ClrType == typeof(Category) && + fk.Properties.Single().Name == "CategoryId" && !fk.Properties.Single().IsShadowProperty()); + var category = context.Model.FindEntityType(typeof(Category))!; + Assert.Contains(category.GetForeignKeys(), fk => + fk.PrincipalEntityType.ClrType == typeof(Category) && + fk.Properties.Single().Name == "ParentCategoryId"); + } +} diff --git a/MoneyMap/Dockerfile b/MoneyMap/Dockerfile index fab5a09..cf40cc0 100644 --- a/MoneyMap/Dockerfile +++ b/MoneyMap/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src # Copy solution and project files for restore @@ -19,7 +19,7 @@ RUN libman restore RUN dotnet publish -c Release -o /app/publish # Runtime stage -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app # Install ImageMagick dependencies for Magick.NET