add database tests

This commit is contained in:
david 2026-08-14 20:39:54 +03:00
parent 925912f4de
commit d76190245f
20 changed files with 5224 additions and 6 deletions

View File

@ -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

View File

@ -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])
}
}

View File

@ -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(

View File

@ -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)
})
}
}

View File

@ -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)
})
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
})
}

View File

@ -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)
})
}
}

View File

@ -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

View File

@ -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)
})
}
}

View File

@ -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{

View File

@ -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 := &notification.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 &notification.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)
})
}
}

View File

@ -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)
})
}
}

View File

@ -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)
})
}
}

View File

@ -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)
})
}
}

View File

@ -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)
}

View File

@ -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{}

View File

@ -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())
}