mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
Merge branch 'dev' into feature/sh-218-additional-contacts
This commit is contained in:
commit
d843ac221d
14 changed files with 4301 additions and 62 deletions
|
|
@ -480,6 +480,76 @@ public class VendorDataServiceTests
|
|||
inactive.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveTradesAsync_ReturnsEmptyOnEmptyDatabase()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var trades = await service.GetActiveTradesAsync(CancellationToken.None);
|
||||
|
||||
trades.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveTradesAsync_FiltersInactiveAndOrdersBySortOrderThenName()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Trades.AddRange(
|
||||
new Trade { Name = "Janitorial", NormalizedName = "janitorial", SortOrder = 7 },
|
||||
new Trade { Name = "Plumbing", NormalizedName = "plumbing", SortOrder = 1 },
|
||||
new Trade { Name = "Electrical", NormalizedName = "electrical", SortOrder = 2 },
|
||||
new Trade { Name = "Retired Trade", NormalizedName = "retired trade", SortOrder = 0, IsActive = false });
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var trades = await service.GetActiveTradesAsync(CancellationToken.None);
|
||||
|
||||
trades.Select(t => t.Name).Should().Equal("Plumbing", "Electrical", "Janitorial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3),
|
||||
VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5));
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
// SH-249: stored TradeSpecialties stay free text; filtering by a legacy
|
||||
// value that is not in the canonical catalog must keep matching.
|
||||
var (items, totalCount) = await service.GetPagedAsync(
|
||||
1,
|
||||
12,
|
||||
isActive: true,
|
||||
trades: new[] { "Backflow Testing" });
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Single().CompanyName.Should().Be("Legacy Co");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDirectoryPagedAsync_TradeFilterStillMatchesLegacyFreeTextValues()
|
||||
{
|
||||
await using var context = NewContext();
|
||||
context.Vendors.AddRange(
|
||||
VendorWithJobs("Legacy Co", "Chicago", "IL", "Backflow Testing, Plumbing", 3),
|
||||
VendorWithJobs("Canonical Co", "Chicago", "IL", "Plumbing", 5));
|
||||
await context.SaveChangesAsync();
|
||||
var service = new VendorDataService(context);
|
||||
|
||||
var (items, totalCount) = await service.GetDirectoryPagedAsync(
|
||||
1,
|
||||
12,
|
||||
isActive: true,
|
||||
trades: new[] { "Backflow Testing" });
|
||||
|
||||
totalCount.Should().Be(1);
|
||||
items.Single().CompanyName.Should().Be("Legacy Co");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ using Data.SeaHavenIndustries;
|
|||
using Data.SeaHavenIndustries.Enums;
|
||||
using FluentAssertions;
|
||||
using FluentValidation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SeaHaven.DataServices.Implementation;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
using SeaHaven.DataServices.Models;
|
||||
using SeaHaven.Services.Configuration;
|
||||
|
|
@ -573,21 +575,8 @@ public class VendorServiceTests
|
|||
GoogleMapsUrl = "https://owner.example/map"
|
||||
}
|
||||
});
|
||||
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Vendor>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 10,
|
||||
CompanyName = "Owner Co",
|
||||
ContactName = "Pat",
|
||||
City = "TechCity",
|
||||
State = "TS",
|
||||
Email = "tech@example.com",
|
||||
CompanyPhone = "(555) 000-9999",
|
||||
TradeSpecialties = "Plumbing"
|
||||
}
|
||||
});
|
||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||
|
||||
|
|
@ -608,7 +597,9 @@ public class VendorServiceTests
|
|||
location.State.Should().Be("OS");
|
||||
location.Label.Should().Be("OwnerCity, OS");
|
||||
|
||||
facets.Trades.Should().BeEquivalentTo(new[] { "Plumbing" });
|
||||
// SH-249: technician free text is no longer a trades source; the canonical
|
||||
// vocabulary comes from the Trade reference table instead.
|
||||
facets.Trades.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -622,8 +613,8 @@ public class VendorServiceTests
|
|||
new() { Id = 2, Name = "alpha co" },
|
||||
new() { Id = 3, Name = "Alpha Co" }
|
||||
});
|
||||
data.Setup(x => x.GetVendorsForFacetsAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Vendor>());
|
||||
data.Setup(x => x.GetActiveTradesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Trade>());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(null, CancellationToken.None);
|
||||
|
||||
|
|
@ -635,19 +626,68 @@ public class VendorServiceTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacetsAsync_TradesRemainDerivedFromTechniciansAndIgnoreCompanies()
|
||||
public async Task GetFacetsAsync_TradesComeFromCanonicalReferenceDataNotTechnicianFreeText()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var data = new Mock<IVendorDataService>();
|
||||
data.Setup(x => x.GetCompaniesForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<VendorCompany> { new() { Id = 1, Name = "No Trade Co" } });
|
||||
data.Setup(x => x.GetVendorsForFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Vendor>
|
||||
{
|
||||
new() { Id = 20, CompanyName = "Trade Co", TradeSpecialties = "HVAC, Roofing" }
|
||||
});
|
||||
data.Setup(x => x.GetActiveTradesAsync(cts.Token))
|
||||
.ReturnsAsync(TradeCatalog.CanonicalTrades
|
||||
.Select((name, index) => new Trade
|
||||
{
|
||||
Id = index + 1,
|
||||
Name = name,
|
||||
NormalizedName = TradeCatalog.Normalize(name),
|
||||
SortOrder = index + 1
|
||||
})
|
||||
.ToList());
|
||||
|
||||
var facets = await NewService(data).GetFacetsAsync(true, CancellationToken.None);
|
||||
var facets = await NewService(data).GetFacetsAsync(true, cts.Token);
|
||||
|
||||
facets.Trades.Should().BeEquivalentTo(new[] { "HVAC", "Roofing" }, opts => opts.WithStrictOrdering());
|
||||
// SH-249: facets serve the canonical Trade vocabulary in catalog order,
|
||||
// regardless of technician TradeSpecialties free text.
|
||||
facets.Trades.Should().BeEquivalentTo(
|
||||
TradeCatalog.CanonicalTrades,
|
||||
opts => opts.WithStrictOrdering());
|
||||
|
||||
// G7 evidence: the caller's cancellation token reaches the data service.
|
||||
data.Verify(x => x.GetActiveTradesAsync(cts.Token), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFacetsAsync_ReturnsCanonicalTradesOnEmptyVendorDatabase()
|
||||
{
|
||||
await using var context = new ApplicationDbContext(new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
|
||||
// Empty vendor/company data; only the canonical Trade reference rows exist
|
||||
// (in production these are seeded by the SH249_TradesVocabulary migration).
|
||||
context.Trades.AddRange(TradeCatalog.CanonicalTrades.Select((name, index) => new Trade
|
||||
{
|
||||
Id = index + 1,
|
||||
Name = name,
|
||||
NormalizedName = TradeCatalog.Normalize(name),
|
||||
SortOrder = index + 1
|
||||
}));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var service = new VendorService(
|
||||
new VendorDataService(context),
|
||||
Mock.Of<IVendorPortalTokenService>(),
|
||||
Mock.Of<IZipCodeDistance>(),
|
||||
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
|
||||
new CreateVendorValidation(),
|
||||
new UpdateVendorValidation(),
|
||||
new WorkOrderVendorUpdateValidation());
|
||||
|
||||
var facets = await service.GetFacetsAsync(null, CancellationToken.None);
|
||||
|
||||
facets.Companies.Should().BeEmpty();
|
||||
facets.Locations.Should().BeEmpty();
|
||||
facets.Trades.Should().BeEquivalentTo(
|
||||
TradeCatalog.CanonicalTrades,
|
||||
opts => opts.WithStrictOrdering());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,19 @@ namespace Data.SeaHavenIndustries
|
|||
.HasIndex(p => new { p.LocationId, p.VendorId })
|
||||
.IsUnique()
|
||||
.HasFilter("[Trade] IS NULL");
|
||||
|
||||
// SH-249 bounded lengths: unique index keys must not be nvarchar(max).
|
||||
builder.Entity<Trade>()
|
||||
.Property(t => t.Name)
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Entity<Trade>()
|
||||
.Property(t => t.NormalizedName)
|
||||
.HasMaxLength(128);
|
||||
|
||||
builder.Entity<Trade>()
|
||||
.HasIndex(t => t.NormalizedName)
|
||||
.IsUnique();
|
||||
}
|
||||
public DbSet<Category> Categories { get; set; }
|
||||
public DbSet<Locations> Locations { get; set; }
|
||||
|
|
@ -207,6 +220,7 @@ namespace Data.SeaHavenIndustries
|
|||
public DbSet<WorkOrderReconciliationJob> WorkOrderReconciliationJobs { get; set; }
|
||||
public DbSet<WorkOrderExternalReceipt> WorkOrderExternalReceipts { get; set; }
|
||||
public DbSet<DropdownOption> DropdownOptions { get; set; }
|
||||
public DbSet<Trade> Trades { get; set; }
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
public DbSet<VendorCompany> VendorCompanies { get; set; }
|
||||
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
|
||||
|
|
|
|||
3897
Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.Designer.cs
generated
Normal file
3897
Data.SeaHavenIndustries/Migrations/20260818150533_SH249_TradesVocabulary.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,65 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Data.SeaHavenIndustries.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SH249_TradesVocabulary : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Trades",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
NormalizedName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Trades", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Trades_NormalizedName",
|
||||
table: "Trades",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
// SH-249 canonical trade vocabulary. PROVISIONAL: sourced from the
|
||||
// in-repo prototype seed in Program.cs; Product must confirm before
|
||||
// release. Keep literals in sync with TradeCatalog.CanonicalTrades
|
||||
// (tests assert the applied table matches the catalog). Extend the
|
||||
// vocabulary after this ships only through new migrations.
|
||||
migrationBuilder.InsertData(
|
||||
table: "Trades",
|
||||
columns: new[] { "Id", "Name", "NormalizedName", "SortOrder", "IsActive" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 1, "Plumbing", "plumbing", 1, true },
|
||||
{ 2, "Electrical", "electrical", 2, true },
|
||||
{ 3, "HVAC", "hvac", 3, true },
|
||||
{ 4, "Fire/Life Safety", "fire/life safety", 4, true },
|
||||
{ 5, "General", "general", 5, true },
|
||||
{ 6, "Roofing", "roofing", 6, true },
|
||||
{ 7, "Janitorial", "janitorial", 7, true },
|
||||
{ 8, "Painting", "painting", 8, true },
|
||||
{ 9, "Locksmith", "locksmith", 9, true },
|
||||
{ 10, "Carpentry", "carpentry", 10, true }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Trades");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2003,6 +2003,38 @@ namespace Data.SeaHavenIndustries.Migrations
|
|||
b.ToTable("Templates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Trade", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Trades");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
|
|||
15
Data.SeaHavenIndustries/Models/Trade.cs
Normal file
15
Data.SeaHavenIndustries/Models/Trade.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
// SH-249: server-owned trade reference data backing the vendor facets
|
||||
// vocabulary. Canonical options no longer derive from free-text
|
||||
// Vendor.TradeSpecialties values; stored vendor strings are preserved
|
||||
// unchanged and remain filterable.
|
||||
public class Trade
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string NormalizedName { get; set; } = "";
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
28
Data.SeaHavenIndustries/Models/TradeCatalog.cs
Normal file
28
Data.SeaHavenIndustries/Models/TradeCatalog.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
namespace Data.SeaHavenIndustries
|
||||
{
|
||||
// SH-249 PROVISIONAL canonical trade vocabulary. Sourced from the
|
||||
// in-repo prototype seed (Api.SeaHavenIndustries/Program.cs, disabled
|
||||
// development seeding) because no authoritative Product list exists in
|
||||
// the repo. Product must confirm the list before release; after the
|
||||
// SH249_TradesVocabulary migration ships, extend the vocabulary only
|
||||
// through new migrations (and keep this catalog in sync — tests assert
|
||||
// the seeded table matches this catalog).
|
||||
public static class TradeCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<string> CanonicalTrades = new[]
|
||||
{
|
||||
"Plumbing",
|
||||
"Electrical",
|
||||
"HVAC",
|
||||
"Fire/Life Safety",
|
||||
"General",
|
||||
"Roofing",
|
||||
"Janitorial",
|
||||
"Painting",
|
||||
"Locksmith",
|
||||
"Carpentry"
|
||||
};
|
||||
|
||||
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
|
@ -66,17 +66,13 @@ namespace SeaHaven.DataServices.Implementation
|
|||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||
public async Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _context.Vendors
|
||||
return await _context.Trades
|
||||
.AsNoTracking()
|
||||
.Where(v => v.IsDeleted == null || v.IsDeleted == false);
|
||||
|
||||
if (isActive.HasValue)
|
||||
query = query.Where(v => v.IsActive == isActive.Value);
|
||||
|
||||
return await query
|
||||
.OrderByDescending(v => v.Id)
|
||||
.Where(t => t.IsActive)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ThenBy(t => t.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ namespace SeaHaven.DataServices.Interfaces
|
|||
Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken);
|
||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
||||
|
||||
// SH-249: canonical trade vocabulary (server-owned reference data) for
|
||||
// vendor facets. Active trades ordered by SortOrder then Name; independent
|
||||
// of technician Vendor.TradeSpecialties free text.
|
||||
Task<IReadOnlyList<Trade>> GetActiveTradesAsync(CancellationToken cancellationToken);
|
||||
|
||||
// SH-198 facet fix: company-owned facet profiles sourced from VendorCompanies,
|
||||
// with status semantics aligned to BuildDirectoryUnionQuery. Null returns all
|
||||
|
|
|
|||
|
|
@ -623,10 +623,12 @@ namespace SeaHaven.Services.Implementation
|
|||
public async Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken)
|
||||
{
|
||||
// Companies and Locations are sourced from company-owned VendorCompany fields
|
||||
// (status semantics aligned to the directory union); Trades remain derived from
|
||||
// non-deleted technician Vendors.
|
||||
// (status semantics aligned to the directory union). Trades come from the
|
||||
// SH-249 server-owned canonical Trade reference data — a stable list that
|
||||
// does not derive from technician TradeSpecialties free text, so it is
|
||||
// populated even on an empty database and independent of the status filter.
|
||||
var companies = await _vendorDataService.GetCompaniesForFacetsAsync(isActive, cancellationToken);
|
||||
var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken);
|
||||
var canonicalTrades = await _vendorDataService.GetActiveTradesAsync(cancellationToken);
|
||||
|
||||
var companyFacets = companies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
|
||||
|
|
@ -647,11 +649,7 @@ namespace SeaHaven.Services.Implementation
|
|||
GoogleMapsUrl = c.GoogleMapsUrl
|
||||
});
|
||||
|
||||
var trades = vendors
|
||||
.SelectMany(v => (v.TradeSpecialties ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value);
|
||||
var trades = canonicalTrades.Select(t => t.Name).ToList();
|
||||
|
||||
var locations = companies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.City) || !string.IsNullOrWhiteSpace(c.State))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
using System.Reflection;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Migrations;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace SeaHavenIndustries.Tests;
|
||||
|
||||
// SH-249: provider-backed migration evidence for the Trades reference table.
|
||||
// Applies the real migration operations (Up, then Down) against SQLite and
|
||||
// asserts the seeded canonical vocabulary matches TradeCatalog.
|
||||
public class SH249TradesVocabularyMigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Up_CreatesTradesTableSeededWithCanonicalCatalogOnEmptyDatabase()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await ApplyMigrationAsync(context, "Up");
|
||||
|
||||
var seeded = await context.Trades
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Equal(
|
||||
TradeCatalog.CanonicalTrades,
|
||||
seeded.Select(t => t.Name).ToArray());
|
||||
Assert.All(seeded, t => Assert.True(t.IsActive));
|
||||
Assert.Equal(
|
||||
TradeCatalog.CanonicalTrades.Select(TradeCatalog.Normalize).ToArray(),
|
||||
seeded.Select(t => t.NormalizedName).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Down_DropsTradesTable()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using var context = new ApplicationDbContext(options);
|
||||
await ApplyMigrationAsync(context, "Up");
|
||||
await ApplyMigrationAsync(context, "Down");
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText =
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'Trades';";
|
||||
var tableCount = Convert.ToInt32(await command.ExecuteScalarAsync());
|
||||
|
||||
Assert.Equal(0, tableCount);
|
||||
}
|
||||
|
||||
// Mirrors Phase4SearchIndexesSqlServerTests: invoke the protected Up/Down
|
||||
// body, translate through the configured provider's SQL generator, and
|
||||
// execute the produced commands against the live database.
|
||||
private static async Task ApplyMigrationAsync(ApplicationDbContext context, string direction)
|
||||
{
|
||||
var builder = new MigrationBuilder(context.Database.ProviderName!);
|
||||
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException($"Migration.{direction} not found.");
|
||||
operation.Invoke(new SH249_TradesVocabulary(), [builder]);
|
||||
|
||||
var sqlGenerator = context.GetService<IMigrationsSqlGenerator>();
|
||||
// InsertData operations need model metadata for column type mapping.
|
||||
var commands = sqlGenerator.Generate(builder.Operations, context.Model);
|
||||
foreach (var command in commands)
|
||||
await context.Database.ExecuteSqlRawAsync(command.CommandText);
|
||||
}
|
||||
}
|
||||
32
infra/cdk/package-lock.json
generated
32
infra/cdk/package-lock.json
generated
|
|
@ -8,12 +8,12 @@
|
|||
"name": "shoc-backend-cdk",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.262.1",
|
||||
"aws-cdk-lib": "2.265.0",
|
||||
"constructs": "10.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.2.0",
|
||||
"aws-cdk": "2.1135.1",
|
||||
"aws-cdk": "2.1137.0",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -21,9 +21,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-awscli-v1": {
|
||||
"version": "2.2.282",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.282.tgz",
|
||||
"integrity": "sha512-7hKMi5tTxDcKGIMIOq14PnY0GBcugW33Uh/2YHDZiEwSxLeFOCYBwhR+BFXONb/EJeVI3RETFgailNZbkcKF6g==",
|
||||
"version": "2.2.292",
|
||||
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.292.tgz",
|
||||
"integrity": "sha512-d4aMFsAFj19FtxVyw8IzlUKv5Zu4sIvgnEjI2IU6IWBJgVbJ4aFnadANqYa+6MwB1CQbGOg0jh8WE77M+Nb/9A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
|
||||
|
|
@ -419,9 +419,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1135.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1135.1.tgz",
|
||||
"integrity": "sha512-g1jcMfWlyYtGamFJ/kPBOCuchl3NfwTF2UwOLTIDN0nJbGm84EAO+c8DlYnaemM8UmKFkdoq4BGdmiNL5nHWwA==",
|
||||
"version": "2.1137.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1137.0.tgz",
|
||||
"integrity": "sha512-SRy2df/5cqGvq8ncph1m93rWEh3RGbvP6uwElw+W0x135vZDzTpfbuq2L8oAHwK2yDt3RNS5sb7Phwb7xqQP2g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -432,9 +432,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib": {
|
||||
"version": "2.262.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.262.1.tgz",
|
||||
"integrity": "sha512-B6YP4r6ojUZCDhl+qBu/CrWzcipR8sIgshcqYvgw013sghPXmVkYdJ3yuI9+DKML3YLSjQrHy1nGJs+Nqq7JCg==",
|
||||
"version": "2.265.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.265.0.tgz",
|
||||
"integrity": "sha512-Ur96d/ZW+gqAW4XtqKQp//D9O/jQvyRoXhAqoHGybpV03qjckGIQcLcGkLV68+jv03TX/rOXHCsa2IEFcCXa4w==",
|
||||
"bundleDependencies": [
|
||||
"@aws/cloudformation-validate",
|
||||
"@balena/dockerignore",
|
||||
|
|
@ -451,11 +451,11 @@
|
|||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-cdk/asset-awscli-v1": "2.2.282",
|
||||
"@aws-cdk/asset-awscli-v1": "2.2.292",
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.6",
|
||||
"@aws-cdk/cloud-assembly-schema": "^54.11.0",
|
||||
"@aws/cloudformation-validate": "1.5.1-beta",
|
||||
"@aws/cloudformation-validate": "1.7.0-beta",
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"case": "1.6.3",
|
||||
"fs-extra": "^11.3.6",
|
||||
|
|
@ -490,7 +490,7 @@
|
|||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": {
|
||||
"version": "1.5.1-beta",
|
||||
"version": "1.7.0-beta",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
|
|
@ -511,14 +511,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"version": "5.0.9",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/case": {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@
|
|||
"deploy": "npm run build && cdk deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.262.1",
|
||||
"aws-cdk-lib": "2.265.0",
|
||||
"constructs": "10.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.2.0",
|
||||
"aws-cdk": "2.1135.1",
|
||||
"aws-cdk": "2.1137.0",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue