修复 21 个测试与代码脱节导致的既有失败

go test ./... 从 21 个失败回到 0。根因都是测试没跟上代码改动,其中两处
被「自动补位」的补丁 hack 掩盖住了:

- handler 的 materialRow 在「列数 20、值 18」时自动插两个 nil,掩盖了
  materials 新增 supplier_id/manufacturer_id 后的列错位。它碰巧补对位置,
  而 order 测试的 activeMaterialRows 没走这个 helper,直接 panic。
  已删掉补丁、把测试数据补齐到 20 列。
- 导入测试的 buildTestWorkbookWithHeaders 按 7 列给数据补位,而表头这时
  已经扩到 9 列(多了供应商、制造商),把「大类/小类」静默塞进了
  「来源/备注」。已按 materialImportHeaders 的实际列序修正补位。

其余为非补丁式的期望过时:materials 的 INSERT 参数补到 15 个、物料列表
查询结果集补到 20 列、导入模板表头断言改为按 materialImportHeaders 取末列
而不是写死下标。

顺带清掉已废弃的升级路径残留:upgrades/ 目录在 85525c97 已删除,增量升级
不再使用,因此删除 scripts/sync-company-integrity.ps1(把 checks SQL 嵌入
upgrade SQL 的生成器)与它的测试,application.go 里指向 upgrades 的错误
文案改为只提 init.sql。
This commit is contained in:
文睿 张 2026-10-14 16:27:06 +08:00
parent 3cc783b5ff
commit 18df4f48e4
9 changed files with 19 additions and 210 deletions

View File

@ -35,10 +35,8 @@ const (
)
// materialRow 构造单行结果集;不传值表示空结果集(用于"查不到"的路径)。
// 传值时长度必须和 columns 对齐,列顺序见 materialColumns。
func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows {
if len(columns) == 20 && len(values) == 18 {
values = append(values[:7], append([]driver.Value{nil, nil}, values[7:]...)...)
}
rows := sqlmock.NewRows(columns)
if len(values) > 0 {
rows.AddRow(values...)
@ -97,7 +95,7 @@ func TestMaterialListScopesToRequestedOrganization(t *testing.T) {
WillReturnRows(materialRow([]string{"count"}, int64(1)))
mock.ExpectQuery(materialListPattern).WithArgs(tc.orgID, "active", 20, 0).
WillReturnRows(materialRow(materialColumns(),
int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, nil, nil, "", "", int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, nil, nil, nil, nil, "", "", int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
w := newMaterialRequest(t, "GET", "/api/materials", "", tc.orgID)
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.wantCode) {
@ -153,12 +151,12 @@ func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) {
mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)).
WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
mock.ExpectExec(materialInsertPattern).
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, "", "", nil, nil, int64(12), nil, "个", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectCommit()
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(7)).
WillReturnRows(materialRow(materialColumns(),
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, nil, nil, "", "", int64(12), nil, "个", "active", 1, time.Now(), time.Now()))
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, nil, nil, nil, nil, "", "", int64(12), nil, "个", "active", 1, time.Now(), time.Now()))
mock.ExpectExec(materialLogInsertPattern).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), sqlmock.AnyArg(), int64(7)).
WillReturnResult(sqlmock.NewResult(1, 1))

View File

@ -54,7 +54,7 @@ func emptyWarehouseSummary() *sqlmock.Rows {
func activeMaterialRows(id int64) *sqlmock.Rows {
now := time.Now()
return sqlmock.NewRows(materialColumns()).
AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, nil, nil, "", "", 1, 1, "个", "active", 1, now, now)
AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, nil, nil, nil, nil, "", "", 1, 1, "个", "active", 1, now, now)
}
// TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。

View File

@ -276,7 +276,7 @@ func (r *ApplicationRepository) CheckApplicationSchema() error {
return err
}
if count == 0 {
return fmt.Errorf("数据库结构缺少 %s.%s:请先执行 scripts/init.sql(空库)或 scripts/upgrades/2026-10-08-company-master-data-isolation.sql(升级)完成建表/升级", c.table, c.column)
return fmt.Errorf("数据库结构缺少 %s.%s:请用 scripts/init.sql 重建数据库(本地库没有增量升级路径)", c.table, c.column)
}
}
return nil

View File

@ -1,133 +0,0 @@
package repository
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A forgotten regeneration must fail -Check without rewriting files; a source
// rule change must subsequently govern the real self-contained migration.
func isolationGenerator(t *testing.T, args ...string) ([]byte, error) {
t.Helper()
shell, err := exec.LookPath("pwsh")
if err != nil {
shell, err = exec.LookPath("powershell")
}
if err != nil {
t.Skip("PowerShell is required to verify the SQL generation tool")
}
commandArgs := []string{"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", filepath.Join("..", "..", "scripts", "sync-company-integrity.ps1")}
return exec.Command(shell, append(commandArgs, args...)...).CombinedOutput()
}
func generatedIsolationFixture(t *testing.T) (string, string) {
t.Helper()
if output, err := isolationGenerator(t, "-Check"); err != nil {
t.Fatalf("committed migration must be in sync: %v\n%s", err, output)
}
dir := t.TempDir()
source := filepath.Join(dir, "checks.sql")
migration := filepath.Join(dir, "upgrade.sql")
for _, copy := range []struct{ from, to string }{
{filepath.Join("..", "..", "scripts", "checks", "company-master-data-integrity.sql"), source},
{filepath.Join("..", "..", "scripts", "upgrades", "2026-10-08-company-master-data-isolation.sql"), migration},
} {
content, err := os.ReadFile(copy.from)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(copy.to, content, 0600); err != nil {
t.Fatal(err)
}
}
return source, migration
}
func changeIsolationCheckFixture(t *testing.T, source string) {
t.Helper()
content, err := os.ReadFile(source)
if err != nil {
t.Fatal(err)
}
// The fixture adds a literal company-2 blocker. Expectations below are hand
// derived from seedIsolationHistory (material 2 belongs to company 2).
changed := strings.Replace(string(content), "-- END COMPANY INTEGRITY CORE", `INSERT INTO tmp_company_integrity_anomalies
SELECT 'generated_rule',id,'Company two requires business review' FROM materials WHERE organization_id=2;
-- END COMPANY INTEGRITY CORE`, 1)
if changed == string(content) {
t.Fatal("canonical check core marker missing")
}
if err = os.WriteFile(source, []byte(changed), 0600); err != nil {
t.Fatal(err)
}
}
func TestCompanyIsolationIntegrityGeneratorDetectsAndRepairsDrift(t *testing.T) {
source, migration := generatedIsolationFixture(t)
before, err := os.ReadFile(migration)
if err != nil {
t.Fatal(err)
}
changeIsolationCheckFixture(t, source)
output, err := isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration, "-Check")
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 {
t.Fatalf("stale embed must fail -Check with exit 1, got %v\n%s", err, output)
}
after, err := os.ReadFile(migration)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(after, before) {
t.Fatal("-Check mutated stale migration")
}
if output, err = isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration); err != nil {
t.Fatalf("sync changed check: %v\n%s", err, output)
}
once, err := os.ReadFile(migration)
if err != nil {
t.Fatal(err)
}
if output, err = isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration); err != nil {
t.Fatalf("repeat sync: %v\n%s", err, output)
}
twice, err := os.ReadFile(migration)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(once, twice) {
t.Fatal("generation was not deterministic")
}
if output, err = isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration, "-Check"); err != nil {
t.Fatalf("regenerated migration must pass -Check: %v\n%s", err, output)
}
}
func TestCompanyIsolationGeneratedRuleBlocksActualUpgrade(t *testing.T) {
db := isolationDB(t, true)
seedIsolationHistory(t, db)
source, migration := generatedIsolationFixture(t)
changeIsolationCheckFixture(t, source)
if output, err := isolationGenerator(t, "-SourcePath", source, "-MigrationPath", migration); err != nil {
t.Fatalf("generate fixture: %v\n%s", err, output)
}
if err := runIsolationSQL(db, source); err != nil {
t.Fatal(err)
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM tmp_company_integrity_anomalies WHERE issue='generated_rule' AND entity_id=2`).Scan(&count); err != nil || count != 1 {
t.Fatalf("standalone fixture rule=%d err=%v", count, err)
}
if err := runIsolationSQL(db, migration); err == nil || !strings.Contains(err.Error(), "integrity anomalies") {
t.Fatalf("generated rule must block actual upgrade, got %v", err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM tmp_company_integrity_anomalies WHERE issue='generated_rule' AND entity_id=2`).Scan(&count); err != nil || count != 1 {
t.Fatalf("embedded fixture rule=%d err=%v", count, err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='stock_logs' AND column_name='organization_id'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("generated blocker changed business schema: %d %v", count, err)
}
}

View File

@ -48,8 +48,8 @@ func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
// 两行折叠成一条多值 INSERT
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
WithArgs(
int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, nil, int64(12), nil, "个", "active", 1,
int64(1), "A010203", "螺母", nil, "", nil, nil, nil, nil, int64(12), nil, "个", "active", 1,
int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, nil, nil, int64(12), nil, "个", "active", 1,
int64(1), "A010203", "螺母", nil, "", nil, nil, nil, nil, nil, int64(12), nil, "个", "active", 1,
).
WillReturnResult(sqlmock.NewResult(31, 2))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).

View File

@ -234,7 +234,9 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
if err != nil {
t.Fatalf("read rows: %v", err)
}
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][6] != "小类" {
// 表头列数以 materialImportHeaders 为准:写死下标会在增删列时静默指向别的列。
if len(headers) == 0 || len(headers[0]) != len(materialImportHeaders) ||
headers[0][0] != "物料名称" || headers[0][len(headers[0])-1] != "小类" {
t.Fatalf("表头 = %v", headers)
}
// 模板必须能被自己的解析器接受(表头一致)
@ -254,11 +256,14 @@ func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
t.Helper()
// 旧的五列测试数据为「名称、规格、单位、大类、小类」;补上新增的两个选填列。
// 测试数据用五列简写「名称、规格、单位、大类、小类」,其余列补空。
// 补位的顺序必须与 materialImportHeaders 一致(物料名称、规格、单位、供应商、
// 制造商、来源、备注、大类、小类):表头改列时这里要同步改,否则大类小类会被
// 静默解析成来源和备注,测试却只是「结果对不上」。
if strings.Join(headers, "\x00") == strings.Join(materialImportHeaders, "\x00") {
for i, row := range dataRows {
if len(row) == 5 {
dataRows[i] = []string{row[0], row[1], row[2], "", "", row[3], row[4]}
dataRows[i] = []string{row[0], row[1], row[2], "", "", "", "", row[3], row[4]}
}
}
}

View File

@ -296,7 +296,7 @@ func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) {
expectActiveSubcategory(mock)
expectLockedCodeRule(mock, &next)
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, "", "", nil, nil, int64(12), nil, "个", "active", 1).
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
mock.ExpectRollback()

View File

@ -26,8 +26,8 @@ func outboundMock(t *testing.T) sqlmock.Sqlmock {
// activeMaterialRow 造一行启用物料,供物料归属/存在性校验与明细补名使用。
func activeMaterialRow(id int64) *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(id, 1, "M-1", "Material", nil, "", nil, nil, nil, "", "", 1, 1, "pc", "active", 1, time.Now(), time.Now())
return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "source", "remark", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(id, 1, "M-1", "Material", nil, "", nil, nil, nil, nil, nil, "", "", 1, 1, "pc", "active", 1, time.Now(), time.Now())
}
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {

View File

@ -1,61 +0,0 @@
# The standalone check is canonical; the upgrade stays self-contained for mysql SOURCE.
# Run from any directory. Use -Check in verification to reject an outdated embed.
[CmdletBinding()]
param(
[string]$SourcePath,
[string]$MigrationPath,
[switch]$Check
)
if (-not $SourcePath) { $SourcePath = Join-Path $PSScriptRoot 'checks/company-master-data-integrity.sql' }
if (-not $MigrationPath) { $MigrationPath = Join-Path $PSScriptRoot 'upgrades/2026-10-08-company-master-data-isolation.sql' }
$ErrorActionPreference = 'Stop'
function Get-MarkedContent {
param([string]$Text, [string]$BeginMarker, [string]$EndMarker)
$begin = $Text.IndexOf($BeginMarker, [System.StringComparison]::Ordinal)
$end = $Text.IndexOf($EndMarker, [System.StringComparison]::Ordinal)
if ($begin -lt 0 -or $end -le $begin -or
$Text.IndexOf($BeginMarker, $begin + $BeginMarker.Length, [System.StringComparison]::Ordinal) -ge 0 -or
$Text.IndexOf($EndMarker, $end + $EndMarker.Length, [System.StringComparison]::Ordinal) -ge 0) {
throw "Expected one ordered marker pair: $BeginMarker / $EndMarker"
}
$contentStart = $begin + $BeginMarker.Length
return @{
Start = $contentStart
End = $end
Content = $Text.Substring($contentStart, $end - $contentStart)
}
}
try {
$source = (Resolve-Path -LiteralPath $SourcePath).Path
$migration = (Resolve-Path -LiteralPath $MigrationPath).Path
if ($source -eq $migration) { throw 'Source and migration must be different files' }
# Normalize only line endings: content, statement order and whitespace in the
# canonical block otherwise remain exact. Output is deterministic UTF-8/LF.
$sourceText = [System.IO.File]::ReadAllText($source).Replace("`r`n", "`n").Replace("`r", "`n")
$migrationText = [System.IO.File]::ReadAllText($migration).Replace("`r`n", "`n").Replace("`r", "`n")
$core = Get-MarkedContent $sourceText '-- BEGIN COMPANY INTEGRITY CORE' '-- END COMPANY INTEGRITY CORE'
$embedded = Get-MarkedContent $migrationText '-- BEGIN GENERATED COMPANY INTEGRITY CHECKS' '-- END GENERATED COMPANY INTEGRITY CHECKS'
$expected = $migrationText.Substring(0, $embedded.Start) + $core.Content + $migrationText.Substring($embedded.End)
if ($Check) {
if ($migrationText -cne $expected) {
Write-Error 'Integrity SQL embed is stale. Run scripts/sync-company-integrity.ps1 to regenerate it.' -ErrorAction Continue
exit 1
}
Write-Output 'Integrity SQL embed is current.'
exit 0
}
[System.IO.File]::WriteAllText($migration, $expected, [System.Text.UTF8Encoding]::new($false))
Write-Output 'Integrity SQL embed regenerated from the canonical check.'
exit 0
} catch {
Write-Error $_ -ErrorAction Continue
exit 1
}