907 lines
28 KiB
Go
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
|
|
}
|
|
}
|