Files
GreenHomeBack/src/Test/GreenHome.SelfCheck/Program.cs
alireza e98b48bfba feat: device reset via RST token + Postgres code-first migrations
Device reset: Device.ResetRequested is a one-shot flag claimed atomically with a single UPDATE, so a queued reset is delivered exactly once. GET api/telemetry/AddData answers it with the bare token RST and nothing else; subsequent requests resume the normal routine. Adds POST api/admin/devices/{id}/request-reset (audited) plus an admin panel button, pending badge and confirm dialog.

Postgres migrations: PostgresGreenHomeDbContext owns Data/Migrations/Postgres while the SqlServer set stays untouched. Registers one context per provider with the base type forwarded, replaces EnsureCreated() with Migrate() for both, adds a complete InitialPostgres migration and an incremental AddDeviceResetRequested for SqlServer, and adds design-time factories so dotnet ef does not have to boot the API host.

Timestamps: Postgres DateTime columns are now timestamp with time zone, because Npgsql rejects Kind=Utc values on a naive timestamp column, which broke every write path (auth, settings, posts, checklists, alerts, audit logs). Removes the SpecifyKind(Unspecified) workarounds, fixes a 3:30 offset where already-UTC filters were passed through ToLocalTime(), and makes DataCopy migrate instead of EnsureCreated while passing timestamps through verbatim.

Tests: Test/GreenHome.SelfCheck applies the Postgres migrations and asserts that a queued reset is consumed exactly once and that timestamps round-trip as Utc.
2026-10-17 16:20:52 +03:30

91 lines
3.5 KiB
C#

// Runnable self-check for the device-reset feature and the Postgres migration set.
//
// set POSTGRES_CONNECTION=... (optional; defaults to the local dev database)
// dotnet run --project Test/GreenHome.SelfCheck
//
// It (1) applies the Postgres migrations to the target database — proving the new
// Data/Migrations/Postgres set is complete — and (2) asserts that a queued reset is
// consumed exactly once, which is the whole guarantee behind "RST goes out on the
// next request only, never again".
using AutoMapper;
using GreenHome.Application;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Services.Telemetry;
using Microsoft.EntityFrameworkCore;
var connectionString = Environment.GetEnvironmentVariable("POSTGRES_CONNECTION")
?? "Host=localhost;Port=5432;Database=greenhome;Username=greenhome;Password=greenhome";
Console.WriteLine($"Target: {connectionString}");
var options = new DbContextOptionsBuilder<PostgresGreenHomeDbContext>()
.UseNpgsql(connectionString)
.Options;
await using var db = new PostgresGreenHomeDbContext(options);
// --reset-db wipes the target schema and lets Migrate() rebuild it from Data/Migrations/Postgres
// — the one-time step needed because EnsureCreated() left tables behind with no migration history.
if (args.Contains("--reset-db"))
{
Console.WriteLine("--reset-db -> dropping schema public");
await db.Database.ExecuteSqlRawAsync("DROP SCHEMA public CASCADE; CREATE SCHEMA public;");
}
await db.Database.MigrateAsync();
var applied = await db.Database.GetAppliedMigrationsAsync();
Console.WriteLine($"Migrations applied ({applied.Count()}): {string.Join(", ", applied.Select(m => m.Split('_').Last()))}");
var mapper = new MapperConfiguration(cfg => cfg.AddProfile<MappingProfile>()).CreateMapper();
var telemetry = new TelemetryService(db, mapper);
// throwaway owner + device so the check is self-contained.
// CreatedAt is deliberately left at its Domain default (DateTime.UtcNow): writing a Kind=Utc value
// must work now that the columns are timestamptz. That line is the regression test for the
// "Cannot write DateTime with Kind=UTC to PostgreSQL type 'timestamp without time zone'" bug.
var user = new User { Mobile = "0" + DateTime.UtcNow.Ticks.ToString()[^10..], Name = "Self", Family = "Check" };
db.Users.Add(user);
await db.SaveChangesAsync();
var device = new Device
{
DeviceName = "SELFCHK",
UserId = user.Id,
Location = string.Empty,
NeshanLocation = string.Empty,
ResetRequested = true
};
db.Devices.Add(device);
await db.SaveChangesAsync();
try
{
var storedUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id);
Assert(storedUser.CreatedAt.Kind == DateTimeKind.Utc, "a timestamptz column must read back as Kind=Utc");
var first = await telemetry.ConsumeResetRequestAsync(device.Id, default);
var second = await telemetry.ConsumeResetRequestAsync(device.Id, default);
Assert(first, "first ConsumeResetRequestAsync must return true");
Assert(!second, "second ConsumeResetRequestAsync must return false (one-shot)");
Assert(!(await db.Devices.AsNoTracking().FirstAsync(d => d.Id == device.Id)).ResetRequested,
"ResetRequested must be cleared in the database");
Console.WriteLine("PASS: a queued reset is delivered exactly once.");
}
finally
{
db.Devices.Remove(device);
db.Users.Remove(user);
await db.SaveChangesAsync();
}
static void Assert(bool condition, string message)
{
if (!condition)
throw new InvalidOperationException($"FAIL: {message}");
}