diff --git a/JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs b/JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs new file mode 100644 index 0000000..8d508a1 --- /dev/null +++ b/JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class CompanyCreateCompatibilityTests +{ + [Fact] + public async Task Exact_Norwegian_name_reuses_owned_company_without_overwriting_existing_fields() + { + await using var fixture = await Fixture.CreateAsync(); + var existing = new Company { OwnerUserId = "owner-a", Name = "Ærlig Økonomi Ås", Location = "Original location", Source = "Finn", RecruiterEmail = "retained@example.test" }; + fixture.Db.Companies.Add(existing); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller.Create(new(" Ærlig Økonomi Ås ", "Imported location", "LinkedIn"), default); + + var company = Assert.IsType(Assert.IsType(result.Result).Value); + Assert.Equal(existing.Id, company.Id); + Assert.Equal("owner-a", company.OwnerUserId); + Assert.Equal("Ærlig Økonomi Ås", company.Name); + Assert.Equal("Original location", company.Location); + Assert.Equal("Finn", company.Source); + Assert.Equal("retained@example.test", company.RecruiterEmail); + Assert.Equal(1, await fixture.Db.Companies.IgnoreQueryFilters().CountAsync()); + } + + [Fact] + public async Task Ascii_case_insensitive_reuse_and_trimmed_input_remain_compatible() + { + await using var fixture = await Fixture.CreateAsync(); + var existing = new Company { OwnerUserId = "owner-a", Name = "Northwind AS", Location = "Original location", Source = "Finn" }; + fixture.Db.Companies.Add(existing); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller.Create(new(" nORTHWIND aS ", "Another location", "LinkedIn"), default); + + var company = Assert.IsType(Assert.IsType(result.Result).Value); + Assert.Equal(existing.Id, company.Id); + Assert.Equal("Northwind AS", company.Name); + Assert.Equal("Original location", company.Location); + Assert.Equal("Finn", company.Source); + Assert.Equal(1, await fixture.Db.Companies.CountAsync()); + } + + [Fact] + public async Task An_identical_foreign_name_creates_a_separate_owned_company_and_preserves_source_normalization() + { + await using var fixture = await Fixture.CreateAsync(); + var foreign = new Company { OwnerUserId = "owner-b", Name = "Ærlig AS", Location = "Private location", Source = "Private source", RecruiterEmail = "private@example.test" }; + fixture.Db.Companies.Add(foreign); + await fixture.Db.SaveChangesAsync(); + + var result = await fixture.Controller.Create(new(" Ærlig AS ", " Bergen ", "https://www.nav.no/jobs"), default); + + var company = Assert.IsType(Assert.IsType(result.Result).Value); + Assert.NotEqual(foreign.Id, company.Id); + Assert.Equal("owner-a", company.OwnerUserId); + Assert.Equal("Ærlig AS", company.Name); + Assert.Equal("Bergen", company.Location); + Assert.Equal("NAV", company.Source); + Assert.Null(company.RecruiterEmail); + Assert.Equal(1, await fixture.Db.Companies.CountAsync()); + Assert.Equal(2, await fixture.Db.Companies.IgnoreQueryFilters().CountAsync()); + var retained = await fixture.Db.Companies.IgnoreQueryFilters().AsNoTracking().SingleAsync(value => value.Id == foreign.Id); + Assert.Equal("owner-b", retained.OwnerUserId); + Assert.Equal("Private location", retained.Location); + Assert.Equal("Private source", retained.Source); + Assert.Equal("private@example.test", retained.RecruiterEmail); + } + + private sealed class Fixture(SqliteConnection connection, JobTrackerContext db) : IAsyncDisposable + { + public JobTrackerContext Db { get; } = db; + public CompaniesController Controller { get; } = new(db) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "owner-a")], "test")), + }, + }, + }; + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var currentUser = new Mock(); + currentUser.SetupGet(value => value.UserId).Returns("owner-a"); + var db = new JobTrackerContext(new DbContextOptionsBuilder().UseSqlite(connection).Options, currentUser.Object); + await db.Database.EnsureCreatedAsync(); + return new Fixture(connection, db); + } + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + } +} diff --git a/JobTrackerApi/Controllers/CompaniesController.cs b/JobTrackerApi/Controllers/CompaniesController.cs index 7ade445..52a236a 100644 --- a/JobTrackerApi/Controllers/CompaniesController.cs +++ b/JobTrackerApi/Controllers/CompaniesController.cs @@ -126,7 +126,7 @@ namespace JobTrackerApi.Controllers existingQuery = existingQuery.Where(c => c.OwnerUserId == userId); var existing = await existingQuery - .FirstOrDefaultAsync(c => c.Name.ToLower() == name.ToLower(), cancellationToken); + .FirstOrDefaultAsync(c => c.Name == name || c.Name.ToLower() == name.ToLower(), cancellationToken); if (existing is not null) { diff --git a/docs/redesign/phase-7/p7-09/COMPANY_CREATE_COMPATIBILITY.md b/docs/redesign/phase-7/p7-09/COMPANY_CREATE_COMPATIBILITY.md new file mode 100644 index 0000000..0a60668 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/COMPANY_CREATE_COMPATIBILITY.md @@ -0,0 +1,11 @@ +# Company-create compatibility prerequisite + +The actual JSON export/import journey exposed a backend prerequisite: importing the exact existing Norwegian company name created a second company. SQLite's default `LOWER()` handles ASCII while the bound .NET lower-case parameter folds Norwegian letters. The previous comparison therefore missed an exact existing name containing `Æ`, `Ø` or `Ã…`. The original failing import trace and the candidate repair evidence remain under `.artifacts/phase7/p7-09/`; the importer does not work around the server by changing company names. + +The bounded repair checks exact equality alongside the existing lower-case comparison inside the same owner-filtered query. Trimming, source normalization, 200-for-reuse/201-for-create, metadata retention and account ownership are preserved. It performs no schema migration, historical deduplication or destructive write. Broader Unicode case-folding equivalence and concurrent creation idempotency remain RD21 debt. + +Three real SQLite regression cases prove exact Norwegian-name reuse without overwriting existing fields, retained ASCII case-insensitive reuse, and separate owned creation when an identical foreign company exists. The last case also verifies retained foreign metadata and source normalization. Fresh Release compilation and these three cases pass. A fresh unfiltered backend run passes **1,057/1,057**, zero skipped, at **20 October 2026 07:05:21–07:05:38 UTC**. The increase from 1,054 is exactly these three ordinary cases; existing cases were not removed or weakened. + +The fresh native Companies suite passes **6/6**, zero retries/skipped/flaky, at **07:10:12–07:11:05 UTC**. It exercises the existing presentation and persisted owner behavior against the rebuilt API and current production frontend export. The [portable evidence](evidence/company-create-results.json) records commands, results and source/binary identities, explicitly identifying its post-run source observation. Earlier native import/company evidence is historical evidence, not substituted for these fresh results. + +Preservation references are **PRES-J08, PRES-J10 and PRES-E09**. The complete matrix stays **56 PARTIAL / C11 DEFERRED**. Actual MariaDB/provider and production qualification, manual reader/device checks, the remaining import/data/privacy/plan/backup migration and RD21 concurrency work are not accepted here. P5-13 stays withheld, followMode stays deferred and Phase 8 is not implemented. This prerequisite is published separately from the active import UI work on the development branch; production readiness remains NO-GO despite the owner's separately authorized release repair. diff --git a/docs/redesign/phase-7/p7-09/evidence/company-create-results.json b/docs/redesign/phase-7/p7-09/evidence/company-create-results.json new file mode 100644 index 0000000..32d1bf5 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/company-create-results.json @@ -0,0 +1,121 @@ +{ + "schemaVersion": 1, + "checkpoint": "P7-09 company-create prerequisite", + "observedUtc": "2026-10-20T07:15:05.299883+00:00", + "baselineBackend": 1054, + "newBackendCases": 3, + "targeted": { + "path": ".artifacts/phase7/current/backend-career/p709-company-compatibility-fresh-20261020T070423Z/command.json", + "command": [ + "rtk", + "proxy", + "C:/Users/Connor/.dotnet/dotnet.exe", + "test", + "JobTrackerApi.Tests/JobTrackerApi.Tests.csproj", + "--configuration", + "Release", + "--no-restore", + "--filter", + "FullyQualifiedName~CompanyCreateCompatibilityTests", + "--logger", + "trx;LogFileName=career.trx", + "--results-directory", + "E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-company-compatibility-fresh-20261020T070423Z" + ], + "startedUtc": "2026-10-20T07:04:23.391824+00:00", + "finishedUtc": "2026-10-20T07:04:41.335549+00:00", + "exitCode": 0, + "logSha256": "f9fe87f762d872f11e2d6a7e14448e9f0451c2794b4094f5fe34eed1cd7cd6ad", + "counts": { + "total": 3, + "passed": 3, + "failed": 0, + "notExecuted": 0 + }, + "resultSha256": "54e0867d3d80c438e9cdd026c1811ea8a52a7d8d9d057c847dac3a1593cf9fa9" + }, + "fullBackend": { + "path": ".artifacts/phase7/current/backend-career/p709-company-full-regression-20261020T070521Z/command.json", + "command": [ + "rtk", + "proxy", + "C:/Users/Connor/.dotnet/dotnet.exe", + "test", + "JobTrackerApi.Tests/JobTrackerApi.Tests.csproj", + "--configuration", + "Release", + "--no-build", + "--logger", + "trx;LogFileName=career.trx", + "--results-directory", + "E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\backend-career\\p709-company-full-regression-20261020T070521Z" + ], + "startedUtc": "2026-10-20T07:05:21.416786+00:00", + "finishedUtc": "2026-10-20T07:05:38.375404+00:00", + "exitCode": 0, + "logSha256": "fa6461b4e59d07bfa2ffe1d0a59daf7ab616a4e87b9dbb80f4548cee703a3537", + "counts": { + "total": 1057, + "passed": 1057, + "failed": 0, + "notExecuted": 0 + }, + "resultSha256": "0c995ebf824d6a4b446af9878bdcfd53e72ee90759fead491d1bd1108be3b45e" + }, + "native": { + "path": ".artifacts/phase7/current/ui/companies.json", + "command": { + "name": "companies", + "command": [ + "rtk", + "proxy", + "node", + "node_modules/@playwright/test/cli.js", + "test", + "e2e/phase7-companies.spec.ts", + "--retries=0", + "--reporter=list,json", + "--output=E:\\Storage\\Projects\\Active\\Job tracker\\.artifacts\\phase7\\current\\ui\\companies-results" + ], + "startedUtc": "2026-10-20T07:10:12.619786+00:00", + "finishedUtc": "2026-10-20T07:11:05.493360+00:00", + "exitCode": 0 + }, + "stats": { + "startTime": "2026-10-20T07:10:13.244Z", + "duration": 52203.75399999999, + "expected": 6, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + } + }, + "preservation": [ + "PRES-J08", + "PRES-J10", + "PRES-E09" + ], + "sources": [ + { + "path": "JobTrackerApi/Controllers/CompaniesController.cs", + "sha256Raw": "a913bcd1e9993bc4f8fd88b5554b92eade263967c4027b2179244354ed96f7ec", + "sha256Lf": "4db8eb5a0d434a346538003c426f9ace9fad55695b9a57c3a4bf441e5c0d37e3" + }, + { + "path": "JobTrackerApi.Tests/CompanyCreateCompatibilityTests.cs", + "sha256Raw": "b15bbe1b256cae2a5712a954d2f84470d5a9acc1b734143d5879690486b5227c", + "sha256Lf": "b15bbe1b256cae2a5712a954d2f84470d5a9acc1b734143d5879690486b5227c" + } + ], + "binaries": [ + { + "path": "JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll", + "sha256": "addbeef095ec5a69b9dc4646f790117d18faa24ab5cb7e36663e84f4fa3f9a66" + }, + { + "path": "JobTrackerApi.Tests/bin/Release/net9.0/JobTrackerApi.Tests.dll", + "sha256": "12f4003d9e0a4f98598175a72d1bc7291c1f08fc166bd2b4e1b47f844c33b013" + } + ], + "qualification": "Source/binary identities observed after the fresh backend and Companies runs; earlier RED/native evidence remains separately retained. Not a pre-run capture or production/MariaDB acceptance." +} diff --git a/docs/redesign/phase-7/p7-09/evidence/company-create-static-checks.json b/docs/redesign/phase-7/p7-09/evidence/company-create-static-checks.json new file mode 100644 index 0000000..ec46d65 --- /dev/null +++ b/docs/redesign/phase-7/p7-09/evidence/company-create-static-checks.json @@ -0,0 +1,104 @@ +{ + "schemaVersion": 1, + "checkpoint": "p7-09", + "status": "PASS", + "checkedUtc": "2026-10-20T07:15:55.632615+00:00", + "commands": [ + { + "name": "secret-scan", + "command": [ + "rtk", + "proxy", + "C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe", + "-X", + "utf8", + "scripts/supply-chain.py", + "scan-secrets" + ], + "startedUtc": "2026-10-20T07:15:51.896284+00:00", + "finishedUtc": "2026-10-20T07:15:54.661570+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T071551Z/secret-scan.log", + "logSha256Raw": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd" + }, + { + "name": "working-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check" + ], + "startedUtc": "2026-10-20T07:15:54.662586+00:00", + "finishedUtc": "2026-10-20T07:15:54.772491+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T071551Z/working-whitespace.log", + "logSha256Raw": "7d4d21b7cfbda7f0adedeec159d5457b079dd3046eab064066ed668c6d1139cd" + }, + { + "name": "staged-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--cached", + "--check" + ], + "startedUtc": "2026-10-20T07:15:54.772959+00:00", + "finishedUtc": "2026-10-20T07:15:54.895580+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T071551Z/staged-whitespace.log", + "logSha256Raw": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "phase7-whitespace", + "command": [ + "rtk", + "proxy", + "git", + "diff", + "--check", + "b5319981ad3d1bd635daff861453d23c0de5320b" + ], + "startedUtc": "2026-10-20T07:15:54.896077+00:00", + "finishedUtc": "2026-10-20T07:15:55.135880+00:00", + "exitCode": 0, + "log": ".artifacts/phase7/current/static/20261020T071551Z/phase7-whitespace.log", + "logSha256Raw": "c42dc788a516c9c9baadd44ae7a8c4c6d5b0e4cbff688f5b45647999e953e099" + } + ], + "untrackedSecretScan": { + "files": 6, + "findings": 0 + }, + "documentation": { + "files": 29, + "relativeLinks": 607, + "errors": [] + }, + "preservation": { + "groups": 57, + "statuses": { + "PARTIAL": 56, + "PASS": 0, + "FAIL": 0, + "DEFERRED": 1 + }, + "originalRoutesPreserved": true, + "currentConcreteRoutes": 33 + }, + "production": { + "main": "f9bf31c9a9a9f40edefb855a0911d7f057fa4990", + "originMain": "f9bf31c9a9a9f40edefb855a0911d7f057fa4990", + "status": "NO-GO", + "qualification": "Local fetched refs; direct push observation recorded separately." + }, + "hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.", + "limits": [ + "Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.", + "Local links check target existence, not URL/heading anchors.", + "Static evidence is not runtime/manual/device/provider/production acceptance." + ] +}