Files
codit/backend/tests/tx_store_concurrent_test.go
2026-06-21 22:38:18 +09:00

907 lines
28 KiB
Go

package tests
import "context"
import "errors"
import "fmt"
import "sync"
import "testing"
import "codit/internal/db"
import "codit/internal/models"
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func createTestBoard(t *testing.T, store *db.Store, user models.User, project models.Project, title string) models.Board {
var board models.Board
var created models.Board
var err error
board = models.Board{
ProjectID: project.ID,
Title: title,
CreatedBy: user.ID,
UpdatedBy: user.ID,
}
created, err = store.CreateBoard(context.Background(), board)
if err != nil {
t.Fatalf("create board %q: %v", title, err)
}
return created
}
func createTestBoardFieldValue(t *testing.T, store *db.Store, boardID string, field string, label string) models.BoardFieldValue {
var fv models.BoardFieldValue
var created models.BoardFieldValue
var err error
fv = models.BoardFieldValue{
Field: field,
Value: label, // Value must be unique within (board, field)
Label: label,
Color: "#ff0000",
}
created, err = store.CreateBoardFieldValue(context.Background(), boardID, fv)
if err != nil {
t.Fatalf("create board field value %q: %v", label, err)
}
return created
}
func createTestBlock(t *testing.T, store *db.Store, user models.User, board models.Board, title string) models.Block {
var block models.Block
var created models.Block
var err error
block = models.Block{
BoardID: board.ID,
CreatedBy: user.ID,
UpdatedBy: user.ID,
Type: "card",
Title: title,
Fields: "{}",
}
created, err = store.CreateBlock(context.Background(), block)
if err != nil {
t.Fatalf("create block %q: %v", title, err)
}
return created
}
func createTestSSHServer(t *testing.T, store *db.Store, user models.User, name string) models.SSHServer {
var item models.SSHServer
var created models.SSHServer
var err error
item = models.SSHServer{
Name: name,
Host: "10.0.0.1",
Port: 22,
Enabled: true,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
}
created, err = store.CreateSSHServer(item)
if err != nil {
t.Fatalf("create ssh server %q: %v", name, err)
}
return created
}
func createTestSSHAccessProfile(t *testing.T, store *db.Store, user models.User, server models.SSHServer, name string) models.SSHAccessProfile {
var item models.SSHAccessProfile
var created models.SSHAccessProfile
var err error
item = models.SSHAccessProfile{
ServerID: server.ID,
Name: name,
RemoteUsername: "deploy",
AuthMethod: "none",
OwnerScope: "admin_shared",
Enabled: true,
DefaultCertValidSeconds: 3600,
MaxCertValidSeconds: 3600,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
}
created, err = store.CreateSSHAccessProfile(context.Background(), item)
if err != nil {
t.Fatalf("create ssh access profile %q: %v", name, err)
}
return created
}
func createTestSSHCredential(t *testing.T, store *db.Store, user models.User, name string) models.SSHCredential {
var item models.SSHCredential
var secret models.SSHSecret
var created models.SSHCredential
var err error
item = models.SSHCredential{
Name: name,
Type: "private_key",
PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
Fingerprint: "SHA256:test-" + name,
Enabled: true,
OwnerScope: "user",
OwnerUserID: user.ID,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
}
secret = models.SSHSecret{
Payload: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
}
created, err = store.CreateSSHCredential(context.Background(), item, secret)
if err != nil {
t.Fatalf("create ssh credential %q: %v", name, err)
}
return created
}
// ---------------------------------------------------------------------------
// TestNestedTransactionReuse: a method that calls beginContext inside a
// BeginStore tx should reuse the outer transaction, not start a new one.
// Verified by checking that a rollback of the outer tx discards all inner work.
// ---------------------------------------------------------------------------
func TestNestedTransactionReuse(t *testing.T) {
var store *db.Store
var txStore *db.Store
var user models.User
var project models.Project
var board models.Board
var fv models.BoardFieldValue
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "nested-tx-user")
project = createTestProject(t, store, user, "nested-tx-proj")
board = createTestBoard(t, store, user, project, "board-nested")
// Open an outer transaction and create a field value inside it.
txStore, err = store.BeginStore(context.Background())
if err != nil {
t.Fatalf("begin outer tx: %v", err)
}
// CreateBoardFieldValue calls beginContext internally.
// It must reuse the outer txStore transaction, not start its own.
fv, err = txStore.CreateBoardFieldValue(context.Background(), board.ID, models.BoardFieldValue{
Field: "status",
Label: "In Progress",
Color: "#0000ff",
})
if err != nil {
_ = txStore.Rollback()
t.Fatalf("create field value inside outer tx: %v", err)
}
// Roll back the outer transaction — the field value must not be committed.
err = txStore.Rollback()
if err != nil {
t.Fatalf("rollback outer tx: %v", err)
}
// Verify the field value is not visible.
var values []models.BoardFieldValue
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
var i int
for i = 0; i < len(values); i++ {
if values[i].ID == fv.ID {
t.Fatal("field value created inside rolled-back outer tx is visible: inner tx was not reusing the outer tx")
}
}
}
// ---------------------------------------------------------------------------
// TestDeleteBoardFieldValueConcurrent: two goroutines delete the same field
// value simultaneously. Exactly one must succeed; the other must get
// ErrFieldValueNotFound. No panic, no double-delete.
// ---------------------------------------------------------------------------
func TestDeleteBoardFieldValueConcurrent(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var fv models.BoardFieldValue
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-fv-user")
project = createTestProject(t, store, user, "del-fv-proj")
board = createTestBoard(t, store, user, project, "board-del-fv")
fv = createTestBoardFieldValue(t, store, board.ID, "status", "todo")
var wg sync.WaitGroup
var successes int
var notFounds int
var mu sync.Mutex
var run func()
run = func() {
defer wg.Done()
var err error
err = store.DeleteBoardFieldValue(context.Background(), board.ID, fv.ID)
mu.Lock()
defer mu.Unlock()
if err == nil {
successes++
} else if errors.Is(err, db.ErrFieldValueNotFound) {
notFounds++
} else {
t.Errorf("unexpected error from DeleteBoardFieldValue: %v", err)
}
}
wg.Add(2)
go run()
go run()
wg.Wait()
if successes != 1 {
t.Errorf("expected exactly 1 successful delete, got %d", successes)
}
if notFounds != 1 {
t.Errorf("expected exactly 1 not-found error, got %d", notFounds)
}
}
// ---------------------------------------------------------------------------
// TestDeleteBoardFieldValueInUseRollsBack: deleting a field value that is used
// by block_properties must fail and must leave the field value intact.
// ---------------------------------------------------------------------------
func TestDeleteBoardFieldValueInUseRollsBack(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var block models.Block
var fv models.BoardFieldValue
var values []models.BoardFieldValue
var err error
var found bool
var i int
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-fv-in-use-user")
project = createTestProject(t, store, user, "del-fv-in-use-proj")
board = createTestBoard(t, store, user, project, "board-del-fv-in-use")
block = createTestBlock(t, store, user, board, "card-del-fv-in-use")
fv = createTestBoardFieldValue(t, store, board.ID, "status", "todo")
_, err = store.UpsertBlockProperties(context.Background(), board.ID, block.ID, models.BlockProperties{Status: fv.Value})
if err != nil {
t.Fatalf("upsert block properties: %v", err)
}
err = store.DeleteBoardFieldValue(context.Background(), board.ID, fv.ID)
if !errors.Is(err, db.ErrFieldValueInUse) {
t.Fatalf("expected ErrFieldValueInUse, got %v", err)
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values: %v", err)
}
found = false
for i = 0; i < len(values); i++ {
if values[i].ID == fv.ID {
found = true
}
}
if !found {
t.Fatal("field value disappeared after failed delete")
}
}
// ---------------------------------------------------------------------------
// TestDeleteSSHCredentialConcurrent: two goroutines delete the same SSH
// credential. Exactly one succeeds; the other gets a "not found" SQL error.
// ---------------------------------------------------------------------------
func TestDeleteSSHCredentialConcurrent(t *testing.T) {
var store *db.Store
var user models.User
var cred models.SSHCredential
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-cred-user")
cred = createTestSSHCredential(t, store, user, "my-key")
var wg sync.WaitGroup
var successes int
var failures int
var mu sync.Mutex
var run func()
run = func() {
defer wg.Done()
var err error
err = store.DeleteSSHCredential(context.Background(), cred.ID)
mu.Lock()
defer mu.Unlock()
if err == nil {
successes++
} else {
failures++
}
// the second delete gets a sql.ErrNoRows or similar — not counted as success
}
wg.Add(2)
go run()
go run()
wg.Wait()
if successes != 1 {
t.Errorf("expected exactly 1 successful delete, got %d", successes)
}
if failures != 1 {
t.Errorf("expected exactly 1 failed delete, got %d", failures)
}
// Verify the credential is gone.
var _, err = store.GetSSHCredential(cred.ID)
if err == nil {
t.Error("credential still exists after concurrent deletes")
}
}
// ---------------------------------------------------------------------------
// TestDeleteSSHCredentialInUseBlocked: deleting a credential referenced by an
// access profile must fail and leave the credential intact.
// ---------------------------------------------------------------------------
func TestDeleteSSHCredentialInUseBlocked(t *testing.T) {
var store *db.Store
var user models.User
var server models.SSHServer
var cred models.SSHCredential
var profile models.SSHAccessProfile
var loaded models.SSHCredential
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-cred-used-user")
server = createTestSSHServer(t, store, user, "credential-target")
cred = createTestSSHCredential(t, store, user, "used-key")
profile = createTestSSHAccessProfile(t, store, user, server, "profile-uses-credential")
profile.AuthMethod = "stored_private_key"
profile.SSHCredentialID = cred.ID
profile, err = store.UpdateSSHAccessProfile(context.Background(), profile)
if err != nil {
t.Fatalf("attach credential to profile: %v", err)
}
err = store.DeleteSSHCredential(context.Background(), cred.ID)
if err == nil {
t.Fatal("expected delete credential to fail while profile references it")
}
loaded, err = store.GetSSHCredential(cred.ID)
if err != nil {
t.Fatalf("credential missing after failed delete: %v", err)
}
if loaded.ID != cred.ID {
t.Fatalf("unexpected credential loaded after failed delete: %s", loaded.ID)
}
}
// ---------------------------------------------------------------------------
// TestDeleteSSHServerWhileProfileAdded: goroutine A checks references (zero),
// goroutine B concurrently adds a profile for the same server, then A deletes.
// With BEGIN IMMEDIATE the delete must either fail (reference found) or fully
// succeed atomically — it must never leave an orphaned access profile.
// ---------------------------------------------------------------------------
func TestDeleteSSHServerWhileProfileAdded(t *testing.T) {
var store *db.Store
var user models.User
var server models.SSHServer
var deleteErr error
var profileErr error
var wg sync.WaitGroup
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-server-user")
server = createTestSSHServer(t, store, user, "target-server")
// Barrier ensures both goroutines start at roughly the same time.
var ready = make(chan struct{})
// Goroutine A: delete the server.
wg.Add(1)
go func() {
defer wg.Done()
<-ready
deleteErr = store.DeleteSSHServer(context.Background(), server.ID)
}()
// Goroutine B: create an access profile for the same server.
wg.Add(1)
go func() {
defer wg.Done()
<-ready
var _, err = store.CreateSSHAccessProfile(context.Background(), models.SSHAccessProfile{
ServerID: server.ID,
Name: "profile-concurrent",
RemoteUsername: "deploy",
AuthMethod: "none",
OwnerScope: "admin_shared",
Enabled: true,
DefaultCertValidSeconds: 3600,
MaxCertValidSeconds: 3600,
CreatedByKind: "user",
CreatedBySubjectID: user.ID,
CreatedBySubjectName: user.Username,
})
profileErr = err
}()
close(ready)
wg.Wait()
// Regardless of which operation won:
// - If delete succeeded, the server is gone and the profile must not exist.
// - If profile creation succeeded, delete must have failed with a reference error.
// What must never happen: both succeed, leaving an orphaned profile record.
if deleteErr == nil && profileErr == nil {
// Both claimed success — verify no orphaned profile exists (server is gone,
// so a profile referencing it would be orphaned).
var profiles []models.SSHAccessProfile
var err error
profiles, err = store.ListSSHAccessProfiles()
if err != nil {
t.Fatalf("list profiles after concurrent ops: %v", err)
}
var i int
for i = 0; i < len(profiles); i++ {
if profiles[i].ServerID == server.ID {
t.Errorf("orphaned access profile exists for deleted server %s", server.ID)
}
}
}
// At least one operation must have succeeded.
if deleteErr != nil && profileErr != nil {
t.Errorf("both operations failed — deleteErr=%v profileErr=%v", deleteErr, profileErr)
}
}
// ---------------------------------------------------------------------------
// TestDeleteSSHServerWithProfileBlocked: the reference checks in DeleteSSHServer
// must run in the same transaction as the delete.
// ---------------------------------------------------------------------------
func TestDeleteSSHServerWithProfileBlocked(t *testing.T) {
var store *db.Store
var user models.User
var server models.SSHServer
var profiles []models.SSHAccessProfile
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "del-server-ref-user")
server = createTestSSHServer(t, store, user, "target-server-ref")
_ = createTestSSHAccessProfile(t, store, user, server, "profile-server-ref")
err = store.DeleteSSHServer(context.Background(), server.ID)
if err == nil {
t.Fatal("expected delete server to fail while profile references it")
}
profiles, err = store.ListSSHAccessProfiles()
if err != nil {
t.Fatalf("list profiles after failed delete: %v", err)
}
if len(profiles) != 1 {
t.Fatalf("expected profile to remain after failed delete, got %d", len(profiles))
}
if profiles[0].ServerID != server.ID {
t.Fatalf("profile references unexpected server after failed delete: %s", profiles[0].ServerID)
}
}
// ---------------------------------------------------------------------------
// TestReorderBoardFieldValuesConcurrent: two goroutines concurrently reorder
// the same field's values. Both use BEGIN IMMEDIATE, so they serialize.
// Both must succeed, and the final ordering must be a valid permutation with
// no duplicate display_order values.
// ---------------------------------------------------------------------------
func TestReorderBoardFieldValuesConcurrent(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var fv1, fv2, fv3 models.BoardFieldValue
var orderAB []string
var orderBA []string
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "reorder-user")
project = createTestProject(t, store, user, "reorder-proj")
board = createTestBoard(t, store, user, project, "board-reorder")
fv1 = createTestBoardFieldValue(t, store, board.ID, "status", "todo")
fv2 = createTestBoardFieldValue(t, store, board.ID, "status", "in-progress")
fv3 = createTestBoardFieldValue(t, store, board.ID, "status", "done")
orderAB = []string{fv1.ID, fv2.ID, fv3.ID}
orderBA = []string{fv3.ID, fv2.ID, fv1.ID}
var wg sync.WaitGroup
var errA, errB error
var ready = make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
<-ready
_, errA = store.ReorderBoardFieldValues(context.Background(), board.ID, "status", orderAB)
}()
wg.Add(1)
go func() {
defer wg.Done()
<-ready
_, errB = store.ReorderBoardFieldValues(context.Background(), board.ID, "status", orderBA)
}()
close(ready)
wg.Wait()
if errA != nil {
t.Errorf("reorder AB failed: %v", errA)
}
if errB != nil {
t.Errorf("reorder BA failed: %v", errB)
}
// Final state must have unique display_order values for all three field values.
var values []models.BoardFieldValue
var err error
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values after concurrent reorder: %v", err)
}
if len(values) != 3 {
t.Fatalf("expected 3 field values, got %d", len(values))
}
var seen = map[int]bool{}
var i int
for i = 0; i < len(values); i++ {
if seen[values[i].DisplayOrder] {
t.Errorf("duplicate display_order %d after concurrent reorder", values[i].DisplayOrder)
}
seen[values[i].DisplayOrder] = true
}
}
// ---------------------------------------------------------------------------
// TestReorderBoardFieldValuesUnknownIDRollsBack: if one ID is unknown, the
// reorder must fail and keep the previous order intact.
// ---------------------------------------------------------------------------
func TestReorderBoardFieldValuesUnknownIDRollsBack(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var fv1 models.BoardFieldValue
var fv2 models.BoardFieldValue
var before []models.BoardFieldValue
var after []models.BoardFieldValue
var beforeOrder map[string]int
var err error
var i int
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "reorder-missing-user")
project = createTestProject(t, store, user, "reorder-missing-proj")
board = createTestBoard(t, store, user, project, "board-reorder-missing")
fv1 = createTestBoardFieldValue(t, store, board.ID, "status", "todo")
fv2 = createTestBoardFieldValue(t, store, board.ID, "status", "done")
before, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values before reorder: %v", err)
}
beforeOrder = map[string]int{}
for i = 0; i < len(before); i++ {
beforeOrder[before[i].ID] = before[i].DisplayOrder
}
_, err = store.ReorderBoardFieldValues(context.Background(), board.ID, "status", []string{fv2.ID, "missing-field-value", fv1.ID})
if !errors.Is(err, db.ErrFieldValueNotFound) {
t.Fatalf("expected ErrFieldValueNotFound, got %v", err)
}
after, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values after failed reorder: %v", err)
}
if len(after) != len(before) {
t.Fatalf("field value count changed after failed reorder: before=%d after=%d", len(before), len(after))
}
for i = 0; i < len(after); i++ {
if beforeOrder[after[i].ID] != after[i].DisplayOrder {
t.Fatalf("display_order changed after failed reorder for %s: before=%d after=%d", after[i].ID, beforeOrder[after[i].ID], after[i].DisplayOrder)
}
}
}
// ---------------------------------------------------------------------------
// TestConcurrentBoardMemberUpsert: two goroutines concurrently upsert the same
// user as a board member. The result must be exactly one member row (no
// duplicate) and no error.
// ---------------------------------------------------------------------------
func TestConcurrentBoardMemberUpsert(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "upsert-member-user")
project = createTestProject(t, store, user, "upsert-member-proj")
board = createTestBoard(t, store, user, project, "board-upsert-member")
var wg sync.WaitGroup
var mu sync.Mutex
var errs []error
var ready = make(chan struct{})
var run func()
run = func() {
defer wg.Done()
<-ready
var err error
err = store.UpsertBoardMember(context.Background(), models.BoardMember{
BoardID: board.ID,
UserID: user.ID,
Role: models.RoleWriter,
})
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}
wg.Add(3)
go run()
go run()
go run()
close(ready)
wg.Wait()
if len(errs) > 0 {
t.Errorf("unexpected errors from concurrent UpsertBoardMember: %v", errs)
}
// Must be exactly one member row.
var members []models.BoardMember
var err error
members, err = store.ListBoardMembers(board.ID)
if err != nil {
t.Fatalf("list board members: %v", err)
}
if len(members) != 1 {
t.Errorf("expected 1 board member after concurrent upserts, got %d", len(members))
}
}
// ---------------------------------------------------------------------------
// TestUpsertBlockPropertiesInvalidAssigneeRollsBack: the assignee rewrite
// deletes existing assignees before inserting the new set. An invalid assignee
// must roll the whole transaction back and keep existing assignees.
// ---------------------------------------------------------------------------
func TestUpsertBlockPropertiesInvalidAssigneeRollsBack(t *testing.T) {
var store *db.Store
var user models.User
var other models.User
var project models.Project
var board models.Board
var block models.Block
var props models.BlockProperties
var loaded models.BlockProperties
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "props-owner-user")
other = createTestUser(t, store, "props-assignee-user")
project = createTestProject(t, store, user, "props-rollback-proj")
board = createTestBoard(t, store, user, project, "board-props-rollback")
block = createTestBlock(t, store, user, board, "card-props-rollback")
props = models.BlockProperties{
Status: "todo",
AssigneeIDs: []string{other.ID},
}
_, err = store.UpsertBlockProperties(context.Background(), board.ID, block.ID, props)
if err != nil {
t.Fatalf("initial upsert block properties: %v", err)
}
props = models.BlockProperties{
Status: "done",
AssigneeIDs: []string{other.ID, "missing-user"},
}
_, err = store.UpsertBlockProperties(context.Background(), board.ID, block.ID, props)
if !errors.Is(err, db.ErrUserNotFound) {
t.Fatalf("expected ErrUserNotFound, got %v", err)
}
loaded, err = store.GetBlockProperties(board.ID, block.ID)
if err != nil {
t.Fatalf("get block properties after failed upsert: %v", err)
}
if loaded.Status != "todo" {
t.Fatalf("status changed after failed upsert: %s", loaded.Status)
}
if len(loaded.AssigneeIDs) != 1 || loaded.AssigneeIDs[0] != other.ID {
t.Fatalf("assignees changed after failed upsert: %#v", loaded.AssigneeIDs)
}
}
// ---------------------------------------------------------------------------
// TestDeleteBlockAttachmentSoftDeletedParent: DeleteBlockAttachment must treat
// attachments under soft-deleted blocks as not found consistently.
// ---------------------------------------------------------------------------
func TestDeleteBlockAttachmentSoftDeletedParent(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
var block models.Block
var attachment models.BlockAttachment
var err error
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "attachment-delete-user")
project = createTestProject(t, store, user, "attachment-delete-proj")
board = createTestBoard(t, store, user, project, "board-attachment-delete")
block = createTestBlock(t, store, user, board, "card-attachment-delete")
attachment = models.BlockAttachment{
ID: "attachment-public-id",
Filename: "report.txt",
ContentType: "text/plain",
Size: 12,
StoragePath: "/tmp/report.txt",
CreatedBy: user.ID,
}
attachment, err = store.CreateBlockAttachment(context.Background(), board.ID, block.ID, attachment)
if err != nil {
t.Fatalf("create block attachment: %v", err)
}
err = store.DeleteBlock(context.Background(), board.ID, block.ID, user.ID)
if err != nil {
t.Fatalf("soft-delete block: %v", err)
}
_, err = store.DeleteBlockAttachment(context.Background(), board.ID, block.ID, attachment.ID)
if !errors.Is(err, db.ErrBlockAttachmentNotFound) {
t.Fatalf("expected ErrBlockAttachmentNotFound, got %v", err)
}
}
// ---------------------------------------------------------------------------
// TestConcurrentCreateBoardFieldValue: multiple goroutines create field values
// for the same board/field simultaneously. All should succeed and produce
// distinct IDs with no constraint violations.
// ---------------------------------------------------------------------------
func TestConcurrentCreateBoardFieldValue(t *testing.T) {
var store *db.Store
var user models.User
var project models.Project
var board models.Board
const n = 8
store = openTestStore(t)
defer store.Close()
user = createTestUser(t, store, "concurrent-create-fv-user")
project = createTestProject(t, store, user, "concurrent-create-fv-proj")
board = createTestBoard(t, store, user, project, "board-concurrent-fv")
var wg sync.WaitGroup
var mu sync.Mutex
var ids []string
var errs []error
var values []models.BoardFieldValue
var ready = make(chan struct{})
var seen map[string]bool
var seenOrder map[int]bool
var id string
var err error
var i int
for i = 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
<-ready
var fv models.BoardFieldValue
var err error
fv, err = store.CreateBoardFieldValue(context.Background(), board.ID, models.BoardFieldValue{
Field: "status",
Value: fmt.Sprintf("value-%d", idx),
Label: fmt.Sprintf("value-%d", idx),
Color: "#aabbcc",
})
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, err)
} else {
ids = append(ids, fv.ID)
}
}(i)
}
close(ready)
wg.Wait()
if len(errs) > 0 {
t.Errorf("unexpected errors creating field values concurrently: %v", errs)
}
if len(ids) != n {
t.Errorf("expected %d created field values, got %d", n, len(ids))
}
// All IDs must be unique.
seen = map[string]bool{}
for _, id = range ids {
if seen[id] {
t.Errorf("duplicate field value ID %s from concurrent creates", id)
}
seen[id] = true
}
values, err = store.ListBoardFieldValues(board.ID, "status")
if err != nil {
t.Fatalf("list field values after concurrent create: %v", err)
}
if len(values) != n {
t.Fatalf("expected %d stored field values, got %d", n, len(values))
}
seenOrder = map[int]bool{}
for i = 0; i < len(values); i++ {
if seenOrder[values[i].DisplayOrder] {
t.Fatalf("duplicate display_order %d after concurrent creates", values[i].DisplayOrder)
}
seenOrder[values[i].DisplayOrder] = true
}
}