diff --git a/server/core/go.mod b/server/core/go.mod index 5cb659c2..58f547a7 100644 --- a/server/core/go.mod +++ b/server/core/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-chi/cors v1.2.2 github.com/go-sql-driver/mysql v1.10.0 github.com/gomodule/redigo v1.9.3 + github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.6 github.com/google/go-github/v72 v72.0.0 github.com/gorilla/schema v1.4.1 diff --git a/server/core/internal/db/data_source_test.go b/server/core/internal/db/data_source_test.go new file mode 100644 index 00000000..68a610f8 --- /dev/null +++ b/server/core/internal/db/data_source_test.go @@ -0,0 +1,355 @@ +package db + +import ( + "context" + "database/sql" + "net/http" + "strconv" + "testing" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/datasource" + "github.com/oxynote/oxynote/server/core/internal/datasource/processor" + "github.com/oxynote/oxynote/server/core/pkg/errutil" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepDataSources(t *testing.T, db *DB, count int, fn func(int, *datasource.DataSource)) []*datasource.DataSource { + t.Helper() + + res := make([]*datasource.DataSource, count) + + now := timeutil.Now().Truncate(time.Second) + + for i := range count { + ds := &datasource.DataSource{ + ID: xid.New(), + Name: "Data Source " + strconv.Itoa(i), + Type: datasource.TypePrometheus, + URL: "http://prometheus-" + strconv.Itoa(i) + ".test", + Credentials: processor.Credentials(`{"username":"user","password":"pass"}`), + Status: processor.ConnectionStatusSuccess, + // distinct timestamps keep the fetch order deterministic. + CreatedAt: now.Add(-time.Duration(i) * time.Second), + } + + if fn != nil { + fn(i, ds) + } + + if ds.OrganizationID == "" { + ds.OrganizationID = prepOrganizations(t, db, 1)[0] + } + + res[i] = ds + + safeCredentials, err := ds.Credentials.Encrypt(db.opts.DataSourceCredentialsSigningSecret) + require.NoError(t, err) + + q, args := db.builder.Insert("data_sources"). + SetMap(map[string]any{ + "id": ds.ID, + "fk_organization_id": ds.OrganizationID, + "name": ds.Name, + "type": ds.Type, + "url": ds.URL, + "credentials": safeCredentials, + "status": ds.Status, + "created_at": ds.CreatedAt, + "updated_at": ds.UpdatedAt, + }).MustSql() + + _, err = db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertDataSource(t *testing.T) { + stubDataSource := func(organizationID, name string) *datasource.DataSource { + return &datasource.DataSource{ + ID: xid.New(), + OrganizationID: organizationID, + Name: name, + Type: datasource.TypePrometheus, + URL: "http://prometheus.test", + Credentials: processor.Credentials(`{"token":"secret"}`), + Status: processor.ConnectionStatusSuccess, + CreatedAt: timeutil.Now().Truncate(time.Second), + } + } + + type tcase struct { + InvalidSecret bool + DataSource *datasource.DataSource + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Invalid signing secret": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + InvalidSecret: true, + DataSource: stubDataSource(org, "Data Source 1"), + Err: assert.AnError, + } + }, + "Duplicate name within the organization": func(t *testing.T, db *DB) tcase { + ds := prepDataSources(t, db, 1, nil)[0] + + return tcase{ + DataSource: stubDataSource(ds.OrganizationID, ds.Name), + Err: errutil.New( + http.StatusBadRequest, + "data_source.duplicate_name", + "name is already in use", + ), + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + DataSource: stubDataSource(org, "Data Source 1"), + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + if c.InvalidSecret { + db.opts.DataSourceCredentialsSigningSecret = "bad" + } + + err := db.InsertDataSource(context.Background(), c.DataSource) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDataSource(context.Background(), c.DataSource.ID, c.DataSource.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.DataSource, res) + }) + } +} + +func Test_agent_UpdateDataSource(t *testing.T) { + type tcase struct { + InvalidSecret bool + DataSource *datasource.DataSource + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Invalid signing secret": func(t *testing.T, db *DB) tcase { + ds := prepDataSources(t, db, 1, nil)[0] + + return tcase{ + InvalidSecret: true, + DataSource: ds, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + ds := prepDataSources(t, db, 1, nil)[0] + ds.Name = "Updated Data Source" + ds.URL = "http://updated.test" + ds.Credentials = processor.Credentials(`{"token":"rotated"}`) + ds.Status = processor.ConnectionStatusSuccess + ds.UpdatedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + + return tcase{ + DataSource: ds, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + if c.InvalidSecret { + db.opts.DataSourceCredentialsSigningSecret = "bad" + } + + err := db.UpdateDataSource(context.Background(), c.DataSource) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDataSource(context.Background(), c.DataSource.ID, c.DataSource.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.DataSource, res) + }) + } +} + +func Test_agent_DeleteDataSource(t *testing.T) { + type tcase struct { + CancelledContext bool + ID xid.ID + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + ds := prepDataSources(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + ID: ds.ID, + OrganizationID: ds.OrganizationID, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + ds := prepDataSources(t, db, 1, nil)[0] + + return tcase{ + ID: ds.ID, + OrganizationID: ds.OrganizationID, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDataSource(ctx, c.ID, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var id xid.ID + + q, args := db.builder.Select("id"). + From("data_sources"). + Where(sq.Eq{ + "id": c.ID, + }).MustSql() + + err = db.sql.Get(&id, q, args...) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + }) + } +} + +func Test_agent_FetchDataSource(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchDataSource(context.Background(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // error - undecryptable credentials + ds := prepDataSources(t, db, 1, nil)[0] + + q, args := db.builder.Update("data_sources"). + SetMap(map[string]any{ + "credentials": []byte("garbage"), + }). + Where(sq.Eq{ + "id": ds.ID, + }).MustSql() + + _, err = db.sql.Exec(q, args...) + require.NoError(t, err) + + res, err = db.FetchDataSource(context.Background(), ds.ID, ds.OrganizationID) + require.Error(t, err) + assert.Nil(t, res) + + // success + ds = prepDataSources(t, db, 1, nil)[0] + + res, err = db.FetchDataSource(context.Background(), ds.ID, ds.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, ds, res) +} + +func Test_agent_FetchDataSources(t *testing.T) { + db := prepTempDB(t) + + // success - no data sources + res, err := db.FetchDataSources(context.Background(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err = db.FetchDataSources(ctx, "non-existent-org-id") + require.Error(t, err) + assert.Nil(t, res) + + // error - undecryptable credentials + org := prepOrganizations(t, db, 1)[0] + ds := prepDataSources(t, db, 1, func(_ int, nds *datasource.DataSource) { + nds.OrganizationID = org + })[0] + + q, args := db.builder.Update("data_sources"). + SetMap(map[string]any{ + "credentials": []byte("garbage"), + }). + Where(sq.Eq{ + "id": ds.ID, + }).MustSql() + + _, err = db.sql.Exec(q, args...) + require.NoError(t, err) + + res, err = db.FetchDataSources(context.Background(), org) + require.Error(t, err) + assert.Nil(t, res) + + // success - ordered by creation time, newest first + org = prepOrganizations(t, db, 1)[0] + sources := prepDataSources(t, db, 3, func(_ int, nds *datasource.DataSource) { + nds.OrganizationID = org + }) + + res, err = db.FetchDataSources(context.Background(), org) + assert.NoError(t, err) + require.Len(t, res, len(sources)) + + for i, ds := range sources { + testutil.AssertFilterEqual(t, *ds, res[i]) + } +} diff --git a/server/core/internal/db/db_test.go b/server/core/internal/db/db_test.go index 68c2d082..78b804e1 100644 --- a/server/core/internal/db/db_test.go +++ b/server/core/internal/db/db_test.go @@ -10,9 +10,12 @@ import ( "strconv" "testing" + "github.com/DATA-DOG/go-sqlmock" + sq "github.com/Masterminds/squirrel" "github.com/dchest/uniuri" "github.com/jackc/pgx/v5/pgconn" "github.com/jmoiron/sqlx" + "github.com/lann/builder" "github.com/orlangure/gnomock" pgDocker "github.com/orlangure/gnomock/preset/postgres" "github.com/oxynote/oxynote/server/core/pkg/errutil" @@ -28,6 +31,11 @@ var _pgDSN string const ( _pgUser string = "pgtest" _pgPass string = "pgpass" + + // _dataSourceSecret is the credentials signing secret used by + // all temporary test databases. It must be a valid AES key + // length (32 bytes). + _dataSourceSecret string = "0123456789abcdef0123456789abcdef" ) func TestMain(m *testing.M) { @@ -208,11 +216,32 @@ func Test_Tx_Rollback(t *testing.T) { require.Error(t, ntx.Rollback()) } +// prepMockDB creates an agent backed by sqlmock for reaching error +// branches that a real database cannot produce (e.g. failures in the +// middle of a multi-statement transaction). +func prepMockDB(t *testing.T) (*agent, sqlmock.Sqlmock) { + t.Helper() + + mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp)) + require.NoError(t, err) + + t.Cleanup(func() { + mockDB.Close() //nolint:errcheck,gosec // error provides no meaningful info + require.NoError(t, mock.ExpectationsWereMet()) + }) + + return &agent{ + sql: sqlx.NewDb(mockDB, "sqlmock"), + builder: sq.StatementBuilderType(builder.EmptyBuilder).PlaceholderFormat(sq.Dollar), + }, mock +} + func prepTempDB(t *testing.T) *DB { name := uniuri.NewLenChars(10, []byte("abcdefghijklmnopqrstuvwxyz")) opts := Options{ - DSN: fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", _pgUser, _pgPass, _pgDSN, name), + DSN: fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", _pgUser, _pgPass, _pgDSN, name), + DataSourceCredentialsSigningSecret: _dataSourceSecret, } tmpDB, err := sqlx.Connect( diff --git a/server/core/internal/db/document_branch_test.go b/server/core/internal/db/document_branch_test.go new file mode 100644 index 00000000..e532be91 --- /dev/null +++ b/server/core/internal/db/document_branch_test.go @@ -0,0 +1,674 @@ +package db + +import ( + "context" + "database/sql" + "strconv" + "testing" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepDocumentBranches(t *testing.T, db *DB, count int, fn func(int, *document.Document)) []*document.Document { + t.Helper() + + res := make([]*document.Document, count) + + now := timeutil.Now().Truncate(time.Second) + + // branches missing a document after fn ran share one lazily + // created parent, so a single call produces sibling branches. + var parent *document.Document + + for i := range count { + doc := &document.Document{ + Branch: document.Branch{ + BranchID: xid.New(), + BranchName: "branch-" + strconv.Itoa(i), + DocumentName: "Branch Document " + strconv.Itoa(i), + Icon: "icon-branch", + Content: document.RootBlock{ + Type: "doc", + Content: []document.Block{ + { + Type: "paragraph", + Text: "Branch content " + strconv.Itoa(i), + }, + }, + }, + RawContent: []byte("Branch content " + strconv.Itoa(i)), + CreatedAt: now, + UpdatedAt: now, + }, + } + + if fn != nil { + fn(i, doc) + } + + if doc.ID.IsZero() { + if parent == nil { + parent = prepDocuments(t, db, 1, nil)[0] + } + + doc.ID = parent.ID + doc.OrganizationID = parent.OrganizationID + } + + res[i] = doc + + q, args := db.builder.Insert("document_branches"). + SetMap(map[string]any{ + "id": doc.BranchID, + "fk_document_id": doc.ID, + "fk_organization_id": doc.OrganizationID, + "branch_name": doc.BranchName, + "document_name": doc.DocumentName, + "icon": doc.Icon, + "content": doc.Content, + "raw_content": doc.RawContent, + "protected": doc.Protected, + `"default"`: doc.Default, + "created_at": doc.CreatedAt, + "fk_created_by": doc.CreatedBy, + "updated_at": doc.UpdatedAt, + "fk_last_updated_by": doc.LastUpdatedBy, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_insertDocumentBranch(t *testing.T) { + type tcase struct { + Document document.Document + Original *document.Document + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Duplicate branch ID": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + doc := prepDocuments(t, db, 1, nil)[0] + doc.BranchID = branch.BranchID + doc.BranchName = "another-branch" + + return tcase{ + Document: *doc, + Err: assert.AnError, + } + }, + "Existing branch is left untouched": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + doc := *branch + doc.BranchID = xid.New() + doc.DocumentName = "Should Not Overwrite" + + return tcase{ + Document: doc, + Original: branch, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + doc.BranchID = xid.New() + doc.BranchName = "feature-x" + + return tcase{ + Document: *doc, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.insertDocumentBranch(context.Background(), c.Document) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + exp := &c.Document + + if c.Original != nil { + exp = c.Original + } + + res, err := db.FetchDocumentByBranchID(context.Background(), exp.BranchID, exp.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, exp, res) + }) + } +} + +func Test_agent_upsertDocumentBranch(t *testing.T) { + type tcase struct { + Document document.Document + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent document": func(_ *testing.T, _ *DB) tcase { + return tcase{ + Document: document.Document{ + ID: xid.New(), + OrganizationID: "non-existent-org-id", + Branch: document.Branch{ + BranchID: xid.New(), + BranchName: "feature-x", + CreatedAt: timeutil.Now().Truncate(time.Second), + UpdatedAt: timeutil.Now().Truncate(time.Second), + }, + }, + Err: assert.AnError, + } + }, + "Successful insert of a new branch": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + doc.BranchID = xid.New() + doc.BranchName = "feature-x" + + return tcase{ + Document: *doc, + } + }, + "Successful update of an existing branch": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + doc.DocumentName = "Updated Name" + doc.Icon = "updated-icon" + doc.RawContent = []byte("updated content") + doc.Protected = true + doc.UpdatedAt = timeutil.Now().Truncate(time.Second).Add(time.Hour) + + return tcase{ + Document: *doc, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.upsertDocumentBranch(context.Background(), c.Document) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentByBranchID(context.Background(), c.Document.BranchID, c.Document.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Document, res) + }) + } +} + +func Test_agent_ForkDocumentBranch(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := db.ForkDocumentBranch(ctx, xid.New(), "org-id", document.DefaultBranch, "feature-x", "user-1") + require.Error(t, err) + + // success - source branch does not exist, nothing inserted + doc := prepDocuments(t, db, 1, nil)[0] + + err = db.ForkDocumentBranch(context.Background(), doc.ID, doc.OrganizationID, "non-existent-branch", "feature-x", "user-1") + require.NoError(t, err) + + count, err := db.CountDocumentBranches(context.Background(), doc.ID, doc.OrganizationID) + require.NoError(t, err) + assert.Equal(t, 1, count) + + // success - fork of the default branch + users := prepUsers(t, db, 1) + + err = db.ForkDocumentBranch(context.Background(), doc.ID, doc.OrganizationID, document.DefaultBranch, "feature-x", users[0]) + require.NoError(t, err) + + var forked document.Document + + q, args := db.builder.Select( + `id AS "branch_id"`, + `branch_name AS "branch_name"`, + `document_name AS "document_name"`, + `icon AS "icon"`, + `content AS "content"`, + `raw_content AS "raw_content"`, + `protected AS "protected"`, + `"default" AS "default"`, + `fk_created_by AS "fk_created_by"`, + `fk_last_updated_by AS "fk_last_updated_by"`, + ).From("document_branches"). + Where(sq.Eq{ + "fk_document_id": doc.ID, + "branch_name": "feature-x", + }).MustSql() + + err = db.sql.Get(&forked, q, args...) + require.NoError(t, err) + + assert.Equal(t, doc.DocumentName, forked.DocumentName) + assert.Equal(t, doc.Icon, forked.Icon) + assert.Equal(t, doc.Content, forked.Content) + assert.Equal(t, doc.RawContent, forked.RawContent) + assert.False(t, forked.Default) + assert.Equal(t, null.StringFrom(users[0]), forked.CreatedBy) + assert.Equal(t, null.StringFrom(users[0]), forked.LastUpdatedBy) + + // success - target branch already exists, nothing overwritten + err = db.ForkDocumentBranch(context.Background(), doc.ID, doc.OrganizationID, document.DefaultBranch, "feature-x", "someone-else") + require.NoError(t, err) + + var createdBy null.String + + q, args = db.builder.Select("fk_created_by"). + From("document_branches"). + Where(sq.Eq{ + "fk_document_id": doc.ID, + "branch_name": "feature-x", + }).MustSql() + + err = db.sql.Get(&createdBy, q, args...) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(users[0]), createdBy) +} + +func Test_agent_FetchMainBranchContent(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchMainBranchContent(context.Background(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Zero(t, res) + + // success + doc := prepDocuments(t, db, 1, nil)[0] + + res, err = db.FetchMainBranchContent(context.Background(), doc.ID, doc.OrganizationID) + assert.NoError(t, err) + assert.Equal(t, document.Content{ + OrganizationID: doc.OrganizationID, + DocumentID: doc.ID, + DocumentName: doc.DocumentName, + Content: doc.Content, + }, res) +} + +func Test_agent_FetchDocumentBranches(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentBranches(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no branches + res, err = db.FetchDocumentBranches(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success + doc := prepDocuments(t, db, 1, nil)[0] + branch := prepDocumentBranches(t, db, 1, func(_ int, ndoc *document.Document) { + ndoc.ID = doc.ID + ndoc.OrganizationID = doc.OrganizationID + })[0] + + exp := []document.BranchSummary{ + { + BranchID: doc.BranchID, + BranchName: doc.BranchName, + DocumentName: doc.DocumentName, + Icon: doc.Icon, + Protected: doc.Protected, + Default: doc.Default, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }, + { + BranchID: branch.BranchID, + BranchName: branch.BranchName, + DocumentName: branch.DocumentName, + Icon: branch.Icon, + Protected: branch.Protected, + Default: branch.Default, + CreatedAt: branch.CreatedAt, + UpdatedAt: branch.UpdatedAt, + }, + } + + res, err = db.FetchDocumentBranches(context.Background(), doc.ID, doc.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, exp, res) +} + +func Test_agent_CountDocumentBranches(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + count, err := db.CountDocumentBranches(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Zero(t, count) + + // success - no branches + count, err = db.CountDocumentBranches(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Zero(t, count) + + // success + doc := prepDocuments(t, db, 1, nil)[0] + prepDocumentBranches(t, db, 2, func(_ int, ndoc *document.Document) { + ndoc.ID = doc.ID + ndoc.OrganizationID = doc.OrganizationID + }) + + count, err = db.CountDocumentBranches(context.Background(), doc.ID, doc.OrganizationID) + assert.NoError(t, err) + assert.Equal(t, 3, count) +} + +func Test_agent_DeleteDocumentBranchByID(t *testing.T) { + type tcase struct { + CancelledContext bool + BranchID xid.ID + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + BranchID: branch.BranchID, + OrganizationID: branch.OrganizationID, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + + return tcase{ + BranchID: branch.BranchID, + OrganizationID: branch.OrganizationID, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDocumentBranchByID(ctx, c.BranchID, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var id xid.ID + + q, args := db.builder.Select("id"). + From("document_branches"). + Where(sq.Eq{ + "id": c.BranchID, + }).MustSql() + + err = db.sql.Get(&id, q, args...) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + }) + } +} + +func Test_agent_UpdateDocumentBranchMetadata(t *testing.T) { + type tcase struct { + CancelledContext bool + Document document.Document + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Document: *branch, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + branch := prepDocumentBranches(t, db, 1, nil)[0] + branch.BranchName = "renamed-branch" + branch.Protected = true + branch.UpdatedAt = branch.UpdatedAt.Add(time.Hour) + branch.LastUpdatedBy = null.StringFrom(users[0]) + + return tcase{ + Document: *branch, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateDocumentBranchMetadata(ctx, c.Document) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentByBranchID(context.Background(), c.Document.BranchID, c.Document.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Document, res) + }) + } +} + +func Test_agent_FetchDocumentByBranchID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchDocumentByBranchID(context.Background(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + branch := prepDocumentBranches(t, db, 1, nil)[0] + + res, err = db.FetchDocumentByBranchID(context.Background(), branch.BranchID, branch.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, branch, res) +} + +func Test_agent_FetchDocumentBranchesUnsafe(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentBranchesUnsafe(ctx, xid.New()) + require.Error(t, err) + assert.Nil(t, res) + + // success - no branches + res, err = db.FetchDocumentBranchesUnsafe(context.Background(), xid.New()) + require.NoError(t, err) + assert.Empty(t, res) + + // success - no organization scoping applied + doc := prepDocuments(t, db, 1, nil)[0] + + exp := []document.BranchSummary{ + { + BranchID: doc.BranchID, + BranchName: doc.BranchName, + DocumentName: doc.DocumentName, + Icon: doc.Icon, + Protected: doc.Protected, + Default: doc.Default, + CreatedAt: doc.CreatedAt, + UpdatedAt: doc.UpdatedAt, + }, + } + + res, err = db.FetchDocumentBranchesUnsafe(context.Background(), doc.ID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, exp, res) +} + +func Test_agent_FetchDocumentUnsafeByBranchID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchDocumentUnsafeByBranchID(context.Background(), xid.New()) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success - no organization scoping applied + branch := prepDocumentBranches(t, db, 1, nil)[0] + + res, err = db.FetchDocumentUnsafeByBranchID(context.Background(), branch.BranchID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, branch, res) +} + +func Test_agent_insertDocumentBranchChangelog(t *testing.T) { + type tcase struct { + DocumentID xid.ID + BranchName string + Changelog document.Changelog + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent branch": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + clog := doc.Changelog() + + return tcase{ + DocumentID: doc.ID, + BranchName: "non-existent-branch", + Changelog: clog, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + DocumentID: doc.ID, + BranchName: doc.BranchName, + Changelog: doc.Changelog(), + } + }, + "Successful update of an existing entry": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + require.NoError(t, db.insertDocumentBranchChangelog( + context.Background(), + doc.ID, + doc.BranchName, + doc.Changelog(), + )) + + doc.RawContent = []byte("updated raw content") + + return tcase{ + DocumentID: doc.ID, + BranchName: doc.BranchName, + Changelog: doc.Changelog(), + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.insertDocumentBranchChangelog(context.Background(), c.DocumentID, c.BranchName, c.Changelog) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var clog document.Changelog + + q, args := db.builder.Select( + `id AS "id"`, + `fk_document_id AS "fk_document_id"`, + `content AS "content"`, + `raw_content AS "raw_content"`, + `created_at AS "created_at"`, + ).From("document_branch_changelogs"). + Where(sq.Eq{ + "id": c.Changelog.ID, + }).MustSql() + + err = db.sql.Get(&clog, q, args...) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.Changelog, clog) + }) + } +} diff --git a/server/core/internal/db/document_comment_test.go b/server/core/internal/db/document_comment_test.go new file mode 100644 index 00000000..a354fbd0 --- /dev/null +++ b/server/core/internal/db/document_comment_test.go @@ -0,0 +1,726 @@ +package db + +import ( + "context" + "database/sql" + "strconv" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/comment" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepDocumentComments(t *testing.T, db *DB, count int, fn func(int, *comment.Comment)) []comment.Comment { //nolint:unparam // fixture helpers share a uniform signature + t.Helper() + + res := make([]comment.Comment, count) + + now := timeutil.Now().Truncate(time.Second) + + // comments missing a branch after fn ran share one lazily created + // branch, so a single call produces comments of the same branch. + var branch *document.Document + + for i := range count { + c := comment.Comment{ + ID: xid.New(), + Content: comment.Content{ + "type": "paragraph", + "text": "Comment " + strconv.Itoa(i), + }, + // distinct timestamps keep the fetch order deterministic. + CreatedAt: now.Add(-time.Duration(i) * time.Second), + } + + if fn != nil { + fn(i, &c) + } + + if c.BranchID.IsZero() { + if branch == nil { + branch = prepDocumentBranches(t, db, 1, nil)[0] + } + + c.BranchID = branch.BranchID + c.DocumentID = branch.ID + c.OrganizationID = branch.OrganizationID + } + + res[i] = c + + q, args := db.builder.Insert("document_comments"). + SetMap(map[string]any{ + "id": c.ID, + "fk_document_id": c.DocumentID, + "fk_organization_id": c.OrganizationID, + "fk_user_id": c.UserID, + "fk_branch_id": c.BranchID, + "resolved": c.Resolved, + "fk_resolved_by": c.ResolvedBy, + "anchor_block_id": c.AnchorBlockID, + "content": c.Content, + "diff_deletion_context": c.DiffDeletionContext, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func prepDocumentCommentReplies(t *testing.T, db *DB, count int, fn func(int, *comment.Reply)) []comment.Reply { + t.Helper() + + res := make([]comment.Reply, count) + + now := timeutil.Now().Truncate(time.Second) + + // replies missing a comment after fn ran share one lazily created + // comment, so a single call produces replies of the same comment. + var parent *comment.Comment + + for i := range count { + r := comment.Reply{ + ID: xid.New(), + Content: comment.Content{ + "type": "paragraph", + "text": "Reply " + strconv.Itoa(i), + }, + // distinct ascending timestamps keep the fetch order + // deterministic. + CreatedAt: now.Add(time.Duration(i) * time.Second), + } + + if fn != nil { + fn(i, &r) + } + + if r.CommentID.IsZero() { + if parent == nil { + parent = &prepDocumentComments(t, db, 1, nil)[0] + } + + r.CommentID = parent.ID + r.OrganizationID = parent.OrganizationID + } + + res[i] = r + + q, args := db.builder.Insert("document_comment_replies"). + SetMap(map[string]any{ + "id": r.ID, + "fk_document_comment_id": r.CommentID, + "fk_organization_id": r.OrganizationID, + "fk_user_id": r.UserID, + "content": r.Content, + "created_at": r.CreatedAt, + "updated_at": r.UpdatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertDocumentComment(t *testing.T) { + type tcase struct { + Comment comment.Comment + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent branch": func(_ *testing.T, _ *DB) tcase { + return tcase{ + Comment: comment.Comment{ + ID: xid.New(), + DocumentID: xid.New(), + OrganizationID: "non-existent-org-id", + BranchID: xid.New(), + Content: comment.Content{ + "text": "hello", + }, + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + users := prepUsers(t, db, 1) + + return tcase{ + Comment: comment.Comment{ + ID: xid.New(), + DocumentID: branch.ID, + OrganizationID: branch.OrganizationID, + BranchID: branch.BranchID, + AnchorBlockID: null.StringFrom("block-1"), + UserID: null.StringFrom(users[0]), + Content: comment.Content{ + "type": "paragraph", + "text": "A fresh comment", + }, + CreatedAt: timeutil.Now().Truncate(time.Second), + Replies: []comment.Reply{}, + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertDocumentComment(context.Background(), c.Comment) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentComment(context.Background(), c.Comment.ID, c.Comment.DocumentID, c.Comment.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Comment, res) + }) + } +} + +func Test_agent_InsertDocumentCommentReply(t *testing.T) { + type tcase struct { + Reply comment.Reply + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent comment": func(_ *testing.T, _ *DB) tcase { + return tcase{ + Reply: comment.Reply{ + ID: xid.New(), + CommentID: xid.New(), + OrganizationID: "non-existent-org-id", + Content: comment.Content{ + "text": "hello", + }, + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + c := prepDocumentComments(t, db, 1, nil)[0] + users := prepUsers(t, db, 1) + + return tcase{ + Reply: comment.Reply{ + ID: xid.New(), + CommentID: c.ID, + OrganizationID: c.OrganizationID, + UserID: null.StringFrom(users[0]), + Content: comment.Content{ + "type": "paragraph", + "text": "A fresh reply", + }, + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertDocumentCommentReply(context.Background(), c.Reply) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentCommentReply(context.Background(), c.Reply.ID, c.Reply.CommentID, c.Reply.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Reply, res) + }) + } +} + +func Test_agent_FetchDocumentComment(t *testing.T) { + t.Run("Not found", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + res, err := db.FetchDocumentComment(context.Background(), xid.New(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + }) + + t.Run("Error returned by the replies query", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + id := xid.New() + + mock.ExpectQuery("SELECT .* FROM document_comments"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(id.String())) + mock.ExpectQuery("SELECT .* FROM document_comment_replies"). + WillReturnError(assert.AnError) + + res, err := a.FetchDocumentComment(context.Background(), id, xid.New(), "org-id") + assert.Equal(t, assert.AnError, err) + assert.Nil(t, res) + }) + + t.Run("Successful fetch with replies", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + c := prepDocumentComments(t, db, 1, nil)[0] + c.Replies = prepDocumentCommentReplies(t, db, 2, func(_ int, r *comment.Reply) { + r.CommentID = c.ID + r.OrganizationID = c.OrganizationID + }) + + res, err := db.FetchDocumentComment(context.Background(), c.ID, c.DocumentID, c.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, &c, res) + }) +} + +func Test_agent_FetchDocumentCommentReply(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchDocumentCommentReply(context.Background(), xid.New(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + r := prepDocumentCommentReplies(t, db, 1, nil)[0] + + res, err = db.FetchDocumentCommentReply(context.Background(), r.ID, r.CommentID, r.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, &r, res) +} + +func Test_agent_UpdateDocumentComment(t *testing.T) { + type tcase struct { + CancelledContext bool + Comment comment.Comment + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + c := prepDocumentComments(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Comment: c, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + c := prepDocumentComments(t, db, 1, nil)[0] + c.Content = comment.Content{ + "type": "paragraph", + "text": "Updated comment", + } + c.Resolved = true + c.ResolvedBy = null.StringFrom(users[0]) + c.AnchorBlockID = null.StringFrom("block-2") + c.UpdatedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + c.Replies = []comment.Reply{} + + return tcase{ + Comment: c, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateDocumentComment(ctx, c.Comment) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentComment(context.Background(), c.Comment.ID, c.Comment.DocumentID, c.Comment.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Comment, res) + }) + } +} + +func Test_agent_UpdateDocumentCommentReply(t *testing.T) { + type tcase struct { + CancelledContext bool + Reply comment.Reply + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + r := prepDocumentCommentReplies(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Reply: r, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + r := prepDocumentCommentReplies(t, db, 1, nil)[0] + r.Content = comment.Content{ + "type": "paragraph", + "text": "Updated reply", + } + r.UpdatedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + + return tcase{ + Reply: r, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateDocumentCommentReply(ctx, c.Reply) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentCommentReply(context.Background(), c.Reply.ID, c.Reply.CommentID, c.Reply.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Reply, res) + }) + } +} + +func Test_agent_DeleteDocumentComment(t *testing.T) { + type tcase struct { + CancelledContext bool + Comment comment.Comment + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + c := prepDocumentComments(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Comment: c, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + c := prepDocumentComments(t, db, 1, nil)[0] + + return tcase{ + Comment: c, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDocumentComment(ctx, c.Comment.ID, c.Comment.DocumentID, c.Comment.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentComment(context.Background(), c.Comment.ID, c.Comment.DocumentID, c.Comment.OrganizationID) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + }) + } +} + +func Test_agent_DeleteDocumentCommentReply(t *testing.T) { + type tcase struct { + CancelledContext bool + Reply comment.Reply + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + r := prepDocumentCommentReplies(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Reply: r, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + r := prepDocumentCommentReplies(t, db, 1, nil)[0] + + return tcase{ + Reply: r, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDocumentCommentReply(ctx, c.Reply.ID, c.Reply.CommentID, c.Reply.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentCommentReply(context.Background(), c.Reply.ID, c.Reply.CommentID, c.Reply.OrganizationID) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + }) + } +} + +func Test_agent_ReplaceDocumentComment(t *testing.T) { + type tcase struct { + CancelledContext bool + Comment comment.Comment + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + c := prepDocumentComments(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Comment: c, + Err: assert.AnError, + } + }, + "Successful replace": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + c := prepDocumentComments(t, db, 1, nil)[0] + c.Content = comment.Content{ + "type": "paragraph", + "text": "Promoted reply content", + } + c.UserID = null.StringFrom(users[0]) + c.AnchorBlockID = null.StringFrom("block-3") + c.CreatedAt = c.CreatedAt.Add(time.Hour) + c.UpdatedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + c.Replies = []comment.Reply{} + + return tcase{ + Comment: c, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.ReplaceDocumentComment(ctx, c.Comment) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentComment(context.Background(), c.Comment.ID, c.Comment.DocumentID, c.Comment.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Comment, res) + }) + } +} + +func Test_agent_FetchDocumentCommentsByBranchID(t *testing.T) { + t.Run("Error returned by the comments query", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentCommentsByBranchID(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Nil(t, res) + }) + + t.Run("Error returned by the replies query", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + id := xid.New() + + mock.ExpectQuery("SELECT .* FROM document_comments"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(id.String())) + mock.ExpectQuery("SELECT .* FROM document_comment_replies"). + WillReturnError(assert.AnError) + + res, err := a.FetchDocumentCommentsByBranchID(context.Background(), xid.New(), "org-id") + assert.Equal(t, assert.AnError, err) + assert.Nil(t, res) + }) + + t.Run("Successful fetch", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + // success - no comments + res, err := db.FetchDocumentCommentsByBranchID(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success - newest comment first, replies in creation order + comments := prepDocumentComments(t, db, 2, nil) + comments[0].Replies = prepDocumentCommentReplies(t, db, 2, func(_ int, r *comment.Reply) { + r.CommentID = comments[0].ID + r.OrganizationID = comments[0].OrganizationID + }) + + res, err = db.FetchDocumentCommentsByBranchID(context.Background(), comments[0].BranchID, comments[0].OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, comments, res) + }) +} + +func Test_agent_DeleteDocumentCommentsByBranchID(t *testing.T) { + type tcase struct { + CancelledContext bool + Comments []comment.Comment + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + comments := prepDocumentComments(t, db, 1, nil) + + return tcase{ + CancelledContext: true, + Comments: comments, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + return tcase{ + Comments: prepDocumentComments(t, db, 2, nil), + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDocumentCommentsByBranchID(ctx, c.Comments[0].BranchID, c.Comments[0].OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentCommentsByBranchID(context.Background(), c.Comments[0].BranchID, c.Comments[0].OrganizationID) + require.NoError(t, err) + assert.Empty(t, res) + }) + } +} diff --git a/server/core/internal/db/document_file_test.go b/server/core/internal/db/document_file_test.go new file mode 100644 index 00000000..5217a1d4 --- /dev/null +++ b/server/core/internal/db/document_file_test.go @@ -0,0 +1,124 @@ +package db + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepDocumentFiles(t *testing.T, db *DB, count int, fn func(int, *document.File)) []document.File { + t.Helper() + + res := make([]document.File, count) + + now := timeutil.Now().Truncate(time.Second) + + for i := range count { + f := document.File{ + ID: "file-" + xid.New().String(), + Location: document.LocationDocument, + CreatedAt: now.Add(-time.Duration(i) * time.Second), + } + + if fn != nil { + fn(i, &f) + } + + if f.DocumentID.IsZero() { + doc := prepDocuments(t, db, 1, nil)[0] + f.DocumentID = doc.ID + f.OrganizationID = doc.OrganizationID + } + + res[i] = f + + q, args := db.builder.Insert("document_files"). + SetMap(map[string]any{ + "id": f.ID, + "location": f.Location, + "fk_document_id": f.DocumentID, + "fk_organization_id": f.OrganizationID, + "created_at": f.CreatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertDocumentFile(t *testing.T) { + type tcase struct { + File document.File + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Duplicate file ID": func(t *testing.T, db *DB) tcase { + f := prepDocumentFiles(t, db, 1, nil)[0] + + return tcase{ + File: f, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + File: document.File{ + ID: "file-1", + Location: document.LocationComment, + DocumentID: doc.ID, + OrganizationID: doc.OrganizationID, + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertDocumentFile(context.Background(), c.File) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentFile(context.Background(), c.File.ID, c.File.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.File, res) + }) + } +} + +func Test_agent_FetchDocumentFile(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchDocumentFile(context.Background(), "non-existent-file-id", "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + f := prepDocumentFiles(t, db, 1, nil)[0] + + res, err = db.FetchDocumentFile(context.Background(), f.ID, f.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, &f, res) +} diff --git a/server/core/internal/db/document_hook_test.go b/server/core/internal/db/document_hook_test.go new file mode 100644 index 00000000..376c0780 --- /dev/null +++ b/server/core/internal/db/document_hook_test.go @@ -0,0 +1,392 @@ +package db + +import ( + "context" + "database/sql" + "testing" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/internal/document/hook" + "github.com/oxynote/oxynote/server/core/internal/document/hook/processor" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepDocumentHooks(t *testing.T, db *DB, count int, fn func(int, *hook.Hook)) []hook.Hook { + t.Helper() + + res := make([]hook.Hook, count) + + now := timeutil.Now().Truncate(time.Second) + + // hooks missing a document after fn ran share one lazily created + // branch, so a single call produces hooks of the same document. + var branch *document.Document + + for i := range count { + hk := hook.Hook{ + ID: xid.New(), + Type: hook.TypeURLWatcher, + BlockID: null.StringFrom("block-" + xid.New().String()), + // JSONB round-trips in canonical form (space after the + // colon), so the fixture uses that form for comparisons. + Settings: processor.Settings(`{"url": "http://watched.test"}`), + State: processor.State(`{"status":"ok"}`), + Score: decimal.NewFromInt(50), + CreatedAt: now, + } + + if fn != nil { + fn(i, &hk) + } + + if hk.DocumentID.IsZero() { + if branch == nil { + branch = prepDocumentBranches(t, db, 1, nil)[0] + } + + hk.DocumentID = branch.ID + hk.OrganizationID = branch.OrganizationID + hk.BranchID = null.ValueFrom(branch.BranchID) + } + + res[i] = hk + + q, args := db.builder.Insert("document_hooks"). + SetMap(map[string]any{ + "id": hk.ID, + "type": hk.Type, + "fk_document_id": hk.DocumentID, + "fk_organization_id": hk.OrganizationID, + "fk_branch_id": hk.BranchID, + "block_id": hk.BlockID, + "settings": hk.Settings, + "state": hk.State, + "score": hk.Score, + "created_at": hk.CreatedAt, + "updated_at": hk.UpdatedAt, + "soft_deleted_at": hk.SoftDeletedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertDocumentHook(t *testing.T) { + type tcase struct { + Hook hook.Hook + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Duplicate hook ID": func(t *testing.T, db *DB) tcase { + hk := prepDocumentHooks(t, db, 1, nil)[0] + + return tcase{ + Hook: hk, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + + return tcase{ + Hook: hook.Hook{ + ID: xid.New(), + Type: hook.TypeScheduledReminder, + DocumentID: branch.ID, + OrganizationID: branch.OrganizationID, + BranchID: null.ValueFrom(branch.BranchID), + Settings: processor.Settings(`{"schedule": "0 0 * * * *"}`), + State: processor.State(`{}`), + Score: decimal.NewFromInt(100), + CreatedAt: timeutil.Now().Truncate(time.Second), + UpdatedAt: null.TimeFrom(timeutil.Now().Truncate(time.Second)), + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertDocumentHook(context.Background(), c.Hook) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentHook(context.Background(), c.Hook.ID, c.Hook.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Hook, res) + }) + } +} + +func Test_agent_FetchDocumentHook(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchDocumentHook(context.Background(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + hk := prepDocumentHooks(t, db, 1, nil)[0] + + res, err = db.FetchDocumentHook(context.Background(), hk.ID, hk.OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, &hk, res) +} + +func Test_agent_FetchDocumentHooksByDocumentID(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentHooksByDocumentID(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no hooks + res, err = db.FetchDocumentHooksByDocumentID(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success + hooks := prepDocumentHooks(t, db, 2, nil) + + res, err = db.FetchDocumentHooksByDocumentID(context.Background(), hooks[0].DocumentID, hooks[0].OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, hooks, res) +} + +func Test_agent_FetchPaginatedDocumentHooks(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchPaginatedDocumentHooks(ctx, xid.NilID(), 10) + require.Error(t, err) + assert.Nil(t, res) + + // success - no hooks + res, err = db.FetchPaginatedDocumentHooks(context.Background(), xid.NilID(), 10) + require.NoError(t, err) + assert.Empty(t, res) + + // success - first page + hooks := prepDocumentHooks(t, db, 3, nil) + + res, err = db.FetchPaginatedDocumentHooks(context.Background(), xid.NilID(), 2) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, hooks[:2], res) + + // success - second page + res, err = db.FetchPaginatedDocumentHooks(context.Background(), hooks[1].ID, 2) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, hooks[2:], res) +} + +func Test_agent_UpdateDocumentHook(t *testing.T) { + type tcase struct { + CancelledContext bool + Hook hook.Hook + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + hk := prepDocumentHooks(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Hook: hk, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + hk := prepDocumentHooks(t, db, 1, nil)[0] + hk.Settings = processor.Settings(`{"url": "http://updated.test"}`) + hk.State = processor.State(`{"status":"failing"}`) + hk.Score = decimal.NewFromInt(10) + hk.UpdatedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + hk.SoftDeletedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + + return tcase{ + Hook: hk, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateDocumentHook(ctx, c.Hook) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentHook(context.Background(), c.Hook.ID, c.Hook.OrganizationID) + require.NoError(t, err) + testutil.AssertFilterEqual(t, &c.Hook, res) + }) + } +} + +func Test_agent_DeleteDocumentHook(t *testing.T) { + type tcase struct { + CancelledContext bool + Hook hook.Hook + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + hk := prepDocumentHooks(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Hook: hk, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + hk := prepDocumentHooks(t, db, 1, nil)[0] + + return tcase{ + Hook: hk, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDocumentHook(ctx, c.Hook.ID, c.Hook.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentHook(context.Background(), c.Hook.ID, c.Hook.OrganizationID) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + }) + } +} + +func Test_agent_FetchDocumentHooksByBranchID(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentHooksByBranchID(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no hooks + res, err = db.FetchDocumentHooksByBranchID(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success - soft-deleted hooks are excluded + hooks := prepDocumentHooks(t, db, 2, func(i int, hk *hook.Hook) { + if i == 1 { + hk.SoftDeletedAt = null.TimeFrom(timeutil.Now().Truncate(time.Second)) + } + }) + + res, err = db.FetchDocumentHooksByBranchID(context.Background(), hooks[0].BranchID.V, hooks[0].OrganizationID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, hooks[:1], res) +} + +func Test_agent_SoftDeleteDocumentHooksByBranchID(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := db.SoftDeleteDocumentHooksByBranchID(ctx, xid.New(), "org-id") + require.Error(t, err) + + // success - active hooks are stamped, already deleted ones keep + // their original timestamp + deletedAt := timeutil.Now().Truncate(time.Second).Add(-time.Hour) + + hooks := prepDocumentHooks(t, db, 2, func(i int, hk *hook.Hook) { + if i == 1 { + hk.SoftDeletedAt = null.TimeFrom(deletedAt) + } + }) + + err = db.SoftDeleteDocumentHooksByBranchID(context.Background(), hooks[0].BranchID.V, hooks[0].OrganizationID) + require.NoError(t, err) + + var stamps []null.Time + + q, args := db.builder.Select("soft_deleted_at"). + From("document_hooks"). + Where(sq.Eq{ + "id": []xid.ID{hooks[0].ID, hooks[1].ID}, + }). + OrderBy("id ASC"). + MustSql() + + err = db.sql.Select(&stamps, q, args...) + require.NoError(t, err) + require.Len(t, stamps, 2) + + require.True(t, stamps[0].Valid) + assert.WithinDuration(t, timeutil.Now(), stamps[0].Time, time.Minute) + assert.True(t, stamps[1].Valid) + assert.WithinDuration(t, deletedAt, stamps[1].Time, time.Second) +} diff --git a/server/core/internal/db/document_maintainers_test.go b/server/core/internal/db/document_maintainers_test.go new file mode 100644 index 00000000..ce497f01 --- /dev/null +++ b/server/core/internal/db/document_maintainers_test.go @@ -0,0 +1,110 @@ +package db + +import ( + "context" + "testing" + + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_agent_UpsertDocumentMaintainers(t *testing.T) { + type tcase struct { + DocumentID xid.ID + OrganizationID string + MaintainerIDs []string + Result []string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent user": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + DocumentID: doc.ID, + OrganizationID: doc.OrganizationID, + MaintainerIDs: []string{"non-existent-user-id"}, + Err: assert.AnError, + } + }, + "Successful upsert": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + users := prepUsers(t, db, 2) + + return tcase{ + DocumentID: doc.ID, + OrganizationID: doc.OrganizationID, + MaintainerIDs: users, + Result: users, + } + }, + "Existing maintainers are left untouched": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + users := prepUsers(t, db, 2) + + require.NoError(t, db.UpsertDocumentMaintainers( + context.Background(), + doc.ID, + doc.OrganizationID, + users[:1], + )) + + return tcase{ + DocumentID: doc.ID, + OrganizationID: doc.OrganizationID, + MaintainerIDs: users, + Result: users, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.UpsertDocumentMaintainers(context.Background(), c.DocumentID, c.OrganizationID, c.MaintainerIDs) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchDocumentMaintainers(context.Background(), c.DocumentID, c.OrganizationID) + require.NoError(t, err) + assert.ElementsMatch(t, c.Result, res) + }) + } +} + +func Test_agent_FetchDocumentMaintainers(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentMaintainers(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no maintainers + res, err = db.FetchDocumentMaintainers(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success + doc := prepDocuments(t, db, 1, nil)[0] + users := prepUsers(t, db, 2) + + require.NoError(t, db.UpsertDocumentMaintainers(context.Background(), doc.ID, doc.OrganizationID, users)) + + res, err = db.FetchDocumentMaintainers(context.Background(), doc.ID, doc.OrganizationID) + assert.NoError(t, err) + assert.ElementsMatch(t, users, res) +} diff --git a/server/core/internal/db/document_reviewers_test.go b/server/core/internal/db/document_reviewers_test.go new file mode 100644 index 00000000..e3b46d93 --- /dev/null +++ b/server/core/internal/db/document_reviewers_test.go @@ -0,0 +1,427 @@ +package db + +import ( + "context" + "database/sql" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepBranchReviewers(t *testing.T, db *DB, count int, fn func(int, *document.BranchReviewer)) []document.BranchReviewer { + t.Helper() + + res := make([]document.BranchReviewer, count) + + // reviewers missing a branch after fn ran share one lazily + // created branch, so a single call produces co-reviewers. + var branch *document.Document + + for i := range count { + reviewer := document.BranchReviewer{} + + if fn != nil { + fn(i, &reviewer) + } + + if reviewer.BranchID.IsZero() { + if branch == nil { + branch = prepDocumentBranches(t, db, 1, nil)[0] + } + + reviewer.BranchID = branch.BranchID + reviewer.OrganizationID = branch.OrganizationID + } + + if reviewer.UserID == "" { + reviewer.UserID = prepUsers(t, db, 1)[0] + } + + res[i] = reviewer + + q, args := db.builder.Insert("branch_reviewers"). + SetMap(map[string]any{ + "fk_branch_id": reviewer.BranchID, + "fk_user_id": reviewer.UserID, + "fk_organization_id": reviewer.OrganizationID, + "currently_approved": reviewer.CurrentlyApproved, + "previously_approved": reviewer.PreviouslyApproved, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_FetchBranchReviewers(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchBranchReviewers(ctx, xid.New(), "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no reviewers + res, err = db.FetchBranchReviewers(context.Background(), xid.New(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success + reviewers := prepBranchReviewers(t, db, 2, nil) + + res, err = db.FetchBranchReviewers(context.Background(), reviewers[0].BranchID, reviewers[0].OrganizationID) + assert.NoError(t, err) + assert.ElementsMatch(t, reviewers, res) +} + +func Test_agent_FetchBranchReviewer(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchBranchReviewer(context.Background(), xid.New(), "user-id", "org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + reviewer := prepBranchReviewers(t, db, 1, func(_ int, r *document.BranchReviewer) { + r.CurrentlyApproved = true + })[0] + + res, err = db.FetchBranchReviewer(context.Background(), reviewer.BranchID, reviewer.UserID, reviewer.OrganizationID) + assert.NoError(t, err) + assert.Equal(t, &reviewer, res) +} + +func Test_agent_InsertBranchReviewer(t *testing.T) { + type tcase struct { + Reviewer document.BranchReviewer + Result document.BranchReviewer + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent branch": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + + return tcase{ + Reviewer: document.BranchReviewer{ + BranchID: xid.New(), + UserID: users[0], + OrganizationID: "non-existent-org-id", + }, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + branch := prepDocumentBranches(t, db, 1, nil)[0] + users := prepUsers(t, db, 1) + + reviewer := document.BranchReviewer{ + BranchID: branch.BranchID, + UserID: users[0], + OrganizationID: branch.OrganizationID, + } + + return tcase{ + Reviewer: reviewer, + Result: reviewer, + } + }, + "Existing reviewer is left untouched": func(t *testing.T, db *DB) tcase { + original := prepBranchReviewers(t, db, 1, func(_ int, r *document.BranchReviewer) { + r.CurrentlyApproved = true + })[0] + + reviewer := original + reviewer.CurrentlyApproved = false + + return tcase{ + Reviewer: reviewer, + Result: original, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertBranchReviewer(context.Background(), c.Reviewer) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchBranchReviewer(context.Background(), c.Reviewer.BranchID, c.Reviewer.UserID, c.Reviewer.OrganizationID) + require.NoError(t, err) + assert.Equal(t, &c.Result, res) + }) + } +} + +func Test_agent_UpdateBranchReviewer(t *testing.T) { + type tcase struct { + CancelledContext bool + Reviewer document.BranchReviewer + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + reviewer := prepBranchReviewers(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Reviewer: reviewer, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + reviewer := prepBranchReviewers(t, db, 1, nil)[0] + reviewer.CurrentlyApproved = true + + return tcase{ + Reviewer: reviewer, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateBranchReviewer(ctx, c.Reviewer) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchBranchReviewer(context.Background(), c.Reviewer.BranchID, c.Reviewer.UserID, c.Reviewer.OrganizationID) + require.NoError(t, err) + assert.Equal(t, &c.Reviewer, res) + }) + } +} + +func Test_agent_DeleteBranchReviewer(t *testing.T) { + type tcase struct { + CancelledContext bool + Reviewer document.BranchReviewer + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + reviewer := prepBranchReviewers(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Reviewer: reviewer, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + reviewer := prepBranchReviewers(t, db, 1, nil)[0] + + return tcase{ + Reviewer: reviewer, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteBranchReviewer(ctx, c.Reviewer.BranchID, c.Reviewer.UserID, c.Reviewer.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchBranchReviewer(context.Background(), c.Reviewer.BranchID, c.Reviewer.UserID, c.Reviewer.OrganizationID) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + }) + } +} + +func Test_agent_PromoteBranchApprovals(t *testing.T) { + t.Run("Error returned by the stale reviewer cleanup", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM branch_reviewers").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.PromoteBranchApprovals(context.Background(), xid.New(), xid.New(), "org-id") + assert.Equal(t, assert.AnError, err) + }) + + t.Run("Error returned by the reviewer update", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM branch_reviewers").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("UPDATE branch_reviewers").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.PromoteBranchApprovals(context.Background(), xid.New(), xid.New(), "org-id") + assert.Equal(t, assert.AnError, err) + }) + + t.Run("Error returned by the reviewer upsert", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM branch_reviewers").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("UPDATE branch_reviewers").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO branch_reviewers").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.PromoteBranchApprovals(context.Background(), xid.New(), xid.New(), "org-id") + assert.Equal(t, assert.AnError, err) + }) + + t.Run("Error returned by the source cleanup", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM branch_reviewers").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("UPDATE branch_reviewers").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO branch_reviewers").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("DELETE FROM branch_reviewers").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.PromoteBranchApprovals(context.Background(), xid.New(), xid.New(), "org-id") + assert.Equal(t, assert.AnError, err) + }) + + t.Run("Cancelled context", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := db.PromoteBranchApprovals(ctx, xid.New(), xid.New(), "org-id") + require.Error(t, err) + }) + + t.Run("Successful promotion", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + doc := prepDocuments(t, db, 1, nil)[0] + target := prepDocumentBranches(t, db, 1, func(_ int, ndoc *document.Document) { + ndoc.ID = doc.ID + ndoc.OrganizationID = doc.OrganizationID + })[0] + users := prepUsers(t, db, 4) + + // the target branch holds one reviewer who never approved + // (dropped), one currently approved (demoted to previously + // approved and shared with the source branch), and one with + // only a stale previous approval. + prepBranchReviewers(t, db, 3, func(i int, r *document.BranchReviewer) { + r.BranchID = target.BranchID + r.OrganizationID = target.OrganizationID + r.UserID = users[i] + r.CurrentlyApproved = i == 1 + r.PreviouslyApproved = i == 2 + }) + + // the source branch holds the shared reviewer with a fresh + // approval and a brand new reviewer without one. + prepBranchReviewers(t, db, 2, func(i int, r *document.BranchReviewer) { + r.BranchID = doc.BranchID + r.OrganizationID = doc.OrganizationID + r.UserID = users[2*i+1] + r.CurrentlyApproved = i == 0 + }) + + err := db.PromoteBranchApprovals(context.Background(), doc.BranchID, target.BranchID, doc.OrganizationID) + require.NoError(t, err) + + // the source branch is cleared. + res, err := db.FetchBranchReviewers(context.Background(), doc.BranchID, doc.OrganizationID) + require.NoError(t, err) + assert.Empty(t, res) + + // users[0] never approved and is dropped; users[1] is demoted + // to previously approved and re-approves via the source branch; + // users[2] loses the stale previous approval; users[3] joins + // without an approval. + res, err = db.FetchBranchReviewers(context.Background(), target.BranchID, target.OrganizationID) + require.NoError(t, err) + assert.ElementsMatch(t, []document.BranchReviewer{ + { + BranchID: target.BranchID, + UserID: users[1], + OrganizationID: target.OrganizationID, + CurrentlyApproved: true, + PreviouslyApproved: true, + }, + { + BranchID: target.BranchID, + UserID: users[2], + OrganizationID: target.OrganizationID, + CurrentlyApproved: false, + PreviouslyApproved: false, + }, + { + BranchID: target.BranchID, + UserID: users[3], + OrganizationID: target.OrganizationID, + CurrentlyApproved: false, + PreviouslyApproved: false, + }, + }, res) + }) +} diff --git a/server/core/internal/db/document_search_job_test.go b/server/core/internal/db/document_search_job_test.go new file mode 100644 index 00000000..259720db --- /dev/null +++ b/server/core/internal/db/document_search_job_test.go @@ -0,0 +1,216 @@ +package db + +import ( + "context" + "database/sql" + "strconv" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/oxynote/oxynote/server/core/internal/document/searchgw" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepDocumentSearchJobs(t *testing.T, db *DB, count int, fn func(int, *searchgw.DocumentSearchJob)) []searchgw.DocumentSearchJob { + t.Helper() + + res := make([]searchgw.DocumentSearchJob, count) + + for i := range count { + job := searchgw.DocumentSearchJob{ + BlockDiff: searchgw.BlocksDifference{ + Updated: []searchgw.Block{ + { + ID: "block-" + strconv.Itoa(i), + OrganizationID: "org-" + strconv.Itoa(i), + DocumentID: xid.New(), + Type: "paragraph", + Text: "Updated block " + strconv.Itoa(i), + }, + }, + }, + } + + if fn != nil { + fn(i, &job) + } + + // the id column is a serial; let the database assign it so + // the sequence stays usable for production inserts. + q, args := db.builder.Insert("document_search_jobs"). + SetMap(map[string]any{ + "block_diff": job.BlockDiff, + }). + Suffix("RETURNING id"). + MustSql() + + err := db.sql.Get(&job.ID, q, args...) + require.NoError(t, err) + + res[i] = job + } + + return res +} + +func Test_agent_InsertDocumentSearchJob(t *testing.T) { + type tcase struct { + CancelledContext bool + BlockDiff searchgw.BlocksDifference + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(_ *testing.T, _ *DB) tcase { + return tcase{ + CancelledContext: true, + Err: assert.AnError, + } + }, + "Successful insert": func(_ *testing.T, _ *DB) tcase { + return tcase{ + BlockDiff: searchgw.BlocksDifference{ + Added: []searchgw.Block{ + { + ID: "block-added", + OrganizationID: "org-added", + DocumentID: xid.New(), + Type: "heading", + Text: "Added block", + }, + }, + Removed: []searchgw.Block{ + { + ID: "block-removed", + OrganizationID: "org-removed", + DocumentID: xid.New(), + Type: "paragraph", + Text: "Removed block", + }, + }, + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.InsertDocumentSearchJob(ctx, c.BlockDiff) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var blockDiff searchgw.BlocksDifference + + q, args := db.builder.Select("block_diff"). + From("document_search_jobs"). + MustSql() + + err = db.sql.Get(&blockDiff, q, args...) + require.NoError(t, err) + assert.Equal(t, c.BlockDiff, blockDiff) + }) + } +} + +func Test_agent_FetchDocumentSearchJobs(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentSearchJobs(ctx, 10) + require.Error(t, err) + assert.Nil(t, res) + + // success - no jobs + res, err = db.FetchDocumentSearchJobs(context.Background(), 10) + require.NoError(t, err) + assert.Empty(t, res) + + // success - limited batch in id order + jobs := prepDocumentSearchJobs(t, db, 3, nil) + + res, err = db.FetchDocumentSearchJobs(context.Background(), 2) + assert.NoError(t, err) + assert.Equal(t, jobs[:2], res) +} + +func Test_agent_DeleteDocumentSearchJob(t *testing.T) { + type tcase struct { + CancelledContext bool + ID int64 + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + jobs := prepDocumentSearchJobs(t, db, 1, nil) + + return tcase{ + CancelledContext: true, + ID: jobs[0].ID, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + jobs := prepDocumentSearchJobs(t, db, 1, nil) + + return tcase{ + ID: jobs[0].ID, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteDocumentSearchJob(ctx, c.ID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var id int64 + + q, args := db.builder.Select("id"). + From("document_search_jobs"). + Where(sq.Eq{ + "id": c.ID, + }).MustSql() + + err = db.sql.Get(&id, q, args...) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + }) + } +} diff --git a/server/core/internal/db/document_test.go b/server/core/internal/db/document_test.go index 94abd159..9f1d057d 100644 --- a/server/core/internal/db/document_test.go +++ b/server/core/internal/db/document_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/DATA-DOG/go-sqlmock" sq "github.com/Masterminds/squirrel" "github.com/guregu/null/v5" "github.com/oxynote/oxynote/server/core/internal/document" @@ -127,11 +128,54 @@ func Test_agent_InsertDocument(t *testing.T) { } type tcase struct { - Document document.Document - Err error + CancelledContext bool + Document document.Document + Err error } cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + CancelledContext: true, + Document: stubDocument(org), + Err: assert.AnError, + } + }, + "Sort index race exhausts retries": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + parent := prepDocuments(t, db, 1, func(_ int, ndoc *document.Document) { + ndoc.OrganizationID = org + })[0] + + // a single child whose sort_index is 1 leaves a gap at 0: + // every attempt counts one sibling, picks sort_index 1, + // and collides again, so the retry loop runs dry. + child := stubDocument(org) + child.ParentID = null.ValueFrom(parent.ID) + + q, args := db.builder.Insert("documents"). + SetMap(map[string]any{ + "id": child.ID, + "sort_index": 1, + "fk_organization_id": org, + "fk_parent_id": child.ParentID, + "created_at": child.CreatedAt, + "updated_at": child.UpdatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + + doc := stubDocument(org) + doc.ParentID = null.ValueFrom(parent.ID) + + return tcase{ + Document: doc, + Err: assert.AnError, + } + }, "Duplicate key": func(t *testing.T, db *DB) tcase { org := prepOrganizations(t, db, 1)[0] doc := prepDocuments(t, db, 1, func(_ int, ndoc *document.Document) { @@ -159,7 +203,14 @@ func Test_agent_InsertDocument(t *testing.T) { db := prepTempDB(t) c := cfn(t, db) - err := db.InsertDocument(context.Background(), c.Document) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.InsertDocument(ctx, c.Document) testutil.RequireEqualError(t, c.Err, err) if err != nil { @@ -181,6 +232,20 @@ func Test_agent_InsertDocument(t *testing.T) { } } +func Test_agent_CheckDocumentExists(t *testing.T) { + db := prepTempDB(t) + + // error - not found + err := db.CheckDocumentExists(context.Background(), xid.New(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + + // success + doc := prepDocuments(t, db, 1, nil)[0] + + err = db.CheckDocumentExists(context.Background(), doc.ID, doc.OrganizationID) + assert.NoError(t, err) +} + func Test_agent_FetchDocument(t *testing.T) { db := prepTempDB(t) @@ -198,6 +263,20 @@ func Test_agent_FetchDocument(t *testing.T) { } func Test_agent_UpdateDocumentTree(t *testing.T) { + // error - failing update statement inside the transaction + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE documents").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.UpdateDocumentTree( + context.Background(), + document.Summaries{{ID: xid.New()}}, + "org-id", + ) + assert.Equal(t, assert.AnError, err) + db := prepTempDB(t) org := prepOrganizations(t, db, 1)[0] @@ -235,7 +314,7 @@ func Test_agent_UpdateDocumentTree(t *testing.T) { // success swapping root elements exp1[0], exp1[1] = exp1[1], exp1[0] // swap root elements - err := db.UpdateDocumentTree(context.Background(), exp1, org) + err = db.UpdateDocumentTree(context.Background(), exp1, org) assert.NoError(t, err) exp2 := exp1[0].Children @@ -310,7 +389,93 @@ func Test_agent_FetchDocumentTree(t *testing.T) { assert.Equal(t, exp1, res) } +func Test_agent_FetchDocumentTreeByDocumentParentID(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchDocumentTreeByDocumentParentID(ctx, null.Value[xid.ID]{}, "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no documents + res, err = db.FetchDocumentTreeByDocumentParentID(context.Background(), null.Value[xid.ID]{}, "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + org := prepOrganizations(t, db, 1)[0] + roots := prepDocuments(t, db, 2, func(_ int, ndoc *document.Document) { + ndoc.OrganizationID = org + }) + + exp := make(document.Summaries, 0, len(roots)) + + for _, doc := range roots { + exp = append(exp, document.Summary{ + ID: doc.ID, + DocumentName: doc.DocumentName, + Icon: doc.Icon, + Protected: doc.Protected, + }) + } + + children := prepDocuments(t, db, 2, func(_ int, sdoc *document.Document) { + sdoc.OrganizationID = org + sdoc.ParentID = null.ValueFrom(roots[0].ID) + }) + + // success - root documents only, children excluded + res, err = db.FetchDocumentTreeByDocumentParentID(context.Background(), null.Value[xid.ID]{}, org) + assert.NoError(t, err) + assert.Equal(t, exp, res) + + exp = exp[:0] + + for _, doc := range children { + exp = append(exp, document.Summary{ + ID: doc.ID, + DocumentName: doc.DocumentName, + Icon: doc.Icon, + Protected: doc.Protected, + }) + } + + // success - direct children of the first root + res, err = db.FetchDocumentTreeByDocumentParentID(context.Background(), null.ValueFrom(roots[0].ID), org) + assert.NoError(t, err) + assert.Equal(t, exp, res) +} + func Test_agent_UpdateDocument(t *testing.T) { + t.Run("Error returned by the document update", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE documents").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.UpdateDocument(context.Background(), document.Document{}) + assert.Equal(t, assert.AnError, err) + }) + + t.Run("Error returned by the branch upsert", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + + mock.ExpectBegin() + mock.ExpectExec("UPDATE documents").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO document_branches").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.UpdateDocument(context.Background(), document.Document{}) + assert.Equal(t, assert.AnError, err) + }) + type tcase struct { Document document.Document Err error @@ -372,6 +537,91 @@ func Test_agent_UpdateDocument(t *testing.T) { } } +func Test_agent_UpdateDocumentParentID(t *testing.T) { + type tcase struct { + CancelledContext bool + ID xid.ID + ParentID null.Value[xid.ID] + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + ID: doc.ID, + OrganizationID: doc.OrganizationID, + Err: assert.AnError, + } + }, + "Successful update to a new parent": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + docs := prepDocuments(t, db, 2, func(_ int, ndoc *document.Document) { + ndoc.OrganizationID = org + }) + + return tcase{ + ID: docs[1].ID, + ParentID: null.ValueFrom(docs[0].ID), + OrganizationID: org, + } + }, + "Successful update to a root document": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + parent := prepDocuments(t, db, 1, func(_ int, ndoc *document.Document) { + ndoc.OrganizationID = org + })[0] + child := prepDocuments(t, db, 1, func(_ int, ndoc *document.Document) { + ndoc.OrganizationID = org + ndoc.ParentID = null.ValueFrom(parent.ID) + })[0] + + return tcase{ + ID: child.ID, + OrganizationID: org, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateDocumentParentID(ctx, c.ID, c.ParentID, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var parentID null.Value[xid.ID] + + q, args := db.builder.Select("fk_parent_id"). + From("documents"). + Where(sq.Eq{ + "id": c.ID, + }).MustSql() + + err = db.sql.Get(&parentID, q, args...) + require.NoError(t, err) + assert.Equal(t, c.ParentID, parentID) + }) + } +} + func Test_agent_DeleteDocument(t *testing.T) { type tcase struct { ID xid.ID diff --git a/server/core/internal/db/github_installation_test.go b/server/core/internal/db/github_installation_test.go new file mode 100644 index 00000000..66796e4a --- /dev/null +++ b/server/core/internal/db/github_installation_test.go @@ -0,0 +1,327 @@ +package db + +import ( + "context" + "database/sql" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepGithubInstallations(t *testing.T, db *DB, count int, organizationID string) []int64 { //nolint:unparam // fixture helpers share a uniform signature + t.Helper() + + res := make([]int64, count) + + for i := range count { + id := int64(i + 1) + + res[i] = id + + vals := map[string]any{ + "installation_id": id, + } + + if organizationID != "" { + vals["fk_organization_id"] = organizationID + } + + q, args := db.builder.Insert("github_installations"). + SetMap(vals).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertGithubInstallation(t *testing.T) { + type tcase struct { + InstallationID int64 + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Duplicate installation ID": func(t *testing.T, db *DB) tcase { + ids := prepGithubInstallations(t, db, 1, "") + + return tcase{ + InstallationID: ids[0], + Err: assert.AnError, + } + }, + "Successful insert": func(_ *testing.T, _ *DB) tcase { + return tcase{ + InstallationID: 123, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertGithubInstallation(context.Background(), c.InstallationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var installationID int64 + + q, args := db.builder.Select("installation_id"). + From("github_installations"). + Where(sq.Eq{ + "installation_id": c.InstallationID, + }).MustSql() + + err = db.sql.Get(&installationID, q, args...) + require.NoError(t, err) + assert.Equal(t, c.InstallationID, installationID) + }) + } +} + +func Test_agent_FetchGithubInstallationByOrganizationID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchGithubInstallationByOrganizationID(context.Background(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Zero(t, res) + + // success + org := prepOrganizations(t, db, 1)[0] + ids := prepGithubInstallations(t, db, 1, org) + + res, err = db.FetchGithubInstallationByOrganizationID(context.Background(), org) + assert.NoError(t, err) + assert.Equal(t, ids[0], res) +} + +func Test_agent_FetchGithubInstallationOrganizationID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchGithubInstallationOrganizationID(context.Background(), 404) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Zero(t, res) + + // success - unassigned installation + org := prepOrganizations(t, db, 1)[0] + ids := prepGithubInstallations(t, db, 1, "") + + res, err = db.FetchGithubInstallationOrganizationID(context.Background(), ids[0]) + require.NoError(t, err) + assert.False(t, res.Valid) + + // success - assigned installation + q, args := db.builder.Update("github_installations"). + SetMap(map[string]any{ + "fk_organization_id": org, + }). + Where(sq.Eq{ + "installation_id": ids[0], + }).MustSql() + + _, err = db.sql.Exec(q, args...) + require.NoError(t, err) + + res, err = db.FetchGithubInstallationOrganizationID(context.Background(), ids[0]) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(org), res) +} + +func Test_agent_UpdateGithubInstallationOrganizationID(t *testing.T) { + type tcase struct { + InstallationID int64 + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent organization": func(t *testing.T, db *DB) tcase { + ids := prepGithubInstallations(t, db, 1, "") + + return tcase{ + InstallationID: ids[0], + OrganizationID: "non-existent-org-id", + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + ids := prepGithubInstallations(t, db, 1, "") + + return tcase{ + InstallationID: ids[0], + OrganizationID: org, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.UpdateGithubInstallationOrganizationID(context.Background(), c.InstallationID, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var organizationID null.String + + q, args := db.builder.Select("fk_organization_id"). + From("github_installations"). + Where(sq.Eq{ + "installation_id": c.InstallationID, + }).MustSql() + + err = db.sql.Get(&organizationID, q, args...) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(c.OrganizationID), organizationID) + }) + } +} + +func Test_agent_DeleteGithubInstallation(t *testing.T) { + type tcase struct { + CancelledContext bool + InstallationID int64 + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + ids := prepGithubInstallations(t, db, 1, "") + + return tcase{ + CancelledContext: true, + InstallationID: ids[0], + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + ids := prepGithubInstallations(t, db, 1, "") + + return tcase{ + InstallationID: ids[0], + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteGithubInstallation(ctx, c.InstallationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var installationID int64 + + q, args := db.builder.Select("installation_id"). + From("github_installations"). + Where(sq.Eq{ + "installation_id": c.InstallationID, + }).MustSql() + + err = db.sql.Get(&installationID, q, args...) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + }) + } +} + +func Test_agent_UnassignGithubInstallationOrganization(t *testing.T) { + type tcase struct { + CancelledContext bool + InstallationID int64 + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + ids := prepGithubInstallations(t, db, 1, org) + + return tcase{ + CancelledContext: true, + InstallationID: ids[0], + OrganizationID: org, + Err: assert.AnError, + } + }, + "Successful unassign": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + ids := prepGithubInstallations(t, db, 1, org) + + return tcase{ + InstallationID: ids[0], + OrganizationID: org, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UnassignGithubInstallationOrganization(ctx, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var organizationID null.String + + q, args := db.builder.Select("fk_organization_id"). + From("github_installations"). + Where(sq.Eq{ + "installation_id": c.InstallationID, + }).MustSql() + + err = db.sql.Get(&organizationID, q, args...) + require.NoError(t, err) + assert.False(t, organizationID.Valid) + }) + } +} diff --git a/server/core/internal/db/notification.go b/server/core/internal/db/notification.go index 279aaf0e..1a99289e 100644 --- a/server/core/internal/db/notification.go +++ b/server/core/internal/db/notification.go @@ -31,6 +31,13 @@ func (a *agent) CreateNotification(ctx context.Context, nt *notification.Notific return err } + // a zero limit means unlimited retention; without this guard + // the subquery below would emit LIMIT 0 and the delete would + // drop every notification of the user. + if a.opts.MaxNotifications == 0 { + return nil + } + b := a.builder.Select("id"). From("notifications"). Where(sq.Eq{ diff --git a/server/core/internal/db/notification_test.go b/server/core/internal/db/notification_test.go new file mode 100644 index 00000000..5d372271 --- /dev/null +++ b/server/core/internal/db/notification_test.go @@ -0,0 +1,454 @@ +package db + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + sq "github.com/Masterminds/squirrel" + "github.com/oxynote/oxynote/server/core/internal/notification" + "github.com/oxynote/oxynote/server/core/pkg/httpserver" + "github.com/oxynote/oxynote/server/core/pkg/sqlutil" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepNotifications(t *testing.T, db *DB, count int, fn func(int, *notification.Notification)) []*notification.Notification { + t.Helper() + + res := make([]*notification.Notification, count) + + now := timeutil.Now().Truncate(time.Second) + + // notifications missing an owner after fn ran share one lazily + // created organization member. + var ( + organizationID string + userID string + ) + + for i := range count { + nt := ¬ification.Notification{ + Core: notification.Core{ + Code: "test.notification", + Metadata: notification.Metadata{ + "index": strconv.Itoa(i), + }, + }, + ID: xid.New(), + // distinct ascending timestamps keep the fetch order + // deterministic. + CreatedAt: now.Add(time.Duration(i) * time.Second), + } + + if fn != nil { + fn(i, nt) + } + + if nt.UserID == "" { + if userID == "" { + organizationID = prepOrganizations(t, db, 1)[0] + userID = prepUsers(t, db, 1)[0] + } + + nt.UserID = userID + nt.OrganizationID = organizationID + } + + res[i] = nt + + q, args := db.builder.Insert("notifications"). + SetMap(map[string]any{ + "id": nt.ID, + "code": nt.Code, + "metadata": nt.Metadata, + "fk_user_id": nt.UserID, + "fk_organization_id": nt.OrganizationID, + "read": nt.Read, + "created_at": nt.CreatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_CreateNotification(t *testing.T) { + stubNotification := func(organizationID, userID string) *notification.Notification { + return ¬ification.Notification{ + Core: notification.Core{ + Code: "test.notification", + Metadata: notification.Metadata{ + "key": "value", + }, + }, + ID: xid.New(), + UserID: userID, + OrganizationID: organizationID, + CreatedAt: timeutil.Now().Truncate(time.Second), + } + } + + t.Run("Cancelled context", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := db.CreateNotification(ctx, stubNotification("org-id", "user-id")) + require.Error(t, err) + }) + + t.Run("Error returned by the insert", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + nt := prepNotifications(t, db, 1, nil)[0] + + err := db.CreateNotification(context.Background(), nt) + require.Error(t, err) + }) + + t.Run("Error returned by the retention delete", func(t *testing.T) { + t.Parallel() + + a, mock := prepMockDB(t) + a.opts.MaxNotifications = 1 + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO notifications").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("DELETE FROM notifications").WillReturnError(assert.AnError) + mock.ExpectRollback() + + err := a.CreateNotification(context.Background(), stubNotification("org-id", "user-id")) + assert.Equal(t, assert.AnError, err) + }) + + t.Run("Successful creation without a retention limit", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + + existing := prepNotifications(t, db, 2, nil) + nt := stubNotification(existing[0].OrganizationID, existing[0].UserID) + + err := db.CreateNotification(context.Background(), nt) + require.NoError(t, err) + + count, err := db.FetchNotificationCount(context.Background(), nt.OrganizationID, nt.UserID, false) + require.NoError(t, err) + assert.Equal(t, uint64(3), count) + }) + + t.Run("Successful creation with retention trimming", func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + db.opts.MaxNotifications = 2 + + existing := prepNotifications(t, db, 2, nil) + nt := stubNotification(existing[0].OrganizationID, existing[0].UserID) + nt.CreatedAt = existing[1].CreatedAt.Add(time.Hour) + + err := db.CreateNotification(context.Background(), nt) + require.NoError(t, err) + + // the oldest notification is trimmed; the two newest remain. + res, _, err := db.FetchManyNotifications( + context.Background(), + nt.OrganizationID, + nt.UserID, + httpserver.Query{Limit: 10, Page: 1}, + ) + require.NoError(t, err) + testutil.AssertFilterEqual(t, []*notification.Notification{nt, existing[1]}, res) + }) +} + +func Test_agent_FetchManyNotifications(t *testing.T) { + db := prepTempDB(t) + + // error - invalid query + res, pages, err := db.FetchManyNotifications(context.Background(), "org-id", "user-id", httpserver.Query{}) + require.Error(t, err) + assert.Nil(t, res) + assert.Zero(t, pages) + + // error - invalid filter key + res, pages, err = db.FetchManyNotifications(context.Background(), "org-id", "user-id", httpserver.Query{ + Limit: 10, + Page: 1, + Filters: map[string]string{ + "unknown": "true", + }, + }) + assert.Equal(t, httpserver.ErrInvalidFilterKey, err) + assert.Nil(t, res) + assert.Zero(t, pages) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, pages, err = db.FetchManyNotifications(ctx, "org-id", "user-id", httpserver.Query{Limit: 10, Page: 1}) + require.Error(t, err) + assert.Nil(t, res) + assert.Zero(t, pages) + + // success - no notifications + res, pages, err = db.FetchManyNotifications(context.Background(), "org-id", "user-id", httpserver.Query{Limit: 10, Page: 1}) + require.NoError(t, err) + assert.Empty(t, res) + assert.Zero(t, pages) + + notifications := prepNotifications(t, db, 3, func(i int, nt *notification.Notification) { + nt.Read = i == 0 + }) + + organizationID := notifications[0].OrganizationID + userID := notifications[0].UserID + + // success - first page, newest first + res, pages, err = db.FetchManyNotifications(context.Background(), organizationID, userID, httpserver.Query{ + Limit: 2, + Page: 1, + }) + require.NoError(t, err) + assert.Equal(t, uint64(2), pages) + testutil.AssertFilterEqual(t, []*notification.Notification{notifications[2], notifications[1]}, res) + + // success - second page + res, pages, err = db.FetchManyNotifications(context.Background(), organizationID, userID, httpserver.Query{ + Limit: 2, + Page: 2, + }) + require.NoError(t, err) + assert.Equal(t, uint64(2), pages) + testutil.AssertFilterEqual(t, []*notification.Notification{notifications[0]}, res) + + // success - filtered by read state + res, pages, err = db.FetchManyNotifications(context.Background(), organizationID, userID, httpserver.Query{ + Limit: 10, + Page: 1, + Filters: map[string]string{ + notification.FilterReadEq: "true", + }, + }) + require.NoError(t, err) + assert.Equal(t, uint64(1), pages) + testutil.AssertFilterEqual(t, []*notification.Notification{notifications[0]}, res) + + // success - sorted by creation time ascending + res, pages, err = db.FetchManyNotifications(context.Background(), organizationID, userID, httpserver.Query{ + Limit: 10, + Page: 1, + SortKeys: []httpserver.SortKey{ + {Key: notification.SortCreatedAt, Asc: true}, + }, + }) + require.NoError(t, err) + assert.Equal(t, uint64(1), pages) + testutil.AssertFilterEqual(t, notifications, res) +} + +func Test_agent_FetchNotificationCount(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + count, err := db.FetchNotificationCount(ctx, "org-id", "user-id", false) + require.Error(t, err) + assert.Zero(t, count) + + // success + notifications := prepNotifications(t, db, 3, func(i int, nt *notification.Notification) { + nt.Read = i == 0 + }) + + count, err = db.FetchNotificationCount(context.Background(), notifications[0].OrganizationID, notifications[0].UserID, false) + require.NoError(t, err) + assert.Equal(t, uint64(2), count) + + count, err = db.FetchNotificationCount(context.Background(), notifications[0].OrganizationID, notifications[0].UserID, true) + require.NoError(t, err) + assert.Equal(t, uint64(1), count) +} + +func Test_agent_MarkReadByNotificationsIDs(t *testing.T) { + type tcase struct { + CancelledContext bool + OrganizationID string + UserID string + IDs []xid.ID + UnreadCount uint64 + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + nt := prepNotifications(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + OrganizationID: nt.OrganizationID, + UserID: nt.UserID, + Err: assert.AnError, + } + }, + "Successful update of specific notifications": func(t *testing.T, db *DB) tcase { + notifications := prepNotifications(t, db, 3, nil) + + return tcase{ + OrganizationID: notifications[0].OrganizationID, + UserID: notifications[0].UserID, + IDs: []xid.ID{notifications[0].ID, notifications[1].ID}, + UnreadCount: 1, + } + }, + "Successful update of all notifications": func(t *testing.T, db *DB) tcase { + notifications := prepNotifications(t, db, 3, nil) + + return tcase{ + OrganizationID: notifications[0].OrganizationID, + UserID: notifications[0].UserID, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.MarkReadByNotificationsIDs(ctx, c.OrganizationID, c.UserID, c.IDs) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + count, err := db.FetchNotificationCount(context.Background(), c.OrganizationID, c.UserID, false) + require.NoError(t, err) + assert.Equal(t, c.UnreadCount, count) + }) + } +} + +func Test_applyNotificationFilter(t *testing.T) { + cc := map[string]struct { + Key string + Value string + Result sq.SelectBuilder + Err error + }{ + "Invalid key": { + Key: "unknown", + Err: httpserver.ErrInvalidFilterKey, + }, + "Invalid read value": { + Key: notification.FilterReadEq, + Value: "not-a-bool", + Err: httpserver.ErrInvalidFilterValue, + }, + "Successful execution with empty key": { + Result: sq.Select(), + }, + "Successful execution with read key": { + Key: notification.FilterReadEq, + Value: "true", + Result: sq.Select().Where(sq.Eq{ + "read": true, + }), + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + b, err := applyNotificationFilter(sq.Select(), c.Key, c.Value) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.Equal(t, c.Result, b) + }) + } +} + +func Test_applyNotificationSort(t *testing.T) { + cc := map[string]struct { + Key httpserver.SortKey + Result sq.SelectBuilder + Err error + }{ + "Invalid key": { + Key: httpserver.SortKey{ + Key: "unknown", + Asc: true, + }, + Err: httpserver.ErrInvalidSortKey, + }, + "Successful execution with empty key": { + Key: httpserver.SortKey{ + Asc: true, + }, + Result: sq.Select().OrderBy(sqlutil.SortString( + httpserver.SortKey{ + Key: "notifications." + notification.SortCreatedAt, + Asc: false, + }, + )), + }, + "Successful execution with created_at key": { + Key: httpserver.SortKey{ + Key: notification.SortCreatedAt, + Asc: true, + }, + Result: sq.Select().OrderBy(sqlutil.SortString( + httpserver.SortKey{ + Key: "notifications." + notification.SortCreatedAt, + Asc: true, + }, + )), + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + b, err := applyNotificationSort(sq.Select(), c.Key) + testutil.AssertEqualError(t, c.Err, err) + + if err != nil { + return + } + + assert.Equal(t, c.Result, b) + }) + } +} diff --git a/server/core/internal/db/organization_test.go b/server/core/internal/db/organization_test.go index 9c2f578e..1a8a188a 100644 --- a/server/core/internal/db/organization_test.go +++ b/server/core/internal/db/organization_test.go @@ -1,12 +1,17 @@ package db import ( + "context" + "database/sql" "strconv" "testing" "time" + sq "github.com/Masterminds/squirrel" + "github.com/oxynote/oxynote/server/core/pkg/testutil" "github.com/oxynote/oxynote/server/core/pkg/timeutil" "github.com/rs/xid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -20,10 +25,12 @@ func prepOrganizations(t *testing.T, db *DB, count int) []string { //nolint:unpa res[i] = id + // the slug embeds the random ID so repeated fixture calls + // within one database never collide on the unique constraint. q, args := db.builder.Insert("organizations").Values( id, "Organization "+strconv.Itoa(i), - "org-"+strconv.Itoa(i), + "org-"+id, "", timeutil.Now().Truncate(time.Second), "", @@ -35,3 +42,158 @@ func prepOrganizations(t *testing.T, db *DB, count int) []string { //nolint:unpa return res } + +func prepOrganizationMembers(t *testing.T, db *DB, organizationID string, userIDs []string) { + t.Helper() + + now := timeutil.Now().Truncate(time.Second) + + for i, userID := range userIDs { + q, args := db.builder.Insert("organization_members"). + SetMap(map[string]any{ + "id": xid.New().String(), + "fk_organization_id": organizationID, + "fk_user_id": userID, + "role": "member", + // distinct timestamps keep the fetch order deterministic. + "created_at": now.Add(time.Duration(i) * time.Second), + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } +} + +func Test_agent_FetchOrganizationMembers(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchOrganizationMembers(ctx, "org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no members + res, err = db.FetchOrganizationMembers(context.Background(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success + org := prepOrganizations(t, db, 1)[0] + users := prepUsers(t, db, 3) + prepOrganizationMembers(t, db, org, users) + + res, err = db.FetchOrganizationMembers(context.Background(), org) + assert.NoError(t, err) + assert.Equal(t, users, res) +} + +func Test_agent_CheckOrganizationMember(t *testing.T) { + db := prepTempDB(t) + + org := prepOrganizations(t, db, 1)[0] + users := prepUsers(t, db, 1) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + ok, err := db.CheckOrganizationMember(ctx, org, users[0]) + require.Error(t, err) + assert.False(t, ok) + + // success - not a member + ok, err = db.CheckOrganizationMember(context.Background(), org, users[0]) + require.NoError(t, err) + assert.False(t, ok) + + // success - member + prepOrganizationMembers(t, db, org, users) + + ok, err = db.CheckOrganizationMember(context.Background(), org, users[0]) + require.NoError(t, err) + assert.True(t, ok) +} + +func Test_agent_FetchOrganizationSlug(t *testing.T) { + db := prepTempDB(t) + + // error - not found + slug, err := db.FetchOrganizationSlug(context.Background(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Empty(t, slug) + + // success + org := prepOrganizations(t, db, 1)[0] + + slug, err = db.FetchOrganizationSlug(context.Background(), org) + assert.NoError(t, err) + assert.Equal(t, "org-"+org, slug) +} + +func Test_agent_UpdateOrganizationLogo(t *testing.T) { + type tcase struct { + CancelledContext bool + OrganizationID string + Logo string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + CancelledContext: true, + OrganizationID: org, + Logo: "new-logo", + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + OrganizationID: org, + Logo: "new-logo", + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateOrganizationLogo(ctx, c.OrganizationID, c.Logo) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var logo string + + q, args := db.builder.Select("logo"). + From("organizations"). + Where(sq.Eq{ + "id": c.OrganizationID, + }).MustSql() + + err = db.sql.Get(&logo, q, args...) + require.NoError(t, err) + assert.Equal(t, c.Logo, logo) + }) + } +} diff --git a/server/core/internal/db/slack_app_test.go b/server/core/internal/db/slack_app_test.go new file mode 100644 index 00000000..4bd93450 --- /dev/null +++ b/server/core/internal/db/slack_app_test.go @@ -0,0 +1,465 @@ +package db + +import ( + "context" + "database/sql" + "strconv" + "testing" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/apps/slackapp" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepSlackApps(t *testing.T, db *DB, count int, fn func(int, *slackapp.App)) []slackapp.App { //nolint:unparam // fixture helpers share a uniform signature + t.Helper() + + res := make([]slackapp.App, count) + + for i := range count { + app := slackapp.App{ + // the team ID embeds a random ID so repeated fixture + // calls within one database never collide. + TeamID: "team-" + xid.New().String(), + Token: "token-" + strconv.Itoa(i), + } + + if fn != nil { + fn(i, &app) + } + + res[i] = app + + q, args := db.builder.Insert("slack_apps"). + SetMap(map[string]any{ + "team_id": app.TeamID, + "fk_organization_id": app.OrganizationID, + "token": app.Token, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func prepSlackMessages(t *testing.T, db *DB, count int, fn func(int, *slackapp.Message)) []slackapp.Message { + t.Helper() + + res := make([]slackapp.Message, count) + + now := timeutil.Now().Truncate(time.Second) + + for i := range count { + msg := slackapp.Message{ + ID: xid.New(), + Text: "Message " + strconv.Itoa(i), + // distinct timestamps keep the fetch order deterministic. + CreatedAt: now.Add(-time.Duration(i) * time.Second), + } + + if fn != nil { + fn(i, &msg) + } + + if msg.OrganizationID == "" { + msg.OrganizationID = prepOrganizations(t, db, 1)[0] + } + + res[i] = msg + + q, args := db.builder.Insert("slack_messages"). + SetMap(map[string]any{ + "id": msg.ID, + "fk_organization_id": msg.OrganizationID, + "text": msg.Text, + "created_at": msg.CreatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertSlackApp(t *testing.T) { + type tcase struct { + App slackapp.App + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent organization": func(_ *testing.T, _ *DB) tcase { + return tcase{ + App: slackapp.App{ + TeamID: "team-1", + OrganizationID: null.StringFrom("non-existent-org-id"), + Token: "token-1", + }, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + App: slackapp.App{ + TeamID: "team-1", + OrganizationID: null.StringFrom(org), + Token: "token-1", + }, + } + }, + "Successful upsert of an existing team": func(t *testing.T, db *DB) tcase { + app := prepSlackApps(t, db, 1, nil)[0] + app.Token = "rotated-token" + + return tcase{ + App: app, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertSlackApp(context.Background(), c.App) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var app slackapp.App + + q, args := db.selectSlackApp(db.builder.Select()). + Where(sq.Eq{ + "team_id": c.App.TeamID, + }).MustSql() + + err = db.sql.Get(&app, q, args...) + require.NoError(t, err) + assert.Equal(t, c.App, app) + }) + } +} + +func Test_agent_InsertSlackMessage(t *testing.T) { + type tcase struct { + Message slackapp.Message + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Duplicate message ID": func(t *testing.T, db *DB) tcase { + msg := prepSlackMessages(t, db, 1, nil)[0] + + return tcase{ + Message: msg, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + + return tcase{ + Message: slackapp.Message{ + ID: xid.New(), + OrganizationID: org, + Text: "Hello from Slack", + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertSlackMessage(context.Background(), c.Message) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var msg slackapp.Message + + q, args := db.selectSlackMessage(db.builder.Select()). + Where(sq.Eq{ + "id": c.Message.ID, + }).MustSql() + + err = db.sql.Get(&msg, q, args...) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.Message, msg) + }) + } +} + +func Test_agent_FetchSlackMessages(t *testing.T) { + db := prepTempDB(t) + + // error - cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchSlackMessages(ctx, "non-existent-org-id") + require.Error(t, err) + assert.Nil(t, res) + + // success - no messages + res, err = db.FetchSlackMessages(context.Background(), "non-existent-org-id") + require.NoError(t, err) + assert.Empty(t, res) + + // success - ordered by creation time, newest first + org := prepOrganizations(t, db, 1)[0] + messages := prepSlackMessages(t, db, 3, func(_ int, msg *slackapp.Message) { + msg.OrganizationID = org + }) + + res, err = db.FetchSlackMessages(context.Background(), org) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, messages, res) +} + +func Test_agent_UpdateSlackAppOrganizationID(t *testing.T) { + type tcase struct { + TeamID string + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent organization": func(t *testing.T, db *DB) tcase { + app := prepSlackApps(t, db, 1, nil)[0] + + return tcase{ + TeamID: app.TeamID, + OrganizationID: "non-existent-org-id", + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + app := prepSlackApps(t, db, 1, nil)[0] + + return tcase{ + TeamID: app.TeamID, + OrganizationID: org, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.UpdateSlackAppOrganizationID(context.Background(), c.TeamID, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var organizationID null.String + + q, args := db.builder.Select("fk_organization_id"). + From("slack_apps"). + Where(sq.Eq{ + "team_id": c.TeamID, + }).MustSql() + + err = db.sql.Get(&organizationID, q, args...) + require.NoError(t, err) + assert.Equal(t, null.StringFrom(c.OrganizationID), organizationID) + }) + } +} + +func Test_agent_FetchSlackAppByTeamID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchSlackAppByTeamID(context.Background(), "non-existent-team-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + app := prepSlackApps(t, db, 1, nil)[0] + + res, err = db.FetchSlackAppByTeamID(context.Background(), app.TeamID) + assert.NoError(t, err) + assert.Equal(t, &app, res) +} + +func Test_agent_FetchSlackAppByOrganizationID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchSlackAppByOrganizationID(context.Background(), "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + org := prepOrganizations(t, db, 1)[0] + app := prepSlackApps(t, db, 1, func(_ int, app *slackapp.App) { + app.OrganizationID = null.StringFrom(org) + })[0] + + res, err = db.FetchSlackAppByOrganizationID(context.Background(), org) + assert.NoError(t, err) + assert.Equal(t, &app, res) +} + +func Test_agent_DeleteSlackApp(t *testing.T) { + type tcase struct { + CancelledContext bool + TeamID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + app := prepSlackApps(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + TeamID: app.TeamID, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + app := prepSlackApps(t, db, 1, nil)[0] + + return tcase{ + TeamID: app.TeamID, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteSlackApp(ctx, c.TeamID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var teamID string + + q, args := db.builder.Select("team_id"). + From("slack_apps"). + Where(sq.Eq{ + "team_id": c.TeamID, + }).MustSql() + + err = db.sql.Get(&teamID, q, args...) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + }) + } +} + +func Test_agent_UnassignSlackAppOrganization(t *testing.T) { + type tcase struct { + CancelledContext bool + TeamID string + OrganizationID string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + app := prepSlackApps(t, db, 1, func(_ int, app *slackapp.App) { + app.OrganizationID = null.StringFrom(org) + })[0] + + return tcase{ + CancelledContext: true, + TeamID: app.TeamID, + OrganizationID: org, + Err: assert.AnError, + } + }, + "Successful unassign": func(t *testing.T, db *DB) tcase { + org := prepOrganizations(t, db, 1)[0] + app := prepSlackApps(t, db, 1, func(_ int, app *slackapp.App) { + app.OrganizationID = null.StringFrom(org) + })[0] + + return tcase{ + TeamID: app.TeamID, + OrganizationID: org, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UnassignSlackAppOrganization(ctx, c.OrganizationID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var organizationID null.String + + q, args := db.builder.Select("fk_organization_id"). + From("slack_apps"). + Where(sq.Eq{ + "team_id": c.TeamID, + }).MustSql() + + err = db.sql.Get(&organizationID, q, args...) + require.NoError(t, err) + assert.False(t, organizationID.Valid) + }) + } +} diff --git a/server/core/internal/db/slack_user_link_test.go b/server/core/internal/db/slack_user_link_test.go new file mode 100644 index 00000000..0adda5ed --- /dev/null +++ b/server/core/internal/db/slack_user_link_test.go @@ -0,0 +1,293 @@ +package db + +import ( + "context" + "database/sql" + "strconv" + "testing" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/guregu/null/v5" + "github.com/oxynote/oxynote/server/core/internal/apps/slackapp" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepSlackUserLinks(t *testing.T, db *DB, count int, fn func(int, *slackapp.UserLink)) []slackapp.UserLink { //nolint:unparam // fixture helpers share a uniform signature + t.Helper() + + res := make([]slackapp.UserLink, count) + + now := timeutil.Now().Truncate(time.Second) + + for i := range count { + link := slackapp.UserLink{ + SlackUserID: "slack-user-" + strconv.Itoa(i), + Settings: slackapp.UserLinkSettings{ + Notifications: true, + }, + CreatedAt: now, + } + + if fn != nil { + fn(i, &link) + } + + if link.TeamID == "" { + link.TeamID = prepSlackApps(t, db, 1, nil)[0].TeamID + } + + if link.UserID == "" { + link.UserID = prepUsers(t, db, 1)[0] + } + + res[i] = link + + q, args := db.builder.Insert("slack_user_links"). + SetMap(map[string]any{ + "slack_user_id": link.SlackUserID, + "fk_team_id": link.TeamID, + "fk_user_id": link.UserID, + "settings": link.Settings, + "created_at": link.CreatedAt, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_InsertSlackUserLink(t *testing.T) { + type tcase struct { + Link slackapp.UserLink + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent team": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + + return tcase{ + Link: slackapp.UserLink{ + SlackUserID: "slack-user-1", + TeamID: "non-existent-team-id", + UserID: users[0], + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + app := prepSlackApps(t, db, 1, nil)[0] + users := prepUsers(t, db, 1) + + return tcase{ + Link: slackapp.UserLink{ + SlackUserID: "slack-user-1", + TeamID: app.TeamID, + UserID: users[0], + Settings: slackapp.UserLinkSettings{ + Notifications: true, + }, + CreatedAt: timeutil.Now().Truncate(time.Second), + }, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.InsertSlackUserLink(context.Background(), c.Link) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var link slackapp.UserLink + + q, args := db.selectSlackUserLink(db.builder.Select()). + Where(sq.Eq{ + "slack_user_id": c.Link.SlackUserID, + "fk_team_id": c.Link.TeamID, + }).MustSql() + + err = db.sql.Get(&link, q, args...) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.Link, link) + }) + } +} + +func Test_agent_UpdateSlackUserLink(t *testing.T) { + type tcase struct { + CancelledContext bool + Link slackapp.UserLink + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + link := prepSlackUserLinks(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Link: link, + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + link := prepSlackUserLinks(t, db, 1, nil)[0] + link.Settings.Notifications = false + + return tcase{ + Link: link, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateSlackUserLink(ctx, c.Link) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var link slackapp.UserLink + + q, args := db.selectSlackUserLink(db.builder.Select()). + Where(sq.Eq{ + "slack_user_id": c.Link.SlackUserID, + "fk_team_id": c.Link.TeamID, + }).MustSql() + + err = db.sql.Get(&link, q, args...) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.Link, link) + }) + } +} + +func Test_agent_FetchSlackUserLink(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchSlackUserLink(context.Background(), "non-existent-slack-user-id", "non-existent-team-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + link := prepSlackUserLinks(t, db, 1, nil)[0] + + res, err = db.FetchSlackUserLink(context.Background(), link.SlackUserID, link.TeamID) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, &link, res) +} + +func Test_agent_FetchSlackUserLinkByUserID(t *testing.T) { + db := prepTempDB(t) + + // error - not found + res, err := db.FetchSlackUserLinkByUserID(context.Background(), "non-existent-user-id", "non-existent-org-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Nil(t, res) + + // success + org := prepOrganizations(t, db, 1)[0] + app := prepSlackApps(t, db, 1, func(_ int, app *slackapp.App) { + app.OrganizationID = null.StringFrom(org) + })[0] + link := prepSlackUserLinks(t, db, 1, func(_ int, link *slackapp.UserLink) { + link.TeamID = app.TeamID + })[0] + + res, err = db.FetchSlackUserLinkByUserID(context.Background(), link.UserID, org) + assert.NoError(t, err) + testutil.AssertFilterEqual(t, &link, res) +} + +func Test_agent_DeleteSlackUserLink(t *testing.T) { + type tcase struct { + CancelledContext bool + Link slackapp.UserLink + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + link := prepSlackUserLinks(t, db, 1, nil)[0] + + return tcase{ + CancelledContext: true, + Link: link, + Err: assert.AnError, + } + }, + "Successful delete": func(t *testing.T, db *DB) tcase { + link := prepSlackUserLinks(t, db, 1, nil)[0] + + return tcase{ + Link: link, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.DeleteSlackUserLink(ctx, c.Link.SlackUserID, c.Link.TeamID) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var slackUserID string + + q, args := db.builder.Select("slack_user_id"). + From("slack_user_links"). + Where(sq.Eq{ + "slack_user_id": c.Link.SlackUserID, + "fk_team_id": c.Link.TeamID, + }).MustSql() + + err = db.sql.Get(&slackUserID, q, args...) + testutil.AssertEqualError(t, sql.ErrNoRows, err) + }) + } +} diff --git a/server/core/internal/db/user_test.go b/server/core/internal/db/user_test.go new file mode 100644 index 00000000..c8dc8c0a --- /dev/null +++ b/server/core/internal/db/user_test.go @@ -0,0 +1,127 @@ +package db + +import ( + "context" + "database/sql" + "strconv" + "testing" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func prepUsers(t *testing.T, db *DB, count int) []string { + t.Helper() + + res := make([]string, count) + + now := timeutil.Now().Truncate(time.Second) + + for i := range count { + id := xid.New().String() + + res[i] = id + + q, args := db.builder.Insert("users"). + SetMap(map[string]any{ + "id": id, + "name": "User " + strconv.Itoa(i), + "email": id + "@test.test", + "email_verified": true, + "image": "image-" + strconv.Itoa(i), + "created_at": now, + "updated_at": now, + }).MustSql() + + _, err := db.sql.Exec(q, args...) + require.NoError(t, err) + } + + return res +} + +func Test_agent_UpdateUserImage(t *testing.T) { + type tcase struct { + CancelledContext bool + UserID string + Image string + Err error + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "Cancelled context": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + + return tcase{ + CancelledContext: true, + UserID: users[0], + Image: "new-image", + Err: assert.AnError, + } + }, + "Successful update": func(t *testing.T, db *DB) tcase { + users := prepUsers(t, db, 1) + + return tcase{ + UserID: users[0], + Image: "new-image", + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if c.CancelledContext { + cancel() + } + + err := db.UpdateUserImage(ctx, c.UserID, c.Image) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + var image string + + q, args := db.builder.Select("image"). + From("users"). + Where(sq.Eq{ + "id": c.UserID, + }).MustSql() + + err = db.sql.Get(&image, q, args...) + require.NoError(t, err) + assert.Equal(t, c.Image, image) + }) + } +} + +func Test_agent_FetchUserName(t *testing.T) { + db := prepTempDB(t) + + // error - not found + name, err := db.FetchUserName(context.Background(), "non-existent-user-id") + testutil.AssertEqualError(t, sql.ErrNoRows, err) + assert.Empty(t, name) + + // success + users := prepUsers(t, db, 1) + + name, err = db.FetchUserName(context.Background(), users[0]) + assert.NoError(t, err) + assert.Equal(t, "User 0", name) +} diff --git a/server/core/pkg/testutil/testutil.go b/server/core/pkg/testutil/testutil.go index b8f28bd5..9b4e1bc4 100644 --- a/server/core/pkg/testutil/testutil.go +++ b/server/core/pkg/testutil/testutil.go @@ -9,10 +9,14 @@ import ( "bufio" "bytes" "context" + "fmt" + "reflect" "sync" "testing" "github.com/go-chi/chi/v5" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -71,6 +75,28 @@ func RequireEqualError(t *testing.T, exp, err error) { require.NoError(t, err) } +// FilterEqual asserts that two objects are equal. +// All ignored types found on any of the provided objects will not be compared. +func FilterEqual(v1, v2 any, ignoreTypes ...any) error { + diff := cmp.Diff(v1, v2, cmpopts.IgnoreTypes(ignoreTypes...), + cmp.Exporter(func(reflect.Type) bool { return true })) + if diff != "" { + return fmt.Errorf("not equal:\n%s", diff) + } + + return nil +} + +// AssertFilterEqual asserts that two objects are equal. +// All ignored types found on any of the provided objects will not be compared. +func AssertFilterEqual(t *testing.T, v1, v2 any, ignoreTypes ...any) { + t.Helper() + + if err := FilterEqual(v1, v2, ignoreTypes...); err != nil { + t.Error(err) + } +} + // NewBuffer creates a fresh instance of concurrent writer and its buffer. func NewBuffer() (*Writer, *bytes.Buffer) { b := &bytes.Buffer{} diff --git a/server/core/pkg/testutil/testutil_test.go b/server/core/pkg/testutil/testutil_test.go new file mode 100644 index 00000000..f366a2b5 --- /dev/null +++ b/server/core/pkg/testutil/testutil_test.go @@ -0,0 +1,53 @@ +package testutil + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func Test_FilterEqual(t *testing.T) { + type object struct { + Name string + CreatedAt time.Time + } + + // error - values differ + err := FilterEqual(object{Name: "a"}, object{Name: "b"}) + assert.Error(t, err) + + // success - ignored types are not compared + err = FilterEqual( + object{Name: "a", CreatedAt: time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)}, + object{Name: "a", CreatedAt: time.Date(2022, 3, 4, 0, 0, 0, 0, time.UTC)}, + time.Time{}, + ) + assert.NoError(t, err) + + // success - equal instants in different locations are equal + err = FilterEqual( + object{CreatedAt: time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC)}, + object{CreatedAt: time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC).Local()}, + ) + assert.NoError(t, err) +} + +func Test_AssertFilterEqual(t *testing.T) { + // error - values differ + nt := &testing.T{} + + AssertFilterEqual(nt, 1, 2) + assert.True(t, nt.Failed()) + + // success + nt = &testing.T{} + + AssertFilterEqual(nt, 1, 1) + assert.False(t, nt.Failed()) +}