mirror of https://github.com/usememos/memos
refactor(api): migrate inbox functionality to user notifications
- Remove standalone InboxService and move functionality to UserService - Rename inbox to user notifications for better API consistency - Add ListUserNotifications, UpdateUserNotification, DeleteUserNotification methods - Update frontend components to use new notification endpoints - Update store layer to support new notification model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>pull/5075/merge
parent
e915e3a46b
commit
bc1550e926
@ -1,149 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package memos.api.v1;
|
||||
|
||||
import "google/api/annotations.proto";
|
||||
import "google/api/client.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
import "google/api/resource.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/field_mask.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "gen/api/v1";
|
||||
|
||||
service InboxService {
|
||||
// ListInboxes lists inboxes for a user.
|
||||
rpc ListInboxes(ListInboxesRequest) returns (ListInboxesResponse) {
|
||||
option (google.api.http) = {get: "/api/v1/{parent=users/*}/inboxes"};
|
||||
option (google.api.method_signature) = "parent";
|
||||
}
|
||||
// UpdateInbox updates an inbox.
|
||||
rpc UpdateInbox(UpdateInboxRequest) returns (Inbox) {
|
||||
option (google.api.http) = {
|
||||
patch: "/api/v1/{inbox.name=inboxes/*}"
|
||||
body: "inbox"
|
||||
};
|
||||
option (google.api.method_signature) = "inbox,update_mask";
|
||||
}
|
||||
// DeleteInbox deletes an inbox.
|
||||
rpc DeleteInbox(DeleteInboxRequest) returns (google.protobuf.Empty) {
|
||||
option (google.api.http) = {delete: "/api/v1/{name=inboxes/*}"};
|
||||
option (google.api.method_signature) = "name";
|
||||
}
|
||||
}
|
||||
|
||||
message Inbox {
|
||||
option (google.api.resource) = {
|
||||
type: "memos.api.v1/Inbox"
|
||||
pattern: "inboxes/{inbox}"
|
||||
name_field: "name"
|
||||
singular: "inbox"
|
||||
plural: "inboxes"
|
||||
};
|
||||
|
||||
// The resource name of the inbox.
|
||||
// Format: inboxes/{inbox}
|
||||
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
|
||||
|
||||
// The sender of the inbox notification.
|
||||
// Format: users/{user}
|
||||
string sender = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
|
||||
// The receiver of the inbox notification.
|
||||
// Format: users/{user}
|
||||
string receiver = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
|
||||
// The status of the inbox notification.
|
||||
Status status = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Output only. The creation timestamp.
|
||||
google.protobuf.Timestamp create_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
|
||||
// The type of the inbox notification.
|
||||
Type type = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
|
||||
// Optional. The activity ID associated with this inbox notification.
|
||||
optional int32 activity_id = 7 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Status enumeration for inbox notifications.
|
||||
enum Status {
|
||||
// Unspecified status.
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
// The notification is unread.
|
||||
UNREAD = 1;
|
||||
// The notification is archived.
|
||||
ARCHIVED = 2;
|
||||
}
|
||||
|
||||
// Type enumeration for inbox notifications.
|
||||
enum Type {
|
||||
// Unspecified type.
|
||||
TYPE_UNSPECIFIED = 0;
|
||||
// Memo comment notification.
|
||||
MEMO_COMMENT = 1;
|
||||
// Version update notification.
|
||||
VERSION_UPDATE = 2;
|
||||
}
|
||||
}
|
||||
|
||||
message ListInboxesRequest {
|
||||
// Required. The parent resource whose inboxes will be listed.
|
||||
// Format: users/{user}
|
||||
string parent = 1 [
|
||||
(google.api.field_behavior) = REQUIRED,
|
||||
(google.api.resource_reference) = {type: "memos.api.v1/User"}
|
||||
];
|
||||
|
||||
// Optional. The maximum number of inboxes to return.
|
||||
// The service may return fewer than this value.
|
||||
// If unspecified, at most 50 inboxes will be returned.
|
||||
// The maximum value is 1000; values above 1000 will be coerced to 1000.
|
||||
int32 page_size = 2 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. A page token, received from a previous `ListInboxes` call.
|
||||
// Provide this to retrieve the subsequent page.
|
||||
string page_token = 3 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. Filter to apply to the list results.
|
||||
// Example: "status=UNREAD" or "type=MEMO_COMMENT"
|
||||
// Supported operators: =, !=
|
||||
// Supported fields: status, type, sender, create_time
|
||||
string filter = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Optional. The order to sort results by.
|
||||
// Example: "create_time desc" or "status asc"
|
||||
string order_by = 5 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
message ListInboxesResponse {
|
||||
// The list of inboxes.
|
||||
repeated Inbox inboxes = 1;
|
||||
|
||||
// A token that can be sent as `page_token` to retrieve the next page.
|
||||
// If this field is omitted, there are no subsequent pages.
|
||||
string next_page_token = 2;
|
||||
|
||||
// The total count of inboxes (may be approximate).
|
||||
int32 total_size = 3;
|
||||
}
|
||||
|
||||
message UpdateInboxRequest {
|
||||
// Required. The inbox to update.
|
||||
Inbox inbox = 1 [(google.api.field_behavior) = REQUIRED];
|
||||
|
||||
// Required. The list of fields to update.
|
||||
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED];
|
||||
|
||||
// Optional. If set to true, allows updating missing fields.
|
||||
bool allow_missing = 3 [(google.api.field_behavior) = OPTIONAL];
|
||||
}
|
||||
|
||||
message DeleteInboxRequest {
|
||||
// Required. The resource name of the inbox to delete.
|
||||
// Format: inboxes/{inbox}
|
||||
string name = 1 [
|
||||
(google.api.field_behavior) = REQUIRED,
|
||||
(google.api.resource_reference) = {type: "memos.api.v1/Inbox"}
|
||||
];
|
||||
}
|
||||
@ -1,224 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func (s *APIV1Service) ListInboxes(ctx context.Context, request *v1pb.ListInboxesRequest) (*v1pb.ListInboxesResponse, error) {
|
||||
// Extract user ID from parent resource name
|
||||
userID, err := ExtractUserIDFromName(request.Parent)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid parent name %q: %v", request.Parent, err)
|
||||
}
|
||||
|
||||
// Get current user for authorization
|
||||
currentUser, err := s.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||||
}
|
||||
if currentUser == nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
||||
}
|
||||
|
||||
// Check if current user can access the requested user's inboxes
|
||||
if currentUser.ID != userID {
|
||||
// Only allow hosts and admins to access other users' inboxes
|
||||
if currentUser.Role != store.RoleHost && currentUser.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "cannot access inboxes for user %q", request.Parent)
|
||||
}
|
||||
}
|
||||
|
||||
var limit, offset int
|
||||
if request.PageToken != "" {
|
||||
var pageToken v1pb.PageToken
|
||||
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
||||
}
|
||||
limit = int(pageToken.Limit)
|
||||
offset = int(pageToken.Offset)
|
||||
} else {
|
||||
limit = int(request.PageSize)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = DefaultPageSize
|
||||
}
|
||||
if limit > MaxPageSize {
|
||||
limit = MaxPageSize
|
||||
}
|
||||
limitPlusOne := limit + 1
|
||||
|
||||
findInbox := &store.FindInbox{
|
||||
ReceiverID: &userID,
|
||||
Limit: &limitPlusOne,
|
||||
Offset: &offset,
|
||||
}
|
||||
|
||||
inboxes, err := s.Store.ListInboxes(ctx, findInbox)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to list inboxes: %v", err)
|
||||
}
|
||||
|
||||
inboxMessages := []*v1pb.Inbox{}
|
||||
nextPageToken := ""
|
||||
if len(inboxes) == limitPlusOne {
|
||||
inboxes = inboxes[:limit]
|
||||
nextPageToken, err = getPageToken(limit, offset+limit)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get next page token: %v", err)
|
||||
}
|
||||
}
|
||||
for _, inbox := range inboxes {
|
||||
inboxMessage := convertInboxFromStore(inbox)
|
||||
if inboxMessage.Type == v1pb.Inbox_TYPE_UNSPECIFIED {
|
||||
continue
|
||||
}
|
||||
inboxMessages = append(inboxMessages, inboxMessage)
|
||||
}
|
||||
|
||||
response := &v1pb.ListInboxesResponse{
|
||||
Inboxes: inboxMessages,
|
||||
NextPageToken: nextPageToken,
|
||||
TotalSize: int32(len(inboxMessages)), // For now, use actual returned count
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpdateInbox(ctx context.Context, request *v1pb.UpdateInboxRequest) (*v1pb.Inbox, error) {
|
||||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||||
}
|
||||
|
||||
inboxID, err := ExtractInboxIDFromName(request.Inbox.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name %q: %v", request.Inbox.Name, err)
|
||||
}
|
||||
|
||||
// Get current user for authorization
|
||||
currentUser, err := s.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||||
}
|
||||
if currentUser == nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
||||
}
|
||||
|
||||
// Get the existing inbox to verify ownership
|
||||
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
|
||||
ID: &inboxID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
|
||||
}
|
||||
if len(inboxes) == 0 {
|
||||
return nil, status.Errorf(codes.NotFound, "inbox %q not found", request.Inbox.Name)
|
||||
}
|
||||
existingInbox := inboxes[0]
|
||||
|
||||
// Check if current user can update this inbox (must be the receiver)
|
||||
if currentUser.ID != existingInbox.ReceiverID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "cannot update inbox for another user")
|
||||
}
|
||||
|
||||
update := &store.UpdateInbox{
|
||||
ID: inboxID,
|
||||
}
|
||||
for _, field := range request.UpdateMask.Paths {
|
||||
if field == "status" {
|
||||
if request.Inbox.Status == v1pb.Inbox_STATUS_UNSPECIFIED {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "status cannot be unspecified")
|
||||
}
|
||||
update.Status = convertInboxStatusToStore(request.Inbox.Status)
|
||||
} else {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported field in update mask: %q", field)
|
||||
}
|
||||
}
|
||||
|
||||
inbox, err := s.Store.UpdateInbox(ctx, update)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to update inbox: %v", err)
|
||||
}
|
||||
|
||||
return convertInboxFromStore(inbox), nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) DeleteInbox(ctx context.Context, request *v1pb.DeleteInboxRequest) (*emptypb.Empty, error) {
|
||||
inboxID, err := ExtractInboxIDFromName(request.Name)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "invalid inbox name %q: %v", request.Name, err)
|
||||
}
|
||||
|
||||
// Get current user for authorization
|
||||
currentUser, err := s.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||||
}
|
||||
if currentUser == nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
||||
}
|
||||
|
||||
// Get the existing inbox to verify ownership
|
||||
inboxes, err := s.Store.ListInboxes(ctx, &store.FindInbox{
|
||||
ID: &inboxID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get inbox: %v", err)
|
||||
}
|
||||
if len(inboxes) == 0 {
|
||||
return nil, status.Errorf(codes.NotFound, "inbox %q not found", request.Name)
|
||||
}
|
||||
existingInbox := inboxes[0]
|
||||
|
||||
// Check if current user can delete this inbox (must be the receiver)
|
||||
if currentUser.ID != existingInbox.ReceiverID {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "cannot delete inbox for another user")
|
||||
}
|
||||
|
||||
if err := s.Store.DeleteInbox(ctx, &store.DeleteInbox{
|
||||
ID: inboxID,
|
||||
}); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete inbox: %v", err)
|
||||
}
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func convertInboxFromStore(inbox *store.Inbox) *v1pb.Inbox {
|
||||
return &v1pb.Inbox{
|
||||
Name: fmt.Sprintf("%s%d", InboxNamePrefix, inbox.ID),
|
||||
Sender: fmt.Sprintf("%s%d", UserNamePrefix, inbox.SenderID),
|
||||
Receiver: fmt.Sprintf("%s%d", UserNamePrefix, inbox.ReceiverID),
|
||||
Status: convertInboxStatusFromStore(inbox.Status),
|
||||
CreateTime: timestamppb.New(time.Unix(inbox.CreatedTs, 0)),
|
||||
Type: v1pb.Inbox_Type(inbox.Message.Type),
|
||||
ActivityId: inbox.Message.ActivityId,
|
||||
}
|
||||
}
|
||||
|
||||
func convertInboxStatusFromStore(status store.InboxStatus) v1pb.Inbox_Status {
|
||||
switch status {
|
||||
case store.UNREAD:
|
||||
return v1pb.Inbox_UNREAD
|
||||
case store.ARCHIVED:
|
||||
return v1pb.Inbox_ARCHIVED
|
||||
default:
|
||||
return v1pb.Inbox_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func convertInboxStatusToStore(status v1pb.Inbox_Status) store.InboxStatus {
|
||||
switch status {
|
||||
case v1pb.Inbox_ARCHIVED:
|
||||
return store.ARCHIVED
|
||||
default:
|
||||
return store.UNREAD
|
||||
}
|
||||
}
|
||||
@ -1,559 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func TestListInboxes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("ListInboxes success", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
// List inboxes (should be empty initially)
|
||||
req := &v1pb.ListInboxesRequest{
|
||||
Parent: fmt.Sprintf("users/%d", user.ID),
|
||||
}
|
||||
|
||||
resp, err := ts.Service.ListInboxes(userCtx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Empty(t, resp.Inboxes)
|
||||
require.Equal(t, int32(0), resp.TotalSize)
|
||||
})
|
||||
|
||||
t.Run("ListInboxes with pagination", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create some inbox entries
|
||||
const systemBotID int32 = 0
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
// List inboxes with page size limit
|
||||
req := &v1pb.ListInboxesRequest{
|
||||
Parent: fmt.Sprintf("users/%d", user.ID),
|
||||
PageSize: 2,
|
||||
}
|
||||
|
||||
resp, err := ts.Service.ListInboxes(userCtx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, 2, len(resp.Inboxes))
|
||||
require.NotEmpty(t, resp.NextPageToken)
|
||||
})
|
||||
|
||||
t.Run("ListInboxes permission denied for different user", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create two users
|
||||
user1, err := ts.CreateRegularUser(ctx, "user1")
|
||||
require.NoError(t, err)
|
||||
user2, err := ts.CreateRegularUser(ctx, "user2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user1 context but try to list user2's inboxes
|
||||
userCtx := ts.CreateUserContext(ctx, user1.ID)
|
||||
|
||||
req := &v1pb.ListInboxesRequest{
|
||||
Parent: fmt.Sprintf("users/%d", user2.ID),
|
||||
}
|
||||
|
||||
_, err = ts.Service.ListInboxes(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot access inboxes")
|
||||
})
|
||||
|
||||
t.Run("ListInboxes host can access other users' inboxes", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a host user and a regular user
|
||||
hostUser, err := ts.CreateHostUser(ctx, "hostuser")
|
||||
require.NoError(t, err)
|
||||
regularUser, err := ts.CreateRegularUser(ctx, "regularuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox for the regular user
|
||||
const systemBotID int32 = 0
|
||||
_, err = ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: regularUser.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set host user context and try to list regular user's inboxes
|
||||
hostCtx := ts.CreateUserContext(ctx, hostUser.ID)
|
||||
|
||||
req := &v1pb.ListInboxesRequest{
|
||||
Parent: fmt.Sprintf("users/%d", regularUser.ID),
|
||||
}
|
||||
|
||||
resp, err := ts.Service.ListInboxes(hostCtx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, 1, len(resp.Inboxes))
|
||||
})
|
||||
|
||||
t.Run("ListInboxes invalid parent format", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.ListInboxesRequest{
|
||||
Parent: "invalid-parent-format",
|
||||
}
|
||||
|
||||
_, err = ts.Service.ListInboxes(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid parent name")
|
||||
})
|
||||
|
||||
t.Run("ListInboxes unauthenticated", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
req := &v1pb.ListInboxesRequest{
|
||||
Parent: "users/1",
|
||||
}
|
||||
|
||||
_, err := ts.Service.ListInboxes(ctx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "user not authenticated")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateInbox(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("UpdateInbox success", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox entry
|
||||
const systemBotID int32 = 0
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
// Update inbox status
|
||||
req := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"status"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := ts.Service.UpdateInbox(userCtx, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, v1pb.Inbox_ARCHIVED, resp.Status)
|
||||
})
|
||||
|
||||
t.Run("UpdateInbox permission denied for different user", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create two users
|
||||
user1, err := ts.CreateRegularUser(ctx, "user1")
|
||||
require.NoError(t, err)
|
||||
user2, err := ts.CreateRegularUser(ctx, "user2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox entry for user2
|
||||
const systemBotID int32 = 0
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user2.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user1 context but try to update user2's inbox
|
||||
userCtx := ts.CreateUserContext(ctx, user1.ID)
|
||||
|
||||
req := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"status"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ts.Service.UpdateInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot update inbox")
|
||||
})
|
||||
|
||||
t.Run("UpdateInbox missing update mask", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: "inboxes/1",
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ts.Service.UpdateInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "update mask is required")
|
||||
})
|
||||
|
||||
t.Run("UpdateInbox invalid name format", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: "invalid-inbox-name",
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"status"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ts.Service.UpdateInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid inbox name")
|
||||
})
|
||||
|
||||
t.Run("UpdateInbox not found", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: "inboxes/99999", // Non-existent inbox
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"status"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ts.Service.UpdateInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.NotFound, st.Code())
|
||||
})
|
||||
|
||||
t.Run("UpdateInbox unsupported field", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox entry
|
||||
const systemBotID int32 = 0
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"unsupported_field"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = ts.Service.UpdateInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsupported field")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteInbox(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("DeleteInbox success", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox entry
|
||||
const systemBotID int32 = 0
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
// Delete inbox
|
||||
req := &v1pb.DeleteInboxRequest{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
}
|
||||
|
||||
_, err = ts.Service.DeleteInbox(userCtx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify inbox is deleted
|
||||
inboxes, err := ts.Store.ListInboxes(ctx, &store.FindInbox{
|
||||
ReceiverID: &user.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(inboxes))
|
||||
})
|
||||
|
||||
t.Run("DeleteInbox permission denied for different user", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create two users
|
||||
user1, err := ts.CreateRegularUser(ctx, "user1")
|
||||
require.NoError(t, err)
|
||||
user2, err := ts.CreateRegularUser(ctx, "user2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox entry for user2
|
||||
const systemBotID int32 = 0
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user2.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user1 context but try to delete user2's inbox
|
||||
userCtx := ts.CreateUserContext(ctx, user1.ID)
|
||||
|
||||
req := &v1pb.DeleteInboxRequest{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
}
|
||||
|
||||
_, err = ts.Service.DeleteInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cannot delete inbox")
|
||||
})
|
||||
|
||||
t.Run("DeleteInbox invalid name format", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.DeleteInboxRequest{
|
||||
Name: "invalid-inbox-name",
|
||||
}
|
||||
|
||||
_, err = ts.Service.DeleteInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid inbox name")
|
||||
})
|
||||
|
||||
t.Run("DeleteInbox not found", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
req := &v1pb.DeleteInboxRequest{
|
||||
Name: "inboxes/99999", // Non-existent inbox
|
||||
}
|
||||
|
||||
_, err = ts.Service.DeleteInbox(userCtx, req)
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.NotFound, st.Code())
|
||||
})
|
||||
}
|
||||
|
||||
func TestInboxCRUDComplete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("Complete CRUD lifecycle", func(t *testing.T) {
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a user
|
||||
user, err := ts.CreateRegularUser(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an inbox entry directly in store
|
||||
const systemBotID int32 = 0
|
||||
inbox, err := ts.Store.CreateInbox(ctx, &store.Inbox{
|
||||
SenderID: systemBotID,
|
||||
ReceiverID: user.ID,
|
||||
Status: store.UNREAD,
|
||||
Message: &storepb.InboxMessage{
|
||||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set user context
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
// 1. List inboxes - should have 1
|
||||
listReq := &v1pb.ListInboxesRequest{
|
||||
Parent: fmt.Sprintf("users/%d", user.ID),
|
||||
}
|
||||
listResp, err := ts.Service.ListInboxes(userCtx, listReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(listResp.Inboxes))
|
||||
require.Equal(t, v1pb.Inbox_UNREAD, listResp.Inboxes[0].Status)
|
||||
|
||||
// 2. Update inbox status to ARCHIVED
|
||||
updateReq := &v1pb.UpdateInboxRequest{
|
||||
Inbox: &v1pb.Inbox{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
Status: v1pb.Inbox_ARCHIVED,
|
||||
},
|
||||
UpdateMask: &fieldmaskpb.FieldMask{
|
||||
Paths: []string{"status"},
|
||||
},
|
||||
}
|
||||
updateResp, err := ts.Service.UpdateInbox(userCtx, updateReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v1pb.Inbox_ARCHIVED, updateResp.Status)
|
||||
|
||||
// 3. List inboxes again - should still have 1 but ARCHIVED
|
||||
listResp, err = ts.Service.ListInboxes(userCtx, listReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(listResp.Inboxes))
|
||||
require.Equal(t, v1pb.Inbox_ARCHIVED, listResp.Inboxes[0].Status)
|
||||
|
||||
// 4. Delete inbox
|
||||
deleteReq := &v1pb.DeleteInboxRequest{
|
||||
Name: fmt.Sprintf("inboxes/%d", inbox.ID),
|
||||
}
|
||||
_, err = ts.Service.DeleteInbox(userCtx, deleteReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 5. List inboxes - should be empty
|
||||
listResp, err = ts.Service.ListInboxes(userCtx, listReq)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, len(listResp.Inboxes))
|
||||
require.Equal(t, int32(0), listResp.TotalSize)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue