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.
91 lines
3.5 KiB
C#
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}");
|
|
}
|