Files
usememos--memos/store/test/reaction_test.go
T
amblued 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
refactor(reaction): link reactions to memo IDs (#6221)
2026-08-22 00:12:35 +08:00

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
}