更新
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package testusers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// CopyAvatars validates all sources and destination collisions before copying.
|
||||
// Existing identical assets are reused. Existing different files are not replaced.
|
||||
func CopyAvatars(sourceDir, mediaDir string) error {
|
||||
if sourceDir == "" || mediaDir == "" {
|
||||
return fmt.Errorf("both avatar source and media directories are required")
|
||||
}
|
||||
assets := make(map[string][]byte)
|
||||
for _, name := range Avatars() {
|
||||
data, err := os.ReadFile(filepath.Join(sourceDir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := png.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || cfg.Width < 256 || cfg.Height < 256 {
|
||||
return fmt.Errorf("invalid or undersized PNG avatar: %s", name)
|
||||
}
|
||||
existing, err := os.ReadFile(filepath.Join(mediaDir, name))
|
||||
if err == nil {
|
||||
if !bytes.Equal(existing, data) {
|
||||
return fmt.Errorf("refusing to overwrite different existing avatar: %s", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
assets[name] = data
|
||||
}
|
||||
if err := os.MkdirAll(mediaDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, data := range assets {
|
||||
file, err := os.OpenFile(filepath.Join(mediaDir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := file.Write(data)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// Package testusers creates explicitly labelled, non-loginable test profiles.
|
||||
// It is only used by an operator-invoked CLI, never during server startup.
|
||||
package testusers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Batch = "cn-adults-20260831-v1"
|
||||
const Count = 100
|
||||
|
||||
type Profile struct {
|
||||
PublicID string `json:"publicId"`
|
||||
IsTest bool `json:"isTest"`
|
||||
TestBatch string `json:"testBatch"`
|
||||
Label string `json:"label"`
|
||||
Nickname string `json:"nickname"`
|
||||
Gender int `json:"gender"`
|
||||
Birthday string `json:"birthday"`
|
||||
Height int `json:"height"`
|
||||
CityCode string `json:"cityCode"`
|
||||
City string `json:"city"`
|
||||
Occupation string `json:"occupation"`
|
||||
Bio string `json:"bio"`
|
||||
AvatarFile string `json:"avatarFile"`
|
||||
Avatar string `json:"avatar"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
type fixtureCity struct {
|
||||
Code string
|
||||
Name string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
// Avatars contains five independently generated adult portraits per gender.
|
||||
// Reuse is intentional for fixtures; these are not 100 real identities.
|
||||
func Avatars() []string {
|
||||
files := make([]string, 0, 10)
|
||||
for gender := 1; gender <= 2; gender++ {
|
||||
for n := 1; n <= 5; n++ {
|
||||
files = append(files, fmt.Sprintf("900028%d%d-1.png", gender, n))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func Generate(publicBase string) ([]Profile, error) {
|
||||
base, err := url.Parse(strings.TrimRight(publicBase, "/"))
|
||||
if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (base.Scheme != "http" && base.Scheme != "https") {
|
||||
return nil, fmt.Errorf("public base must be an absolute HTTP(S) uploads URL without credentials, query or fragment")
|
||||
}
|
||||
if base.Scheme == "http" && base.Hostname() != "127.0.0.1" && base.Hostname() != "localhost" && base.Hostname() != "::1" {
|
||||
return nil, fmt.Errorf("non-local avatar URLs require HTTPS")
|
||||
}
|
||||
surnames := []string{"陈", "林", "周", "许", "苏", "沈", "陆", "顾", "方", "季"}
|
||||
given := [][]string{{"沐川", "知远", "星河", "景行", "予安"}, {"晚晴", "知夏", "语桐", "清禾", "若宁"}}
|
||||
cities := []fixtureCity{
|
||||
{"310100", "上海", 31.2304, 121.4737}, {"440100", "广州", 23.1291, 113.2644},
|
||||
{"440300", "深圳", 22.5431, 114.0579}, {"330100", "杭州", 30.2741, 120.1551},
|
||||
{"510100", "成都", 30.5728, 104.0668}, {"420100", "武汉", 30.5928, 114.3055},
|
||||
{"320100", "南京", 32.0603, 118.7969}, {"350200", "厦门", 24.4798, 118.0894},
|
||||
{"610100", "西安", 34.3416, 108.9398}, {"370200", "青岛", 36.0671, 120.3826},
|
||||
}
|
||||
jobs := []string{"设计师", "工程师", "教师", "摄影师", "产品经理"}
|
||||
hobbies := []string{"摄影与城市漫步", "阅读与咖啡", "跑步与音乐", "旅行与美食", "电影与绘画"}
|
||||
ages := [2][5]int{{27, 30, 24, 33, 28}, {26, 29, 24, 32, 27}}
|
||||
items := make([]Profile, 0, Count)
|
||||
for gender := 1; gender <= 2; gender++ {
|
||||
for i := 0; i < 50; i++ {
|
||||
avatarFile := fmt.Sprintf("900028%d%d-1.png", gender, i%5+1)
|
||||
city := cities[i%len(cities)]
|
||||
ring := i / len(cities)
|
||||
latitude := city.Latitude + float64(ring-2)*0.012 + float64(gender-1)*0.006
|
||||
longitude := city.Longitude + float64((ring*2+gender)%5-2)*0.012
|
||||
items = append(items, Profile{
|
||||
PublicID: fmt.Sprintf("TESTCN%06d", (gender-1)*50+i+1),
|
||||
IsTest: true, TestBatch: Batch, Label: "测试用户",
|
||||
Nickname: "测试·" + surnames[i/5] + given[gender-1][i%5],
|
||||
Gender: gender, Birthday: fmt.Sprintf("%d-%02d-%02d", 2026-ages[gender-1][i%5], i%6+1, i%27+1),
|
||||
Height: 160 + (2-gender)*12 + i%12,
|
||||
CityCode: city.Code, City: city.Name, Occupation: jobs[i%len(jobs)],
|
||||
Bio: "【测试数据】虚构资料,AI生成中国成年人头像,仅供功能测试,非真实交友用户。兴趣示例:" + hobbies[i%len(hobbies)] + "。",
|
||||
AvatarFile: avatarFile, Avatar: base.String() + "/" + avatarFile,
|
||||
Latitude: latitude, Longitude: longitude,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package testusers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateBalancedLabelledAdults(t *testing.T) {
|
||||
items, err := Generate("https://im.bchongw.com/uploads/")
|
||||
if err != nil || len(items) != 100 {
|
||||
t.Fatalf("len=%d err=%v", len(items), err)
|
||||
}
|
||||
ids, names := map[string]bool{}, map[string]bool{}
|
||||
counts := map[int]int{}
|
||||
files := map[string]bool{}
|
||||
for _, file := range Avatars() {
|
||||
files[file] = true
|
||||
}
|
||||
for _, p := range items {
|
||||
if ids[p.PublicID] || names[p.Nickname] || !p.IsTest || p.TestBatch != Batch || p.Label != "测试用户" || !strings.HasPrefix(p.Nickname, "测试·") {
|
||||
t.Fatalf("missing label or duplicate identity: %+v", p)
|
||||
}
|
||||
ids[p.PublicID], names[p.Nickname] = true, true
|
||||
counts[p.Gender]++
|
||||
birthday, err := time.Parse("2006-01-02", p.Birthday)
|
||||
if err != nil || birthday.After(time.Date(2008, 8, 31, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Fatalf("invalid adult birthday: %s", p.Birthday)
|
||||
}
|
||||
if !files[p.AvatarFile] || p.Avatar != "https://im.bchongw.com/uploads/"+p.AvatarFile || !regexp.MustCompile(`^[0-9]+-[0-9]+\.png$`).MatchString(p.AvatarFile) {
|
||||
t.Fatalf("avatar incompatible with media route: %s", p.Avatar)
|
||||
}
|
||||
if !strings.Contains(p.Bio, "非真实交友用户") {
|
||||
t.Fatal("missing synthetic identity disclosure")
|
||||
}
|
||||
}
|
||||
if counts[1] != 50 || counts[2] != 50 || len(counts) != 2 {
|
||||
t.Fatalf("gender distribution: %v", counts)
|
||||
}
|
||||
again, _ := Generate("https://im.bchongw.com/uploads/")
|
||||
if !reflect.DeepEqual(items, again) {
|
||||
t.Fatal("fixture generation must be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsafePublicBase(t *testing.T) {
|
||||
for _, base := range []string{"", "/uploads", "http://example.com/uploads", "https://user:secret@example.com/uploads", "https://example.com/uploads?x=1", "https://example.com/#bad", "javascript:alert(1)"} {
|
||||
if _, err := Generate(base); err == nil {
|
||||
t.Errorf("accepted unsafe base %q", base)
|
||||
}
|
||||
}
|
||||
for _, base := range []string{"https://example.com/uploads", "http://127.0.0.1:8888/uploads", "http://localhost:8888/uploads"} {
|
||||
if _, err := Generate(base); err != nil {
|
||||
t.Errorf("rejected valid base %q: %v", base, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatarCopyIsRepeatableAndDoesNotOverwrite(t *testing.T) {
|
||||
source, destination := t.TempDir(), t.TempDir()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 256, 256))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range Avatars() {
|
||||
if err := os.WriteFile(filepath.Join(source, file), buf.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := CopyAvatars(source, destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
conflict := filepath.Join(destination, Avatars()[0])
|
||||
if err := os.WriteFile(conflict, []byte("existing user file"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CopyAvatars(source, destination); err == nil {
|
||||
t.Fatal("overwrote an unrelated file")
|
||||
}
|
||||
if got, _ := os.ReadFile(conflict); string(got) != "existing user file" {
|
||||
t.Fatal("collision changed existing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundledAvatarsArePresent(t *testing.T) {
|
||||
if err := CopyAvatars(filepath.Join("..", "..", "..", "fixtures", "test-users", "avatars"), t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package testusers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Batch string `json:"batch"`
|
||||
Created int `json:"created"`
|
||||
Skipped int `json:"skipped"`
|
||||
Male int `json:"male"`
|
||||
Female int `json:"female"`
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
// Seed is atomic and repeatable: rerunning a complete batch is a no-op.
|
||||
// Conflicting real users and incomplete/modified batches are never overwritten.
|
||||
func Seed(ctx context.Context, db *sql.DB, publicBase, confirmDatabase string) (Result, error) {
|
||||
result := Result{Batch: Batch, IDs: []int64{}}
|
||||
profiles, err := Generate(publicBase)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer conn.Close()
|
||||
var database string
|
||||
if err = conn.QueryRowContext(ctx, `SELECT DATABASE()`).Scan(&database); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if confirmDatabase == "" || database != confirmDatabase {
|
||||
return result, fmt.Errorf("database confirmation does not match the connected database")
|
||||
}
|
||||
var locked int
|
||||
if err = conn.QueryRowContext(ctx, `SELECT GET_LOCK(?,10)`, "xingyu:testusers:"+Batch).Scan(&locked); err != nil || locked != 1 {
|
||||
return result, fmt.Errorf("could not acquire test-user batch lock")
|
||||
}
|
||||
defer func() { _, _ = conn.ExecContext(context.Background(), `DO RELEASE_LOCK(?)`, "xingyu:testusers:"+Batch) }()
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var batchCount int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE test_batch=?`, Batch).Scan(&batchCount); err != nil {
|
||||
return result, fmt.Errorf("test-user schema unavailable; apply migration 028 first: %w", err)
|
||||
}
|
||||
if batchCount != 0 && batchCount != Count {
|
||||
return result, fmt.Errorf("batch has %d users, expected 0 or %d; refusing to modify partial data", batchCount, Count)
|
||||
}
|
||||
for _, p := range profiles {
|
||||
var id int64
|
||||
var isTest bool
|
||||
var existingBatch string
|
||||
var deleted sql.NullTime
|
||||
err = tx.QueryRowContext(ctx, `SELECT id,is_test,test_batch,deleted_at FROM users WHERE public_id=? FOR UPDATE`, p.PublicID).Scan(&id, &isTest, &existingBatch, &deleted)
|
||||
if err == nil {
|
||||
if batchCount != Count || !isTest || existingBatch != Batch || deleted.Valid {
|
||||
return Result{}, fmt.Errorf("public ID %s conflicts with an existing or deleted user; no users changed", p.PublicID)
|
||||
}
|
||||
var gender int
|
||||
if err = tx.QueryRowContext(ctx, `SELECT gender FROM user_profiles WHERE user_id=?`, id).Scan(&gender); err != nil || gender != p.Gender {
|
||||
return Result{}, fmt.Errorf("existing test profile %s has been modified or is incomplete", p.PublicID)
|
||||
}
|
||||
result.Skipped++
|
||||
} else if err == sql.ErrNoRows && batchCount == 0 {
|
||||
res, insertErr := tx.ExecContext(ctx, `INSERT INTO users (public_id,password_hash,is_test,test_batch,status) VALUES (?,'!TEST_PROFILE_NO_LOGIN',1,?,1)`, p.PublicID, Batch)
|
||||
if insertErr != nil {
|
||||
return Result{}, insertErr
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO user_profiles (user_id,nickname,avatar_url,cover_url,gender,birthday,height_cm,city_code,city_name,occupation,bio,profile_score) VALUES (?,?,?,'',?,?,?,?,?,?,?,80)`, id, p.Nickname, p.Avatar, p.Gender, p.Birthday, p.Height, p.CityCode, p.City, p.Occupation, p.Bio)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
// Fixture coordinates stay close to the declared city center and are
|
||||
// explicitly marked as imported, never as a real GPS observation.
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO user_privacy_settings (user_id,distance_visible,online_visible,last_active_visible,allow_profile_visit_record) VALUES (?,1,1,0,0)`, id)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO user_location_states(user_id,city_code,location_cell,latitude,longitude,source) VALUES(?,?,'fixture',?,?,'fixture')`, id, p.CityCode, p.Latitude, p.Longitude)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.Created++
|
||||
} else {
|
||||
if err == sql.ErrNoRows {
|
||||
err = fmt.Errorf("batch identity mismatch at %s", p.PublicID)
|
||||
}
|
||||
return Result{}, err
|
||||
}
|
||||
if p.Gender == 1 {
|
||||
result.Male++
|
||||
} else {
|
||||
result.Female++
|
||||
}
|
||||
result.IDs = append(result.IDs, id)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package testusers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// This test never connects to the application database. It creates its own
|
||||
// uniquely named schema on an explicitly supplied loopback MySQL connection.
|
||||
func isolatedMySQL(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("IM_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set IM_TEST_MYSQL_DSN to enable isolated local MySQL integration tests")
|
||||
}
|
||||
cfg, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal("invalid test MySQL DSN")
|
||||
}
|
||||
host, _, err := net.SplitHostPort(cfg.Addr)
|
||||
if err != nil || cfg.Net != "tcp" || (host != "127.0.0.1" && host != "localhost" && host != "::1") || cfg.DBName != "" {
|
||||
t.Fatal("integration tests require a loopback TCP DSN without a database name")
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
admin, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { admin.Close() })
|
||||
database := fmt.Sprintf("im_fixture_test_%d", time.Now().UnixNano())
|
||||
if !regexp.MustCompile(`^im_fixture_test_[0-9]+$`).MatchString(database) {
|
||||
t.Fatal("unsafe isolated database name")
|
||||
}
|
||||
if _, err = admin.Exec("CREATE DATABASE `" + database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Only drop the exact schema successfully created by this test.
|
||||
t.Cleanup(func() {
|
||||
if _, err := admin.Exec("DROP DATABASE `" + database + "`"); err != nil {
|
||||
t.Errorf("cleanup isolated schema %s: %v", database, err)
|
||||
}
|
||||
})
|
||||
cfg.DBName, cfg.MultiStatements = database, true
|
||||
db, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
for _, name := range []string{"001_users.sql", "028_test_users.sql"} {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "migrations", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec(string(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMySQLSeedRepeatabilityAndIsolation(t *testing.T) {
|
||||
db := isolatedMySQL(t)
|
||||
var database string
|
||||
if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('REAL_FIXTURE','sentinel')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := Seed(ctx, db, "https://example.com/uploads", "wrong_database"); err == nil {
|
||||
t.Fatal("missing target-database protection")
|
||||
}
|
||||
first, err := Seed(ctx, db, "https://example.com/uploads", database)
|
||||
if err != nil || first.Created != 100 || first.Male != 50 || first.Female != 50 || len(first.IDs) != 100 {
|
||||
t.Fatalf("first seed: %+v, %v", first, err)
|
||||
}
|
||||
second, err := Seed(ctx, db, "https://example.com/uploads", database)
|
||||
if err != nil || second.Created != 0 || second.Skipped != 100 || second.IDs[0] != first.IDs[0] {
|
||||
t.Fatalf("repeat seed: %+v, %v", second, err)
|
||||
}
|
||||
for query, want := range map[string]int{
|
||||
`SELECT COUNT(*) FROM users WHERE is_test=0 AND test_batch='' AND password_hash='sentinel'`: 1,
|
||||
`SELECT COUNT(*) FROM users WHERE is_test=1 AND phone_hash IS NULL AND phone_cipher IS NULL AND password_hash='!TEST_PROFILE_NO_LOGIN'`: 100,
|
||||
`SELECT COUNT(*) FROM user_profiles WHERE last_active_at IS NULL AND is_vip=0 AND vip_level=0`: 100,
|
||||
`SELECT COUNT(*) FROM user_profiles WHERE gender=1`: 50,
|
||||
`SELECT COUNT(*) FROM user_profiles WHERE gender=2`: 50,
|
||||
`SELECT COUNT(*) FROM user_sessions`: 0,
|
||||
} {
|
||||
var got int
|
||||
if err := db.QueryRow(query).Scan(&got); err != nil || got != want {
|
||||
t.Errorf("query %s: got %d want %d err %v", query, got, want, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE users SET test_batch='changed' WHERE public_id='TESTCN000100'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Seed(ctx, db, "https://example.com/uploads", database); err == nil || !strings.Contains(err.Error(), "expected 0 or 100") {
|
||||
t.Fatalf("partial batch should not be silently repaired: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLSeedRollsBackOnRealUserCollision(t *testing.T) {
|
||||
db := isolatedMySQL(t)
|
||||
var database string
|
||||
if err := db.QueryRow(`SELECT DATABASE()`).Scan(&database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Force a late collision to prove earlier inserts in this batch roll back.
|
||||
if _, err := db.Exec(`INSERT INTO users (public_id,password_hash) VALUES ('TESTCN000099','real-user-sentinel')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Seed(context.Background(), db, "https://example.com/uploads", database); err == nil {
|
||||
t.Fatal("real-user collision must fail")
|
||||
}
|
||||
var total, tests, profiles int
|
||||
_ = db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(is_test),0) FROM users`).Scan(&total, &tests)
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM user_profiles`).Scan(&profiles)
|
||||
if total != 1 || tests != 0 || profiles != 0 {
|
||||
t.Fatalf("partial seed survived rollback: users=%d tests=%d profiles=%d", total, tests, profiles)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user