736 lines
26 KiB
Go
736 lines
26 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
|
|
|
"github.com/usememos/memos/internal/profile"
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"github.com/usememos/memos/server/auth"
|
|
"github.com/usememos/memos/store"
|
|
teststore "github.com/usememos/memos/store/test"
|
|
)
|
|
|
|
// newIntegrationService builds a minimal APIV1Service backed by an in-memory
|
|
// SQLite database. The store is closed automatically via t.Cleanup.
|
|
func newIntegrationService(t *testing.T) *APIV1Service {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st := teststore.NewTestingStore(ctx, t)
|
|
t.Cleanup(func() { st.Close() })
|
|
p := &profile.Profile{Demo: true, Data: t.TempDir(), Driver: "sqlite", DSN: ":memory:"}
|
|
return NewAPIV1Service("test-secret", p, st)
|
|
}
|
|
|
|
// userCtx returns a context that authenticates as the given user.
|
|
func userCtx(ctx context.Context, userID int32) context.Context {
|
|
return context.WithValue(ctx, auth.UserIDContextKey, userID)
|
|
}
|
|
|
|
// collectEventsFor reads events from ch for the given duration and returns them.
|
|
func collectEventsFor(ch <-chan []byte, d time.Duration) []string {
|
|
var out []string
|
|
deadline := time.After(d)
|
|
for {
|
|
select {
|
|
case data := <-ch:
|
|
out = append(out, string(data))
|
|
case <-deadline:
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
|
|
func requireMemoChanged(t *testing.T, ch <-chan []byte) {
|
|
t.Helper()
|
|
require.Equal(t, memoChangedSSEFrame, string(mustReceive(t, ch, time.Second)))
|
|
}
|
|
|
|
func requireSpaceChanged(t *testing.T, ch <-chan []byte) {
|
|
t.Helper()
|
|
require.Equal(t, spaceChangedSSEFrame, string(mustReceive(t, ch, time.Second)))
|
|
}
|
|
|
|
func requireNoMemoChanged(t *testing.T, ch <-chan []byte) {
|
|
t.Helper()
|
|
select {
|
|
case event := <-ch:
|
|
require.Failf(t, "unexpected SSE event", "received %q", event)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestAttachmentMutationsPublishMemoChangedOnlyWhenBound(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner := createSpaceTestUser(ctx, t, svc, "sse-attachment-owner", store.RoleUser)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
memo, err := svc.CreateMemo(ownerCtx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "attachment event memo", Visibility: v1pb.Visibility_PRIVATE},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
createAttachment := func(filename string, memoName *string) *v1pb.Attachment {
|
|
t.Helper()
|
|
attachment, err := svc.CreateAttachment(ownerCtx, &v1pb.CreateAttachmentRequest{Attachment: &v1pb.Attachment{
|
|
Filename: filename,
|
|
Type: "text/plain",
|
|
Content: []byte(filename),
|
|
Memo: memoName,
|
|
}})
|
|
require.NoError(t, err)
|
|
return attachment
|
|
}
|
|
|
|
unbound := createAttachment("sse-unbound.txt", nil)
|
|
requireNoMemoChanged(t, client.events)
|
|
_, err = svc.UpdateAttachment(ownerCtx, &v1pb.UpdateAttachmentRequest{
|
|
Attachment: &v1pb.Attachment{Name: unbound.Name, Filename: "sse-unbound-renamed.txt"},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"filename"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireNoMemoChanged(t, client.events)
|
|
_, err = svc.DeleteAttachment(ownerCtx, &v1pb.DeleteAttachmentRequest{Name: unbound.Name})
|
|
require.NoError(t, err)
|
|
requireNoMemoChanged(t, client.events)
|
|
|
|
bound := createAttachment("sse-bound.txt", &memo.Name)
|
|
requireMemoChanged(t, client.events)
|
|
_, err = svc.UpdateAttachment(ownerCtx, &v1pb.UpdateAttachmentRequest{
|
|
Attachment: &v1pb.Attachment{Name: bound.Name, Filename: "sse-bound-renamed.txt"},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"filename"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, client.events)
|
|
_, err = svc.DeleteAttachment(ownerCtx, &v1pb.DeleteAttachmentRequest{Name: bound.Name})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, client.events)
|
|
|
|
firstUnbound := createAttachment("sse-batch-unbound-first.txt", nil)
|
|
secondUnbound := createAttachment("sse-batch-unbound-second.txt", nil)
|
|
requireNoMemoChanged(t, client.events)
|
|
_, err = svc.BatchDeleteAttachments(ownerCtx, &v1pb.BatchDeleteAttachmentsRequest{
|
|
Names: []string{firstUnbound.Name, secondUnbound.Name},
|
|
})
|
|
require.NoError(t, err)
|
|
requireNoMemoChanged(t, client.events)
|
|
|
|
mixedUnbound := createAttachment("sse-batch-mixed-unbound.txt", nil)
|
|
mixedBound := createAttachment("sse-batch-mixed-bound.txt", &memo.Name)
|
|
requireMemoChanged(t, client.events)
|
|
_, err = svc.BatchDeleteAttachments(ownerCtx, &v1pb.BatchDeleteAttachmentsRequest{
|
|
Names: []string{mixedUnbound.Name, mixedBound.Name},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, client.events)
|
|
}
|
|
|
|
func TestUpdateAttachmentPublishesAfterCommittedAccessChange(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner := createSpaceTestUser(ctx, t, svc, "sse-attachment-access-owner", store.RoleUser)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
space, err := svc.CreateSpace(ownerCtx, &v1pb.CreateSpaceRequest{
|
|
SpaceId: "sse-attachment-access-space",
|
|
Space: &v1pb.Space{Title: "Attachment access space"},
|
|
})
|
|
require.NoError(t, err)
|
|
spaceName := space.Name
|
|
memo, err := svc.CreateMemo(ownerCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: "attachment access changed during update",
|
|
Visibility: v1pb.Visibility_SPACE,
|
|
Space: &spaceName,
|
|
}})
|
|
require.NoError(t, err)
|
|
attachment, err := svc.CreateAttachment(ownerCtx, &v1pb.CreateAttachmentRequest{Attachment: &v1pb.Attachment{
|
|
Filename: "before.txt",
|
|
Type: "text/plain",
|
|
Content: []byte("attachment"),
|
|
Memo: &memo.Name,
|
|
}})
|
|
require.NoError(t, err)
|
|
attachmentUID, err := ExtractAttachmentUIDFromName(attachment.Name)
|
|
require.NoError(t, err)
|
|
storedAttachment, err := svc.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedAttachment)
|
|
|
|
// Authorization is checked before the attachment update. Simulate membership
|
|
// changing in the same transaction so the committed response can no longer be
|
|
// built through the read-authorized GetAttachment service method.
|
|
trigger := fmt.Sprintf(`
|
|
CREATE TRIGGER revoke_attachment_owner_membership
|
|
AFTER UPDATE OF filename ON attachment
|
|
WHEN NEW.id = %d
|
|
BEGIN
|
|
DELETE FROM space_member WHERE user_id = %d;
|
|
END`, storedAttachment.ID, owner.ID)
|
|
_, err = svc.Store.GetDriver().GetDB().ExecContext(ctx, trigger)
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
updated, err := svc.UpdateAttachment(ownerCtx, &v1pb.UpdateAttachmentRequest{
|
|
Attachment: &v1pb.Attachment{Name: attachment.Name, Filename: "after.txt"},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"filename"}},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "after.txt", updated.Filename)
|
|
requireMemoChanged(t, client.events)
|
|
requireNoMemoChanged(t, client.events)
|
|
|
|
_, err = svc.GetAttachment(ownerCtx, &v1pb.GetAttachmentRequest{Name: attachment.Name})
|
|
require.Equal(t, codes.PermissionDenied, status.Code(err), "the authorized read would fail after the committed membership change")
|
|
}
|
|
|
|
func TestDeleteAttachmentPublishesBeforeStorageCleanupFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner := createSpaceTestUser(ctx, t, svc, "sse-attachment-cleanup-owner", store.RoleUser)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
memo, err := svc.CreateMemo(ownerCtx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "attachment cleanup failure", Visibility: v1pb.Visibility_PRIVATE},
|
|
})
|
|
require.NoError(t, err)
|
|
attachment, err := svc.CreateAttachment(ownerCtx, &v1pb.CreateAttachmentRequest{Attachment: &v1pb.Attachment{
|
|
Filename: "cleanup.txt",
|
|
Type: "text/plain",
|
|
Content: []byte("attachment"),
|
|
Memo: &memo.Name,
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
_, err = svc.DeleteAttachment(store.WithDeleteAttachmentStorageFailpoint(ownerCtx), &v1pb.DeleteAttachmentRequest{Name: attachment.Name})
|
|
require.Equal(t, codes.Internal, status.Code(err))
|
|
require.ErrorContains(t, err, "attachments were deleted but storage cleanup failed")
|
|
requireMemoChanged(t, client.events)
|
|
requireNoMemoChanged(t, client.events)
|
|
|
|
attachmentUID, err := ExtractAttachmentUIDFromName(attachment.Name)
|
|
require.NoError(t, err)
|
|
storedAttachment, err := svc.Store.GetAttachment(ctx, &store.FindAttachment{UID: &attachmentUID})
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedAttachment, "the database deletion must remain committed")
|
|
}
|
|
|
|
func TestSpaceMutationsPublishSpaceChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner := createSpaceTestUser(ctx, t, svc, "sse-space-owner", store.RoleAdmin)
|
|
member := createSpaceTestUser(ctx, t, svc, "sse-space-member", store.RoleUser)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
space, err := svc.CreateSpace(ownerCtx, &v1pb.CreateSpaceRequest{
|
|
SpaceId: "sse-space",
|
|
Space: &v1pb.Space{Title: "SSE Space"},
|
|
})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
|
|
space.Title = "Renamed SSE Space"
|
|
_, err = svc.UpdateSpace(ownerCtx, &v1pb.UpdateSpaceRequest{
|
|
Space: space,
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
|
|
invitation, err := svc.CreateSpaceInvitation(ownerCtx, &v1pb.CreateSpaceInvitationRequest{
|
|
Parent: space.Name,
|
|
SpaceInvitation: &v1pb.SpaceInvitation{Invitee: BuildUserName(member.Username), Role: v1pb.SpaceMember_USER},
|
|
})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
membership, err := svc.AcceptSpaceInvitation(userCtx(ctx, member.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
|
|
membership.Role = v1pb.SpaceMember_ADMIN
|
|
_, err = svc.UpdateSpaceMember(ownerCtx, &v1pb.UpdateSpaceMemberRequest{
|
|
SpaceMember: membership,
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"role"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
|
|
_, err = svc.DeleteSpaceMember(ownerCtx, &v1pb.DeleteSpaceMemberRequest{Name: membership.Name})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
|
|
_, err = svc.DeleteSpace(ownerCtx, &v1pb.DeleteSpaceRequest{Name: space.Name})
|
|
require.NoError(t, err)
|
|
requireSpaceChanged(t, client.events)
|
|
}
|
|
|
|
func TestUpdateMemoSSEBroadcastsToAllSubscribers(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "audience-owner", Role: store.RoleAdmin, Email: "audience-owner@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
memo, err := svc.CreateMemo(ownerCtx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "public before update", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
ownerClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(ownerClient)
|
|
otherClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(otherClient)
|
|
|
|
_, err = svc.UpdateMemo(ownerCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{Name: memo.Name, Visibility: v1pb.Visibility_PRIVATE},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, ownerClient.events)
|
|
requireMemoChanged(t, otherClient.events)
|
|
|
|
_, err = svc.UpdateMemo(ownerCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{Name: memo.Name, Content: "private after update"},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, ownerClient.events)
|
|
requireMemoChanged(t, otherClient.events)
|
|
}
|
|
|
|
func TestMoveSpaceAudienceMemoSSEBroadcastsWithoutAudienceCalculation(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner := createSpaceTestUser(ctx, t, svc, "move-audience-owner", store.RoleAdmin)
|
|
sourceMember := createSpaceTestUser(ctx, t, svc, "move-source-member", store.RoleUser)
|
|
targetMember := createSpaceTestUser(ctx, t, svc, "move-target-member", store.RoleUser)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
|
|
source, err := svc.CreateSpace(ownerCtx, &v1pb.CreateSpaceRequest{
|
|
SpaceId: "move-source-space",
|
|
Space: &v1pb.Space{Title: "Source"},
|
|
})
|
|
require.NoError(t, err)
|
|
target, err := svc.CreateSpace(ownerCtx, &v1pb.CreateSpaceRequest{
|
|
SpaceId: "move-target-space",
|
|
Space: &v1pb.Space{Title: "Target"},
|
|
})
|
|
require.NoError(t, err)
|
|
inviteAndAcceptSpaceTestUser(ctx, t, svc, owner, sourceMember, source, v1pb.SpaceMember_USER)
|
|
inviteAndAcceptSpaceTestUser(ctx, t, svc, owner, targetMember, target, v1pb.SpaceMember_USER)
|
|
|
|
sourceName := source.Name
|
|
memo, err := svc.CreateMemo(ownerCtx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{
|
|
Content: "move between spaces",
|
|
Visibility: v1pb.Visibility_SPACE,
|
|
Space: &sourceName,
|
|
}})
|
|
require.NoError(t, err)
|
|
sourceClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(sourceClient)
|
|
targetClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(targetClient)
|
|
outsiderClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(outsiderClient)
|
|
|
|
targetName := target.Name
|
|
_, err = svc.UpdateMemo(ownerCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{
|
|
Name: memo.Name,
|
|
Visibility: v1pb.Visibility_SPACE,
|
|
Space: &targetName,
|
|
},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"space", "visibility"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, sourceClient.events)
|
|
requireMemoChanged(t, targetClient.events)
|
|
requireMemoChanged(t, outsiderClient.events)
|
|
|
|
_, err = svc.UpdateMemo(ownerCtx, &v1pb.UpdateMemoRequest{
|
|
Memo: &v1pb.Memo{Name: memo.Name, Content: "target only now"},
|
|
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"content"}},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, sourceClient.events)
|
|
requireMemoChanged(t, targetClient.events)
|
|
requireMemoChanged(t, outsiderClient.events)
|
|
}
|
|
|
|
// ---- CreateMemoComment double-broadcast fix ----
|
|
|
|
func TestCreateMemoComment_NoDuplicateSSEBroadcast(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
|
|
// Create an admin so the store is initialised, then a regular commenter.
|
|
author, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "author", Role: store.RoleAdmin, Email: "author@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
commenter, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "commenter", Role: store.RoleUser, Email: "commenter@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
authorCtx := userCtx(ctx, author.ID)
|
|
commenterCtx := userCtx(ctx, commenter.ID)
|
|
|
|
// Create a public memo so the commenter can react.
|
|
parent, err := svc.CreateMemo(authorCtx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "parent memo", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Subscribe after the parent memo is created so its change event does not
|
|
// pollute the assertion window.
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
// Comment creation is one mutation and emits one invalidation event.
|
|
_, err = svc.CreateMemoComment(commenterCtx, &v1pb.CreateMemoCommentRequest{
|
|
Name: parent.Name,
|
|
Comment: &v1pb.Memo{Content: "a comment", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Give the synchronous broadcast a moment to land in the buffer, then
|
|
// collect everything that arrived.
|
|
events := collectEventsFor(client.events, 150*time.Millisecond)
|
|
|
|
require.Len(t, events, 1, "expected exactly one SSE event for a comment creation, got: %v", events)
|
|
assert.Equal(t, memoChangedSSEFrame, events[0])
|
|
}
|
|
|
|
func TestCreateMemoComment_SSEBroadcastContainsNoSubject(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
author, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "comment-context-author", Role: store.RoleAdmin, Email: "comment-context-author@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
commenter, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "private-comment-author", Role: store.RoleUser, Email: "private-comment-author@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
parent, err := svc.CreateMemo(userCtx(ctx, author.ID), &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "public context", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
authorClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(authorClient)
|
|
commenterClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(commenterClient)
|
|
|
|
_, err = svc.CreateMemoComment(userCtx(ctx, commenter.ID), &v1pb.CreateMemoCommentRequest{
|
|
Name: parent.Name,
|
|
Comment: &v1pb.Memo{
|
|
Content: "private reply",
|
|
Visibility: v1pb.Visibility_PRIVATE,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, commenterClient.events)
|
|
requireMemoChanged(t, authorClient.events)
|
|
}
|
|
|
|
func TestCreateMemoWithAttachment_NoDuplicateUpdatedSSEBroadcast(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
|
|
user, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "user", Role: store.RoleAdmin, Email: "user@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
uctx := userCtx(ctx, user.ID)
|
|
|
|
attachment, err := svc.CreateAttachment(uctx, &v1pb.CreateAttachmentRequest{
|
|
Attachment: &v1pb.Attachment{
|
|
Filename: "test.txt",
|
|
Size: 5,
|
|
Type: "text/plain",
|
|
Content: []byte("hello"),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
_, err = svc.CreateMemo(uctx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{
|
|
Content: "memo with initial attachment",
|
|
Visibility: v1pb.Visibility_PUBLIC,
|
|
Attachments: []*v1pb.Attachment{
|
|
{Name: attachment.Name},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
events := collectEventsFor(client.events, 150*time.Millisecond)
|
|
|
|
require.Len(t, events, 1, "expected exactly one SSE event for memo creation with attachment, got: %v", events)
|
|
assert.Equal(t, memoChangedSSEFrame, events[0])
|
|
}
|
|
|
|
func TestUpsertMemoReactionPublishesMemoChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
|
|
user, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "user", Role: store.RoleAdmin, Email: "user@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
uctx := userCtx(ctx, user.ID)
|
|
|
|
memo, err := svc.CreateMemo(uctx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "reacted memo", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
_, err = svc.UpsertMemoReaction(uctx, &v1pb.UpsertMemoReactionRequest{
|
|
Name: memo.Name,
|
|
Reaction: &v1pb.Reaction{
|
|
ReactionType: "👍",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
requireMemoChanged(t, client.events)
|
|
mustNotReceive(t, client.events, 100*time.Millisecond)
|
|
}
|
|
|
|
func TestDeleteMemoReactionPublishesMemoChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
|
|
user, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "user", Role: store.RoleAdmin, Email: "user@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
uctx := userCtx(ctx, user.ID)
|
|
|
|
memo, err := svc.CreateMemo(uctx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "reacted memo", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
reaction, err := svc.UpsertMemoReaction(uctx, &v1pb.UpsertMemoReactionRequest{
|
|
Name: memo.Name,
|
|
Reaction: &v1pb.Reaction{
|
|
ReactionType: "❤️",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
_, err = svc.DeleteMemoReaction(uctx, &v1pb.DeleteMemoReactionRequest{
|
|
Name: reaction.Name,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
requireMemoChanged(t, client.events)
|
|
mustNotReceive(t, client.events, 100*time.Millisecond)
|
|
}
|
|
|
|
func TestDeleteMemo_DeletesOnlyRequestedMemo(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
rootAuthor, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "delete-root-author", Role: store.RoleAdmin, Email: "delete-root-author@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
commentAuthor, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "delete-comment-author", Role: store.RoleUser, Email: "delete-comment-author@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
replyAuthor, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "delete-reply-author", Role: store.RoleUser, Email: "delete-reply-author@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
root, err := svc.CreateMemo(userCtx(ctx, rootAuthor.ID), &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "context memo", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
comment, err := svc.CreateMemoComment(userCtx(ctx, commentAuthor.ID), &v1pb.CreateMemoCommentRequest{
|
|
Name: root.Name,
|
|
Comment: &v1pb.Memo{Content: "branch", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
reply, err := svc.CreateMemoComment(userCtx(ctx, replyAuthor.ID), &v1pb.CreateMemoCommentRequest{
|
|
Name: comment.Name,
|
|
Comment: &v1pb.Memo{Content: "nested reply"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
replyClient := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(replyClient)
|
|
_, err = svc.DeleteMemo(userCtx(ctx, commentAuthor.ID), &v1pb.DeleteMemoRequest{Name: comment.Name})
|
|
require.NoError(t, err)
|
|
requireMemoChanged(t, replyClient.events)
|
|
|
|
commentUID, err := ExtractMemoUIDFromName(comment.Name)
|
|
require.NoError(t, err)
|
|
storedComment, err := svc.Store.GetMemo(ctx, &store.FindMemo{UID: &commentUID})
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedComment)
|
|
replyUID, err := ExtractMemoUIDFromName(reply.Name)
|
|
require.NoError(t, err)
|
|
storedReply, err := svc.Store.GetMemo(ctx, &store.FindMemo{UID: &replyUID})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedReply, "deleting a COMMENT endpoint must not cascade to the replying memo")
|
|
rootUID, err := ExtractMemoUIDFromName(root.Name)
|
|
require.NoError(t, err)
|
|
storedRoot, err := svc.Store.GetMemo(ctx, &store.FindMemo{UID: &rootUID})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedRoot)
|
|
}
|
|
|
|
func TestDeleteMemoCleansAttachmentStorage(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
owner, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "delete-cleanup-owner", Role: store.RoleAdmin, Email: "delete-cleanup-owner@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
ownerCtx := userCtx(ctx, owner.ID)
|
|
memo, err := svc.CreateMemo(ownerCtx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "attachment cleanup", Visibility: v1pb.Visibility_PRIVATE},
|
|
})
|
|
require.NoError(t, err)
|
|
memoUID, err := ExtractMemoUIDFromName(memo.Name)
|
|
require.NoError(t, err)
|
|
storedMemo, err := svc.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedMemo)
|
|
attachmentPath := filepath.Join(t.TempDir(), "sse-delete-cleanup.txt")
|
|
require.NoError(t, os.WriteFile(attachmentPath, []byte("cleanup"), 0o600))
|
|
attachment, err := svc.Store.CreateAttachment(ctx, &store.Attachment{
|
|
UID: "sse-delete-cleanup",
|
|
CreatorID: owner.ID,
|
|
Filename: "cleanup.txt",
|
|
Type: "text/plain",
|
|
Size: 7,
|
|
Blob: []byte("cleanup"),
|
|
StorageType: storepb.AttachmentStorageType_LOCAL,
|
|
Reference: attachmentPath,
|
|
MemoID: &storedMemo.ID,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
_, err = svc.DeleteMemo(ownerCtx, &v1pb.DeleteMemoRequest{Name: memo.Name})
|
|
require.NoError(t, err, "committed deletion must not be reported as a failure")
|
|
requireMemoChanged(t, client.events)
|
|
|
|
deletedMemo, err := svc.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
|
require.NoError(t, err)
|
|
require.Nil(t, deletedMemo)
|
|
deletedAttachment, err := svc.Store.GetAttachment(ctx, &store.FindAttachment{ID: &attachment.ID})
|
|
require.NoError(t, err)
|
|
require.Nil(t, deletedAttachment)
|
|
_, err = os.Stat(attachmentPath)
|
|
require.ErrorIs(t, err, os.ErrNotExist)
|
|
}
|
|
|
|
func TestSetMemoAttachmentsPublishesMemoChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
|
|
user, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "user", Role: store.RoleAdmin, Email: "user@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
uctx := userCtx(ctx, user.ID)
|
|
|
|
memo, err := svc.CreateMemo(uctx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "memo with attachments", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
attachment, err := svc.CreateAttachment(uctx, &v1pb.CreateAttachmentRequest{
|
|
Attachment: &v1pb.Attachment{
|
|
Filename: "test.txt",
|
|
Size: 5,
|
|
Type: "text/plain",
|
|
Content: []byte("hello"),
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
_, err = svc.SetMemoAttachments(uctx, &v1pb.SetMemoAttachmentsRequest{
|
|
Name: memo.Name,
|
|
Attachments: []*v1pb.Attachment{
|
|
{Name: attachment.Name},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
requireMemoChanged(t, client.events)
|
|
mustNotReceive(t, client.events, 100*time.Millisecond)
|
|
}
|
|
|
|
func TestSetMemoRelationsPublishesMemoChanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
svc := newIntegrationService(t)
|
|
|
|
user, err := svc.Store.CreateUser(ctx, &store.User{
|
|
Username: "user", Role: store.RoleAdmin, Email: "user@example.com",
|
|
})
|
|
require.NoError(t, err)
|
|
uctx := userCtx(ctx, user.ID)
|
|
|
|
memo1, err := svc.CreateMemo(uctx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "memo one", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
memo2, err := svc.CreateMemo(uctx, &v1pb.CreateMemoRequest{
|
|
Memo: &v1pb.Memo{Content: "memo two", Visibility: v1pb.Visibility_PUBLIC},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
client := svc.SSEHub.Subscribe()
|
|
defer svc.SSEHub.Unsubscribe(client)
|
|
|
|
_, err = svc.SetMemoRelations(uctx, &v1pb.SetMemoRelationsRequest{
|
|
Name: memo1.Name,
|
|
Relations: []*v1pb.MemoRelation{
|
|
{
|
|
RelatedMemo: &v1pb.MemoRelation_Memo{Name: memo2.Name},
|
|
Type: v1pb.MemoRelation_REFERENCE,
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
requireMemoChanged(t, client.events)
|
|
mustNotReceive(t, client.events, 100*time.Millisecond)
|
|
}
|