package dbutil import ( "reflect" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "github.com/google/uuid" ) // buildMapping (flat struct) func TestBuildMappingFlat(t *testing.T) { tm := getMapping(reflect.TypeOf(testUser{})) want := map[string]bool{ "id": true, "username": true, "email": true, "password": true, "first_name": true, "last_name": true, "login_count": true, "created": true, "active": true, } for col := range want { if _, ok := tm.columns[col]; !ok { t.Errorf("missing mapping for column %q", col) } } if len(tm.columns) != len(want) { t.Errorf("column count = %d, want %d", len(tm.columns), len(want)) } } // buildMapping (nested struct / DTO) func TestBuildMappingNested(t *testing.T) { type DTO struct { Membership testMembership User testUser } tm := getMapping(reflect.TypeOf(DTO{})) if _, ok := tm.columns["membership.id"]; !ok { t.Error("missing membership.id") } if _, ok := tm.columns["membership.user_id"]; !ok { t.Error("missing membership.user_id") } if _, ok := tm.columns["user.id"]; !ok { t.Error("missing user.id") } if _, ok := tm.columns["user.username"]; !ok { t.Error("missing user.username") } // No unprefixed columns should exist if _, ok := tm.columns["id"]; ok { t.Error("should not have unprefixed 'id'") } } // buildMapping (alias tag) func TestBuildMappingAlias(t *testing.T) { type DTO struct { User testUser CreatedBy testUser `alias:"created_by"` } tm := getMapping(reflect.TypeOf(DTO{})) if _, ok := tm.columns["user.id"]; !ok { t.Error("missing user.id") } if _, ok := tm.columns["created_by.id"]; !ok { t.Error("missing created_by.id") } if _, ok := tm.columns["created_by.username"]; !ok { t.Error("missing created_by.username") } } // buildMapping (pointer-to-struct for LEFT JOINs) func TestBuildMappingPointerStruct(t *testing.T) { type DTO struct { Session testSession User *testUser `alias:"test_user"` } tm := getMapping(reflect.TypeOf(DTO{})) if _, ok := tm.columns["session.id"]; !ok { t.Error("missing session.id") } if _, ok := tm.columns["test_user.id"]; !ok { t.Error("missing test_user.id (via alias tag)") } if len(tm.ptrStructs) != 1 { t.Errorf("ptrStructs len = %d, want 1", len(tm.ptrStructs)) } } // buildMapping (embedded struct) func TestBuildMappingEmbedded(t *testing.T) { type Base struct { ID uuid.UUID `db:"id"` Created time.Time `db:"created"` } type Extended struct { Base Name string `db:"name"` } tm := getMapping(reflect.TypeOf(Extended{})) if _, ok := tm.columns["id"]; !ok { t.Error("missing flattened id from embedded Base") } if _, ok := tm.columns["created"]; !ok { t.Error("missing flattened created from embedded Base") } if _, ok := tm.columns["name"]; !ok { t.Error("missing name") } } // isModelStruct func TestIsModelStruct(t *testing.T) { if !isModelStruct(reflect.TypeOf(testUser{})) { t.Error("testUser should be a model struct") } if isModelStruct(reflect.TypeOf(time.Time{})) { t.Error("time.Time should not be a model struct") } if isModelStruct(reflect.TypeOf(struct{ X int }{})) { t.Error("anonymous struct without db tags should not be a model struct") } } // DebugMapping func TestDebugMapping(t *testing.T) { type DTO struct { User testUser } m := DebugMapping(DTO{}) if path, ok := m["user.id"]; !ok || path != "User.ID" { t.Errorf("user.id mapping = %q, ok = %v", path, ok) } if path, ok := m["user.username"]; !ok || path != "User.Username" { t.Errorf("user.username mapping = %q, ok = %v", path, ok) } } // ScanOne (flat struct via sqlmock) func TestScanOneFlat(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() id := uuid.New() now := time.Now().Truncate(time.Second) rows := sqlmock.NewRows([]string{"id", "username", "email", "password", "first_name", "last_name", "login_count", "created", "active"}). AddRow(id, "alice", "alice@test.com", "hash", "Alice", "Smith", int32(5), now, true) mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() var user testUser err = ScanOne(sqlRows, &user) if err != nil { t.Fatal(err) } if user.ID != id { t.Errorf("ID = %v, want %v", user.ID, id) } if user.Username != "alice" { t.Errorf("Username = %q", user.Username) } if user.Email != "alice@test.com" { t.Errorf("Email = %q", user.Email) } if user.FirstName != "Alice" { t.Errorf("FirstName = %q", user.FirstName) } if user.LoginCount != 5 { t.Errorf("LoginCount = %d", user.LoginCount) } if user.Active != true { t.Errorf("Active = %v", user.Active) } } // ScanAll (multiple rows) func TestScanAllFlat(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() id1, id2 := uuid.New(), uuid.New() now := time.Now().Truncate(time.Second) rows := sqlmock.NewRows([]string{"id", "key", "user_id", "org_id", "created", "user_agent", "revoked"}). AddRow(id1, "key1", uuid.New(), nil, now, "Mozilla", false). AddRow(id2, "key2", uuid.New(), nil, now, "Chrome", true) mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() var sessions []testSession err = ScanAll(sqlRows, &sessions) if err != nil { t.Fatal(err) } if len(sessions) != 2 { t.Fatalf("len = %d, want 2", len(sessions)) } if sessions[0].Key != "key1" { t.Errorf("[0].Key = %q", sessions[0].Key) } if sessions[1].Revoked != true { t.Errorf("[1].Revoked = %v", sessions[1].Revoked) } } // ScanOne (nested DTO with prefixed columns) func TestScanOneNested(t *testing.T) { type DTO struct { Membership testMembership User testUser } db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() mID, uID, orgID := uuid.New(), uuid.New(), uuid.New() now := time.Now().Truncate(time.Second) cols := []string{ "membership.id", "membership.user_id", "membership.org_id", "membership.created_by", "membership.joined", "membership.login_count", "user.id", "user.username", "user.email", } rows := sqlmock.NewRows(cols). AddRow( mID, uID, orgID, uuid.New(), now, int32(10), uID, "alice", "alice@test.com", ) mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() var dto DTO err = ScanOne(sqlRows, &dto) if err != nil { t.Fatal(err) } if dto.Membership.ID != mID { t.Errorf("Membership.ID = %v, want %v", dto.Membership.ID, mID) } if dto.Membership.OrgID != orgID { t.Errorf("Membership.OrgID = %v, want %v", dto.Membership.OrgID, orgID) } if dto.User.Username != "alice" { t.Errorf("User.Username = %q", dto.User.Username) } if dto.Membership.LoginCount != 10 { t.Errorf("Membership.LoginCount = %v, want 10", dto.Membership.LoginCount) } } // ScanOne (alias tag) func TestScanOneAlias(t *testing.T) { type DTO struct { User testUser CreatedBy testUser `alias:"created_by"` } db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() userID, cbID := uuid.New(), uuid.New() cols := []string{"user.id", "user.username", "created_by.id", "created_by.username"} rows := sqlmock.NewRows(cols). AddRow(userID, "alice", cbID, "bob") mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() var dto DTO err = ScanOne(sqlRows, &dto) if err != nil { t.Fatal(err) } if dto.User.ID != userID { t.Errorf("User.ID = %v, want %v", dto.User.ID, userID) } if dto.User.Username != "alice" { t.Errorf("User.Username = %q", dto.User.Username) } if dto.CreatedBy.ID != cbID { t.Errorf("CreatedBy.ID = %v, want %v", dto.CreatedBy.ID, cbID) } if dto.CreatedBy.Username != "bob" { t.Errorf("CreatedBy.Username = %q", dto.CreatedBy.Username) } } // Pointer-to-struct nil detection (LEFT JOIN) func TestScanOnePtrStructNilDetection(t *testing.T) { type DTO struct { Session testSession User *testUser `alias:"test_user"` } db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() sessID := uuid.New() now := time.Now().Truncate(time.Second) cols := []string{ "session.id", "session.key", "session.user_id", "session.org_id", "session.created", "session.user_agent", "session.revoked", // All user columns are NULL (LEFT JOIN miss) "test_user.id", "test_user.username", } rows := sqlmock.NewRows(cols). AddRow( sessID, "key1", uuid.New(), nil, now, "Mozilla", false, // NULL user uuid.Nil, "", ) mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() var dto DTO err = ScanOne(sqlRows, &dto) if err != nil { t.Fatal(err) } if dto.Session.ID != sessID { t.Errorf("Session.ID = %v, want %v", dto.Session.ID, sessID) } if dto.User != nil { t.Errorf("User should be nil for LEFT JOIN miss, got %+v", dto.User) } } func TestScanOnePtrStructNonNil(t *testing.T) { type DTO struct { Session testSession User *testUser `alias:"test_user"` } db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() sessID, userID := uuid.New(), uuid.New() now := time.Now().Truncate(time.Second) cols := []string{ "session.id", "session.key", "session.user_id", "session.org_id", "session.created", "session.user_agent", "session.revoked", "test_user.id", "test_user.username", } rows := sqlmock.NewRows(cols). AddRow( sessID, "key1", userID, nil, now, "Mozilla", false, userID, "alice", ) mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() var dto DTO err = ScanOne(sqlRows, &dto) if err != nil { t.Fatal(err) } if dto.User == nil { t.Fatal("User should not be nil") } if dto.User.ID != userID { t.Errorf("User.ID = %v, want %v", dto.User.ID, userID) } if dto.User.Username != "alice" { t.Errorf("User.Username = %q", dto.User.Username) } } // ScanOne: sql.ErrNoRows func TestScanOneNoRows(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() rows := sqlmock.NewRows([]string{"id", "username"}) mock.ExpectQuery("SELECT").WillReturnRows(rows) sqlRows, err := db.Query("SELECT anything") if err != nil { t.Fatal(err) } defer sqlRows.Close() type Small struct { ID string `db:"id"` Username string `db:"username"` } var s Small err = ScanOne(sqlRows, &s) if err == nil { t.Error("expected error for no rows") } } // Columns function func TestColumnsFunction(t *testing.T) { result := Columns(testSession{}, "s") if !containsSubstr(result, `s.id AS "test_session.id"`) { t.Errorf("missing s.id alias: %s", result) } if !containsSubstr(result, `s.key AS "test_session.key"`) { t.Errorf("missing s.key alias: %s", result) } } func TestColumnsWithPrefix(t *testing.T) { result := Columns(testUser{}, "cb", "created_by") if !containsSubstr(result, `cb.id AS "created_by.id"`) { t.Errorf("missing custom prefix alias: %s", result) } }