package test import ( "context" "fmt" "sync" "testing" "time" "github.com/lithammer/shortuuid/v4" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) func TestAttachmentNeedsInstanceStorageSetting(t *testing.T) { tests := []struct { name string attachment *store.Attachment want bool }{ { name: "nil attachment", }, { name: "local attachment", attachment: &store.Attachment{ StorageType: storepb.AttachmentStorageType_LOCAL, }, }, { name: "s3 attachment without payload", attachment: &store.Attachment{ StorageType: storepb.AttachmentStorageType_S3, }, }, { name: "s3 attachment with embedded config", attachment: &store.Attachment{ StorageType: storepb.AttachmentStorageType_S3, Payload: &storepb.AttachmentPayload{ Payload: &storepb.AttachmentPayload_S3Object_{ S3Object: &storepb.AttachmentPayload_S3Object{ S3Config: &storepb.StorageS3Config{}, }, }, }, }, want: true, }, { name: "s3 attachment without embedded config", attachment: &store.Attachment{ StorageType: storepb.AttachmentStorageType_S3, Payload: &storepb.AttachmentPayload{ Payload: &storepb.AttachmentPayload_S3Object_{ S3Object: &storepb.AttachmentPayload_S3Object{}, }, }, }, want: true, }, { name: "s3 attachment with storage ID", attachment: &store.Attachment{ StorageType: storepb.AttachmentStorageType_S3, Payload: &storepb.AttachmentPayload{ Payload: &storepb.AttachmentPayload_S3Object_{ S3Object: &storepb.AttachmentPayload_S3Object{StorageId: "s3-primary"}, }, }, }, want: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := store.AttachmentNeedsInstanceStorageSetting(test.attachment); got != test.want { t.Fatalf("AttachmentNeedsInstanceStorageSetting() = %v, want %v", got, test.want) } }) } } func TestAttachmentStore(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) _, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: 101, Filename: "test.epub", Blob: []byte("test"), Type: "application/epub+zip", Size: 637607, Payload: &storepb.AttachmentPayload{ Payload: &storepb.AttachmentPayload_S3Object_{ S3Object: &storepb.AttachmentPayload_S3Object{Key: "attachments/test.jpg", StorageId: "s3-primary"}, }, MotionMedia: &storepb.MotionMedia{ Family: storepb.MotionMediaFamily_APPLE_LIVE_PHOTO, Role: storepb.MotionMediaRole_STILL, GroupId: "live-photo-pair", }, MediaMetadata: &storepb.MediaMetadata{ Width: proto.Int32(1200), Height: proto.Int32(800), Details: &storepb.MediaMetadata_Photo{Photo: &storepb.PhotoMetadata{ CameraMake: "Test Camera", SourceExifOrientation: proto.Int32(6), }}, }, }, }) require.NoError(t, err) correctFilename := "test.epub" incorrectFilename := "test.png" attachment, err := ts.GetAttachment(ctx, &store.FindAttachment{ Filename: &correctFilename, }) require.NoError(t, err) require.Equal(t, correctFilename, attachment.Filename) require.Equal(t, int32(1), attachment.ID) require.Equal(t, int32(1200), attachment.Payload.GetMediaMetadata().GetWidth()) require.Equal(t, "Test Camera", attachment.Payload.GetMediaMetadata().GetPhoto().GetCameraMake()) require.Equal(t, int32(6), attachment.Payload.GetMediaMetadata().GetPhoto().GetSourceExifOrientation()) require.Equal(t, "attachments/test.jpg", attachment.Payload.GetS3Object().GetKey()) require.Equal(t, "s3-primary", attachment.Payload.GetS3Object().GetStorageId()) require.Equal(t, "live-photo-pair", attachment.Payload.GetMotionMedia().GetGroupId()) notFoundAttachment, err := ts.GetAttachment(ctx, &store.FindAttachment{ Filename: &incorrectFilename, }) require.NoError(t, err) require.Nil(t, notFoundAttachment) var correctCreatorID int32 = 101 var incorrectCreatorID int32 = 102 _, err = ts.GetAttachment(ctx, &store.FindAttachment{ CreatorID: &correctCreatorID, }) require.NoError(t, err) notFoundAttachment, err = ts.GetAttachment(ctx, &store.FindAttachment{ CreatorID: &incorrectCreatorID, }) require.NoError(t, err) require.Nil(t, notFoundAttachment) err = ts.DeleteAttachment(ctx, &store.DeleteAttachment{ ID: 1, }) require.NoError(t, err) err = ts.DeleteAttachment(ctx, &store.DeleteAttachment{ ID: 2, }) require.ErrorContains(t, err, "attachment not found") ts.Close() } func TestMemoMutationRollsBackOnBindingConflict(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) memo, err := ts.CreateMemo(ctx, &store.Memo{ UID: shortuuid.New(), CreatorID: user.ID, Content: "original", Visibility: store.Private, }) require.NoError(t, err) otherMemo, err := ts.CreateMemo(ctx, &store.Memo{ UID: shortuuid.New(), CreatorID: user.ID, Content: "other", Visibility: store.Private, }) require.NoError(t, err) available, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "available.png", Type: "image/png", }) require.NoError(t, err) conflicting, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "conflicting.png", Type: "image/png", MemoID: &otherMemo.ID, }) require.NoError(t, err) updatedContent := "updated" err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoID: memo.ID, MemoCreatorID: user.ID, ExpectedMemoContent: memo.Content, MemoUpdate: &store.UpdateMemo{ID: memo.ID, Content: &updatedContent}, Bindings: []*store.MemoAttachmentBinding{ {ID: available.ID, UID: available.UID, UpdatedTs: time.Now().Unix()}, {ID: conflicting.ID, UID: conflicting.UID, UpdatedTs: time.Now().Unix() + 1}, }, }) require.ErrorIs(t, err, store.ErrMemoMutationConflict) storedMemo, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) require.NoError(t, err) require.Equal(t, "original", storedMemo.Content) storedAvailable, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &available.ID}) require.NoError(t, err) require.Nil(t, storedAvailable.MemoID) storedConflicting, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &conflicting.ID}) require.NoError(t, err) require.NotNil(t, storedConflicting.MemoID) require.Equal(t, otherMemo.ID, *storedConflicting.MemoID) } func TestMemoCreationMutationRollsBackDependentWrites(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) topLevelUID := shortuuid.New() topLevel := &store.Memo{UID: topLevelUID, CreatorID: user.ID, Content: "top level", Visibility: store.Private} err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoCreate: topLevel, MemoCreatorID: user.ID, ExpectedMemoContent: topLevel.Content, RequiredAttachmentIDs: []int32{2147483000}, }) require.ErrorIs(t, err, store.ErrMemoMutationConflict) storedTopLevel, err := ts.GetMemo(ctx, &store.FindMemo{UID: &topLevelUID}) require.NoError(t, err) require.Nil(t, storedTopLevel, "dependent write failure must roll back the top-level memo row") root, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "root", Visibility: store.Public}) require.NoError(t, err) commentUID := shortuuid.New() comment := &store.Memo{UID: commentUID, CreatorID: user.ID, Content: "comment", Visibility: store.Public} err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoCreate: comment, CommentContextMemoID: &root.ID, MemoCreatorID: user.ID, ExpectedMemoContent: comment.Content, RequiredAttachmentIDs: []int32{2147483000}, }) require.ErrorIs(t, err, store.ErrMemoMutationConflict) storedComment, err := ts.GetMemo(ctx, &store.FindMemo{UID: &commentUID}) require.NoError(t, err) require.Nil(t, storedComment, "dependent write failure must roll back the comment row") } func TestMemoCreationMutationCommitsMemoBindingsAndRelationsTogether(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) target, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "target", Visibility: store.Private}) require.NoError(t, err) attachment, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "created.png", Type: "image/png", }) require.NoError(t, err) created := &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "created", Visibility: store.Private} err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoCreate: created, MemoCreatorID: user.ID, ExpectedMemoContent: created.Content, Bindings: []*store.MemoAttachmentBinding{{ID: attachment.ID, UID: attachment.UID, UpdatedTs: time.Now().Unix()}}, RequiredAttachmentIDs: []int32{attachment.ID}, ReplaceReferenceRelations: true, ReferenceRelations: []*store.MemoRelation{{ RelatedMemoID: target.ID, Type: store.MemoRelationReference, }}, }) require.NoError(t, err) require.Positive(t, created.ID) storedAttachment, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID}) require.NoError(t, err) require.NotNil(t, storedAttachment.MemoID) require.Equal(t, created.ID, *storedAttachment.MemoID) relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{MemoIDList: []int32{created.ID}}) require.NoError(t, err) require.Len(t, relations, 1) require.Equal(t, created.ID, relations[0].MemoID) require.Equal(t, target.ID, relations[0].RelatedMemoID) } func TestMemoMutationUpdatesMemoAndBindingsTogether(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) memo, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "old", Visibility: store.Private}) require.NoError(t, err) removed, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "removed.png", Type: "image/png", MemoID: &memo.ID, }) require.NoError(t, err) added, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "added.png", Type: "image/png", }) require.NoError(t, err) updatedContent := "new" err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoID: memo.ID, MemoCreatorID: user.ID, ExpectedMemoContent: memo.Content, MemoUpdate: &store.UpdateMemo{ID: memo.ID, Content: &updatedContent}, Bindings: []*store.MemoAttachmentBinding{ {ID: added.ID, UID: added.UID, UpdatedTs: time.Now().Unix()}, }, RemovedAttachmentIDs: []int32{removed.ID}, RequiredAttachmentIDs: []int32{added.ID}, }) require.NoError(t, err) storedMemo, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) require.NoError(t, err) require.Equal(t, "new", storedMemo.Content) storedAdded, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &added.ID}) require.NoError(t, err) require.NotNil(t, storedAdded.MemoID) require.Equal(t, memo.ID, *storedAdded.MemoID) storedRemoved, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &removed.ID}) require.NoError(t, err) require.Nil(t, storedRemoved) } func TestMemoMutationRollsBackWhenRequiredAttachmentIsMissing(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) memo, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "old", Visibility: store.Private}) require.NoError(t, err) removed, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "kept.png", Type: "image/png", MemoID: &memo.ID, }) require.NoError(t, err) added, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: user.ID, Filename: "rolled-back.png", Type: "image/png", }) require.NoError(t, err) updatedContent := "new" err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoID: memo.ID, MemoCreatorID: user.ID, ExpectedMemoContent: memo.Content, MemoUpdate: &store.UpdateMemo{ID: memo.ID, Content: &updatedContent}, Bindings: []*store.MemoAttachmentBinding{ {ID: added.ID, UID: added.UID, UpdatedTs: time.Now().Unix()}, }, RemovedAttachmentIDs: []int32{removed.ID}, RequiredAttachmentIDs: []int32{added.ID + removed.ID + 1000}, }) require.ErrorIs(t, err, store.ErrMemoMutationConflict) storedMemo, err := ts.GetMemo(ctx, &store.FindMemo{ID: &memo.ID}) require.NoError(t, err) require.Equal(t, "old", storedMemo.Content) storedAdded, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &added.ID}) require.NoError(t, err) require.Nil(t, storedAdded.MemoID) storedRemoved, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &removed.ID}) require.NoError(t, err) require.NotNil(t, storedRemoved.MemoID) require.Equal(t, memo.ID, *storedRemoved.MemoID) } func TestMemoMutationRollsBackMemoAndRelationsTogether(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) source, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "original", Visibility: store.Private}) require.NoError(t, err) originalTarget, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "original target", Visibility: store.Private}) require.NoError(t, err) replacementTarget, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "replacement target", Visibility: store.Private}) require.NoError(t, err) _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ MemoID: source.ID, RelatedMemoID: originalTarget.ID, Type: store.MemoRelationReference, }) require.NoError(t, err) updatedContent := "must roll back" err = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoID: source.ID, MemoCreatorID: user.ID, ExpectedMemoContent: source.Content, MemoUpdate: &store.UpdateMemo{ID: source.ID, Content: &updatedContent}, ReplaceReferenceRelations: true, ReferenceRelations: []*store.MemoRelation{ {MemoID: source.ID, RelatedMemoID: replacementTarget.ID, Type: store.MemoRelationReference}, {MemoID: source.ID, RelatedMemoID: originalTarget.ID, Type: store.MemoRelationComment}, }, }) require.Error(t, err) stored, err := ts.GetMemo(ctx, &store.FindMemo{ID: &source.ID}) require.NoError(t, err) require.Equal(t, "original", stored.Content) referenceType := store.MemoRelationReference relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{MemoID: &source.ID, Type: &referenceType}) require.NoError(t, err) require.Len(t, relations, 1) require.Equal(t, originalTarget.ID, relations[0].RelatedMemoID) } func TestAttachmentStoreWithFilter(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) _, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: 101, Filename: "test.png", Blob: []byte("test"), Type: "image/png", Size: 1000, }) require.NoError(t, err) _, err = ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: 101, Filename: "test.jpg", Blob: []byte("test"), Type: "image/jpeg", Size: 2000, }) require.NoError(t, err) _, err = ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: 101, Filename: "test.pdf", Blob: []byte("test"), Type: "application/pdf", Size: 3000, }) require.NoError(t, err) attachments, err := ts.ListAttachments(ctx, &store.FindAttachment{ CreatorID: &[]int32{101}[0], Filters: []string{`mime_type == "image/png"`}, }) require.NoError(t, err) require.Len(t, attachments, 1) require.Equal(t, "image/png", attachments[0].Type) attachments, err = ts.ListAttachments(ctx, &store.FindAttachment{ CreatorID: &[]int32{101}[0], Filters: []string{`mime_type in ["image/png", "image/jpeg"]`}, }) require.NoError(t, err) require.Len(t, attachments, 2) attachments, err = ts.ListAttachments(ctx, &store.FindAttachment{ CreatorID: &[]int32{101}[0], Filters: []string{`filename.contains("test")`}, }) require.NoError(t, err) require.Len(t, attachments, 3) ts.Close() } func TestAttachmentUpdate(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) attachment, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: 101, Filename: "original.png", Blob: []byte("test"), Type: "image/png", Size: 1000, }) require.NoError(t, err) // Update filename newFilename := "updated.png" err = ts.UpdateAttachment(ctx, &store.UpdateAttachment{ ID: attachment.ID, Filename: &newFilename, }) require.NoError(t, err) // Verify update found, err := ts.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID}) require.NoError(t, err) require.Equal(t, newFilename, found.Filename) ts.Close() } func TestAttachmentGetByUID(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) uid := shortuuid.New() _, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: uid, CreatorID: 101, Filename: "test.png", Blob: []byte("test"), Type: "image/png", Size: 1000, }) require.NoError(t, err) // Get by UID found, err := ts.GetAttachment(ctx, &store.FindAttachment{UID: &uid}) require.NoError(t, err) require.NotNil(t, found) require.Equal(t, uid, found.UID) // Get non-existent UID nonExistentUID := "non-existent-uid" notFound, err := ts.GetAttachment(ctx, &store.FindAttachment{UID: &nonExistentUID}) require.NoError(t, err) require.Nil(t, notFound) ts.Close() } func TestAttachmentListWithPagination(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) // Create 5 attachments for i := 0; i < 5; i++ { _, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: shortuuid.New(), CreatorID: 101, Filename: fmt.Sprintf("test%d.png", i), Blob: []byte("test"), Type: "image/png", Size: int64(1000 + i), }) require.NoError(t, err) } // Test limit limit := 3 attachments, err := ts.ListAttachments(ctx, &store.FindAttachment{ CreatorID: &[]int32{101}[0], Limit: &limit, }) require.NoError(t, err) require.Equal(t, 3, len(attachments)) // Test offset offset := 2 offsetAttachments, err := ts.ListAttachments(ctx, &store.FindAttachment{ CreatorID: &[]int32{101}[0], Limit: &limit, Offset: &offset, }) require.NoError(t, err) require.Equal(t, 3, len(offsetAttachments)) ts.Close() } func TestAttachmentInvalidUID(t *testing.T) { t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) // Create with invalid UID (contains spaces) _, err := ts.CreateAttachment(ctx, &store.Attachment{ UID: "invalid uid with spaces", CreatorID: 101, Filename: "test.png", Blob: []byte("test"), Type: "image/png", Size: 1000, }) require.Error(t, err) require.Contains(t, err.Error(), "invalid uid") ts.Close() } // TestMemoMutationConcurrentUpdatesNoSQLiteBusy: concurrent distinct-memo mutations all succeed (issue #6186). func TestMemoMutationConcurrentUpdatesNoSQLiteBusy(t *testing.T) { if getDriverFromEnv() != "sqlite" { t.Skip("skipping sqlite lock regression test for non-sqlite driver") } t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) // One memo per goroutine: writers don't share rows, so all must succeed. const workers = 10 memos := make([]*store.Memo, workers) for i := range memos { memo, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "original", Visibility: store.Private}) require.NoError(t, err) memos[i] = memo } start := make(chan struct{}) errs := make([]error, workers) var wg sync.WaitGroup content := "updated" for i := 0; i < workers; i++ { wg.Go(func() { <-start errs[i] = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoID: memos[i].ID, MemoCreatorID: user.ID, ExpectedMemoContent: memos[i].Content, MemoUpdate: &store.UpdateMemo{ID: memos[i].ID, Content: &content}, }) }) } close(start) wg.Wait() for _, err := range errs { require.NoError(t, err) } } // TestMemoMutationConcurrentUpdatesSameMemoNoSQLiteBusy: same-memo races only yield success or ErrMemoMutationConflict. func TestMemoMutationConcurrentUpdatesSameMemoNoSQLiteBusy(t *testing.T) { if getDriverFromEnv() != "sqlite" { t.Skip("skipping sqlite lock regression test for non-sqlite driver") } t.Parallel() ctx := context.Background() ts := NewTestingStore(ctx, t) defer ts.Close() user, err := createTestingHostUser(ctx, ts) require.NoError(t, err) memo, err := ts.CreateMemo(ctx, &store.Memo{UID: shortuuid.New(), CreatorID: user.ID, Content: "original", Visibility: store.Private}) require.NoError(t, err) const workers = 10 start := make(chan struct{}) errs := make([]error, workers) var wg sync.WaitGroup content := "updated" for i := 0; i < workers; i++ { wg.Go(func() { <-start errs[i] = ts.ApplyMemoMutation(ctx, &store.MemoMutation{ MemoID: memo.ID, MemoCreatorID: user.ID, ExpectedMemoContent: memo.Content, MemoUpdate: &store.UpdateMemo{ID: memo.ID, Content: &content}, }) }) } close(start) wg.Wait() for _, err := range errs { if err == nil { continue } require.ErrorIs(t, err, store.ErrMemoMutationConflict) } }