diff --git a/scripts/validate-gm.sh b/scripts/validate-gm.sh index a2c8ca2..012b8b7 100755 --- a/scripts/validate-gm.sh +++ b/scripts/validate-gm.sh @@ -64,6 +64,25 @@ s = open(path).read().replace("", f'/dev/null + SPECDRIFT="$WORK/tools/specdrift" +fi +export GOLDPATH_SPECDRIFT="$SPECDRIFT" + echo "── initial migration (goldpath db init — Development migrates, EnsureCreated is gone)" (cd "$APP" && dotnet run --project "$ROOT/tools/Goldpath.Cli" -- db init --path .) @@ -87,18 +106,6 @@ if [ -d "$APP/src/$NAME.Api" ]; then fi echo "── spec-lint (specdrift: validate + drift)" -# Pinned engine, consumed as the PUBLISHED tool (README dependency policy: source -# references are the exception, not the silent default). A local checkout is used only -# via an explicit GOLDPATH_SPECDRIFT_SRC and announces itself — a green local run must -# mean a green CI run. -SPECDRIFT_VERSION=0.4.2 -if [ -n "${GOLDPATH_SPECDRIFT_SRC:-}" ]; then - echo "── spec-lint: using LOCAL specdrift checkout (GOLDPATH_SPECDRIFT_SRC=$GOLDPATH_SPECDRIFT_SRC) — not the $SPECDRIFT_VERSION pin" - SPECDRIFT="dotnet run --project $GOLDPATH_SPECDRIFT_SRC/src/Specdrift --" -else - [ -x "$WORK/tools/specdrift" ] || dotnet tool install --tool-path "$WORK/tools" specdrift --version "$SPECDRIFT_VERSION" >/dev/null - SPECDRIFT="$WORK/tools/specdrift" -fi $SPECDRIFT validate "$APP/.goldpath/manifest.yaml" --schema "$ROOT/schemas/manifest/v1/goldpath-manifest.schema.json" --rules "$APP/.specdrift/rules.yaml" # First generation: commit the contract (what a team does on day one), then drift must be clean. if [ -d "$APP/src/$NAME.Api" ]; then diff --git a/tests/Goldpath.Cli.Tests/AddWorkerMutationTests.cs b/tests/Goldpath.Cli.Tests/AddWorkerMutationTests.cs new file mode 100644 index 0000000..eade502 --- /dev/null +++ b/tests/Goldpath.Cli.Tests/AddWorkerMutationTests.cs @@ -0,0 +1,879 @@ +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Mutation-killing companions to : every generated byte, every +/// refusal message and every process invocation is asserted EXACTLY — a skeleton that is +/// "almost right" compiles into an app that refuses to start (the launchSettings lesson). +/// +public class AddWorkerMutationTests +{ + private sealed record Result(int Code, string Output, string Error); + + private static Result Add(FakeApp app, IProcessRunner runner, string name, string trigger) + => Add(app.Root, runner, name, trigger); + + private static Result Add(string root, IProcessRunner runner, string name, string trigger) + { + var output = new StringWriter(); + var error = new StringWriter(); + var code = CliRunner.Run(["add", "worker", name, "--trigger", trigger, "--path", root], runner, output, error); + return new Result(code, output.ToString(), error.ToString()); + } + + private static string Line(string text) => text + Environment.NewLine; + + private static string Normalize(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string ProjectDir(FakeApp app, string projectName) => Path.Combine(app.Root, "src", projectName); + + // ── refusals: message + exit code, nothing written ────────────────────────────────── + + [Fact] + public void Unknown_trigger_lists_the_accepted_ones() + { + using var app = new FakeApp(); + var result = Add(app, new FakeProcessRunner(), "payments", "cron"); + Assert.Equal(2, result.Code); + Assert.Equal(Line("goldpath: unknown trigger 'cron' — one of: queue, schedule, jobs"), result.Error); + } + + [Fact] + public void A_directory_without_a_manifest_is_refused_with_the_path() + { + var bare = Path.Combine(Path.GetTempPath(), $"goldpath-cli-bare-{Guid.NewGuid():N}"); + Directory.CreateDirectory(bare); + try + { + var result = Add(bare, new FakeProcessRunner(), "payments", "schedule"); + Assert.Equal(1, result.Code); + var manifest = Path.Combine(bare, ".goldpath", "manifest.yaml"); + Assert.Equal(Line($"goldpath: no manifest at {manifest} — goldpath add runs inside a Goldpath-generated app (or pass --path)."), result.Error); + } + finally + { + Directory.Delete(bare, recursive: true); + } + } + + [Fact] + public void A_worker_manifest_is_refused_by_its_kind() + { + using var app = new FakeApp(kind: "worker"); + var result = Add(app, new FakeProcessRunner(), "payments", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: this manifest is kind 'worker' — workers join a SOLUTION's AppHost; run goldpath add there."), result.Error); + } + + [Fact] + public void A_manifest_without_a_kind_is_reported_as_none() + { + using var app = new FakeApp(); + var lines = File.ReadAllLines(app.Manifest).Where(line => !line.StartsWith("kind:", StringComparison.Ordinal)); + File.WriteAllLines(app.Manifest, lines); + + var result = Add(app, new FakeProcessRunner(), "payments", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: this manifest is kind '' — workers join a SOLUTION's AppHost; run goldpath add there."), result.Error); + } + + [Fact] + public void A_queue_worker_without_messaging_teaches_the_broker_seam() + { + using var app = new FakeApp(); + var result = Add(app, new FakeProcessRunner(), "payments", "queue"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: a queue worker consumes from the broker — no AddGoldpathMessaging(...) found in the composition root. Wire messaging first (a broker resource + the AddGoldpathMessaging block), then re-run."), result.Error); + } + + private static void DropConnectionString(FakeApp app) + => File.WriteAllText(app.Program, app.Read(app.Program).Replace("var shopDbConnection = builder.Configuration.GetConnectionString(\"shopdb\");", string.Empty, StringComparison.Ordinal)); + + [Theory] + [InlineData("queue")] + [InlineData("jobs")] + public void A_persisting_worker_without_a_connection_name_is_refused(string trigger) + { + using var app = new FakeApp(messagingWired: true); + DropConnectionString(app); + var result = Add(app, new FakeProcessRunner(), "payments", trigger); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: no GetConnectionString(...) found in the composition root — this worker persists into the app database and needs its connection name."), result.Error); + Assert.False(Directory.Exists(ProjectDir(app, "Shop.PaymentsWorker"))); + } + + [Fact] + public void A_schedule_worker_needs_no_connection_name() + { + using var app = new FakeApp(); + DropConnectionString(app); + var result = Add(app, new FakeProcessRunner(), "cleanup", "schedule"); + Assert.Equal(0, result.Code); + Assert.Equal(string.Empty, result.Error); + } + + [Fact] + public void An_existing_project_directory_is_refused_by_name() + { + using var app = new FakeApp(); + Directory.CreateDirectory(ProjectDir(app, "Shop.CleanupWorker")); + var result = Add(app, new FakeProcessRunner(), "cleanup", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: src/Shop.CleanupWorker already exists — pick another name or remove it first."), result.Error); + } + + [Fact] + public void A_missing_solution_file_is_refused_before_anything_is_written() + { + using var app = new FakeApp(); + var appHostBefore = app.Read(app.AppHost); + File.Delete(Path.Combine(app.Root, "Shop.sln")); + var result = Add(app, new FakeProcessRunner(), "cleanup", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: no .sln at the app root — goldpath add worker wires the project into the solution."), result.Error); + Assert.False(Directory.Exists(ProjectDir(app, "Shop.CleanupWorker"))); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + } + + [Fact] + public void Two_solution_files_are_refused_with_the_count() + { + using var app = new FakeApp(); + File.WriteAllText(Path.Combine(app.Root, "Other.sln"), ""); + var result = Add(app, new FakeProcessRunner(), "cleanup", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: 2 .sln files at the app root — goldpath cannot choose; keep exactly one."), result.Error); + } + + [Fact] + public void A_name_without_letters_is_a_usage_error() + { + using var app = new FakeApp(); + var result = Add(app, new FakeProcessRunner(), "---", "schedule"); + Assert.Equal(2, result.Code); + Assert.Equal(Line("goldpath: '---' does not yield a project name — use letters (e.g. payments, eod-report)."), result.Error); + Assert.DoesNotContain(Directory.GetDirectories(Path.Combine(app.Root, "src")), d => d.EndsWith("Worker", StringComparison.Ordinal)); + } + + // ── the process seam: exact invocations, exact rollback ──────────────────────────── + + [Fact] + public void The_solution_entry_is_the_exact_dotnet_sln_add_call() + { + using var app = new FakeApp(); + // A second top-level file: only the *.sln pattern must find the solution. + File.WriteAllText(Path.Combine(app.Root, "README.md"), "# Shop"); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Add(app, runner, "cleanup", "schedule").Code); + + var sln = runner.Calls[0]; + Assert.Equal("dotnet", sln.FileName); + Assert.Equal( + ["sln", Path.Combine(app.Root, "Shop.sln"), "add", Path.Combine(app.Root, "src", "Shop.CleanupWorker", "Shop.CleanupWorker.csproj")], + sln.Arguments); + Assert.Equal(app.Root, sln.WorkingDirectory); + + // Then the engine, in order: validate, drift — both with the app root as CWD. + Assert.Equal(3, runner.Calls.Count); + Assert.Contains("validate", runner.Calls[1].Arguments); + Assert.Equal(["drift", "--repo", "."], runner.Calls[2].Arguments.TakeLast(3)); + Assert.Equal(app.Root, runner.Calls[2].WorkingDirectory); + } + + [Fact] + public void A_failed_sln_add_restores_everything_and_says_so() + { + using var app = new FakeApp(); + var appHostBefore = app.Read(app.AppHost); + var appHostProjectBefore = app.Read(app.AppHostProject); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["sln"] = 1; + + var result = Add(app, runner, "cleanup", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: dotnet sln add failed — see its output above."), result.Error); + Assert.Equal(string.Empty, result.Output); // the engine never ran + Assert.Single(runner.Calls); + Assert.False(Directory.Exists(ProjectDir(app, "Shop.CleanupWorker"))); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + Assert.Equal(appHostProjectBefore, app.Read(app.AppHostProject)); + } + + [Fact] + public void A_red_validate_skips_drift_and_reports_the_rollback() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["validate"] = 1; + + var result = Add(app, runner, "cleanup", "schedule"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: worker 'Shop.CleanupWorker' (schedule) wired — running the engine (specdrift validate + drift)"), result.Output); + Assert.Equal(Line("goldpath: the engine rejected the result — ALL files restored; fix the findings above and retry (the worker was NOT added)."), result.Error); + Assert.DoesNotContain(runner.Calls, c => c.Arguments.Contains("drift")); + } + + [Fact] + public void A_red_drift_after_a_green_validate_still_restores() + { + using var app = new FakeApp(); + var appHostBefore = app.Read(app.AppHost); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["--repo"] = 1; // `drift --repo .` (validate's `.specdrift/rules.yaml` would match "drift") + + var result = Add(app, runner, "cleanup", "schedule"); + Assert.Equal(1, result.Code); + Assert.Contains(runner.Calls, c => c.Arguments.Contains("--repo")); // drift DID run + Assert.False(Directory.Exists(ProjectDir(app, "Shop.CleanupWorker"))); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + Assert.DoesNotContain("engine clean", result.Output, StringComparison.Ordinal); + } + + // ── the success story: exact console, exact AppHost, exact project ───────────────── + + [Fact] + public void Queue_success_prints_the_queue_decisions() + { + using var app = new FakeApp(messagingWired: true); + var result = Add(app, new FakeProcessRunner(), "payments", "queue"); + Assert.Equal(0, result.Code); + Assert.Equal(string.Empty, result.Error); + Assert.Equal( + Line("goldpath: worker 'Shop.PaymentsWorker' (queue) wired — running the engine (specdrift validate + drift)") + + Line("goldpath: worker 'Shop.PaymentsWorker' added as resource 'payments-worker' — engine clean. Your decisions (goldpath never guesses domain opt-ins):") + + Line(" → rename WorkItemQueued to the REAL upstream event (broker-bound contracts implement IIntegrationEvent — GP0401)") + + Line(" → consuming an event the API publishes? the record moves to a shared .Contracts classlib referenced by BOTH — events are wire contracts, never duplicated (docs/rfc/goldpath-event-contracts.md)") + + Line(" → put the real work into the consumer; it commits WITH the inbox bookkeeping — exactly-once by construction") + + Line(" → the worker owns its OWN tables' migrations: run `goldpath db add add-worker` and commit the migration"), + result.Output); + } + + [Fact] + public void Schedule_success_prints_the_schedule_decisions() + { + using var app = new FakeApp(); + var result = Add(app, new FakeProcessRunner(), "cleanup", "schedule"); + Assert.Equal(0, result.Code); + Assert.Equal( + Line("goldpath: worker 'Shop.CleanupWorker' (schedule) wired — running the engine (specdrift validate + drift)") + + Line("goldpath: worker 'Shop.CleanupWorker' added as resource 'cleanup-worker' — engine clean. Your decisions (goldpath never guesses domain opt-ins):") + + Line(" → put the real work into IntervalJob.RunTickAsync (time-abstracted, directly testable); configure Worker:Interval") + + Line(" → when the work outgrows a timer (chunks, resume, SLA), move to --trigger jobs — the Jobs module is the landing pad"), + result.Output); + } + + [Fact] + public void Jobs_success_prints_the_jobs_decisions() + { + using var app = new FakeApp(); + var result = Add(app, new FakeProcessRunner(), "eod-report", "jobs"); + Assert.Equal(0, result.Code); + Assert.Equal( + Line("goldpath: worker 'Shop.EodReportWorker' (jobs) wired — running the engine (specdrift validate + drift)") + + Line("goldpath: worker 'Shop.EodReportWorker' added as resource 'eod-report-worker' — engine clean. Your decisions (goldpath never guesses domain opt-ins):") + + Line(" → replace NightlyReportJob's body with the real aggregation; review the cron and the Deadline (every job has an SLA — GP1302)") + + Line(" → the worker runs its OWN fleet (SchedulerName) against the app database — the Api's scheduler is untouched; both consoles ride MapGoldpathJobsAdmin") + + Line(" → the shared jobs tables stay the API context's migrations (the D3 exclusion is generated); run `goldpath db add add-worker` for the worker's PRIVATE tables"), + result.Output); + } + + private const string AppHostHead = """ + var builder = DistributedApplication.CreateBuilder(args); + + var database = builder.AddPostgres("dbserver").AddDatabase("shopdb"); + // goldpath:features resources — the drift profile is the source of these rows + + builder.AddProject("api") + .WithReference(database).WaitFor(database) + // goldpath:features references — the drift profile is the source of these rows + .WithHttpHealthCheck("/health/ready"); + + // goldpath:workers — additional worker projects wire here (goldpath add worker) + + + """; // the wiring block opens with an empty line — two newlines after the anchor + + private const string AppHostTail = """ + + builder.Build().Run(); + """; + + [Fact] + public void Queue_apphost_chains_database_then_messaging_then_probe() + { + using var app = new FakeApp(messagingWired: true); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "payments", "queue").Code); + Assert.Equal( + AppHostHead + + "builder.AddProject(\"payments-worker\")\n" + + " .WithReference(database).WaitFor(database)\n" + + " .WithReference(messaging).WaitFor(messaging)\n" + + " .WithHttpHealthCheck(\"/health/ready\");\n" + + AppHostTail, + Normalize(app.Read(app.AppHost))); + } + + [Fact] + public void Jobs_apphost_chains_database_then_probe() + { + using var app = new FakeApp(); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "eod-report", "jobs").Code); + Assert.Equal( + AppHostHead + + "builder.AddProject(\"eod-report-worker\")\n" + + " .WithReference(database).WaitFor(database)\n" + + " .WithHttpHealthCheck(\"/health/ready\");\n" + + AppHostTail, + Normalize(app.Read(app.AppHost))); + } + + [Fact] + public void Schedule_apphost_is_probe_only() + { + using var app = new FakeApp(); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "cleanup", "schedule").Code); + Assert.Equal( + AppHostHead + + "builder.AddProject(\"cleanup-worker\")\n" + + " .WithHttpHealthCheck(\"/health/ready\");\n" + + AppHostTail, + Normalize(app.Read(app.AppHost))); + } + + [Fact] + public void The_apphost_project_reference_lands_right_after_the_workers_anchor() + { + using var app = new FakeApp(); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "cleanup", "schedule").Code); + Assert.Equal(""" + + true + + + + + + + + + + + """, Normalize(app.Read(app.AppHostProject))); + } + + // ── naming ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void A_web_project_not_named_Api_keeps_its_full_name_as_the_prefix() + { + using var app = new FakeApp(); + File.Move(app.ApiProject, Path.Combine(app.Root, "src", "Shop.Api", "Shopfront.csproj")); + var runner = new FakeProcessRunner(); + var result = Add(app, runner, "payments", "schedule"); + Assert.Equal(0, result.Code); + Assert.True(Directory.Exists(ProjectDir(app, "Shopfront.PaymentsWorker"))); + Assert.Contains("builder.AddProject(\"payments-worker\")", app.Read(app.AppHost), StringComparison.Ordinal); + } + + [Fact] + public void A_short_name_yields_a_short_kebab_resource() + { + using var app = new FakeApp(); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "eod", "jobs").Code); + Assert.True(Directory.Exists(ProjectDir(app, "Shop.EodWorker"))); + Assert.Contains("builder.AddProject(\"eod-worker\")", app.Read(app.AppHost), StringComparison.Ordinal); + } + + // ── the skeleton, byte for byte ──────────────────────────────────────────────────── + + private static void AssertProjectIsExactly(string projectDir, IReadOnlyDictionary expected) + { + var actual = Directory.EnumerateFiles(projectDir, "*", SearchOption.AllDirectories) + .ToDictionary(path => Path.GetRelativePath(projectDir, path).Replace('\\', '/'), path => Normalize(File.ReadAllText(path)), StringComparer.Ordinal); + Assert.Equal(expected.Keys.Order(StringComparer.Ordinal), actual.Keys.Order(StringComparer.Ordinal)); + foreach (var (file, content) in expected) + { + Assert.True(content == actual[file], $"{file} differs from the golden skeleton"); + } + } + + [Fact] + public void The_queue_skeleton_is_exactly_the_golden_one() + { + using var app = new FakeApp(messagingWired: true); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "payments", "queue").Code); + AssertProjectIsExactly(ProjectDir(app, "Shop.PaymentsWorker"), QueueFiles); + } + + [Fact] + public void The_schedule_skeleton_is_exactly_the_golden_one() + { + using var app = new FakeApp(); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "cleanup", "schedule").Code); + AssertProjectIsExactly(ProjectDir(app, "Shop.CleanupWorker"), ScheduleFiles); + } + + [Fact] + public void The_jobs_skeleton_is_exactly_the_golden_one() + { + using var app = new FakeApp(); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "eod-report", "jobs").Code); + AssertProjectIsExactly(ProjectDir(app, "Shop.EodReportWorker"), JobsFiles); + } + + [Fact] + public void Sqlserver_swaps_exactly_the_provider_package() + { + using var app = new FakeApp(sqlServer: true); + Assert.Equal(0, Add(app, new FakeProcessRunner(), "eod-report", "jobs").Code); + var csproj = Normalize(File.ReadAllText(Path.Combine(ProjectDir(app, "Shop.EodReportWorker"), "Shop.EodReportWorker.csproj"))); + Assert.Equal( + JobsFiles["Shop.EodReportWorker.csproj"].Replace("Npgsql.EntityFrameworkCore.PostgreSQL", "Microsoft.EntityFrameworkCore.SqlServer", StringComparison.Ordinal), + csproj); + } + + // Golden skeletons — what `goldpath add worker` ships for each trigger (the "Shop" fixture). + + private static readonly Dictionary QueueFiles = new(StringComparer.Ordinal) + { + ["GlobalUsings.cs"] = """ + global using Goldpath; + + """, + ["Program.cs"] = """ + using Shop.PaymentsWorker.WorkItems; + using MassTransit; + using Microsoft.EntityFrameworkCore; + + // A web host on purpose: readiness/liveness probes are the deployment contract of a + // worker too — the HTTP surface carries probes, never business APIs. + var builder = WebApplication.CreateBuilder(args); + + builder.AddGoldpathServiceDefaults(); + + // Connection strings come from the AppHost; configuration stays tolerant, usage fails loudly. + var workDbConnection = builder.Configuration.GetConnectionString("shopdb"); + builder.AddGoldpathData(options => + { + // Design time (`dotnet ef`): the provider must BIND without a connection. + if (workDbConnection is not null) + { + options.UseNpgsql(workDbConnection); + } + else + { + options.UseNpgsql(); + } + }); + + builder.AddGoldpathMessaging(bus => + { + bus.AddConsumer(); + // Consumer-side INBOX: every receive endpoint dedups on MessageId — exactly-once processing. + bus.AddGoldpathOutbox(outbox => outbox.UsePostgres()); + bus.UsingRabbitMq((context, cfg) => + { + if (builder.Configuration.GetConnectionString("messaging") is { } messagingConnection) + { + cfg.Host(new Uri(messagingConnection)); + } + + cfg.ConfigureGoldpathEndpoints(context); + }); + }); + + var app = builder.Build(); + + app.MapGoldpathDefaultEndpoints(); + // Smoke-visible read model (what has been processed) — intentionally the only endpoint. + app.MapGet("/api/v1/processed", async (WorkDbContext db) => + await db.ProcessedWorkItems.OrderBy(w => w.ProcessedAt).ToListAsync()); + + app.Run(); + + """, + ["Properties/launchSettings.json"] = """ + { + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5409", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + + """, + ["Shop.PaymentsWorker.csproj"] = """ + + + + net10.0 + + + + + + + + + + + + + + + + """, + ["WorkItems/ProcessedWorkItem.cs"] = """ + namespace Shop.PaymentsWorker.WorkItems; + + /// The durable result of one processed message — the walking skeleton's "work done" proof. + public class ProcessedWorkItem + { + /// The upstream work-item identity (also the primary key: a natural dedup backstop). + public Guid Id { get; set; } + + /// What was processed. + public string Payload { get; set; } = string.Empty; + + /// When processing committed (UTC policy: DateTimeOffset). + public DateTimeOffset ProcessedAt { get; set; } + } + + """, + ["WorkItems/WorkDbContext.cs"] = """ + using MassTransit; + using Microsoft.EntityFrameworkCore; + + namespace Shop.PaymentsWorker.WorkItems; + + public class WorkDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet ProcessedWorkItems => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + => configurationBuilder.ApplyGoldpathConventions(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyGoldpathModelDefaults(); + + // Inbox/outbox tables: the consumer-side dedup store (exactly-once processing). + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + } + } + + """, + ["WorkItems/WorkItemQueued.cs"] = """ + namespace Shop.PaymentsWorker.WorkItems; + + /// + /// The broker-bound contract this worker drains (implements IIntegrationEvent — + /// GP0401). Rename/replace it with the real upstream event. + /// + public record WorkItemQueued(Guid WorkItemId, string Payload) : IIntegrationEvent; + + """, + ["WorkItems/WorkItemQueuedConsumer.cs"] = """ + using MassTransit; + + namespace Shop.PaymentsWorker.WorkItems; + + /// + /// The walking-skeleton consumer: inbox-guarded (exactly-once), commits its result in the + /// same transaction as the dedup bookkeeping. Replace the body with the real work. + /// + public class WorkItemQueuedConsumer(WorkDbContext db) : IConsumer + { + /// + public async Task Consume(ConsumeContext context) + { + db.ProcessedWorkItems.Add(new ProcessedWorkItem + { + Id = context.Message.WorkItemId, + Payload = context.Message.Payload, + ProcessedAt = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(context.CancellationToken); + } + } + + """, + }; + + private static readonly Dictionary ScheduleFiles = new(StringComparer.Ordinal) + { + ["GlobalUsings.cs"] = """ + global using Goldpath; + + """, + ["Jobs/IntervalJob.cs"] = """ + namespace Shop.CleanupWorker.Jobs; + + /// + /// BCL skeleton (template-completion RFC D3): dependency-free + /// scheduling that the Jobs module later replaces without touching the host shape. Put the + /// actual work in ; keep the timer loop free of business logic. + /// + public sealed class IntervalJob(ILogger logger, IConfiguration configuration) : BackgroundService + { + /// Ticks executed so far (smoke-observable via /api/v1/ticks). + public int TickCount { get; private set; } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var interval = configuration.GetValue("Worker:Interval", TimeSpan.FromMinutes(1)); + using var timer = new PeriodicTimer(interval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await RunTickAsync(); + } + } + + /// One unit of scheduled work — replace the log line with the real job. + public Task RunTickAsync() + { + TickCount++; + logger.LogInformation("Interval tick {TickCount} executed.", TickCount); + return Task.CompletedTask; + } + } + + """, + ["Program.cs"] = """ + using Shop.CleanupWorker.Jobs; + + // A web host on purpose: readiness/liveness probes are the deployment contract of a + // worker too — the HTTP surface carries probes, never business APIs. + var builder = WebApplication.CreateBuilder(args); + + builder.AddGoldpathServiceDefaults(); + + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + var app = builder.Build(); + + app.MapGoldpathDefaultEndpoints(); + // Smoke-visible tick counter — intentionally the only endpoint. + app.MapGet("/api/v1/ticks", (IntervalJob job) => new { job.TickCount }); + + app.Run(); + + """, + ["Properties/launchSettings.json"] = """ + { + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5362", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + + """, + ["Shop.CleanupWorker.csproj"] = """ + + + + net10.0 + + + + + + + + + + + """, + }; + + private static readonly Dictionary JobsFiles = new(StringComparer.Ordinal) + { + ["GlobalUsings.cs"] = """ + global using Goldpath; + + """, + ["Program.cs"] = """ + using Shop.EodReportWorker.Reports; + using Microsoft.EntityFrameworkCore; + + // A web host on purpose: readiness/liveness probes are the deployment contract of a + // worker too — the HTTP surface carries probes (and the jobs console), never business APIs. + var builder = WebApplication.CreateBuilder(args); + + builder.AddGoldpathServiceDefaults(); + + // Connection strings come from the AppHost; configuration stays tolerant, usage fails loudly. + var reportsDbConnection = builder.Configuration.GetConnectionString("shopdb"); + builder.AddGoldpathData(options => + { + // Design time (`dotnet ef`): the provider must BIND without a connection. + if (reportsDbConnection is not null) + { + options.UseNpgsql(reportsDbConnection); + } + else + { + options.UseNpgsql(); + } + }); + + // Clustered jobs (Goldpath.Jobs) on the APP database, as this worker's OWN fleet: the + // SchedulerName separates it from the Api's scheduler — same tables, two clusters, + // zero contention for fires (one scheduler per PROCESS, one fleet per PURPOSE). + builder.AddGoldpathJobs(jobs => + { + jobs.ConnectionName = "shopdb"; + jobs.SchedulerName = "shop-eodreportworker"; + jobs.AddJob(j => + { + j.Cron = "0 0 1 * * ?"; // nightly at 01:00 + j.Deadline = TimeSpan.FromHours(2); // every job has an SLA (GP1302) + j.MaxParallelChunks = 2; + }); + }); + + var app = builder.Build(); + + app.MapGoldpathDefaultEndpoints(); + app.MapGoldpathJobsAdmin(exposeUnsecured: true); // internal fleet console — keep it behind the cluster boundary (H2 opt-out, visible) + + app.Run(); + + """, + ["Properties/launchSettings.json"] = """ + { + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5486", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } + + """, + ["Reports/DailyReportRow.cs"] = """ + namespace Shop.EodReportWorker.Reports; + + /// One summarized day — the walking skeleton's "chunk did real work" proof. + public class DailyReportRow + { + /// Day offset the row summarizes (the job's range payloads walk these). + public int DayOffset { get; set; } + + /// When the summary was (re)generated (UTC policy: DateTimeOffset). + public DateTimeOffset GeneratedAt { get; set; } + } + + """, + ["Reports/NightlyReportJob.cs"] = """ + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.DependencyInjection; + + namespace Shop.EodReportWorker.Reports; + + /// + /// The walking-skeleton job: summarizes the last 30 days in 5-day CHUNKS. After every chunk + /// the runner checkpoints — kill the pod mid-run and another node resumes where it stopped + /// (never from the start). Replace the body with the real aggregation. + /// + public sealed class NightlyReportJob : IGoldpathJob + { + /// + public Task PlanAsync(GoldpathJobContext context, CancellationToken cancellationToken) + => Task.FromResult(GoldpathJobPlanner.ByRange(totalItems: 30, chunkSize: 5)); // count, never materialize (GP1303) + + /// + public async Task ExecuteChunkAsync(GoldpathJobChunk chunk, GoldpathJobContext context, CancellationToken cancellationToken) + { + var db = context.Services.GetRequiredService(); + var (start, endExclusive) = GoldpathJobPlanner.ParseRange(chunk.Payload); + for (var dayOffset = (int)start; dayOffset < endExclusive; dayOffset++) + { + var row = await db.DailyReports.FindAsync([dayOffset], cancellationToken) + ?? db.DailyReports.Add(new DailyReportRow { DayOffset = dayOffset }).Entity; + row.GeneratedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(cancellationToken); // one batched write per chunk (house rule) + } + } + + """, + ["Reports/ReportsDbContext.cs"] = """ + using Microsoft.EntityFrameworkCore; + + namespace Shop.EodReportWorker.Reports; + + public class ReportsDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet DailyReports => Set(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + => configurationBuilder.ApplyGoldpathConventions(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyGoldpathModelDefaults(); + modelBuilder.Entity(report => + { + report.HasKey(r => r.DayOffset); + // The key IS the day — never an identity (day 0 must not become "generated"). + report.Property(r => r.DayOffset).ValueGeneratedNever(); + }); + + // SHARED tables with the Api's fleet: the SchedulerName in Program.cs keeps + // the clusters apart, and the API'S context OWNS their migrations — this head + // maps them for querying only (one table set, ONE owner: migrations RFC D3). + modelBuilder.AddGoldpathJobs(excludeFromMigrations: true); + } + } + + """, + ["Shop.EodReportWorker.csproj"] = """ + + + + net10.0 + + + + + + + + + + + + + + + """, + }; +} diff --git a/tests/Goldpath.Cli.Tests/DbCommandMutationTests.cs b/tests/Goldpath.Cli.Tests/DbCommandMutationTests.cs new file mode 100644 index 0000000..a17ce76 --- /dev/null +++ b/tests/Goldpath.Cli.Tests/DbCommandMutationTests.cs @@ -0,0 +1,400 @@ +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Mutation-killing companions to : the exact dotnet ef +/// argument lists (in order, with the app root as CWD), every console line and every refusal — +/// the CLI is thin over the tool, so the invocation IS the behaviour. +/// +public class DbCommandMutationTests +{ + private sealed record Result(int Code, string Output, string Error); + + /// Scripts exit codes by EXACT argument list — the marker dictionary cannot tell `restore` from `tool restore`. + private sealed class ExactRunner(Func, int> exitCode) : IProcessRunner + { + public List Calls { get; } = []; + + public int Run(string fileName, IReadOnlyList arguments, string workingDirectory) + { + Calls.Add(new ProcessCall(fileName, arguments, workingDirectory)); + return exitCode(arguments); + } + } + + private static Result Db(FakeApp app, IProcessRunner runner, params string[] verb) + { + var output = new StringWriter(); + var error = new StringWriter(); + var code = CliRunner.Run(["db", .. verb, "--path", app.Root], runner, output, error); + return new Result(code, output.ToString(), error.ToString()); + } + + private static Result Check(FakeApp app, IProcessRunner runner) + { + var output = new StringWriter(); + var error = new StringWriter(); + var code = CliRunner.Run(["check", "--path", app.Root], runner, output, error); + return new Result(code, output.ToString(), error.ToString()); + } + + private static string Line(string text) => text + Environment.NewLine; + + private static string Owner(FakeApp app) => Path.Combine(app.Root, "src", "Shop.Api", "Shop.Api.csproj"); + + private static string OwnerRel => Path.Combine("src", "Shop.Api", "Shop.Api.csproj"); + + private static void DropOwner(FakeApp app) + => File.WriteAllText(app.ApiProject, app.Read(app.ApiProject).Replace("Microsoft.EntityFrameworkCore.Design", "Nothing.Here", StringComparison.Ordinal)); + + private static string[] Ef(FakeApp app, params string[] args) + => ["ef", .. args, "--project", Owner(app), "--startup-project", Owner(app)]; + + // ── the door: owners, restores, verbs ────────────────────────────────────────────── + + [Theory] + [InlineData("init")] + [InlineData("status")] + public void Ownerless_init_and_status_say_nothing_to_do(string verb) + { + using var app = new FakeApp(); + DropOwner(app); + var runner = new FakeProcessRunner(); + var result = Db(app, runner, verb); + Assert.Equal(0, result.Code); + Assert.Equal(Line("── goldpath db: no migration owner (no project references Microsoft.EntityFrameworkCore.Design) — nothing to do."), result.Output); + Assert.Empty(runner.Calls); // no restore, no ef + } + + [Theory] + [InlineData("add", "x")] + [InlineData("bundle")] + public void Ownerless_add_and_bundle_teach_the_Design_reference(params string[] verb) + { + using var app = new FakeApp(); + DropOwner(app); + var result = Db(app, new FakeProcessRunner(), verb); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: no migration owner found — a project owns migrations by referencing Microsoft.EntityFrameworkCore.Design; regenerate from a current template or add the reference to the project that owns the schema."), result.Error); + } + + [Fact] + public void Every_verb_opens_with_tool_restore_then_restore_in_the_app_root() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Db(app, runner, "status").Code); + + Assert.Equal("dotnet", runner.Calls[0].FileName); + Assert.Equal(["tool", "restore"], runner.Calls[0].Arguments); + Assert.Equal(app.Root, runner.Calls[0].WorkingDirectory); + Assert.Equal("dotnet", runner.Calls[1].FileName); + Assert.Equal(["restore"], runner.Calls[1].Arguments); + Assert.Equal(app.Root, runner.Calls[1].WorkingDirectory); + } + + [Fact] + public void A_failed_tool_restore_stops_before_restore() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["tool"] = 1; + var result = Db(app, runner, "init"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: dotnet tool restore failed — the pinned dotnet-ef tool comes from .config/dotnet-tools.json; see the output above."), result.Error); + Assert.Single(runner.Calls); + } + + [Fact] + public void A_failed_restore_teaches_the_package_feed() + { + using var app = new FakeApp(); + var runner = new ExactRunner(args => args.SequenceEqual(["restore"]) ? 1 : 0); + var result = Db(app, runner, "init"); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath: dotnet restore failed — wire your package feed (nuget.config), then re-run."), result.Error); + Assert.Equal(2, runner.Calls.Count); + } + + [Fact] + public void Add_without_a_name_is_a_usage_error_with_the_shape() + { + using var app = new FakeApp(); + var result = Db(app, new FakeProcessRunner(), "add"); + Assert.Equal(2, result.Code); + Assert.Equal(Line("goldpath: goldpath db add needs a name: goldpath db add "), result.Error); + } + + [Fact] + public void An_unknown_verb_lists_the_four() + { + using var app = new FakeApp(); + var result = Db(app, new FakeProcessRunner(), "frobnicate"); + Assert.Equal(2, result.Code); + Assert.Equal(Line("goldpath: unknown db verb 'frobnicate' — one of: init, add, status, bundle"), result.Error); + } + + // ── init ─────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Init_narrates_the_Initial_migration_and_the_done_line() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + var result = Db(app, runner, "init"); + Assert.Equal(0, result.Code); + Assert.Equal( + Line($"── goldpath db init: Initial migration for {OwnerRel}") + + Line("── goldpath db init: done — Development now migrates from these; production applies the bundle"), + result.Output); + Assert.Equal(3, runner.Calls.Count); + Assert.Equal("dotnet", runner.Calls[2].FileName); + Assert.Equal(Ef(app, "migrations", "add", "Initial"), runner.Calls[2].Arguments); + Assert.Equal(app.Root, runner.Calls[2].WorkingDirectory); + } + + [Fact] + public void Init_skips_an_owner_with_Migrations_but_still_finishes() + { + using var app = new FakeApp(); + Directory.CreateDirectory(Path.Combine(app.Root, "src", "Shop.Api", "Migrations")); + var runner = new FakeProcessRunner(); + var result = Db(app, runner, "init"); + Assert.Equal(0, result.Code); + Assert.Equal( + Line($"── goldpath db init: {OwnerRel} already has Migrations/ — skipped") + + Line("── goldpath db init: done — Development now migrates from these; production applies the bundle"), + result.Output); + Assert.Equal(2, runner.Calls.Count); // restores only + } + + [Fact] + public void Init_returns_the_ef_exit_code_and_never_says_done() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["Initial"] = 7; + var result = Db(app, runner, "init"); + Assert.Equal(7, result.Code); + Assert.Equal(Line($"── goldpath db init: Initial migration for {OwnerRel}"), result.Output); + } + + // ── the first-contract commit ────────────────────────────────────────────────────── + + [Fact] + public void The_first_contract_commit_copies_only_json_exports() + { + using var app = new FakeApp(); + var openapi = Path.Combine(app.Root, "src", "Shop.Api", "openapi"); + Directory.CreateDirectory(openapi); + File.WriteAllText(Path.Combine(openapi, "Shop.Api.json"), "{}"); + File.WriteAllText(Path.Combine(openapi, "notes.txt"), "not a contract"); + var result = Db(app, new FakeProcessRunner(), "init"); + Assert.Equal(0, result.Code); + Assert.Equal("{}", File.ReadAllText(Path.Combine(app.Root, "specs", "Shop.Api.json"))); + Assert.False(File.Exists(Path.Combine(app.Root, "specs", "notes.txt"))); + Assert.EndsWith(Line("goldpath: first OpenAPI contract committed to specs/Shop.Api.json"), result.Output, StringComparison.Ordinal); + } + + [Fact] + public void An_already_committed_contract_is_never_clobbered() + { + using var app = new FakeApp(); + var openapi = Path.Combine(app.Root, "src", "Shop.Api", "openapi"); + Directory.CreateDirectory(openapi); + File.WriteAllText(Path.Combine(openapi, "Shop.Api.json"), "{}"); + Directory.CreateDirectory(Path.Combine(app.Root, "specs")); + File.WriteAllText(Path.Combine(app.Root, "specs", "Shop.Api.json"), "edited by hand"); + var result = Db(app, new FakeProcessRunner(), "init"); + Assert.Equal(0, result.Code); + Assert.Equal("edited by hand", File.ReadAllText(Path.Combine(app.Root, "specs", "Shop.Api.json"))); + Assert.DoesNotContain("first OpenAPI contract committed", result.Output, StringComparison.Ordinal); + } + + // ── add ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public void Add_probes_pending_changes_then_adds_with_the_exact_arguments() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["has-pending-model-changes"] = 1; + var result = Db(app, runner, "add", "AddThing"); + Assert.Equal(0, result.Code); + Assert.Equal(Line($"── goldpath db add: 'AddThing' for {OwnerRel}"), result.Output); + Assert.Equal(4, runner.Calls.Count); + Assert.Equal(Ef(app, "migrations", "has-pending-model-changes"), runner.Calls[2].Arguments); + Assert.Equal(Ef(app, "migrations", "add", "AddThing"), runner.Calls[3].Arguments); + Assert.All(runner.Calls.Skip(2), c => Assert.Equal("dotnet", c.FileName)); + Assert.All(runner.Calls.Skip(2), c => Assert.Equal(app.Root, c.WorkingDirectory)); + } + + [Fact] + public void Add_returns_the_ef_exit_code_when_the_migration_fails() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["has-pending-model-changes"] = 1; + runner.ExitCodeWhenArgumentsContain["AddThing"] = 5; + Assert.Equal(5, Db(app, runner, "add", "AddThing").Code); + } + + [Fact] + public void Add_skip_line_names_the_owner() + { + using var app = new FakeApp(); + var result = Db(app, new FakeProcessRunner(), "add", "AddThing"); // exit 0 = model unchanged + Assert.Equal(0, result.Code); + Assert.Equal(Line($"── goldpath db add: {OwnerRel} model unchanged — skipped (no empty migration)"), result.Output); + } + + // ── status ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Status_probes_every_owner_and_reports_green_exactly() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + var result = Db(app, runner, "status"); + Assert.Equal(0, result.Code); + Assert.Equal(Line("── goldpath db status: every owner's migrations match its model"), result.Output); + Assert.Equal(string.Empty, result.Error); + Assert.Equal(3, runner.Calls.Count); + Assert.Equal(Ef(app, "migrations", "has-pending-model-changes"), runner.Calls[2].Arguments); + } + + [Fact] + public void Status_names_every_pending_owner_in_the_red_line() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["has-pending-model-changes"] = 1; + var result = Db(app, runner, "status"); + Assert.Equal(1, result.Code); + Assert.Equal(string.Empty, result.Output); + Assert.Equal(Line($"goldpath db status: the model changed but no migration captures it in: {OwnerRel} — run goldpath db add ."), result.Error); + } + + // ── bundle ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Bundle_defaults_to_artifacts_migrations_under_the_app_root() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + var result = Db(app, runner, "bundle"); + Assert.Equal(0, result.Code); + var target = Path.Combine(app.Root, "artifacts", "migrations"); + Assert.Equal( + Line("── goldpath db bundle: Shop.Api") + + Line($"── goldpath db bundle: artifacts in {target} — deployment runs these BEFORE the new app version starts (never the app process)"), + result.Output); + Assert.Equal(3, runner.Calls.Count); + Assert.Equal(Ef(app, "migrations", "bundle", "--force", "--output", Path.Combine(target, "Shop.Api-migrations")), runner.Calls[2].Arguments); + Assert.Equal(app.Root, runner.Calls[2].WorkingDirectory); + } + + [Fact] + public void Bundle_honours_an_explicit_output_directory() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + var dist = Path.Combine(app.Root, "dist"); + var result = Db(app, runner, "bundle", dist); + Assert.Equal(0, result.Code); + Assert.Equal(Ef(app, "migrations", "bundle", "--force", "--output", Path.Combine(dist, "Shop.Api-migrations")), runner.Calls[2].Arguments); + Assert.Contains(Line($"── goldpath db bundle: artifacts in {dist} — deployment runs these BEFORE the new app version starts (never the app process)"), result.Output, StringComparison.Ordinal); + } + + [Fact] + public void Bundle_returns_the_ef_exit_code_without_the_artifacts_line() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["bundle"] = 3; + var result = Db(app, runner, "bundle"); + Assert.Equal(3, result.Code); + Assert.Equal(Line("── goldpath db bundle: Shop.Api"), result.Output); + } + + // ── goldpath check's hook ────────────────────────────────────────────────────────── + + [Fact] + public void Check_skips_the_db_step_entirely_on_an_ownerless_app() + { + using var app = new FakeApp(); + DropOwner(app); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Check(app, runner).Code); + Assert.DoesNotContain(runner.Calls, c => c.Arguments.Contains("restore")); + Assert.DoesNotContain(runner.Calls, c => c.Arguments.Contains("ef")); + Assert.Contains(runner.Calls, c => c.Arguments.Contains("build")); // the build still ran + } + + [Fact] + public void Check_goes_red_when_tool_restore_fails() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["tool"] = 1; + var result = Check(app, runner); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath check: dotnet tool restore failed — the pinned dotnet-ef tool comes from .config/dotnet-tools.json."), result.Error); + var restore = Assert.Single(runner.Calls, c => c.Arguments.Contains("tool")); + Assert.Equal("dotnet", restore.FileName); + Assert.Equal(["tool", "restore"], restore.Arguments); + Assert.Equal(app.Root, restore.WorkingDirectory); + Assert.DoesNotContain(runner.Calls, c => c.Arguments.Contains("build")); + } + + [Fact] + public void Check_goes_red_when_restore_fails() + { + using var app = new FakeApp(); + var runner = new ExactRunner(args => args.SequenceEqual(["restore"]) ? 1 : 0); + var result = Check(app, runner); + Assert.Equal(1, result.Code); + Assert.Equal(Line("goldpath check: dotnet restore failed — wire your package feed (nuget.config)."), result.Error); + Assert.DoesNotContain(runner.Calls, c => c.Arguments.Contains("ef")); + } + + // ── owner discovery ──────────────────────────────────────────────────────────────── + + [Fact] + public void Owners_fan_out_in_ordinal_path_order() + { + using var app = new FakeApp(); + var worker = Path.Combine(app.Root, "src", "Shop.Worker"); + Directory.CreateDirectory(worker); + File.WriteAllText(Path.Combine(worker, "Shop.Worker.csproj"), ""); + var runner = new FakeProcessRunner(); + var result = Db(app, runner, "init"); + Assert.Equal(0, result.Code); + var owners = runner.Calls.Skip(2).Select(c => c.Arguments[^1]).ToList(); + Assert.Equal([Owner(app), Path.Combine(worker, "Shop.Worker.csproj")], owners); + Assert.StartsWith( + Line($"── goldpath db init: Initial migration for {OwnerRel}") + + Line($"── goldpath db init: Initial migration for {Path.Combine("src", "Shop.Worker", "Shop.Worker.csproj")}"), + result.Output, StringComparison.Ordinal); + } + + [Fact] + public void Only_csproj_files_outside_bin_and_obj_can_own_migrations() + { + using var app = new FakeApp(); + var api = Path.Combine(app.Root, "src", "Shop.Api"); + // A doc MENTIONING the package, and build outputs carrying a copy of the csproj: none are owners. + File.WriteAllText(Path.Combine(api, "README.md"), "references Microsoft.EntityFrameworkCore.Design"); + foreach (var shadow in new[] { Path.Combine(api, "bin", "Debug"), Path.Combine(api, "obj", "Debug") }) + { + Directory.CreateDirectory(shadow); + File.Copy(app.ApiProject, Path.Combine(shadow, "Shop.Api.csproj")); + } + + var runner = new FakeProcessRunner(); + Assert.Equal(0, Db(app, runner, "init").Code); + var ef = Assert.Single(runner.Calls, c => c.Arguments.Contains("Initial")); + Assert.Equal(Owner(app), ef.Arguments[^1]); + } +} diff --git a/tests/Goldpath.Cli.Tests/ExportMutationTests.cs b/tests/Goldpath.Cli.Tests/ExportMutationTests.cs new file mode 100644 index 0000000..56202d0 --- /dev/null +++ b/tests/Goldpath.Cli.Tests/ExportMutationTests.cs @@ -0,0 +1,311 @@ +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Exact-output tests for goldpath export compose: the compose file is a GENERATED +/// artifact, so every line, indentation and ordering is contract — a loose Contains would +/// let a dropped line or a renamed env var slip through (the mutation survivors showed it). +/// +public class ExportMutationTests +{ + // Every vocabulary word once: each container kind, a postgres with and without a database, + // a project with env + healthcheck + every container reference, a bare project, a project + // referencing projects, and a reference to a variable the AppHost never declared. + private const string AppHost = """ + var builder = DistributedApplication.CreateBuilder(args); + + var database = builder.AddPostgres("dbserver").AddDatabase("ordersdb"); + var mssql = builder.AddSqlServer("mssql").AddDatabase("legacydb"); + var messaging = builder.AddRabbitMQ("messaging"); + var cache = builder.AddRedis("redis"); + builder.AddPostgres("plain"); + + var api = builder.AddProject("api") + .WithReference(database).WaitFor(database) + .WithReference(mssql) + .WithReference(messaging).WaitFor(messaging) + .WithReference(cache).WaitFor(cache) + .WithEnvironment("Worker:Interval", "00:00:01") + .WithHttpHealthCheck("/health/ready"); + + var worker = builder.AddProject("worker") + .WithReference(database); + + builder.AddProject("gateway") + .WithReference(api) + .WithReference(worker) + .WithReference(ghost) + .WithHttpHealthCheck("/health/ready"); + + builder.Build().Run(); + """; + + private const string ExpectedCompose = """ + # GENERATED by `goldpath export compose` FROM the AppHost — do not hand-edit; + # change the AppHost and re-run (foundation §10: the two definitions cannot diverge). + # DEV tier: fixed credentials, one node. Environments stay CI-built manifests. + services: + dbserver: + image: postgres:17-alpine + environment: + POSTGRES_PASSWORD: goldpath-dev + POSTGRES_DB: ordersdb + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"] + interval: 2s + retries: 30 + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: goldpath-dev1! + + messaging: + image: rabbitmq:4 + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "check_port_connectivity"] + interval: 3s + retries: 30 + + redis: + image: redis:7-alpine + + plain: + image: postgres:17-alpine + environment: + POSTGRES_PASSWORD: goldpath-dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"] + interval: 2s + retries: 30 + + api: + build: + context: . + dockerfile: src/Shop.Api/Dockerfile + ports: + - "8080" # random host port — `docker compose port api 8080` + healthcheck: + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && head -1 <&3 | grep -q 200"] + interval: 3s + retries: 40 + environment: + ASPNETCORE_URLS: http://+:8080 + # Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4). + ASPNETCORE_ENVIRONMENT: Development + Worker__Interval: "00:00:01" + ConnectionStrings__ordersdb: Host=dbserver;Port=5432;Database=ordersdb;Username=postgres;Password=goldpath-dev + ConnectionStrings__legacydb: Server=mssql,1433;Database=legacydb;User Id=sa;Password=goldpath-dev1!;TrustServerCertificate=true + ConnectionStrings__messaging: amqp://guest:guest@messaging:5672 + ConnectionStrings__redis: redis:6379 + depends_on: + dbserver: + condition: service_healthy + messaging: + condition: service_healthy + redis: + condition: service_started + + worker: + build: + context: . + dockerfile: src/Shop.Worker/Dockerfile + ports: + - "8080" # random host port — `docker compose port worker 8080` + environment: + ASPNETCORE_URLS: http://+:8080 + # Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4). + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__ordersdb: Host=dbserver;Port=5432;Database=ordersdb;Username=postgres;Password=goldpath-dev + + gateway: + build: + context: . + dockerfile: src/Shop.Gateway/Dockerfile + ports: + - "8080" # random host port — `docker compose port gateway 8080` + healthcheck: + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && head -1 <&3 | grep -q 200"] + interval: 3s + retries: 40 + environment: + ASPNETCORE_URLS: http://+:8080 + # Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4). + ASPNETCORE_ENVIRONMENT: Development + services__api__http__0: http://api:8080 + services__worker__http__0: http://worker:8080 + depends_on: + api: + condition: service_healthy + worker: + condition: service_started + """; + + // The anchors AppFiles.Locate needs to find the AppHost by content. + private const string Anchors = "\n// goldpath:features resources\n// goldpath:features references\n// goldpath:workers\n"; + + private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string Compose() + => Lf(ExportCommand.WriteCompose(ExportCommand.Parse(AppHost), safe => safe.Replace('_', '.'))); + + [Fact] + public void The_compose_file_matches_the_golden_output_byte_for_byte() + { + var compose = Compose(); + // Each service ends with a blank separator line — the last one too. + Assert.EndsWith("condition: service_started\n\n", compose, StringComparison.Ordinal); + Assert.Equal(Lf(ExpectedCompose), compose.TrimEnd('\n')); + } + + [Fact] + public void Parse_reads_every_field_of_the_vocabulary() + { + var resources = ExportCommand.Parse(AppHost); + Assert.Equal(["dbserver", "mssql", "messaging", "redis", "plain", "api", "worker", "gateway"], resources.Select(r => r.Name)); + Assert.Equal(["postgres", "sqlserver", "rabbitmq", "redis", "postgres", "project", "project", "project"], resources.Select(r => r.Kind)); + + // The chain's variable is the reference handle; a chain without one falls back to the name. + Assert.Equal(["database", "mssql", "messaging", "cache", "plain", "api", "worker", "gateway"], resources.Select(r => r.Variable)); + + var plain = resources.Single(r => r.Name == "plain"); + Assert.Null(plain.DatabaseName); + Assert.Null(plain.ProjectSafe); + Assert.False(plain.HasHealthCheck); + Assert.Equal("legacydb", resources.Single(r => r.Name == "mssql").DatabaseName); + + var api = resources.Single(r => r.Name == "api"); + Assert.Equal("Shop_Api", api.ProjectSafe); + Assert.True(api.HasHealthCheck); + Assert.Equal(["database", "mssql", "messaging", "cache"], api.References); + Assert.Equal(["database", "messaging", "cache"], api.WaitsFor); + Assert.Equal(new Dictionary { ["Worker:Interval"] = "00:00:01" }, api.Environment); + + var worker = resources.Single(r => r.Name == "worker"); + Assert.False(worker.HasHealthCheck); + Assert.Empty(worker.WaitsFor); + Assert.Empty(worker.Environment); + } + + [Fact] + public void A_reference_to_an_undeclared_variable_is_skipped_not_fatal() + { + var resources = ExportCommand.Parse(""" + builder.AddProject("api").WithReference(ghost).WaitFor(ghost); + """); + var compose = Lf(ExportCommand.WriteCompose(resources, _ => "Shop.Api")); + Assert.DoesNotContain("ghost", compose, StringComparison.Ordinal); + Assert.DoesNotContain("depends_on", compose, StringComparison.Ordinal); + } + + [Fact] + public void A_project_without_dependencies_emits_no_depends_on_block() + { + var resources = ExportCommand.Parse(""" + builder.AddProject("api"); + """); + var compose = Lf(ExportCommand.WriteCompose(resources, _ => "Shop.Api")); + Assert.Equal(""" + # GENERATED by `goldpath export compose` FROM the AppHost — do not hand-edit; + # change the AppHost and re-run (foundation §10: the two definitions cannot diverge). + # DEV tier: fixed credentials, one node. Environments stay CI-built manifests. + services: + api: + build: + context: . + dockerfile: src/Shop.Api/Dockerfile + ports: + - "8080" # random host port — `docker compose port api 8080` + environment: + ASPNETCORE_URLS: http://+:8080 + # Dev tier: migrations apply on boot; ENVIRONMENTS run the CI bundle (migrations D4). + ASPNETCORE_ENVIRONMENT: Development + + """.Replace("\r\n", "\n", StringComparison.Ordinal) + "\n", compose); + } + + [Fact] + public void WriteCompose_fails_with_the_disagreement_message_when_no_directory_matches() + { + var resources = ExportCommand.Parse(""" + builder.AddProject("api"); + """); + var exception = Assert.Throws(() => ExportCommand.WriteCompose(resources, _ => null)); + Assert.Equal("no project directory matches Projects.Shop_Missing — the AppHost and src/ disagree.", exception.Message); + } + + [Fact] + public void The_run_reports_each_step_exactly_and_lays_the_dockerfile_once() + { + using var app = new FakeApp(); + var output = new StringWriter(); + var error = new StringWriter(); + + Assert.Equal(0, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), output, error)); + + var dockerfileRelative = Path.Combine("src", "Shop.Api", "Dockerfile"); + Assert.Equal( + "── docker-compose.yml generated FROM the AppHost (re-run after AppHost changes; edits belong upstream)\n" + + $"── Dockerfile laid: {dockerfileRelative}\n" + + "── compose is the DEV tier (fixed credentials, one node): environments stay CI-built manifests (foundation §10)\n", + Lf(output.ToString())); + Assert.Equal(string.Empty, error.ToString()); + + // The compose on disk IS WriteCompose's output for the FakeApp AppHost. + var expected = Lf(ExportCommand.WriteCompose(ExportCommand.Parse(File.ReadAllText(app.AppHost)), _ => "Shop.Api")); + Assert.Equal(expected, Lf(File.ReadAllText(Path.Combine(app.Root, "docker-compose.yml")))); + Assert.Contains(" dbserver:\n image: postgres:17-alpine", expected, StringComparison.Ordinal); + + Assert.Equal(Lf(""" + # GENERATED by `goldpath export compose` (laid once — edit freely afterwards). + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + WORKDIR /app + COPY . . + # The host's global.json pins an exact SDK the image may not carry — inside the container + # the IMAGE TAG is the determinism, so the pin steps aside for the build. + RUN rm -f global.json && dotnet publish src/Shop.Api/Shop.Api.csproj -c Release -o /out + + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY --from=build /out . + EXPOSE 8080 + ENTRYPOINT ["dotnet", "Shop.Api.dll"] + """), Lf(File.ReadAllText(Path.Combine(app.Root, dockerfileRelative)))); + + // Second run: the Dockerfile already exists, so its line is NOT reported again. + var second = new StringWriter(); + Assert.Equal(0, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), second, error)); + Assert.Equal( + "── docker-compose.yml generated FROM the AppHost (re-run after AppHost changes; edits belong upstream)\n" + + "── compose is the DEV tier (fixed credentials, one node): environments stay CI-built manifests (foundation §10)\n", + Lf(second.ToString())); + } + + [Fact] + public void An_apphost_without_resources_fails_before_writing_anything() + { + using var app = new FakeApp(); + File.WriteAllText(app.AppHost, "var builder = DistributedApplication.CreateBuilder(args);" + Anchors + "builder.Build().Run();\n"); + var error = new StringWriter(); + + Assert.Equal(1, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), TextWriter.Null, error)); + + Assert.Equal("goldpath: the AppHost declares no resources — nothing to export.\n", Lf(error.ToString())); + Assert.False(File.Exists(Path.Combine(app.Root, "docker-compose.yml"))); + } + + [Fact] + public void A_project_without_a_source_directory_fails_with_the_disagreement_message() + { + using var app = new FakeApp(); + File.WriteAllText(app.AppHost, """builder.AddProject("api");""" + Anchors + "builder.Build().Run();\n"); + var error = new StringWriter(); + + Assert.Equal(1, CliRunner.Run(["export", "compose", "--path", app.Root], new FakeProcessRunner(), TextWriter.Null, error)); + + Assert.Equal("goldpath: no project directory matches Projects.Shop_Missing — the AppHost and src/ disagree.\n", Lf(error.ToString())); + Assert.False(File.Exists(Path.Combine(app.Root, "docker-compose.yml"))); + } +} diff --git a/tests/Goldpath.Cli.Tests/FeatureRecipesMutationTests.cs b/tests/Goldpath.Cli.Tests/FeatureRecipesMutationTests.cs new file mode 100644 index 0000000..b9f985c --- /dev/null +++ b/tests/Goldpath.Cli.Tests/FeatureRecipesMutationTests.cs @@ -0,0 +1,516 @@ +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Mutation-score companions to : every literal a recipe emits +/// is product text (the template generates the same bytes), so each one is pinned verbatim — +/// including the teaching comments inside the registration blocks, the NextSteps prose, the +/// error messages, and both sides of every conditional a recipe decides on. +/// +public class FeatureRecipesMutationTests +{ + private static AppFacts Facts(string provider = "postgres", string? connection = "shopdb", bool caching = false, bool jobs = false, bool messaging = true, bool auth = true) + => new() + { + DbContextName = "ShopDbContext", + DatabaseProvider = provider, + ConnectionName = connection, + CachingWired = caching, + JobsWired = jobs, + MessagingWired = messaging, + AuthWired = auth, + }; + + private static int Add(string feature, FakeApp app, FakeProcessRunner runner, TextWriter? output = null, TextWriter? error = null) + => CliRunner.Run(["add", "feature", feature, "--path", app.Root], runner, output ?? TextWriter.Null, error ?? TextWriter.Null); + + // ---- approvals / fileexchange: the whole registration block, comments included ---- + + [Fact] + public void Approvals_plan_is_exact() + { + var plan = FeatureRecipes.Build("approvals", Facts()); + Assert.Equal("approvals", plan.ManifestKey); + Assert.Equal(["Goldpath.Approvals"], plan.ApiPackages); + Assert.Equal( + [ + "builder.AddGoldpathApprovals(approvals =>", + "{", + " // Declare YOUR authority chains here (goldpath never guesses who may approve):", + " // approvals.AddLadder(\"credit-limit\", l => l", + " // .Rung(\"expert\", 1_000_000m, TimeSpan.FromHours(8))", + " // .TopRung(\"general-manager\", TimeSpan.FromHours(24)));", + "});", + ], + plan.Registrations); + Assert.Equal([" modelBuilder.AddGoldpathApprovalModel(); // approvals + delegations (worklist survives restarts)"], plan.ModelCalls); + Assert.Equal([" approvals: true"], plan.ManifestLines); + Assert.Equal(["declare ladders in AddGoldpathApprovals; schedule EscalateOverdueAsync through the jobs module"], plan.NextSteps); + Assert.Empty(plan.Endpoints); + Assert.Empty(plan.JobsOptionsLines); + Assert.Empty(plan.BusLines); + } + + [Fact] + public void Fileexchange_plan_is_exact() + { + var plan = FeatureRecipes.Build("fileexchange", Facts()); + Assert.Equal("fileExchange", plan.ManifestKey); + Assert.Equal(["Goldpath.FileExchange"], plan.ApiPackages); + Assert.Equal( + [ + "builder.AddGoldpathFileExchange(files =>", + "{", + " // Declare YOUR rails here (goldpath never guesses a counterparty format):", + " // files.AddRail(\"registry-daily\", r => r.Header(1)", + " // .ParseLine(MyRow.Parse).ValidateRow(x => x.IsValid ? null : \"reason\")", + " // .Handle((row, ct) => ApplyAsync(row, ct)));", + "});", + ], + plan.Registrations); + Assert.Equal([" modelBuilder.AddGoldpathFileExchangeModel(); // processed keys + quarantine + archive marks"], plan.ModelCalls); + Assert.Equal([" fileExchange: true"], plan.ManifestLines); + Assert.Equal(["declare rails in AddGoldpathFileExchange; schedule pick-up through the jobs module"], plan.NextSteps); + Assert.Empty(plan.Endpoints); + } + + // ---- execution-ladder modules: NextSteps prose is the operator's checklist ---- + + [Fact] + public void Archival_next_steps_are_exact() + { + Assert.Equal( + [ + "declare lifecycles in AddGoldpathArchival: Graph + Key + DueWhen + ArchiveAfter + RetainFor per aggregate", + "classified data in an archived graph needs the dataprotection feature — erasure redacts through its catalog (GP1401)", + "put /goldpath/admin/* behind an ops-scoped policy before exposing beyond the cluster boundary", + ], + FeatureRecipes.Build("archival", Facts()).NextSteps); + } + + [Fact] + public void Bulk_next_steps_and_model_calls_are_exact() + { + var plan = FeatureRecipes.Build("bulk", Facts()); + Assert.Equal( + [ + "declare batch shapes in AddGoldpathBulk: MaxRows (mandatory, GP1501) + RowKey + Validate per file kind", + "register a row handler per shape: IGoldpathBulkRowHandler — no SaveChanges inside (GP1502), the chunk batches it", + "put /goldpath/admin/* behind an ops-scoped policy before exposing beyond the cluster boundary", + ], + plan.NextSteps); + Assert.Equal( + [ + " modelBuilder.AddGoldpathBulk(); // files + batches + rows + value-free report", + " modelBuilder.AddGoldpathJobs(); // run model + clustered Quartz store (same database)", + ], + plan.ModelCalls); + } + + [Fact] + public void Notification_plan_on_a_fresh_postgres_app_is_exact() + { + var plan = FeatureRecipes.Build("notification", Facts()); + Assert.Equal( + [ + "builder.AddGoldpathJobs(jobs =>", + "{", + " jobs.ConnectionName = \"shopdb\"; // runs + schedules live in the app database", + " jobs.AddGoldpathNotificationJobs(); // send (frequent) + body-retention (nightly)", + "});", + "builder.AddGoldpathNotification(notification =>", + "{", + " // Declare YOUR templates here (code templates: PR-reviewed, hash-stamped — GP1602 wants a retention window):", + " // notification.AddTemplate(\"order-confirmed\", t => t", + " // .Channel(\"email\", c => c.Subject(\"\", \"...\").Body(\"\", \"... {{Token}} ...\"))", + " // .DeleteBodyAfter(TimeSpan.FromDays(90)));", + "});", + ], + plan.Registrations); + Assert.Equal( + [ + "declare templates in AddGoldpathNotification (code, per channel per culture; DeleteBodyAfter is GP1602's ask)", + "request through IGoldpathNotifier with a UNIQUE dedupKey — direct SmtpClient is GP1601-flagged (evidence hole)", + "configure the channel: Goldpath:Notification:Email { Host, Port, UseSsl, User, Password, From }", + ], + plan.NextSteps); + } + + [Fact] + public void Notification_plan_on_a_fresh_sqlserver_app_pins_the_store_provider_in_place() + { + var plan = FeatureRecipes.Build("notification", Facts(provider: "sqlserver")); + // The provider line sits between the connection name and the jobs call — position matters, not just presence. + Assert.Equal( + [ + "builder.AddGoldpathJobs(jobs =>", + "{", + " jobs.ConnectionName = \"shopdb\"; // runs + schedules live in the app database", + " jobs.Provider = GoldpathJobStoreProvider.SqlServer;", + " jobs.AddGoldpathNotificationJobs(); // send (frequent) + body-retention (nightly)", + "});", + ], + plan.Registrations.Take(6)); + } + + [Fact] + public void Campaign_plan_on_a_fresh_postgres_app_is_exact() + { + var plan = FeatureRecipes.Build("campaign", Facts()); + Assert.Equal( + [ + "builder.AddGoldpathJobs(jobs =>", + "{", + " jobs.ConnectionName = \"shopdb\"; // runs + schedules live in the app database", + " jobs.AddGoldpathCampaignJobs(); // pacer: the cron guarantees a LEADER exists; pacing is in-memory ticks", + "});", + "builder.AddGoldpathCampaign(campaign =>", + "{", + " // Declare YOUR campaign types here (code, PR-reviewed; operators create INSTANCES via the admin API):", + " // campaign.AddCampaign(\"your-campaign\", c => c", + " // .MaxTargets(1_000_000) // mandatory — GP1701", + " // .Targets((services, parameters) => /* keyset-ORDERED IAsyncEnumerable */)", + " // .DefaultPolicy(p => p with { Tps = 50, MaxInFlight = 1_000 }));", + "});", + ], + plan.Registrations); + Assert.Equal( + [ + "declare campaign types in AddGoldpathCampaign: MaxTargets (mandatory, GP1701) + a keyset-ORDERED Targets stream + DefaultPolicy", + "register an item handler per type: IGoldpathCampaignItemHandler — no SaveChanges inside (GP1702), outcomes ride the sink", + "operators launch instances via POST /goldpath/admin/campaign (audited); throttle is LIVE — no restart to slow a screaming gateway", + "put /goldpath/admin/* behind an ops-scoped policy before exposing beyond the cluster boundary", + ], + plan.NextSteps); + } + + [Fact] + public void Campaign_plan_on_a_fresh_sqlserver_app_pins_the_store_provider_in_place() + { + var plan = FeatureRecipes.Build("campaign", Facts(provider: "sqlserver")); + Assert.Equal( + [ + "builder.AddGoldpathJobs(jobs =>", + "{", + " jobs.ConnectionName = \"shopdb\"; // runs + schedules live in the app database", + " jobs.Provider = GoldpathJobStoreProvider.SqlServer;", + " jobs.AddGoldpathCampaignJobs(); // pacer: the cron guarantees a LEADER exists; pacing is in-memory ticks", + "});", + ], + plan.Registrations.Take(6)); + } + + [Theory] + [InlineData("archival")] + [InlineData("bulk")] + [InlineData("notification")] + [InlineData("campaign")] + public void Jobs_riding_recipes_on_postgres_never_pin_a_store_provider(string feature) + { + // Postgres is the default store: a provider line here would be drift against the template. + var plan = FeatureRecipes.Build(feature, Facts(provider: "postgres")); + Assert.DoesNotContain(plan.Registrations, line => line.Contains("jobs.Provider", StringComparison.Ordinal)); + } + + // ---- admin surfaces: the VISIBLE opt-out without auth, nothing with it ---- + + [Theory] + [InlineData("archival", "app.MapGoldpathArchivalAdmin(exposeUnsecured: true); // lifecycle verbs: retrieve/hold/erase/verify")] + [InlineData("bulk", "app.MapGoldpathBulkAdmin(exposeUnsecured: true); // intake verbs: upload/report/approve/reject")] + [InlineData("notification", "app.MapGoldpathNotificationAdmin(exposeUnsecured: true); // read-only evidence views (recipients masked)")] + [InlineData("campaign", "app.MapGoldpathCampaignAdmin(exposeUnsecured: true); // audited verbs: create/pause/resume/abort/throttle")] + public void Admin_endpoints_without_auth_carry_the_explicit_unsecured_opt_out(string feature, string moduleAdmin) + { + var plan = FeatureRecipes.Build(feature, Facts(auth: false)); + Assert.Equal( + [ + "app.MapGoldpathJobsAdmin(exposeUnsecured: true); // run console API: trigger/pause/reschedule/audit", + moduleAdmin, + ], + plan.Endpoints); + } + + [Theory] + [InlineData("archival")] + [InlineData("bulk")] + [InlineData("notification")] + [InlineData("campaign")] + public void Admin_endpoints_with_auth_take_the_policy_default(string feature) + { + var plan = FeatureRecipes.Build(feature, Facts(auth: true)); + Assert.Equal("app.MapGoldpathJobsAdmin(); // run console API: trigger/pause/reschedule/audit", plan.Endpoints[0]); + Assert.DoesNotContain(plan.Endpoints, line => line.Contains("exposeUnsecured", StringComparison.Ordinal)); + } + + // ---- the connection-name guard, per module, with its own teaching message ---- + + [Theory] + [InlineData("locking", "locking reuses the app database")] + [InlineData("archival", "the archive store lives in the app database")] + [InlineData("bulk", "the bulk file store lives in the app database")] + [InlineData("notification", "the notification evidence store lives in the app database")] + [InlineData("campaign", "the campaign plan lives in the app database")] + public void Database_backed_recipes_without_a_connection_name_fail_with_their_own_message(string feature, string reason) + { + var e = Assert.Throws(() => FeatureRecipes.Build(feature, Facts(connection: null))); + Assert.StartsWith("no GetConnectionString(...) found in the composition root — ", e.Message, StringComparison.Ordinal); + Assert.Contains(reason, e.Message, StringComparison.Ordinal); + Assert.EndsWith("needs its connection name.", e.Message, StringComparison.Ordinal); + } + + [Fact] + public void Campaign_checks_the_connection_name_before_the_broker_rule() + { + var e = Assert.Throws(() => FeatureRecipes.Build("campaign", Facts(connection: null, messaging: false))); + Assert.Contains("campaign plan", e.Message, StringComparison.Ordinal); + } + + [Fact] + public void Unknown_feature_names_the_whole_menu() + { + var e = Assert.Throws(() => FeatureRecipes.Build("quantumsafe", Facts())); + Assert.Equal( + "unknown feature 'quantumsafe' — one of: multitenancy, audittrail, softdelete, idempotency, dataprotection, caching, locking, approvals, fileexchange, archival, bulk, notification, campaign", + e.Message); + } + + [Fact] + public void Unknown_feature_through_the_cli_is_a_usage_error_with_the_menu() + { + using var app = new FakeApp(); + var error = new StringWriter(); + Assert.Equal(2, Add("quantumsafe", app, new FakeProcessRunner(), error: error)); + Assert.Contains("goldpath: unknown feature 'quantumsafe' — one of: multitenancy, ", error.ToString(), StringComparison.Ordinal); + } + + // ---- AppFacts: every fact read from the app, including the absent cases ---- + + [Fact] + public void AppFacts_reports_no_provider_when_the_api_project_references_neither() + { + using var app = new FakeApp(); + File.WriteAllText(app.ApiProject, app.Read(app.ApiProject).Replace("Npgsql.EntityFrameworkCore.PostgreSQL", "Some.Other.Package", StringComparison.Ordinal)); + Assert.Equal("none", AppFacts.Read(AppFiles.Locate(app.Root)).DatabaseProvider); + } + + [Fact] + public void AppFacts_reports_a_null_connection_when_the_composition_root_has_none() + { + using var app = new FakeApp(); + File.WriteAllText(app.Program, app.Read(app.Program).Replace("GetConnectionString(\"shopdb\")", "GetSection(\"shopdb\").Value", StringComparison.Ordinal)); + Assert.Null(AppFacts.Read(AppFiles.Locate(app.Root)).ConnectionName); + } + + [Fact] + public void AppFacts_reads_jobs_messaging_and_auth_wiring() + { + using var app = new FakeApp(jobsWired: true, messagingWired: true, authWired: true); + var facts = AppFacts.Read(AppFiles.Locate(app.Root)); + Assert.True(facts.JobsWired); + Assert.True(facts.MessagingWired); + Assert.True(facts.AuthWired); + + using var bare = new FakeApp(); + var bareFacts = AppFacts.Read(AppFiles.Locate(bare.Root)); + Assert.False(bareFacts.JobsWired); + Assert.False(bareFacts.MessagingWired); + Assert.False(bareFacts.AuthWired); + } + + [Fact] + public void AppFacts_fails_loud_when_the_model_file_declares_no_class() + { + using var app = new FakeApp(); + // Keep the anchor (Locate finds the file by it) but drop the class declaration. + File.WriteAllText(app.Model, "// goldpath:features model — the drift profile is the source of these rows\n"); + var e = Assert.Throws(() => AppFacts.Read(AppFiles.Locate(app.Root))); + Assert.Equal($"no class declaration found in {app.Model} — cannot infer the DbContext type.", e.Message); + } + + // ---- AddFeatureCommand: guards, branches, and the rollback path ---- + + [Fact] + public void Missing_manifest_fails_with_the_path_it_looked_at() + { + var root = Path.Combine(Path.GetTempPath(), $"goldpath-cli-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var error = new StringWriter(); + var exitCode = CliRunner.Run(["add", "feature", "softdelete", "--path", root], new FakeProcessRunner(), TextWriter.Null, error); + Assert.Equal(1, exitCode); + Assert.Contains($"no manifest at {Path.Combine(root, ".goldpath", "manifest.yaml")} — goldpath add runs inside a Goldpath-generated app (or pass --path).", error.ToString(), StringComparison.Ordinal); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Manifest_without_a_kind_is_refused_as_none() + { + using var app = new FakeApp(); + File.WriteAllText(app.Manifest, app.Read(app.Manifest).Replace("kind: solution\n", string.Empty, StringComparison.Ordinal)); + var error = new StringWriter(); + + Assert.Equal(1, Add("softdelete", app, new FakeProcessRunner(), error: error)); + Assert.Contains("goldpath: this manifest is kind '' — Ring B features live in the owning SOLUTION's manifest; run goldpath add there.", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Worker_manifest_is_refused_naming_its_kind() + { + using var app = new FakeApp(kind: "worker"); + var error = new StringWriter(); + Assert.Equal(1, Add("softdelete", app, new FakeProcessRunner(), error: error)); + Assert.Contains("this manifest is kind 'worker' — ", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Model_growing_features_tell_the_team_to_add_a_migration() + { + using var app = new FakeApp(); + var output = new StringWriter(); + Assert.Equal(0, Add("softdelete", app, new FakeProcessRunner(), output: output)); + Assert.Contains(" → the model grew: run `goldpath db add AddSoftdelete` and commit the migration (production applies the bundle — migrations RFC D5)", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Features_that_leave_the_model_alone_do_not_ask_for_a_migration() + { + using var app = new FakeApp(); + var output = new StringWriter(); + Assert.Equal(0, Add("dataprotection", app, new FakeProcessRunner(), output: output)); + Assert.DoesNotContain("goldpath db add", output.ToString(), StringComparison.Ordinal); + Assert.Contains(" → classify once: [GoldpathPersonalData] on sensitive properties — every sink (audit rows, logs) masks them", output.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Features_without_endpoints_never_look_for_the_endpoints_anchor() + { + using var app = new FakeApp(); + // A team that dropped the endpoints anchor must still be able to add a feature that maps none. + File.WriteAllText(app.Program, app.Read(app.Program).Replace("// goldpath:features endpoints — admin surfaces map here (put them behind the auth floor)\n", string.Empty, StringComparison.Ordinal)); + Assert.Equal(0, Add("dataprotection", app, new FakeProcessRunner())); + Assert.Contains("builder.AddGoldpathDataProtection();", app.Read(app.Program), StringComparison.Ordinal); + } + + [Fact] + public void Apply_failure_restores_the_already_written_files_and_fails_loud() + { + using var app = new FakeApp(); + // Manifest + csproj are written BEFORE Program.cs is edited; the missing endpoints anchor + // blows up in between, so the rollback must undo what already landed. + File.WriteAllText(app.Program, app.Read(app.Program).Replace("// goldpath:features endpoints — admin surfaces map here (put them behind the auth floor)\n", string.Empty, StringComparison.Ordinal)); + var before = new[] { app.Manifest, app.ApiProject, app.AppHostProject, app.Program, app.Model, app.AppHost }.ToDictionary(p => p, app.Read); + var output = new StringWriter(); + var error = new StringWriter(); + + Assert.Equal(1, Add("archival", app, new FakeProcessRunner(), output, error)); + + Assert.Contains("anchor '// goldpath:features endpoints' not found", error.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("added — engine clean", output.ToString(), StringComparison.Ordinal); + foreach (var (path, content) in before) + { + Assert.Equal(content, app.Read(path)); + } + } + + // ---- every recipe through the CLI: plan lines land verbatim, and a second run is a no-op ---- + + public static TheoryData AllFeatures() + { + var data = new TheoryData(); + foreach (var name in FeatureRecipes.Names) + { + data.Add(name); + } + + return data; + } + + [Theory] + [MemberData(nameof(AllFeatures))] + public void Every_recipe_lands_its_plan_verbatim_and_is_idempotent(string feature) + { + using var app = new FakeApp(messagingWired: true); + var plan = FeatureRecipes.Build(feature, AppFacts.Read(AppFiles.Locate(app.Root))); + var runner = new FakeProcessRunner(); + var output = new StringWriter(); + + Assert.Equal(0, Add(feature, app, runner, output)); + + var program = app.Read(app.Program); + var model = app.Read(app.Model); + var appHost = app.Read(app.AppHost); + var manifest = app.Read(app.Manifest); + foreach (var package in plan.ApiPackages) + { + Assert.Contains($" ", app.Read(app.ApiProject), StringComparison.Ordinal); + } + + foreach (var package in plan.AppHostPackages) + { + Assert.Contains($" ", app.Read(app.AppHostProject), StringComparison.Ordinal); + } + + foreach (var line in plan.Registrations.Concat(plan.Middleware).Concat(plan.Endpoints).Concat(plan.BusLines)) + { + Assert.Contains($"\n{line}\n", program, StringComparison.Ordinal); + } + + foreach (var line in plan.ModelCalls) + { + Assert.Contains($"\n{line}\n", model, StringComparison.Ordinal); + } + + foreach (var line in plan.Resources.Concat(plan.References)) + { + Assert.Contains($"\n{line}\n", appHost, StringComparison.Ordinal); + } + + foreach (var line in plan.ManifestLines) + { + Assert.Contains($"\n{line}\n", manifest, StringComparison.Ordinal); + } + + foreach (var step in plan.NextSteps) + { + Assert.Contains($" → {step}", output.ToString(), StringComparison.Ordinal); + } + + // Second run: already enabled — nothing rewritten, no engine round-trip. + var engineRuns = runner.Calls.Count; + var second = new StringWriter(); + Assert.Equal(0, Add(feature, app, runner, second)); + Assert.Equal($"goldpath: '{feature}' is already enabled ({plan.ManifestKey}) — nothing to do.{Environment.NewLine}", second.ToString()); + Assert.Equal(engineRuns, runner.Calls.Count); + Assert.Equal(program, app.Read(app.Program)); + Assert.Equal(model, app.Read(app.Model)); + Assert.Equal(appHost, app.Read(app.AppHost)); + Assert.Equal(manifest, app.Read(app.Manifest)); + } + + [Fact] + public void Registration_blocks_keep_their_order_in_the_composition_root() + { + using var app = new FakeApp(); + Assert.Equal(0, Add("approvals", app, new FakeProcessRunner())); + + var lines = app.Read(app.Program).Split('\n'); + var anchor = Array.FindIndex(lines, l => l.Contains("goldpath:features registrations", StringComparison.Ordinal)); + Assert.Equal( + [ + "builder.AddGoldpathApprovals(approvals =>", + "{", + " // Declare YOUR authority chains here (goldpath never guesses who may approve):", + " // approvals.AddLadder(\"credit-limit\", l => l", + " // .Rung(\"expert\", 1_000_000m, TimeSpan.FromHours(8))", + " // .TopRung(\"general-manager\", TimeSpan.FromHours(24)));", + "});", + ], + lines.Skip(anchor + 1).Take(7)); + } +} diff --git a/tests/Goldpath.Cli.Tests/InitCommandTests.cs b/tests/Goldpath.Cli.Tests/InitCommandTests.cs new file mode 100644 index 0000000..f9321a0 --- /dev/null +++ b/tests/Goldpath.Cli.Tests/InitCommandTests.cs @@ -0,0 +1,215 @@ +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Exact-behaviour tests for goldpath init: the prompts it asks, the manifest it +/// writes, the engine call it makes and the messages it refuses with — each one is the +/// user-facing contract, so every assertion is on the full text. +/// +public class InitCommandTests +{ + /// Records every question and answers by question prefix (default: empty). + private sealed class ScriptedPrompter(Dictionary? answers = null) : IPrompter + { + public List Questions { get; } = []; + + public string Choose(string question, IReadOnlyList choices, string defaultChoice) => defaultChoice; + + public IReadOnlyList ChooseMany(string question, IReadOnlyList choices) => []; + + public bool Confirm(string question, bool defaultAnswer) => defaultAnswer; + + public string Input(string question) + { + Questions.Add(question); + return answers?.FirstOrDefault(a => question.StartsWith(a.Key, StringComparison.Ordinal)).Value ?? string.Empty; + } + } + + /// A disposable solution directory named legacy-shop (Pascal: LegacyShop). + private sealed class Solution : IDisposable + { + private readonly string parent = Path.Combine(Path.GetTempPath(), $"goldpath-init-{Guid.NewGuid():N}"); + + public Solution(bool sln = true, bool csproj = false) + { + Directory.CreateDirectory(Root); + if (sln) + { + File.WriteAllText(Path.Combine(Root, "Legacy.sln"), ""); + } + + if (csproj) + { + Directory.CreateDirectory(Path.Combine(Root, "src", "Legacy.Api")); + File.WriteAllText(Path.Combine(Root, "src", "Legacy.Api", "Legacy.Api.csproj"), ""); + } + } + + public string Root => Path.Combine(parent, "legacy-shop"); + + public string Manifest => Path.Combine(Root, ".goldpath", "manifest.yaml"); + + public void Dispose() => Directory.Delete(parent, recursive: true); + } + + private static string Lf(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal); + + private static string SchemaPath => Path.Combine(Path.GetTempPath(), "goldpath-manifest.schema.v1.json"); + + [Fact] + public void Init_asks_three_questions_with_the_directory_name_as_the_default() + { + using var solution = new Solution(); + var prompter = new ScriptedPrompter(); + + Assert.Equal(0, InitCommand.Run(solution.Root, prompter, new FakeProcessRunner(), TextWriter.Null, TextWriter.Null)); + + Assert.Equal( + ["Solution name (default: LegacyShop)", "Owning team (kebab-case, e.g. team-orders)", "One-line description"], + prompter.Questions); + } + + [Fact] + public void Blank_answers_fall_back_to_the_defaults_in_manifest_and_output() + { + using var solution = new Solution(); + // Whitespace is "no answer" — the fallback wins, not a blank field. + var prompter = new ScriptedPrompter(new Dictionary { ["Solution"] = " ", ["Owning"] = "\t", ["One-line"] = "" }); + var output = new StringWriter(); + + Assert.Equal(0, InitCommand.Run(solution.Root, prompter, new FakeProcessRunner(), output, TextWriter.Null)); + + Assert.Equal(""" + # Attached by `goldpath init` (L2): the manifest is the single source of truth from here + # on — grow it as capabilities adopt the golden path; `goldpath check` validates it. + # Code rewiring stays YOURS until the transformation pack: init attaches, never rewrites. + schemaVersion: 1 + kind: solution + name: LegacyShop + description: LegacyShop — attached to the golden path (L2) + owner: platform-team + """.Replace("\r\n", "\n", StringComparison.Ordinal), Lf(File.ReadAllText(solution.Manifest))); + + Assert.Equal( + "── goldpath init: LegacyShop attached (kind: solution, owner: platform-team)\n" + + " the manifest is now this solution's single source of truth (ADR-0001);\n" + + " next: declare providers/features AS they adopt the path, `goldpath check` on every change;\n" + + " code rewiring and the skills family arrive with the transformation pack — init attaches, never rewrites.\n", + Lf(output.ToString())); + } + + [Fact] + public void Answers_are_trimmed_and_land_verbatim_in_the_manifest() + { + using var solution = new Solution(); + var prompter = new ScriptedPrompter(new Dictionary + { + ["Solution"] = " Orders ", + ["Owning"] = "team-orders\n", + ["One-line"] = " Order intake ", + }); + var output = new StringWriter(); + + Assert.Equal(0, InitCommand.Run(solution.Root, prompter, new FakeProcessRunner(), output, TextWriter.Null)); + + var manifest = Lf(File.ReadAllText(solution.Manifest)); + Assert.EndsWith("name: Orders\ndescription: Order intake\nowner: team-orders", manifest, StringComparison.Ordinal); + Assert.StartsWith("── goldpath init: Orders attached (kind: solution, owner: team-orders)\n", Lf(output.ToString()), StringComparison.Ordinal); + } + + [Fact] + public void Init_validates_through_the_engine_with_exact_arguments() + { + using var solution = new Solution(); + var runner = new FakeProcessRunner(); + + Assert.Equal(0, InitCommand.Run(solution.Root, new ScriptedPrompter(), runner, TextWriter.Null, TextWriter.Null)); + + var call = Assert.Single(runner.Calls); + Assert.Equal("specdrift", call.FileName); + Assert.Equal(solution.Root, call.WorkingDirectory); + // No .specdrift/rules.yaml in a fresh attach — schema only. + Assert.Equal(["validate", Path.Combine(".goldpath", "manifest.yaml"), "--schema", SchemaPath], call.Arguments); + } + + [Fact] + public void A_rejected_manifest_fails_with_the_engine_message_and_leaves_nothing() + { + using var solution = new Solution(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["validate"] = 3; + var output = new StringWriter(); + + var exception = Assert.Throws(() => InitCommand.Run(solution.Root, new ScriptedPrompter(), runner, output, TextWriter.Null)); + + Assert.Equal("the engine rejected the manifest — nothing attached (fix the inputs and retry).", exception.Message); + Assert.Equal(string.Empty, output.ToString()); + Assert.False(Directory.Exists(Path.Combine(solution.Root, ".goldpath"))); + } + + [Fact] + public void A_rejected_manifest_keeps_a_goldpath_directory_that_held_other_files() + { + using var solution = new Solution(); + Directory.CreateDirectory(Path.Combine(solution.Root, ".goldpath")); + File.WriteAllText(Path.Combine(solution.Root, ".goldpath", "notes.md"), "mine"); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["validate"] = 1; + + Assert.Throws(() => InitCommand.Run(solution.Root, new ScriptedPrompter(), runner, TextWriter.Null, TextWriter.Null)); + + Assert.False(File.Exists(solution.Manifest)); + Assert.True(File.Exists(Path.Combine(solution.Root, ".goldpath", "notes.md"))); + } + + [Fact] + public void An_attached_solution_is_refused_by_its_manifest_path_before_any_prompt() + { + using var solution = new Solution(); + Directory.CreateDirectory(Path.Combine(solution.Root, ".goldpath")); + File.WriteAllText(solution.Manifest, "schemaVersion: 1\n"); + var prompter = new ScriptedPrompter(); + var runner = new FakeProcessRunner(); + + var exception = Assert.Throws(() => InitCommand.Run(solution.Root, prompter, runner, TextWriter.Null, TextWriter.Null)); + + Assert.Equal($"{solution.Manifest} already exists — this solution is attached; `goldpath check` is the daily verb.", exception.Message); + Assert.Empty(prompter.Questions); + Assert.Empty(runner.Calls); + } + + [Fact] + public void A_directory_without_sln_or_csproj_is_refused_through_the_cli() + { + using var solution = new Solution(sln: false); + var error = new StringWriter(); + + // The CLI path: the refusal happens before the console prompter asks anything. + Assert.Equal(1, CliRunner.Run(["init", "--path", solution.Root], new FakeProcessRunner(), TextWriter.Null, error)); + + Assert.Equal($"goldpath: no .sln or .csproj under {solution.Root} — goldpath init attaches to an EXISTING solution (a new one starts with goldpath new).\n", Lf(error.ToString())); + Assert.False(Directory.Exists(Path.Combine(solution.Root, ".goldpath"))); + } + + [Fact] + public void A_nested_csproj_alone_is_enough_to_attach() + { + using var solution = new Solution(sln: false, csproj: true); + + Assert.Equal(0, InitCommand.Run(solution.Root, new ScriptedPrompter(), new FakeProcessRunner(), TextWriter.Null, TextWriter.Null)); + + Assert.True(File.Exists(solution.Manifest)); + } + + [Fact] + public void A_sln_alone_is_enough_to_attach() + { + using var solution = new Solution(sln: true, csproj: false); + + Assert.Equal(0, InitCommand.Run(solution.Root, new ScriptedPrompter(), new FakeProcessRunner(), TextWriter.Null, TextWriter.Null)); + + Assert.True(File.Exists(solution.Manifest)); + } +} diff --git a/tests/Goldpath.Cli.Tests/NewServiceMutationTests.cs b/tests/Goldpath.Cli.Tests/NewServiceMutationTests.cs new file mode 100644 index 0000000..6525ad5 --- /dev/null +++ b/tests/Goldpath.Cli.Tests/NewServiceMutationTests.cs @@ -0,0 +1,945 @@ +using System.Text.Json; +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Mutation-killing companions to : every generated file, every +/// engine call, every refusal is pinned byte-for-byte, so a flipped branch or a blanked +/// literal in NewServiceCommand cannot hide behind a loose Contains. +/// +public class NewServiceMutationTests +{ + private const string SmokeAnchorLine = " // goldpath:smoke heads — additional heads (goldpath new service|gateway) prove here"; + + private sealed record Outcome(int ExitCode, string Output, string Error); + + private static Outcome Run(FakeApp app, FakeProcessRunner runner, params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + var exit = CliRunner.Run([.. args, "--path", app.Root], runner, output, error); + return new Outcome(exit, output.ToString(), error.ToString()); + } + + private static string SmokePath(FakeApp app) => Path.Combine(app.Root, "tests", "SmokeTests.cs"); + + private static void GiveSmokeAnchor(FakeApp app) + { + Directory.CreateDirectory(Path.Combine(app.Root, "tests")); + File.WriteAllText(SmokePath(app), + "public class SmokeTests\n{\n public async Task Flow()\n {\n" + SmokeAnchorLine + "\n }\n}\n"); + } + + private static string Src(FakeApp app, params string[] parts) + => Path.Combine([app.Root, "src", .. parts]); + + // ── service: generated files, pinned exactly ───────────────────────────────────── + + [Fact] + public void Service_csproj_is_exact_for_postgres() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + Assert.Equal(""" + + + + + net10.0 + $(MSBuildProjectDirectory)/openapi + true + true + + + + + + + + + + + + + """, File.ReadAllText(Src(app, "Shop.BillingService", "Shop.BillingService.csproj"))); + } + + [Fact] + public void Service_program_is_exact_for_postgres() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + Assert.Equal(""" + using Goldpath; + using Microsoft.EntityFrameworkCore; + using Shop.BillingService; + + // A service head (microservice layout): its OWN database, its OWN manifest — the unit + // Goldpath binds to is the manifest, not the repo (foundation §10). Features compose on + // the PRIMARY head today; per-service features arrive with the products pilot. + var builder = WebApplication.CreateBuilder(args); + + builder.AddGoldpathServiceDefaults(); + builder.AddGoldpathApiDefaults(); + + // Design time and docgen tolerate a missing connection; nothing connects until used. + var connection = builder.Configuration.GetConnectionString("billingservicedb"); + builder.AddGoldpathData(options => + { + if (connection is not null) + { + options.UseNpgsql(connection); + } + else + { + options.UseNpgsql(); + } + }); + + var app = builder.Build(); + + app.MapGoldpathDefaultEndpoints(); + app.MapGoldpathApi(); + app.MapGet("/api/v1/ping", () => new { service = "billing-service", status = "alive" }); + + app.Run(); + """, File.ReadAllText(Src(app, "Shop.BillingService", "Program.cs"))); + } + + [Fact] + public void Service_dbcontext_file_is_exact() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + // ServiceDbClass: the LAST dotted segment with Service → Db — file AND class name. + Assert.Equal(""" + using Goldpath; + using Microsoft.EntityFrameworkCore; + + namespace Shop.BillingService; + + /// + /// This service's OWN schema (db-per-service): starts empty on purpose — entities arrive + /// with the service's features, migrations with `goldpath db add`. + /// + public class BillingDb(DbContextOptions options) : DbContext(options) + { + /// + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + => configurationBuilder.ApplyGoldpathConventions(); + } + """, File.ReadAllText(Src(app, "Shop.BillingService", "BillingDb.cs"))); + } + + [Fact] + public void Service_launch_settings_and_manifest_are_exact() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + // 5300 + |Σ c*31| % 200 for "Shop.BillingService" — a literal, so the arithmetic cannot drift. + Assert.Equal(""" + { + "profiles": { + "Shop.BillingService": { + "commandName": "Project", + "applicationUrl": "http://localhost:5442" + } + } + } + """, File.ReadAllText(Src(app, "Shop.BillingService", "Properties", "launchSettings.json"))); + + Assert.Equal(""" + schemaVersion: 1 + kind: service + name: Shop.BillingService + description: billing-service — a service head with its own database (db-per-service) + owner: platform-team + boundedContext: billing + specs: + openapi: + - specs/Shop.BillingService.json + """, File.ReadAllText(Src(app, "Shop.BillingService", ".goldpath", "manifest.yaml"))); + } + + [Fact] + public void Sqlserver_app_gets_the_sqlserver_package_provider_and_server() + { + using var app = new FakeApp(sqlServer: true); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + var csproj = File.ReadAllText(Src(app, "Shop.BillingService", "Shop.BillingService.csproj")); + Assert.Contains(" \n", csproj, StringComparison.Ordinal); + Assert.DoesNotContain("Npgsql", csproj, StringComparison.Ordinal); + + var program = File.ReadAllText(Src(app, "Shop.BillingService", "Program.cs")); + Assert.Contains(" options.UseSqlServer(connection);\n", program, StringComparison.Ordinal); + Assert.Contains(" options.UseSqlServer();\n", program, StringComparison.Ordinal); + Assert.DoesNotContain("UseNpgsql", program, StringComparison.Ordinal); + + Assert.Contains("var Shop_BillingServiceDb = builder.AddSqlServer(\"billing-service-db\").AddDatabase(\"billingservicedb\");\n", + app.Read(app.AppHost), StringComparison.Ordinal); + Assert.DoesNotContain("AddPostgres(\"billing-service-db\")", app.Read(app.AppHost), StringComparison.Ordinal); + } + + [Fact] + public void Service_wires_the_apphost_and_its_csproj_exactly() + { + using var app = new FakeApp(); + var appHostBefore = app.Read(app.AppHost); + var projectBefore = app.Read(app.AppHostProject); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + // Wiring lands right after the workers anchor; the split leaves the trailing blank line. + const string anchor = "// goldpath:workers — additional worker projects wire here (goldpath add worker)"; + var expectedAppHost = appHostBefore.Replace(anchor, anchor + "\n" + """ + var Shop_BillingServiceDb = builder.AddPostgres("billing-service-db").AddDatabase("billingservicedb"); + var Shop_BillingServiceResource = builder.AddProject("billing-service") + .WithReference(Shop_BillingServiceDb).WaitFor(Shop_BillingServiceDb) + .WithHttpHealthCheck("/health/ready"); + + """, StringComparison.Ordinal); + Assert.Equal(expectedAppHost, app.Read(app.AppHost)); + + const string referenceAnchor = " "; + Assert.Equal( + projectBefore.Replace(referenceAnchor, + referenceAnchor + "\n ", + StringComparison.Ordinal), + app.Read(app.AppHostProject)); + } + + [Fact] + public void Service_appends_the_exact_smoke_block() + { + using var app = new FakeApp(); + GiveSmokeAnchor(app); + var before = File.ReadAllText(SmokePath(app)); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + Assert.Equal(before.Replace(SmokeAnchorLine, SmokeAnchorLine + "\n" + """ + var Shop_BillingServiceClient = app.CreateHttpClient("billing-service"); + await WaitUntilAsync(async () => + (await Shop_BillingServiceClient.GetAsync("/health/ready", timeout.Token)).IsSuccessStatusCode, timeout.Token); + """, StringComparison.Ordinal), File.ReadAllText(SmokePath(app))); + } + + [Fact] + public void Service_prints_the_exact_three_lines() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(0, result.ExitCode); + Assert.Equal( + "── goldpath new service: Shop.BillingService (billing-service) — its OWN database, its OWN manifest (kind: service)\n" + + " next: build once, then `goldpath db init` commits its first contract to specs/Shop.BillingService.json and generates its Initial migration once it has entities;\n" + + " features still compose on the PRIMARY head — per-service features arrive with the products pilot (platform RFC).\n", + result.Output.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Equal(string.Empty, result.Error); + } + + [Fact] + public void Service_calls_sln_add_then_validate_validate_manifest_drift_in_order() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + var sln = Path.Combine(app.Root, "Shop.sln"); + var csproj = Src(app, "Shop.BillingService", "Shop.BillingService.csproj"); + Assert.Equal(4, runner.Calls.Count); + + Assert.Equal("dotnet", runner.Calls[0].FileName); + Assert.Equal(["sln", sln, "add", csproj], runner.Calls[0].Arguments); + Assert.Equal(app.Root, runner.Calls[0].WorkingDirectory); + + Assert.Equal("validate", runner.Calls[1].Arguments[0]); + Assert.Equal(Path.Combine(".goldpath", "manifest.yaml"), runner.Calls[1].Arguments[1]); + + // The NEW manifest is validated at its own relative path — src//.goldpath/manifest.yaml. + Assert.Equal("validate", runner.Calls[2].Arguments[0]); + Assert.Equal(Path.Combine("src", "Shop.BillingService", ".goldpath", "manifest.yaml"), runner.Calls[2].Arguments[1]); + + Assert.Equal("drift", runner.Calls[3].Arguments[0]); + } + + [Fact] + public void Service_manifest_without_architecture_block_gains_one() + { + using var app = new FakeApp(); + var before = app.Read(app.Manifest); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(before.TrimEnd('\n') + "\narchitecture:\n deploymentModel: microservice\n", app.Read(app.Manifest)); + } + + [Fact] + public void Service_flips_an_existing_deployment_model_in_place() + { + using var app = new FakeApp(); + File.AppendAllText(app.Manifest, "\narchitecture:\n deploymentModel: modular-monolith\n style: vertical-slice\n"); + var before = app.Read(app.Manifest); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(before.Replace("modular-monolith", "microservice", StringComparison.Ordinal), app.Read(app.Manifest)); + } + + [Fact] + public void Service_leaves_a_microservice_manifest_untouched() + { + using var app = new FakeApp(); + File.AppendAllText(app.Manifest, "\narchitecture:\n deploymentModel: microservice\n"); + var before = app.Read(app.Manifest); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(before, app.Read(app.Manifest)); + } + + // ── service: naming edge cases ─────────────────────────────────────────────────── + + [Fact] + public void Api_project_without_the_Api_suffix_keeps_its_whole_name_as_prefix() + { + using var app = new FakeApp(); + File.Move(app.ApiProject, Src(app, "Shop.Api", "Shop.Web.csproj")); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + Assert.True(File.Exists(Src(app, "Shop.Web.BillingService", "Shop.Web.BillingService.csproj"))); + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + Assert.Contains("var Shop_Web_BillingServiceResource = builder.AddProject(\"billing-service\")", + app.Read(app.AppHost), StringComparison.Ordinal); + Assert.Contains("\"applicationUrl\": \"http://localhost:5334\"", + File.ReadAllText(Src(app, "Shop.Web.BillingService", "Properties", "launchSettings.json")), StringComparison.Ordinal); + } + + [Fact] + public void Multi_word_name_derives_kebab_and_db_names() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "order-history").ExitCode); + + var program = File.ReadAllText(Src(app, "Shop.OrderHistoryService", "Program.cs")); + Assert.Contains("GetConnectionString(\"order-historyservicedb\")", program, StringComparison.Ordinal); + Assert.Contains("AddGoldpathData", program, StringComparison.Ordinal); + Assert.Contains("service = \"order-history-service\"", program, StringComparison.Ordinal); + Assert.Contains("boundedContext: order-history\n", + File.ReadAllText(Src(app, "Shop.OrderHistoryService", ".goldpath", "manifest.yaml")), StringComparison.Ordinal); + Assert.Contains("AddDatabase(\"order-historyservicedb\")", app.Read(app.AppHost), StringComparison.Ordinal); + } + + // ── service: refusals ──────────────────────────────────────────────────────────── + + [Fact] + public void Service_refuses_a_non_solution_manifest() + { + using var app = new FakeApp(kind: "service"); + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: this manifest is kind 'service' — service and gateway heads join a SOLUTION's AppHost.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Empty(runner.Calls); + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + } + + [Fact] + public void Gateway_refuses_a_non_solution_manifest() + { + using var app = new FakeApp(kind: "worker"); + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: this manifest is kind 'worker' — service and gateway heads join a SOLUTION's AppHost.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.False(Directory.Exists(Src(app, "Shop.Gateway"))); + } + + [Fact] + public void Service_refuses_when_no_provider_can_be_inferred() + { + using var app = new FakeApp(); + File.WriteAllText(app.ApiProject, """ + + + + + + + """); + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: a service head owns a database, and this app's provider could not be inferred — the api csproj references neither Npgsql.EntityFrameworkCore.PostgreSQL nor Microsoft.EntityFrameworkCore.SqlServer.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + } + + [Fact] + public void Service_refuses_an_existing_project_directory() + { + using var app = new FakeApp(); + var projectDir = Src(app, "Shop.BillingService"); + Directory.CreateDirectory(projectDir); + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(1, result.ExitCode); + Assert.Equal($"goldpath: {projectDir} already exists — pick another name.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Empty(runner.Calls); + Assert.True(Directory.Exists(projectDir)); // the refusal never deletes what it found + } + + [Fact] + public void Gateway_refuses_a_second_gateway() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(1, result.ExitCode); + Assert.Equal($"goldpath: {Src(app, "Shop.Gateway")} already exists — one gateway per solution.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + } + + [Fact] + public void Two_solution_files_are_refused() + { + using var app = new FakeApp(); + File.WriteAllText(Path.Combine(app.Root, "Other.sln"), ""); + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(1, result.ExitCode); + Assert.Equal($"goldpath: 2 .sln files at {app.Root} — exactly one expected.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + } + + [Fact] + public void Other_root_files_do_not_count_as_solutions() + { + // A root README beside the .sln: only *.sln is counted, and only *.cs is scanned for + // the smoke anchor — a markdown note carrying the anchor text is never edited. + using var app = new FakeApp(); + var notes = Path.Combine(app.Root, "NOTES.md"); + File.WriteAllText(notes, "notes\n// goldpath:smoke heads\n"); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal("notes\n// goldpath:smoke heads\n", File.ReadAllText(notes)); + } + + [Fact] + public void Failed_sln_add_fails_loudly_and_restores() + { + using var app = new FakeApp(); + GiveSmokeAnchor(app); + var before = new[] { app.Manifest, app.AppHost, app.AppHostProject, SmokePath(app) } + .ToDictionary(p => p, app.Read, StringComparer.Ordinal); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["sln"] = 1; + + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: dotnet sln add failed — see the output above.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Single(runner.Calls); // the engine never runs on a half-added project + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + foreach (var (path, text) in before) + { + Assert.Equal(text, app.Read(path)); + } + } + + [Fact] + public void Gateway_failed_sln_add_fails_loudly_and_restores() + { + using var app = new FakeApp(); + var before = new[] { app.Manifest, app.AppHost, app.AppHostProject } + .ToDictionary(p => p, app.Read, StringComparer.Ordinal); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["sln"] = 1; + + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: dotnet sln add failed — see the output above.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Single(runner.Calls); + Assert.False(Directory.Exists(Src(app, "Shop.Gateway"))); + foreach (var (path, text) in before) + { + Assert.Equal(text, app.Read(path)); + } + } + + // ── the gate: each engine call alone is enough to refuse ───────────────────────── + + [Theory] + [InlineData("--rules")] // only the app-manifest validate carries --rules + [InlineData("BillingService/.goldpath")] // only the NEW manifest's validate + [InlineData("drift")] + public void Any_single_red_engine_call_refuses_with_the_exact_message(string marker) + { + using var app = new FakeApp(); + GiveSmokeAnchor(app); + var smokeBefore = File.ReadAllText(SmokePath(app)); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain[marker] = 1; + + var result = Run(app, runner, "new", "service", "Billing"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: the engine rejected the result — ALL files restored; nothing half-applied.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Equal(string.Empty, result.Output); + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + Assert.Equal(smokeBefore, File.ReadAllText(SmokePath(app))); // the smoke is in the snapshot too + } + + [Fact] + public void Red_engine_after_the_gateway_restores_the_gateway_appsettings() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + var settingsPath = Src(app, "Shop.Gateway", "appsettings.json"); + var settingsBefore = File.ReadAllText(settingsPath); + var appHostBefore = app.Read(app.AppHost); + + runner.ExitCodeWhenArgumentsContain["drift"] = 1; + Assert.Equal(1, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(settingsBefore, File.ReadAllText(settingsPath)); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + } + + [Fact] + public void Gateway_red_engine_restores_everything_byte_identical() + { + using var app = new FakeApp(); + GiveSmokeAnchor(app); + var before = new[] { app.Manifest, app.AppHost, app.AppHostProject, SmokePath(app), Path.Combine(app.Root, "Shop.sln") } + .ToDictionary(p => p, app.Read, StringComparer.Ordinal); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["validate"] = 1; + + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: the engine rejected the result — ALL files restored; nothing half-applied.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.False(Directory.Exists(Src(app, "Shop.Gateway"))); + foreach (var (path, text) in before) + { + Assert.Equal(text, app.Read(path)); + } + } + + // ── smoke discovery ────────────────────────────────────────────────────────────── + + [Fact] + public void No_smoke_file_is_fine() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + } + + [Fact] + public void Smoke_anchor_under_bin_or_obj_is_ignored() + { + using var app = new FakeApp(); + var binSmoke = Src(app, "Shop.Api", "bin", "Debug", "SmokeTests.cs"); + var objSmoke = Src(app, "Shop.Api", "obj", "Debug", "SmokeTests.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(binSmoke)!); + Directory.CreateDirectory(Path.GetDirectoryName(objSmoke)!); + const string stale = "// goldpath:smoke heads\n"; + File.WriteAllText(binSmoke, stale); + File.WriteAllText(objSmoke, stale); + + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(stale, File.ReadAllText(binSmoke)); + Assert.Equal(stale, File.ReadAllText(objSmoke)); + } + + // ── gateway appsettings discovery ──────────────────────────────────────────────── + + [Fact] + public void Only_a_Gateway_directory_is_a_gateway() + { + // Sibling projects with a YARP-shaped appsettings are NOT the gateway: nothing is edited. + using var app = new FakeApp(); + const string yarp = "{ \"ReverseProxy\": { \"Routes\": { \"x\": {} }, \"Clusters\": { \"x\": {} } } }"; + var apiSettings = Src(app, "Shop.Api", "appsettings.json"); + var hostSettings = Src(app, "Shop.AppHost", "appsettings.json"); + File.WriteAllText(apiSettings, yarp); + File.WriteAllText(hostSettings, yarp); + + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Equal(yarp, File.ReadAllText(apiSettings)); + Assert.Equal(yarp, File.ReadAllText(hostSettings)); + } + + [Fact] + public void A_gateway_directory_without_appsettings_is_not_a_gateway() + { + using var app = new FakeApp(); + Directory.CreateDirectory(Src(app, "Shop.Gateway")); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.False(File.Exists(Src(app, "Shop.Gateway", "appsettings.json"))); + Assert.DoesNotContain("WithReference(Shop_BillingServiceResource)", app.Read(app.AppHost), StringComparison.Ordinal); + } + + [Fact] + public void A_gateway_without_the_references_anchor_gets_the_route_but_no_reference() + { + using var app = new FakeApp(); + Directory.CreateDirectory(Src(app, "Shop.Gateway")); + var settingsPath = Src(app, "Shop.Gateway", "appsettings.json"); + File.WriteAllText(settingsPath, "{ \"ReverseProxy\": { \"Routes\": { \"api\": {} }, \"Clusters\": { \"api\": {} } } }"); + + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + Assert.Contains("\"billing-service\": {", File.ReadAllText(settingsPath), StringComparison.Ordinal); + Assert.DoesNotContain("WithReference(Shop_BillingServiceResource)", app.Read(app.AppHost), StringComparison.Ordinal); + } + + [Fact] + public void Service_after_the_gateway_prepends_exact_route_and_cluster_blocks() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + + Assert.Equal(""" + { + "ReverseProxy": { + "Routes": { + "billing-service": { + "ClusterId": "billing-service", + "Match": { "Path": "/billing-service/{**rest}" }, + "Transforms": [ { "PathRemovePrefix": "/billing-service" } ] + }, + "api": { + "ClusterId": "api", + "Match": { "Path": "/api/{**rest}" }, + "Transforms": [ { "PathRemovePrefix": "/api" } ] + } + }, + "Clusters": { + "billing-service": { + "Destinations": { "head": { "Address": "https+http://billing-service" } } + }, + "api": { + "Destinations": { "head": { "Address": "https+http://api" } } + } + } + } + } + """, File.ReadAllText(Src(app, "Shop.Gateway", "appsettings.json"))); + + Assert.Contains( + " // goldpath:gateway references — services join here (goldpath new service)\n" + + " .WithReference(Shop_BillingServiceResource)\n" + + " .WithHttpHealthCheck(\"/health/ready\");\n", + app.Read(app.AppHost), StringComparison.Ordinal); + } + + [Fact] + public void A_malformed_route_edit_fails_inside_the_guard() + { + // Empty Routes/Clusters objects make the prepended comma dangle: the parse must + // throw BEFORE the write, and the snapshot must put everything back. + using var app = new FakeApp(); + Directory.CreateDirectory(Src(app, "Shop.Gateway")); + var settingsPath = Src(app, "Shop.Gateway", "appsettings.json"); + const string empty = "{ \"ReverseProxy\": { \"Routes\": {}, \"Clusters\": {} } }"; + File.WriteAllText(settingsPath, empty); + var appHostBefore = app.Read(app.AppHost); + + var runner = new FakeProcessRunner(); + Assert.ThrowsAny(() => Run(app, runner, "new", "service", "Billing")); + Assert.Equal(empty, File.ReadAllText(settingsPath)); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + Assert.False(Directory.Exists(Src(app, "Shop.BillingService"))); + } + + // ── gateway: generated files, pinned exactly ───────────────────────────────────── + + [Fact] + public void Gateway_csproj_and_program_are_exact() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + + Assert.Equal(""" + + + + + net10.0 + + + + + + + + + + """, File.ReadAllText(Src(app, "Shop.Gateway", "Shop.Gateway.csproj"))); + + Assert.Equal(""" + using Goldpath; + + // The YARP gateway head (modules: [yarpGateway]): routes /{head}/… to the api and every + // service over Aspire service discovery — configuration, not code (ADR-0003: YARP is + // configured, never wrapped). Routes live in appsettings; goldpath new service appends. + var builder = WebApplication.CreateBuilder(args); + + builder.AddGoldpathServiceDefaults(); + builder.Services.AddServiceDiscovery(); // the resolver below needs the discovery CORE (config provider) + builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddServiceDiscoveryDestinationResolver(); + + var app = builder.Build(); + + app.MapGoldpathDefaultEndpoints(); + app.MapReverseProxy(); + + app.Run(); + """, File.ReadAllText(Src(app, "Shop.Gateway", "Program.cs"))); + } + + [Fact] + public void Gateway_launch_settings_and_manifest_are_exact() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + + // 5300 + |Σ c*31| % 200 for "Shop.Gateway". + Assert.Equal(""" + { + "profiles": { + "Shop.Gateway": { + "commandName": "Project", + "applicationUrl": "http://localhost:5418" + } + } + } + """, File.ReadAllText(Src(app, "Shop.Gateway", "Properties", "launchSettings.json"))); + + Assert.Equal(""" + schemaVersion: 1 + kind: gateway + name: Shop.Gateway + description: YARP gateway — routes /{head}/… to the api and every service + owner: platform-team + autoRegisterServices: true + """, File.ReadAllText(Src(app, "Shop.Gateway", ".goldpath", "manifest.yaml"))); + } + + [Fact] + public void Gateway_after_a_service_routes_both_heads_exactly() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "service", "Billing").ExitCode); + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(0, result.ExitCode); + + Assert.Equal(""" + { + "ReverseProxy": { + "Routes": { + "api": { + "ClusterId": "api", + "Match": { "Path": "/api/{**rest}" }, + "Transforms": [ { "PathRemovePrefix": "/api" } ] + }, + "billing-service": { + "ClusterId": "billing-service", + "Match": { "Path": "/billing-service/{**rest}" }, + "Transforms": [ { "PathRemovePrefix": "/billing-service" } ] + } + }, + "Clusters": { + "api": { + "Destinations": { "head": { "Address": "https+http://api" } } + }, + "billing-service": { + "Destinations": { "head": { "Address": "https+http://billing-service" } } + } + } + } + } + """, File.ReadAllText(Src(app, "Shop.Gateway", "appsettings.json"))); + + // Composed LAST: every reference by its named variable, just above Build().Run(). + Assert.EndsWith(""" + builder.AddProject("gateway") + .WithReference(api) + .WithReference(Shop_BillingServiceResource) + // goldpath:gateway references — services join here (goldpath new service) + .WithHttpHealthCheck("/health/ready"); + builder.Build().Run(); + """, app.Read(app.AppHost), StringComparison.Ordinal); + Assert.StartsWith("var builder = DistributedApplication.CreateBuilder(args);", app.Read(app.AppHost), StringComparison.Ordinal); + + Assert.Equal( + "── goldpath new gateway: Shop.Gateway — YARP over Aspire service discovery; routes /{head}/… for: api, billing-service\n" + + " new services register their route automatically (goldpath new service edits the gateway's appsettings).\n", + result.Output.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + } + + [Fact] + public void Gateway_wires_its_csproj_reference_and_calls_the_engine_on_its_manifest() + { + using var app = new FakeApp(); + var projectBefore = app.Read(app.AppHostProject); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + + const string referenceAnchor = " "; + Assert.Equal( + projectBefore.Replace(referenceAnchor, + referenceAnchor + "\n ", + StringComparison.Ordinal), + app.Read(app.AppHostProject)); + + Assert.Equal(4, runner.Calls.Count); + Assert.Equal("dotnet", runner.Calls[0].FileName); + Assert.Equal(["sln", Path.Combine(app.Root, "Shop.sln"), "add", Src(app, "Shop.Gateway", "Shop.Gateway.csproj")], runner.Calls[0].Arguments); + Assert.Equal(app.Root, runner.Calls[0].WorkingDirectory); + Assert.Equal("validate", runner.Calls[2].Arguments[0]); + Assert.Equal(Path.Combine("src", "Shop.Gateway", ".goldpath", "manifest.yaml"), runner.Calls[2].Arguments[1]); + Assert.Equal("drift", runner.Calls[3].Arguments[0]); + } + + [Fact] + public void Gateway_appends_the_exact_smoke_block_with_the_routed_probe() + { + using var app = new FakeApp(); + GiveSmokeAnchor(app); + var before = File.ReadAllText(SmokePath(app)); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + + Assert.Equal(before.Replace(SmokeAnchorLine, SmokeAnchorLine + "\n" + """ + var gatewayClient = app.CreateHttpClient("gateway"); + await WaitUntilAsync(async () => + (await gatewayClient.GetAsync("/health/ready", timeout.Token)).IsSuccessStatusCode, timeout.Token); + // Routed THROUGH the head: a 2xx here is the whole chain answering. + Assert.True((await gatewayClient.GetAsync("/api/health/ready", timeout.Token)).IsSuccessStatusCode); + """, StringComparison.Ordinal), File.ReadAllText(SmokePath(app))); + } + + [Fact] + public void Gateway_routes_only_the_api_and_service_heads() + { + using var app = new FakeApp(); + File.WriteAllText(app.AppHost, app.Read(app.AppHost).Replace( + "// goldpath:workers — additional worker projects wire here (goldpath add worker)", + "// goldpath:workers — additional worker projects wire here (goldpath add worker)\n" + + "var eod = builder.AddProject(\"eod-worker\");\n", + StringComparison.Ordinal)); + + var runner = new FakeProcessRunner(); + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(0, result.ExitCode); + var settings = File.ReadAllText(Src(app, "Shop.Gateway", "appsettings.json")); + Assert.DoesNotContain("eod-worker", settings, StringComparison.Ordinal); + Assert.Contains("\"api\": {\n \"ClusterId\": \"api\",", settings, StringComparison.Ordinal); + Assert.Contains("for: api\n", result.Output, StringComparison.Ordinal); + Assert.DoesNotContain("WithReference(eod)", app.Read(app.AppHost), StringComparison.Ordinal); + } + + [Fact] + public void Gateway_manifest_normalises_crlf_and_declares_the_module_once() + { + using var app = new FakeApp(); + var crlf = app.Read(app.Manifest).Replace("\n", "\r\n", StringComparison.Ordinal) + "\r\n"; + File.WriteAllText(app.Manifest, crlf); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + Assert.Equal(crlf.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n') + "\nmodules: [yarpGateway]\n", app.Read(app.Manifest)); + } + + [Fact] + public void Gateway_leaves_a_manifest_that_already_declares_yarpGateway_untouched() + { + using var app = new FakeApp(); + File.AppendAllText(app.Manifest, "\nmodules: [yarpGateway]\n"); + var before = app.Read(app.Manifest); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + Assert.Equal(before, app.Read(app.Manifest)); + } + + [Fact] + public void Gateway_needs_the_build_run_line_to_land_last() + { + using var app = new FakeApp(); + var appHostBefore = app.Read(app.AppHost).Replace("builder.Build().Run();", "await builder.Build().RunAsync();", StringComparison.Ordinal); + File.WriteAllText(app.AppHost, appHostBefore); + var runner = new FakeProcessRunner(); + + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: the AppHost has no 'builder.Build().Run();' line — cannot place the gateway last.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + Assert.False(Directory.Exists(Src(app, "Shop.Gateway"))); + } + + [Fact] + public void Gateway_refuses_a_service_head_without_a_named_variable() + { + using var app = new FakeApp(); + var appHostBefore = app.Read(app.AppHost).Replace( + "// goldpath:workers — additional worker projects wire here (goldpath add worker)", + "// goldpath:workers — additional worker projects wire here (goldpath add worker)\n" + + "builder.AddProject(\"orders-service\");\n", + StringComparison.Ordinal); + File.WriteAllText(app.AppHost, appHostBefore); + var runner = new FakeProcessRunner(); + + var result = Run(app, runner, "new", "gateway"); + Assert.Equal(1, result.ExitCode); + Assert.Equal("goldpath: the 'orders-service' head has no named resource variable in the AppHost — regenerate the service with a current goldpath, or name the chain's variable.\n", + result.Error.Replace(Environment.NewLine, "\n", StringComparison.Ordinal)); + Assert.Equal(appHostBefore, app.Read(app.AppHost)); + Assert.False(Directory.Exists(Src(app, "Shop.Gateway"))); + } + + [Fact] + public void Gateway_references_the_api_by_its_literal_name_even_when_the_chain_is_not_renamed() + { + // The api handle is `api` by CONTRACT: a chain the regex cannot rename (no _Api + // project suffix) still gets `.WithReference(api)` — never a HeadVar lookup. + using var app = new FakeApp(); + File.WriteAllText(app.AppHost, app.Read(app.AppHost).Replace("Projects.Shop_Api", "Projects.Shop_Web", StringComparison.Ordinal)); + var runner = new FakeProcessRunner(); + Assert.Equal(0, Run(app, runner, "new", "gateway").ExitCode); + var appHost = app.Read(app.AppHost); + Assert.Contains("\nbuilder.AddProject(\"api\")\n", appHost, StringComparison.Ordinal); + Assert.Contains("builder.AddProject(\"gateway\")\n .WithReference(api)\n", appHost, StringComparison.Ordinal); + } +} diff --git a/tests/Goldpath.Cli.Tests/WizardDiscoverRunnerMutationTests.cs b/tests/Goldpath.Cli.Tests/WizardDiscoverRunnerMutationTests.cs new file mode 100644 index 0000000..824ef23 --- /dev/null +++ b/tests/Goldpath.Cli.Tests/WizardDiscoverRunnerMutationTests.cs @@ -0,0 +1,677 @@ +using Xunit; + +namespace Goldpath.Cli.Tests; + +/// +/// Records every question the wizard asks (text, choices, default) and answers from a +/// script — so the prompts themselves are under test, not just the derivation. +/// +internal sealed class RecordingPrompter( + string name = "Shop", + string database = "postgresql", + string auth = "openid", + string layout = "vertical-slice", + IReadOnlyList? features = null, + bool outbox = false, + bool generate = true) : IPrompter +{ + public List Inputs { get; } = []; + public List<(string Question, IReadOnlyList Choices, string Default)> Chooses { get; } = []; + public List<(string Question, IReadOnlyList Choices)> ChooseManys { get; } = []; + public List<(string Question, bool Default)> Confirms { get; } = []; + + public string Choose(string question, IReadOnlyList choices, string defaultChoice) + { + Chooses.Add((question, choices, defaultChoice)); + return Chooses.Count switch { 1 => database, 2 => auth, _ => layout }; + } + + public IReadOnlyList ChooseMany(string question, IReadOnlyList choices) + { + ChooseManys.Add((question, choices)); + return features ?? []; + } + + public bool Confirm(string question, bool defaultAnswer) + { + Confirms.Add((question, defaultAnswer)); + return Confirms.Count == 1 ? outbox : generate; + } + + public string Input(string question) + { + Inputs.Add(question); + return name; + } +} + +/// +/// Mutation-gate tests: every line below pins an EXACT value (argument list, prompt text, +/// output line, exit code) so a mutant that silences, garbles or flips it dies. +/// +public class WizardMutationTests +{ + private static WizardCommand.Plan Derive(IReadOnlyList? features = null, bool outbox = false, + string db = "postgresql", string auth = "openid", string layout = "vertical-slice") + => WizardCommand.Derive(new WizardCommand.Answers("Shop", db, auth, layout, features ?? [], outbox)); + + [Fact] + public void The_walking_skeleton_derives_exactly_db_auth_and_no_broker() + { + var plan = Derive(); + + Assert.Equal(["--db", "postgresql", "--auth", "openid", "--broker", "none"], plan.Arguments); + Assert.Equal( + [ + "database: postgresql — every shape owns one", + "auth: openid", + "layout: vertical-slice", + "no broker needed — removed (nothing you chose publishes through one)", + "no Redis — removed (only the caching module brings it)", + "modules: none — the walking skeleton only", + ], + plan.Notes); + } + + [Fact] + public void Every_answer_maps_to_its_argument_in_a_fixed_order() + { + var plan = Derive(features: ["campaign", "caching", "idempotency", "archival"], db: "sqlserver", auth: "none", layout: "clean-architecture"); + + Assert.Equal( + [ + "--db", "sqlserver", "--auth", "none", "--layout", "clean-architecture", + "--features", "campaign", "--features", "caching", "--features", "idempotency", "--features", "archival", + ], + plan.Arguments); + Assert.Equal( + [ + "database: sqlserver — every shape owns one", + "auth: none — admin surfaces opt out VISIBLY; acceptable only behind an authenticating boundary", + "layout: clean-architecture", + "broker: rabbitmq — campaign REQUIRES one (the release path IS broker fan-out, RFC D8)", + "redis joins — caching is its only source (HybridCache L1+L2)", + "jobs scheduler + the operations console ride the app database (campaign, archival)", + "modules: campaign, caching, idempotency, archival", + ], + plan.Notes); + } + + [Fact] + public void The_outbox_keeps_the_broker_and_idempotency_without_caching_learns_its_fallback() + { + var plan = Derive(features: ["idempotency"], outbox: true); + + Assert.Equal(["--db", "postgresql", "--auth", "openid", "--features", "idempotency"], plan.Arguments); + Assert.Equal( + [ + "database: postgresql — every shape owns one", + "auth: openid", + "layout: vertical-slice", + "broker: rabbitmq — the outbox publishes THROUGH a broker", + "no Redis — removed (only the caching module brings it)", + "idempotency stores keys in a memory cache — enable caching for Redis-backed keys", + "modules: idempotency", + ], + plan.Notes); + } + + [Theory] + [InlineData("archival")] + [InlineData("bulk")] + [InlineData("notification")] + [InlineData("campaign")] + public void Each_jobs_rider_brings_the_scheduler_note(string rider) + { + var plan = Derive(features: [rider]); + Assert.Contains($"jobs scheduler + the operations console ride the app database ({rider})", plan.Notes); + } + + [Theory] + [InlineData("multitenancy")] + [InlineData("caching")] + [InlineData("softdelete")] + public void Non_riders_bring_no_scheduler_note(string module) + { + var plan = Derive(features: [module]); + Assert.DoesNotContain(plan.Notes, n => n.StartsWith("jobs scheduler", StringComparison.Ordinal)); + Assert.Contains($"modules: {module}", plan.Notes); + } + + [Fact] + public void Duplicate_and_unknown_modules_collapse_before_the_generator() + { + var plan = Derive(features: ["bulk", "bulk", "blockchain"]); + Assert.Equal(["--db", "postgresql", "--auth", "openid", "--broker", "none", "--features", "bulk"], plan.Arguments); + Assert.Contains("modules: bulk", plan.Notes); + } + + [Fact] + public void The_module_menu_is_the_canonical_recipe_list() + { + Assert.Equal(FeatureRecipes.Names, WizardCommand.Modules); + } + + [Fact] + public void The_wizard_asks_exactly_these_questions_with_these_defaults() + { + var prompter = new RecordingPrompter(generate: false); + + WizardCommand.Run(prompter, new FakeProcessRunner(), TextWriter.Null, TextWriter.Null); + + Assert.Equal(["Solution name (e.g. OrderPlatform)"], prompter.Inputs); + Assert.Equal(3, prompter.Chooses.Count); + Assert.Equal("Database", prompter.Chooses[0].Question); + Assert.Equal(["postgresql", "sqlserver"], prompter.Chooses[0].Choices); + Assert.Equal("postgresql", prompter.Chooses[0].Default); + Assert.Equal("Authentication", prompter.Chooses[1].Question); + Assert.Equal(["openid", "apikey", "none"], prompter.Chooses[1].Choices); + Assert.Equal("openid", prompter.Chooses[1].Default); + Assert.Equal("Code layout", prompter.Chooses[2].Question); + Assert.Equal(["vertical-slice", "clean-architecture"], prompter.Chooses[2].Choices); + Assert.Equal("vertical-slice", prompter.Chooses[2].Default); + var modules = Assert.Single(prompter.ChooseManys); + Assert.Equal("Which modules does this app need?", modules.Question); + Assert.Equal(WizardCommand.Modules, modules.Choices); + Assert.Equal( + [("Will it publish integration events to other systems (outbox)?", false), ("Generate?", true)], + prompter.Confirms); + } + + [Fact] + public void Declining_prints_the_derived_shape_and_the_equivalent_command_verbatim() + { + var output = new StringWriter { NewLine = "\n" }; + var runner = new FakeProcessRunner(); + + var exit = WizardCommand.Run(new RecordingPrompter(name: " Shop ", generate: false), runner, output, TextWriter.Null); + + Assert.Equal(0, exit); + Assert.Empty(runner.Calls); + // The name is trimmed; the command line is what the user would type by hand. + Assert.Equal( + "── goldpath new (wizard): say what the app DOES — the infrastructure is derived, with reasons.\n" + + "\n" + + "── the derived shape:\n" + + " database: postgresql — every shape owns one\n" + + " auth: openid\n" + + " layout: vertical-slice\n" + + " no broker needed — removed (nothing you chose publishes through one)\n" + + " no Redis — removed (only the caching module brings it)\n" + + " modules: none — the walking skeleton only\n" + + "\n" + + " equivalent command: goldpath new solution -n Shop --db postgresql --auth openid --broker none\n" + + "── nothing generated.\n", + output.ToString()); + } + + [Fact] + public void Accepting_hands_the_exact_argument_list_to_dotnet_new() + { + var output = new StringWriter { NewLine = "\n" }; + var runner = new FakeProcessRunner(); + + var exit = WizardCommand.Run( + new RecordingPrompter(name: "Shop", auth: "apikey", layout: "clean-architecture", features: ["caching"], outbox: true), + runner, output, TextWriter.Null); + + Assert.Equal(0, exit); + Assert.Equal("dotnet", runner.Calls[0].FileName); + Assert.Equal( + ["new", "goldpath-solution", "-n", "Shop", "--db", "postgresql", "--auth", "apikey", "--layout", "clean-architecture", "--features", "caching"], + runner.Calls[0].Arguments); + Assert.Contains(" equivalent command: goldpath new solution -n Shop --db postgresql --auth apikey --layout clean-architecture --features caching\n", output.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("nothing generated", output.ToString(), StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void A_blank_name_is_a_usage_error_before_any_other_question(string blank) + { + var prompter = new RecordingPrompter(name: blank); + + var exception = Assert.Throws( + () => WizardCommand.Run(prompter, new FakeProcessRunner(), TextWriter.Null, TextWriter.Null)); + + Assert.Equal("the wizard needs a solution name.", exception.Message); + Assert.Empty(prompter.Chooses); + } +} + +public class DiscoverMutationTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), $"goldpath-discover-mut-{Guid.NewGuid():N}"); + + public DiscoverMutationTests() => Directory.CreateDirectory(_root); + + public void Dispose() => Directory.Delete(_root, recursive: true); + + private string Manifest(string relativeSolutionDir, string body) + { + var dir = Path.Combine(_root, relativeSolutionDir, ".goldpath"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "manifest.yaml"); + File.WriteAllText(path, body); + return path; + } + + private (int Exit, string[] Lines, string Error) Run(params string[] rest) + { + var output = new StringWriter { NewLine = "\n" }; + var error = new StringWriter { NewLine = "\n" }; + var exit = CliRunner.Run(["discover", .. rest], new FakeProcessRunner(), output, error); + return (exit, output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries), error.ToString()); + } + + private static string Rel(params string[] parts) => Path.Combine(parts); + + [Fact] + public void Lines_are_ordinal_sorted_and_say_exactly_what_each_manifest_declares() + { + Manifest(Rel("apps", "zeta"), "kind: solution\nname: Zeta\nproducts:\n - name: qorpe.apiPortal\n enabled: true\n - name: \"qorpe.billing\"\n"); + Manifest(Rel("apps", "alpha"), "kind: worker\nname: 'Alpha'\n"); + Manifest("", "kind: solution\nname: Root\n"); + + var (exit, lines, error) = Run("--path", _root); + + Assert.Equal(0, exit); + Assert.Empty(error); + Assert.Equal( + [ + $". kind=solution name=Root", + $"{Rel("apps", "alpha")} kind=worker name=Alpha", + $"{Rel("apps", "zeta")} kind=solution name=Zeta products=qorpe.apiPortal,qorpe.billing", + $"── 3 manifest(s) under {_root}", + ], + lines); + } + + [Fact] + public void A_manifest_that_declares_nothing_reads_as_question_marks() + { + Manifest("bare", ""); + + var (_, lines, _) = Run("--path", _root); + + Assert.Equal($"bare kind=? name=?", lines[0]); + } + + [Fact] + public void Products_end_at_the_next_top_level_key_and_accept_bare_name_items() + { + // `name:` AFTER the products array is the solution's name, never a product; + // an un-indented `- name:` item is still an item; a bare `name:` line inside + // the array (a folded item) still counts. + Manifest("a", "kind: solution\nproducts:\n- name: one\n- name: two\nname: After\n"); + Manifest("b", "kind: solution\nname: B\nproducts:\n -\n name: folded\n"); + + var (_, lines, _) = Run("--path", _root); + + Assert.Equal("a kind=solution name=After products=one,two", lines[0]); + Assert.Equal("b kind=solution name=B products=folded", lines[1]); + } + + [Fact] + public void Every_vendor_and_build_directory_is_skipped_by_exact_name() + { + foreach (var skipped in new[] { "node_modules", "bin", "obj", ".git", ".vs", "dist", "coverage", "TestResults" }) + { + Manifest(Rel(skipped, "inside"), $"kind: solution\nname: In{skipped}\n"); + } + + Manifest("keeper", "kind: solution\nname: Keeper\n"); + + var (_, lines, _) = Run("--path", _root); + + Assert.Equal(["keeper kind=solution name=Keeper", $"── 1 manifest(s) under {_root}"], lines); + } + + [Fact] + public void An_empty_tree_names_the_root_it_searched() + { + var (exit, lines, _) = Run("--path", _root); + + Assert.Equal(0, exit); + Assert.Equal([$"── no Goldpath manifests under {_root}"], lines); + } + + [Fact] + public void No_path_means_the_current_directory() + { + var (exit, lines, _) = Run(); + + Assert.Equal(0, exit); + Assert.EndsWith($" under {Directory.GetCurrentDirectory()}", lines[^1], StringComparison.Ordinal); + } + + [Fact] + public void A_missing_directory_is_a_usage_error_naming_the_full_path() + { + var missing = Path.Combine(_root, "nowhere"); + + var (exit, lines, error) = Run("--path", missing); + + Assert.Equal(2, exit); + Assert.Empty(lines); + Assert.Equal($"goldpath: no such directory: {missing}\n", error); + } + + [Fact] + public void An_unreadable_manifest_is_reported_on_stderr_and_still_counted() + { + var path = Manifest("locked", "kind: solution\nname: Locked\n"); + // An exclusive handle makes ReadAllText fail with an IOException on every OS .NET + // enforces FileShare on — the inventory must go on, not die on one bad file. + using var handle = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var (exit, lines, error) = Run("--path", _root); + + Assert.Equal(0, exit); + Assert.StartsWith($"goldpath: could not read {path} — ", error, StringComparison.Ordinal); + Assert.Equal(["locked kind=? name=?", $"── 1 manifest(s) under {_root}"], lines); + } +} + +public class CliRunnerMutationTests +{ + private static (int Exit, string Out, string Err) Run(FakeProcessRunner runner, params string[] args) + { + var output = new StringWriter { NewLine = "\n" }; + var error = new StringWriter { NewLine = "\n" }; + var exit = CliRunner.Run(args, runner, output, error); + return (exit, output.ToString(), error.ToString()); + } + + private static (int Exit, string Out, string Err) Run(params string[] args) => Run(new FakeProcessRunner(), args); + + /// Runs with an empty console so ConsolePrompter answers every question with its default. + private static T WithEmptyConsole(Func body) + { + var stdin = Console.In; + var stdout = Console.Out; + Console.SetIn(new StringReader("")); + Console.SetOut(TextWriter.Null); + try + { + return body(); + } + finally + { + Console.SetIn(stdin); + Console.SetOut(stdout); + } + } + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + [InlineData("help")] + public void Help_prints_the_full_usage_on_stdout(string verb) + { + var (exit, output, error) = Run(verb); + + Assert.Equal(0, exit); + Assert.Empty(error); + Assert.StartsWith("goldpath — the Goldpath golden-path CLI (thin and deterministic)\n", output, StringComparison.Ordinal); + Assert.Contains(" goldpath add feature [--path ] wire a Ring B feature into an existing app\n", output, StringComparison.Ordinal); + Assert.Contains(" goldpath --help | --version\n", output, StringComparison.Ordinal); + Assert.EndsWith("features: multitenancy, audittrail, softdelete, idempotency, dataprotection, caching, locking, archival, bulk, notification, campaign\n", output, StringComparison.Ordinal); + } + + [Theory] + [InlineData("--version")] + [InlineData("-v")] + public void Version_is_the_informational_version_without_build_metadata(string verb) + { + var expected = typeof(CliRunner).Assembly + .GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .Single().InformationalVersion.Split('+')[0]; + + var (exit, output, error) = Run(verb); + + Assert.Equal(0, exit); + Assert.Empty(error); + Assert.Equal($"{expected}\n", output); + } + + [Theory] + [InlineData("frobnicate")] + [InlineData("add")] + [InlineData("add", "feature")] + [InlineData("add", "worker")] + [InlineData("db")] + [InlineData("export")] + [InlineData("export", "image")] + public void An_unknown_shape_prints_usage_on_stderr_and_exits_2(params string[] args) + { + var (exit, output, error) = Run(args); + + Assert.Equal(2, exit); + Assert.Empty(output); + Assert.StartsWith("goldpath — the Goldpath golden-path CLI", error, StringComparison.Ordinal); + Assert.Contains("usage:", error, StringComparison.Ordinal); + } + + [Fact] + public void Bare_new_is_the_wizard_not_a_usage_error() + { + // ConsolePrompter on an empty stdin yields no name: the WIZARD refuses, with its + // own message — proof the verb reached it rather than falling through to usage. + var (exit, output, error) = WithEmptyConsole(() => Run("new")); + + Assert.Equal(2, exit); + Assert.StartsWith("── goldpath new (wizard): say what the app DOES", output, StringComparison.Ordinal); + Assert.Equal("goldpath: the wizard needs a solution name.\n", error); + } + + [Fact] + public void Init_reaches_the_init_command_and_attaches_with_defaults() + { + var root = Path.Combine(Path.GetTempPath(), $"goldpath-init-mut-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + File.WriteAllText(Path.Combine(root, "Legacy.sln"), ""); + try + { + var runner = new FakeProcessRunner(); + var (exit, _, error) = WithEmptyConsole(() => Run(runner, "init", "--path", root)); + + Assert.Equal(0, exit); + Assert.Empty(error); + Assert.True(File.Exists(Path.Combine(root, ".goldpath", "manifest.yaml"))); + Assert.Contains(runner.Calls, c => c.Arguments.Contains("validate")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Theory] + [InlineData("init")] + [InlineData("check")] + [InlineData("discover")] + [InlineData("export", "compose")] + [InlineData("add", "feature", "caching")] + [InlineData("db", "status")] + public void Only_path_is_understood_after_these_verbs(params string[] verb) + { + var (exit, output, error) = Run([.. verb, "--frobnicate", "x"]); + + Assert.Equal(2, exit); + Assert.Empty(output); + Assert.Equal("goldpath: unexpected arguments: --frobnicate x (only --path is understood here)\n", error); + } + + [Fact] + public void No_path_means_the_current_directory_for_add_feature() + { + var (exit, _, error) = Run("add", "feature", "caching"); + + Assert.Equal(1, exit); + Assert.Equal( + $"goldpath: no manifest at {Path.Combine(".", ".goldpath", "manifest.yaml")} — goldpath add runs inside a Goldpath-generated app (or pass --path).\n", + error); + } + + [Fact] + public void Add_feature_dispatches_the_name_and_path_verbatim() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + + var (exit, output, _) = Run(runner, "add", "feature", "softdelete", "--path", app.Root); + + Assert.Equal(0, exit); + Assert.Contains("goldpath: 'softdelete' wired", output, StringComparison.Ordinal); + Assert.All(runner.Calls, c => Assert.Equal(app.Root, c.WorkingDirectory)); + } + + [Fact] + public void Db_add_and_bundle_take_a_name_before_path() + { + using var app = new FakeApp(); + var runner = new FakeProcessRunner(); + runner.ExitCodeWhenArgumentsContain["has-pending-model-changes"] = 1; // a model change is pending + + var (exit, output, _) = Run(runner, "db", "add", "add-thing", "--path", app.Root); + + Assert.Equal(0, exit); + Assert.Contains("── goldpath db add: 'AddThing' for ", output, StringComparison.Ordinal); + Assert.Contains(runner.Calls, c => c.Arguments.Contains("AddThing")); + + var bundleRunner = new FakeProcessRunner(); + var (bundleExit, _, bundleError) = Run(bundleRunner, "db", "bundle", "out", "--path", app.Root); + + Assert.Equal(0, bundleExit); + Assert.Empty(bundleError); + Assert.Contains(bundleRunner.Calls, c => c.Arguments.Contains("bundle")); + } + + [Fact] + public void Db_add_without_a_name_teaches_the_shape() + { + using var app = new FakeApp(); + + var (exit, _, error) = Run("db", "add", "--path", app.Root); + + Assert.Equal(2, exit); + Assert.Equal("goldpath: goldpath db add needs a name: goldpath db add \n", error); + } + + [Fact] + public void Db_init_and_status_never_swallow_a_stray_token_as_a_name() + { + using var app = new FakeApp(); + + foreach (var verb in new[] { "init", "status" }) + { + var (exit, _, error) = Run("db", verb, "stray", "--path", app.Root); + + Assert.Equal(2, exit); + Assert.Equal($"goldpath: unexpected arguments: stray --path {app.Root} (only --path is understood here)\n", error); + } + } + + [Theory] + [InlineData("add")] + [InlineData("bundle")] + public void Db_add_and_bundle_with_nothing_after_them_run_against_the_current_directory(string verb) + { + // The test process runs from its bin directory: no migration owner there, so the + // command teaches — the point is that an EMPTY rest never indexes rest[0]. + var (exit, _, error) = Run("db", verb); + + Assert.Equal(1, exit); + Assert.StartsWith("goldpath: no migration owner found", error, StringComparison.Ordinal); + } + + [Fact] + public void Add_worker_defaults_to_a_queue_trigger() + { + using var app = new FakeApp(messagingWired: true); + + var (exit, _, error) = Run("add", "worker", "payments", "--path", app.Root); + + Assert.Equal(0, exit); + Assert.Empty(error); + Assert.True(File.Exists(Path.Combine(app.Root, "src", "Shop.PaymentsWorker", "WorkItems", "WorkItemQueuedConsumer.cs"))); + } + + [Fact] + public void Add_worker_defaults_to_the_current_directory() + { + var (exit, _, error) = Run("add", "worker", "payments", "--trigger", "schedule"); + + Assert.Equal(1, exit); + Assert.Equal( + $"goldpath: no manifest at {Path.Combine(".", ".goldpath", "manifest.yaml")} — goldpath add runs inside a Goldpath-generated app (or pass --path).\n", + error); + } + + [Fact] + public void Add_worker_reads_trigger_and_path_in_either_order() + { + using var app = new FakeApp(); + + var (exit, _, error) = Run("add", "worker", "eod-report", "--path", app.Root, "--trigger", "jobs"); + + Assert.Equal(0, exit); + Assert.Empty(error); + Assert.True(File.Exists(Path.Combine(app.Root, "src", "Shop.EodReportWorker", "Reports", "NightlyReportJob.cs"))); + } + + [Theory] + [InlineData("--trigger")] + [InlineData("--path")] + [InlineData("--frobnicate", "x")] + [InlineData("--trigger", "jobs", "--path")] + public void Add_worker_refuses_a_dangling_or_unknown_flag(params string[] rest) + { + var (exit, output, error) = Run(["add", "worker", "payments", .. rest]); + + Assert.Equal(2, exit); + Assert.Empty(output); + Assert.Equal($"goldpath: unexpected arguments: {string.Join(' ', rest)} (only --trigger and --path are understood here)\n", error); + } + + [Fact] + public void Add_worker_with_an_unknown_trigger_exits_2() + { + var (exit, _, error) = Run("add", "worker", "payments", "--trigger", "cron"); + + Assert.Equal(2, exit); + Assert.Equal("goldpath: unknown trigger 'cron' — one of: queue, schedule, jobs\n", error); + } + + [Fact] + public void New_passes_every_template_argument_through_in_order() + { + var runner = new FakeProcessRunner(); + var outDir = Path.Combine(Path.GetTempPath(), $"goldpath-new-mut-{Guid.NewGuid():N}"); + Directory.CreateDirectory(outDir); // db init scans -o for owners; keep it empty and ours + try + { + var (exit, _, _) = Run(runner, "new", "worker", "-n", "Billing.Nightly", "--trigger", "schedule", "-o", outDir); + + Assert.Equal(0, exit); + Assert.Equal(["new", "goldpath-worker", "-n", "Billing.Nightly", "--trigger", "schedule", "-o", outDir], runner.Calls[0].Arguments); + } + finally + { + Directory.Delete(outDir, recursive: true); + } + } + + [Fact] + public void A_failure_exception_exits_1_with_the_message_prefixed() + { + var (exit, output, error) = Run("export", "compose", "--path", Path.GetTempPath()); + + Assert.Equal(1, exit); + Assert.Empty(output); + Assert.StartsWith("goldpath: ", error, StringComparison.Ordinal); + Assert.EndsWith("\n", error, StringComparison.Ordinal); + } +}