fe9a9bdf64
Backend Tests / Static Checks (push) Waiting to run
Backend Tests / Tests (internal) (push) Waiting to run
Backend Tests / Tests (other) (push) Waiting to run
Backend Tests / Tests (server) (push) Waiting to run
Backend Tests / Tests (store) (push) Waiting to run
Build Canary Image / build-frontend (push) Waiting to run
Build Canary Image / build-push (linux/amd64) (push) Blocked by required conditions
Build Canary Image / build-push (linux/arm64) (push) Blocked by required conditions
Build Canary Image / merge (push) Blocked by required conditions
Frontend Tests / Lint (push) Waiting to run
Frontend Tests / Build (push) Waiting to run
Proto Linter / Lint Protos (push) Waiting to run
Release Please / release-please (push) Waiting to run
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/usememos/memos/store"
|
|
"github.com/usememos/memos/store/db"
|
|
)
|
|
|
|
func TestReactionStore(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-store")
|
|
|
|
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "💗",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, reaction)
|
|
require.NotEmpty(t, reaction.ID)
|
|
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
|
MemoID: &memo.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 1)
|
|
require.Equal(t, reaction, reactions[0])
|
|
|
|
// Test GetReaction.
|
|
gotReaction, err := ts.GetReaction(ctx, &store.FindReaction{
|
|
ID: &reaction.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, gotReaction)
|
|
require.Equal(t, reaction.ID, gotReaction.ID)
|
|
require.Equal(t, reaction.CreatorID, gotReaction.CreatorID)
|
|
require.Equal(t, reaction.MemoID, gotReaction.MemoID)
|
|
require.Equal(t, reaction.ReactionType, gotReaction.ReactionType)
|
|
|
|
// Test GetReaction with non-existent ID.
|
|
nonExistentID := int32(99999)
|
|
notFoundReaction, err := ts.GetReaction(ctx, &store.FindReaction{
|
|
ID: &nonExistentID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Nil(t, notFoundReaction)
|
|
|
|
// An empty delete filter must not remove every reaction.
|
|
require.NoError(t, ts.DeleteReaction(ctx, &store.DeleteReaction{}))
|
|
reactions, err = ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 1)
|
|
|
|
err = ts.DeleteReaction(ctx, &store.DeleteReaction{
|
|
ID: &reaction.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reactions, err = ts.ListReactions(ctx, &store.FindReaction{
|
|
MemoID: &memo.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 0)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionDeleteByMemoID(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo1 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-delete-memo-1")
|
|
memo2 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-delete-memo-2")
|
|
|
|
for _, reaction := range []*store.Reaction{
|
|
{CreatorID: user.ID, MemoID: memo1.ID, ReactionType: "👍"},
|
|
{CreatorID: user.ID, MemoID: memo1.ID, ReactionType: "❤️"},
|
|
{CreatorID: user.ID, MemoID: memo2.ID, ReactionType: "👍"},
|
|
} {
|
|
_, err := ts.UpsertReaction(ctx, reaction)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.NoError(t, ts.DeleteReaction(ctx, &store.DeleteReaction{MemoID: &memo1.ID}))
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 1)
|
|
require.Equal(t, memo2.ID, reactions[0].MemoID)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionRejectsDeletedMemo(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-deleted-memo")
|
|
require.NoError(t, ts.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}))
|
|
|
|
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.ErrorIs(t, err, store.ErrReactionMemoNotFound)
|
|
require.Nil(t, reaction)
|
|
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
|
require.NoError(t, err)
|
|
require.Empty(t, reactions)
|
|
|
|
existingMemo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-existing-after-rejection")
|
|
reaction, err = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: existingMemo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, reaction)
|
|
require.Equal(t, existingMemo.ID, reaction.MemoID)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionListByCreatorID(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user1, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
|
|
user2, err := createTestingUserWithRole(ctx, ts, "user2", store.RoleUser)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user1.ID, "reaction-list-by-creator")
|
|
|
|
// User 1 creates reaction
|
|
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user1.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// User 2 creates reaction
|
|
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user2.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "❤️",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// List all reactions for the memo.
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
|
MemoID: &memo.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 2)
|
|
|
|
// List by creator ID
|
|
user1Reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
|
CreatorID: &user1.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, user1Reactions, 1)
|
|
require.Equal(t, "👍", user1Reactions[0].ReactionType)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionMultipleMemoIDs(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo1 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-memo-1")
|
|
memo2 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-memo-2")
|
|
memo3 := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-memo-3")
|
|
|
|
// Create reactions for different memos.
|
|
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo1.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo2.ID,
|
|
ReactionType: "❤️",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo3.ID,
|
|
ReactionType: "🔥",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// List by memo ID list, excluding the third memo.
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
|
MemoIDList: []int32{memo1.ID, memo2.ID},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 2)
|
|
require.ElementsMatch(t, []int32{memo1.ID, memo2.ID}, []int32{reactions[0].MemoID, reactions[1].MemoID})
|
|
|
|
reaction, err := ts.GetReaction(ctx, &store.FindReaction{MemoIDList: []int32{memo2.ID}})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, reaction)
|
|
require.Equal(t, memo2.ID, reaction.MemoID)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionUpsertDifferentTypes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-different-types")
|
|
|
|
// Create first reaction
|
|
reaction1, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Create second reaction with different type (should create new, not update)
|
|
reaction2, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "❤️",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Both reactions should exist
|
|
require.NotEqual(t, reaction1.ID, reaction2.ID)
|
|
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{
|
|
MemoID: &memo.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, reactions, 2)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionDeletedWithMemo(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ts := NewTestingStore(ctx, t)
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-delete-with-memo")
|
|
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, ts.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}))
|
|
got, err := ts.GetReaction(ctx, &store.FindReaction{ID: &reaction.ID})
|
|
require.NoError(t, err)
|
|
require.Nil(t, got)
|
|
|
|
ts.Close()
|
|
}
|
|
|
|
func TestReactionInsertedAfterMemoCleanupStartsIsRemoved(t *testing.T) {
|
|
setupContext := context.Background()
|
|
barrierDriver := &memoDeleteBarrierDriver{
|
|
deleteStarted: make(chan struct{}),
|
|
continueDelete: make(chan struct{}),
|
|
}
|
|
ts := newReactionTestingStoreWithDriver(setupContext, t, func(driver store.Driver) store.Driver {
|
|
barrierDriver.Driver = driver
|
|
return barrierDriver
|
|
})
|
|
ctx, cancel := context.WithTimeout(setupContext, 10*time.Second)
|
|
defer cancel()
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-insert-during-delete")
|
|
|
|
deleteResult := make(chan error, 1)
|
|
go func() {
|
|
deleteResult <- ts.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID})
|
|
}()
|
|
deleteReleased := false
|
|
defer func() {
|
|
if !deleteReleased {
|
|
close(barrierDriver.continueDelete)
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-barrierDriver.deleteStarted:
|
|
case err := <-deleteResult:
|
|
require.FailNowf(t, "memo delete returned before reaching barrier", "error: %v", err)
|
|
case <-ctx.Done():
|
|
require.FailNow(t, "timed out waiting for memo delete barrier")
|
|
}
|
|
|
|
// Store.DeleteMemo has completed its application-managed relation and
|
|
// attachment cleanup, but the physical memo delete has not started. This
|
|
// recreates the old window where a prior reaction cleanup could be bypassed.
|
|
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, reaction)
|
|
|
|
close(barrierDriver.continueDelete)
|
|
deleteReleased = true
|
|
select {
|
|
case err := <-deleteResult:
|
|
require.NoError(t, err)
|
|
case <-ctx.Done():
|
|
require.FailNow(t, "timed out waiting for memo delete")
|
|
}
|
|
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
|
require.NoError(t, err)
|
|
require.Empty(t, reactions)
|
|
}
|
|
|
|
func TestReactionUpsertBlocksWhileMemoDeleteIsUncommitted(t *testing.T) {
|
|
setupContext := context.Background()
|
|
ts := NewTestingStore(setupContext, t)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, ts.Close())
|
|
})
|
|
ctx, cancel := context.WithTimeout(setupContext, 10*time.Second)
|
|
defer cancel()
|
|
|
|
user, err := createTestingHostUser(ctx, ts)
|
|
require.NoError(t, err)
|
|
memo := createReactionTestMemo(ctx, t, ts, user.ID, "reaction-waits-for-delete")
|
|
|
|
dbTx, err := ts.GetDriver().GetDB().BeginTx(ctx, nil)
|
|
require.NoError(t, err)
|
|
txOpen := true
|
|
defer func() {
|
|
if txOpen {
|
|
_ = dbTx.Rollback()
|
|
}
|
|
}()
|
|
|
|
deleteMemoQuery := "DELETE FROM memo WHERE id = ?"
|
|
if getDriverFromEnv() == "postgres" {
|
|
deleteMemoQuery = "DELETE FROM memo WHERE id = $1"
|
|
}
|
|
_, err = dbTx.ExecContext(ctx, deleteMemoQuery, memo.ID)
|
|
require.NoError(t, err)
|
|
|
|
var reaction *store.Reaction
|
|
var upsertErr error
|
|
if getDriverFromEnv() == "sqlite" {
|
|
type upsertResult struct {
|
|
reaction *store.Reaction
|
|
err error
|
|
}
|
|
resultChannel := make(chan upsertResult, 1)
|
|
go func() {
|
|
reaction, err := ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
resultChannel <- upsertResult{reaction: reaction, err: err}
|
|
}()
|
|
|
|
poll := time.NewTicker(time.Millisecond)
|
|
defer poll.Stop()
|
|
for ts.GetDriver().GetDB().Stats().InUse < 2 {
|
|
select {
|
|
case result := <-resultChannel:
|
|
require.FailNowf(t, "reaction upsert completed before memo delete committed", "reaction: %+v, error: %v", result.reaction, result.err)
|
|
case <-poll.C:
|
|
case <-ctx.Done():
|
|
require.FailNow(t, "timed out waiting for reaction upsert to reach SQLite")
|
|
}
|
|
}
|
|
|
|
require.NoError(t, dbTx.Commit())
|
|
txOpen = false
|
|
select {
|
|
case result := <-resultChannel:
|
|
reaction, upsertErr = result.reaction, result.err
|
|
case <-ctx.Done():
|
|
require.FailNow(t, "timed out waiting for SQLite reaction upsert")
|
|
}
|
|
} else {
|
|
// Both client/server database engines must wait for the uncommitted
|
|
// parent deletion before deciding whether the memo still exists.
|
|
lockWaitContext, lockWaitCancel := context.WithTimeout(ctx, 500*time.Millisecond)
|
|
startTime := time.Now()
|
|
reaction, upsertErr = ts.UpsertReaction(lockWaitContext, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
lockWaitCancel()
|
|
require.Error(t, upsertErr)
|
|
require.Nil(t, reaction)
|
|
require.GreaterOrEqual(t, time.Since(startTime), 450*time.Millisecond, "reaction upsert must wait for the memo delete transaction")
|
|
|
|
require.NoError(t, dbTx.Commit())
|
|
txOpen = false
|
|
reaction, upsertErr = ts.UpsertReaction(ctx, &store.Reaction{
|
|
CreatorID: user.ID,
|
|
MemoID: memo.ID,
|
|
ReactionType: "👍",
|
|
})
|
|
}
|
|
require.ErrorIs(t, upsertErr, store.ErrReactionMemoNotFound)
|
|
require.Nil(t, reaction)
|
|
|
|
reactions, err := ts.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
|
require.NoError(t, err)
|
|
require.Empty(t, reactions)
|
|
}
|
|
|
|
type memoDeleteBarrierDriver struct {
|
|
store.Driver
|
|
deleteStarted chan struct{}
|
|
continueDelete chan struct{}
|
|
}
|
|
|
|
func (d *memoDeleteBarrierDriver) DeleteMemo(ctx context.Context, delete *store.DeleteMemo) error {
|
|
close(d.deleteStarted)
|
|
select {
|
|
case <-d.continueDelete:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
return d.Driver.DeleteMemo(ctx, delete)
|
|
}
|
|
|
|
func newReactionTestingStoreWithDriver(ctx context.Context, t *testing.T, wrap func(store.Driver) store.Driver) *store.Store {
|
|
t.Helper()
|
|
testingProfile := getTestingProfileForDriver(t, getDriverFromEnv())
|
|
databaseDriver, err := db.NewDBDriver(testingProfile)
|
|
require.NoError(t, err)
|
|
|
|
ts := store.New(wrap(databaseDriver), testingProfile)
|
|
require.NoError(t, ts.Migrate(ctx))
|
|
t.Cleanup(func() {
|
|
require.NoError(t, ts.Close())
|
|
})
|
|
return ts
|
|
}
|
|
|
|
func createReactionTestMemo(ctx context.Context, t *testing.T, ts *store.Store, creatorID int32, uid string) *store.Memo {
|
|
t.Helper()
|
|
memo, err := ts.CreateMemo(ctx, &store.Memo{
|
|
UID: uid,
|
|
CreatorID: creatorID,
|
|
Content: uid,
|
|
Visibility: store.Private,
|
|
})
|
|
require.NoError(t, err)
|
|
return memo
|
|
}
|