diff --git a/store/db/postgres/inbox.go b/store/db/postgres/inbox.go index 7df32e287..93bee9913 100644 --- a/store/db/postgres/inbox.go +++ b/store/db/postgres/inbox.go @@ -53,7 +53,8 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I if find.MessageType != nil { // Filter by message type using PostgreSQL JSON extraction // Note: The type field in JSON is stored as string representation of the enum name - where, args = append(where, "message->>'type' = "+placeholder(len(args)+1)), append(args, find.MessageType.String()) + // Cast to JSONB since the column is TEXT + where, args = append(where, "message::JSONB->>'type' = "+placeholder(len(args)+1)), append(args, find.MessageType.String()) } query := "SELECT id, created_ts, sender_id, receiver_id, status, message FROM inbox WHERE " + strings.Join(where, " AND ") + " ORDER BY created_ts DESC" diff --git a/store/test/activity_test.go b/store/test/activity_test.go index 1328199e8..879856289 100644 --- a/store/test/activity_test.go +++ b/store/test/activity_test.go @@ -99,3 +99,270 @@ func TestActivityListMultiple(t *testing.T) { ts.Close() } + +func TestActivityListByType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activities with MEMO_COMMENT type + _, err = ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + _, err = ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + // List by type + activityType := store.ActivityTypeMemoComment + activities, err := ts.ListActivities(ctx, &store.FindActivity{Type: &activityType}) + require.NoError(t, err) + require.Len(t, activities, 2) + for _, activity := range activities { + require.Equal(t, store.ActivityTypeMemoComment, activity.Type) + } + + ts.Close() +} + +func TestActivityPayloadMemoComment(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activity with MemoComment payload + memoID := int32(123) + relatedMemoID := int32(456) + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{ + MemoComment: &storepb.ActivityMemoCommentPayload{ + MemoId: memoID, + RelatedMemoId: relatedMemoID, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, activity.Payload) + require.NotNil(t, activity.Payload.MemoComment) + require.Equal(t, memoID, activity.Payload.MemoComment.MemoId) + require.Equal(t, relatedMemoID, activity.Payload.MemoComment.RelatedMemoId) + + // Verify payload is preserved when listing + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.NotNil(t, found.Payload.MemoComment) + require.Equal(t, memoID, found.Payload.MemoComment.MemoId) + require.Equal(t, relatedMemoID, found.Payload.MemoComment.RelatedMemoId) + + ts.Close() +} + +func TestActivityEmptyPayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activity with empty payload + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.NotNil(t, activity.Payload) + + // Verify empty payload is handled correctly + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.NotNil(t, found.Payload) + require.Nil(t, found.Payload.MemoComment) + + ts.Close() +} + +func TestActivityLevel(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create activity with INFO level + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.Equal(t, store.ActivityLevelInfo, activity.Level) + + // Verify level is preserved when listing + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.Equal(t, store.ActivityLevelInfo, found.Level) + + ts.Close() +} + +func TestActivityCreatorID(t *testing.T) { + 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) + + // Create activity for user1 + activity1, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user1.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.Equal(t, user1.ID, activity1.CreatorID) + + // Create activity for user2 + activity2, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user2.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.Equal(t, user2.ID, activity2.CreatorID) + + // List all and verify creator IDs + activities, err := ts.ListActivities(ctx, &store.FindActivity{}) + require.NoError(t, err) + require.Len(t, activities, 2) + + ts.Close() +} + +func TestActivityCreatedTs(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + require.NotZero(t, activity.CreatedTs) + + // Verify timestamp is preserved when listing + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.Equal(t, activity.CreatedTs, found.CreatedTs) + + ts.Close() +} + +func TestActivityListEmpty(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // List activities when none exist + activities, err := ts.ListActivities(ctx, &store.FindActivity{}) + require.NoError(t, err) + require.Len(t, activities, 0) + + ts.Close() +} + +func TestActivityListWithIDAndType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{}, + }) + require.NoError(t, err) + + // List with both ID and Type filters + activityType := store.ActivityTypeMemoComment + activities, err := ts.ListActivities(ctx, &store.FindActivity{ + ID: &activity.ID, + Type: &activityType, + }) + require.NoError(t, err) + require.Len(t, activities, 1) + require.Equal(t, activity.ID, activities[0].ID) + + ts.Close() +} + +func TestActivityPayloadComplexMemoComment(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a memo first to use its ID + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "test-memo-for-activity", + CreatorID: user.ID, + Content: "Test memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create comment memo + commentMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "comment-memo", + CreatorID: user.ID, + Content: "This is a comment", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create activity with real memo IDs + activity, err := ts.CreateActivity(ctx, &store.Activity{ + CreatorID: user.ID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{ + MemoComment: &storepb.ActivityMemoCommentPayload{ + MemoId: memo.ID, + RelatedMemoId: commentMemo.ID, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, memo.ID, activity.Payload.MemoComment.MemoId) + require.Equal(t, commentMemo.ID, activity.Payload.MemoComment.RelatedMemoId) + + // Verify payload is preserved + found, err := ts.GetActivity(ctx, &store.FindActivity{ID: &activity.ID}) + require.NoError(t, err) + require.Equal(t, memo.ID, found.Payload.MemoComment.MemoId) + require.Equal(t, commentMemo.ID, found.Payload.MemoComment.RelatedMemoId) + + ts.Close() +} diff --git a/store/test/idp_test.go b/store/test/idp_test.go index 0522454f8..d80cf488b 100644 --- a/store/test/idp_test.go +++ b/store/test/idp_test.go @@ -58,3 +58,384 @@ func TestIdentityProviderStore(t *testing.T) { require.Equal(t, 0, len(idpList)) ts.Close() } + +func TestIdentityProviderGetByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create IDP + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + + // Get by ID + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, idp.Id, found.Id) + require.Equal(t, idp.Name, found.Name) + + // Get by non-existent ID + nonExistentID := int32(99999) + notFound, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &nonExistentID}) + require.NoError(t, err) + require.Nil(t, notFound) + + ts.Close() +} + +func TestIdentityProviderListMultiple(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create multiple IDPs + _, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("GitHub OAuth")) + require.NoError(t, err) + _, err = ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Google OAuth")) + require.NoError(t, err) + _, err = ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("GitLab OAuth")) + require.NoError(t, err) + + // List all + idpList, err := ts.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) + require.NoError(t, err) + require.Len(t, idpList, 3) + + ts.Close() +} + +func TestIdentityProviderListByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create multiple IDPs + idp1, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("GitHub OAuth")) + require.NoError(t, err) + _, err = ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Google OAuth")) + require.NoError(t, err) + + // List by specific ID + idpList, err := ts.ListIdentityProviders(ctx, &store.FindIdentityProvider{ID: &idp1.Id}) + require.NoError(t, err) + require.Len(t, idpList, 1) + require.Equal(t, "GitHub OAuth", idpList[0].Name) + + ts.Close() +} + +func TestIdentityProviderUpdateName(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Original Name")) + require.NoError(t, err) + require.Equal(t, "Original Name", idp.Name) + + // Update name + newName := "Updated Name" + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + Name: &newName, + }) + require.NoError(t, err) + require.Equal(t, "Updated Name", updated.Name) + + // Verify update persisted + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "Updated Name", found.Name) + + ts.Close() +} + +func TestIdentityProviderUpdateIdentifierFilter(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + require.Equal(t, "", idp.IdentifierFilter) + + // Update identifier filter + newFilter := "@example.com$" + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + IdentifierFilter: &newFilter, + }) + require.NoError(t, err) + require.Equal(t, "@example.com$", updated.IdentifierFilter) + + // Verify update persisted + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "@example.com$", found.IdentifierFilter) + + ts.Close() +} + +func TestIdentityProviderUpdateConfig(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + + // Update config + newConfig := &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "new_client_id", + ClientSecret: "new_client_secret", + AuthUrl: "https://newprovider.com/auth", + TokenUrl: "https://newprovider.com/token", + UserInfoUrl: "https://newprovider.com/user", + Scopes: []string{"openid", "profile", "email"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "sub", + DisplayName: "name", + Email: "email", + }, + }, + }, + } + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + Config: newConfig, + }) + require.NoError(t, err) + require.Equal(t, "new_client_id", updated.Config.GetOauth2Config().ClientId) + require.Equal(t, "new_client_secret", updated.Config.GetOauth2Config().ClientSecret) + require.Contains(t, updated.Config.GetOauth2Config().Scopes, "openid") + + // Verify update persisted + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "new_client_id", found.Config.GetOauth2Config().ClientId) + + ts.Close() +} + +func TestIdentityProviderUpdateMultipleFields(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Original")) + require.NoError(t, err) + + // Update multiple fields at once + newName := "Updated IDP" + newFilter := "^admin@" + updated, err := ts.UpdateIdentityProvider(ctx, &store.UpdateIdentityProviderV1{ + ID: idp.Id, + Type: storepb.IdentityProvider_OAUTH2, + Name: &newName, + IdentifierFilter: &newFilter, + }) + require.NoError(t, err) + require.Equal(t, "Updated IDP", updated.Name) + require.Equal(t, "^admin@", updated.IdentifierFilter) + + ts.Close() +} + +func TestIdentityProviderDelete(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + idp, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("Test IDP")) + require.NoError(t, err) + + // Delete + err = ts.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: idp.Id}) + require.NoError(t, err) + + // Verify deletion + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Nil(t, found) + + ts.Close() +} + +func TestIdentityProviderDeleteNotAffectOthers(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create multiple IDPs + idp1, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("IDP 1")) + require.NoError(t, err) + idp2, err := ts.CreateIdentityProvider(ctx, createTestOAuth2IDP("IDP 2")) + require.NoError(t, err) + + // Delete first one + err = ts.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: idp1.Id}) + require.NoError(t, err) + + // Verify second still exists + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp2.Id}) + require.NoError(t, err) + require.NotNil(t, found) + require.Equal(t, "IDP 2", found.Name) + + // Verify list only contains second + idpList, err := ts.ListIdentityProviders(ctx, &store.FindIdentityProvider{}) + require.NoError(t, err) + require.Len(t, idpList, 1) + + ts.Close() +} + +func TestIdentityProviderOAuth2ConfigScopes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create IDP with multiple scopes + idp, err := ts.CreateIdentityProvider(ctx, &storepb.IdentityProvider{ + Name: "Multi-Scope OAuth", + Type: storepb.IdentityProvider_OAUTH2, + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"openid", "profile", "email", "groups"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "sub", + DisplayName: "name", + Email: "email", + }, + }, + }, + }, + }) + require.NoError(t, err) + + // Verify scopes are preserved + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Len(t, found.Config.GetOauth2Config().Scopes, 4) + require.Contains(t, found.Config.GetOauth2Config().Scopes, "openid") + require.Contains(t, found.Config.GetOauth2Config().Scopes, "groups") + + ts.Close() +} + +func TestIdentityProviderFieldMapping(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create IDP with custom field mapping + idp, err := ts.CreateIdentityProvider(ctx, &storepb.IdentityProvider{ + Name: "Custom Field Mapping", + Type: storepb.IdentityProvider_OAUTH2, + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"login"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "preferred_username", + DisplayName: "full_name", + Email: "email_address", + }, + }, + }, + }, + }) + require.NoError(t, err) + + // Verify field mapping is preserved + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, "preferred_username", found.Config.GetOauth2Config().FieldMapping.Identifier) + require.Equal(t, "full_name", found.Config.GetOauth2Config().FieldMapping.DisplayName) + require.Equal(t, "email_address", found.Config.GetOauth2Config().FieldMapping.Email) + + ts.Close() +} + +func TestIdentityProviderIdentifierFilterPatterns(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + testCases := []struct { + name string + filter string + }{ + {"Domain filter", "@company\\.com$"}, + {"Prefix filter", "^admin_"}, + {"Complex regex", "^[a-z]+@(dept1|dept2)\\.example\\.com$"}, + {"Empty filter", ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + idp, err := ts.CreateIdentityProvider(ctx, &storepb.IdentityProvider{ + Name: tc.name, + Type: storepb.IdentityProvider_OAUTH2, + IdentifierFilter: tc.filter, + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"login"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "sub", + }, + }, + }, + }, + }) + require.NoError(t, err) + + found, err := ts.GetIdentityProvider(ctx, &store.FindIdentityProvider{ID: &idp.Id}) + require.NoError(t, err) + require.Equal(t, tc.filter, found.IdentifierFilter) + + // Cleanup + err = ts.DeleteIdentityProvider(ctx, &store.DeleteIdentityProvider{ID: idp.Id}) + require.NoError(t, err) + }) + } + + ts.Close() +} + +// Helper function to create a test OAuth2 IDP +func createTestOAuth2IDP(name string) *storepb.IdentityProvider { + return &storepb.IdentityProvider{ + Name: name, + Type: storepb.IdentityProvider_OAUTH2, + IdentifierFilter: "", + Config: &storepb.IdentityProviderConfig{ + Config: &storepb.IdentityProviderConfig_Oauth2Config{ + Oauth2Config: &storepb.OAuth2Config{ + ClientId: "client_id", + ClientSecret: "client_secret", + AuthUrl: "https://provider.com/auth", + TokenUrl: "https://provider.com/token", + UserInfoUrl: "https://provider.com/userinfo", + Scopes: []string{"login"}, + FieldMapping: &storepb.FieldMapping{ + Identifier: "login", + DisplayName: "name", + Email: "email", + }, + }, + }, + }, + } +} diff --git a/store/test/inbox_test.go b/store/test/inbox_test.go index 0c74bc104..e3205099d 100644 --- a/store/test/inbox_test.go +++ b/store/test/inbox_test.go @@ -52,3 +52,528 @@ func TestInboxStore(t *testing.T) { require.Equal(t, 0, len(inboxes)) ts.Close() } + +func TestInboxListByID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + inbox, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // List by ID + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ID: &inbox.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, inbox.ID, inboxes[0].ID) + + // List by non-existent ID + nonExistentID := int32(99999) + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ID: &nonExistentID}) + require.NoError(t, err) + require.Len(t, inboxes, 0) + + ts.Close() +} + +func TestInboxListBySenderID(t *testing.T) { + 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) + + // Create inbox from system bot (senderID = 0) + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Create inbox from user2 + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: user2.ID, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // List by sender ID = user2 + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{SenderID: &user2.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user2.ID, inboxes[0].SenderID) + + // List by sender ID = 0 (system bot) + systemBotID := int32(0) + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{SenderID: &systemBotID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, int32(0), inboxes[0].SenderID) + + ts.Close() +} + +func TestInboxListByStatus(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create UNREAD inbox + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Create another inbox and archive it + inbox2, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + _, err = ts.UpdateInbox(ctx, &store.UpdateInbox{ID: inbox2.ID, Status: store.ARCHIVED}) + require.NoError(t, err) + + // List by UNREAD status + unreadStatus := store.UNREAD + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{Status: &unreadStatus}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, store.UNREAD, inboxes[0].Status) + + // List by ARCHIVED status + archivedStatus := store.ARCHIVED + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{Status: &archivedStatus}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, store.ARCHIVED, inboxes[0].Status) + + ts.Close() +} + +func TestInboxListByMessageType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create MEMO_COMMENT inboxes + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // List by MEMO_COMMENT type + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{MessageType: &memoCommentType}) + require.NoError(t, err) + require.Len(t, inboxes, 2) + for _, inbox := range inboxes { + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + } + + ts.Close() +} + +func TestInboxListPagination(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create 5 inboxes + for i := 0; i < 5; i++ { + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + } + + // Test Limit only + limit := 3 + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + Limit: &limit, + }) + require.NoError(t, err) + require.Len(t, inboxes, 3) + + // Test Limit + Offset (offset requires limit in the implementation) + limit = 2 + offset := 2 + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + + // Test Limit + Offset skipping to end + limit = 10 + offset = 3 + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) // Only 2 remaining after offset of 3 + + ts.Close() +} + +func TestInboxListCombinedFilters(t *testing.T) { + 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) + + // Create various inboxes + // user2 -> user1, MEMO_COMMENT, UNREAD + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: user2.ID, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // user2 -> user1, TYPE_UNSPECIFIED, UNREAD + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: user2.ID, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_TYPE_UNSPECIFIED}, + }) + require.NoError(t, err) + + // system -> user1, MEMO_COMMENT, ARCHIVED + inbox3, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + _, err = ts.UpdateInbox(ctx, &store.UpdateInbox{ID: inbox3.ID, Status: store.ARCHIVED}) + require.NoError(t, err) + + // Combined filter: ReceiverID + SenderID + Status + unreadStatus := store.UNREAD + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user1.ID, + SenderID: &user2.ID, + Status: &unreadStatus, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + + // Combined filter: ReceiverID + MessageType + Status + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user1.ID, + MessageType: &memoCommentType, + Status: &unreadStatus, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user2.ID, inboxes[0].SenderID) + + ts.Close() +} + +func TestInboxMessagePayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create inbox with message payload containing activity ID + activityID := int32(123) + inbox, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + ActivityId: &activityID, + }, + }) + require.NoError(t, err) + require.NotNil(t, inbox.Message) + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + require.Equal(t, activityID, *inbox.Message.ActivityId) + + // List and verify payload is preserved + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, activityID, *inboxes[0].Message.ActivityId) + + ts.Close() +} + +func TestInboxUpdateStatus(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + inbox, err := ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + require.Equal(t, store.UNREAD, inbox.Status) + + // Update to ARCHIVED + updated, err := ts.UpdateInbox(ctx, &store.UpdateInbox{ + ID: inbox.ID, + Status: store.ARCHIVED, + }) + require.NoError(t, err) + require.Equal(t, store.ARCHIVED, updated.Status) + require.Equal(t, inbox.ID, updated.ID) + + // Verify the update persisted + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ID: &inbox.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, store.ARCHIVED, inboxes[0].Status) + + ts.Close() +} + +func TestInboxListByMessageTypeMultipleTypes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create inboxes with different message types + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_TYPE_UNSPECIFIED}, + }) + require.NoError(t, err) + + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Filter by MEMO_COMMENT - should get 2 + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + for _, inbox := range inboxes { + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + } + + // Filter by TYPE_UNSPECIFIED - should get 1 + unspecifiedType := storepb.InboxMessage_TYPE_UNSPECIFIED + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &unspecifiedType, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, storepb.InboxMessage_TYPE_UNSPECIFIED, inboxes[0].Message.Type) + + ts.Close() +} + +func TestInboxMessageTypeFilterWithPayload(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create inbox with full payload + activityID := int32(456) + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_MEMO_COMMENT, + ActivityId: &activityID, + }, + }) + require.NoError(t, err) + + // Create inbox with different type but also has payload + otherActivityID := int32(789) + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_TYPE_UNSPECIFIED, + ActivityId: &otherActivityID, + }, + }) + require.NoError(t, err) + + // Filter by type should work correctly even with complex JSON payload + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, activityID, *inboxes[0].Message.ActivityId) + + ts.Close() +} + +func TestInboxMessageTypeFilterWithStatusAndPagination(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create multiple inboxes with various combinations + for i := 0; i < 5; i++ { + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + } + + // Archive 2 of them + allInboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user.ID}) + require.NoError(t, err) + for i := 0; i < 2; i++ { + _, err = ts.UpdateInbox(ctx, &store.UpdateInbox{ID: allInboxes[i].ID, Status: store.ARCHIVED}) + require.NoError(t, err) + } + + // Filter by type + status + pagination + memoCommentType := storepb.InboxMessage_MEMO_COMMENT + unreadStatus := store.UNREAD + limit := 2 + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + Status: &unreadStatus, + Limit: &limit, + }) + require.NoError(t, err) + require.Len(t, inboxes, 2) + for _, inbox := range inboxes { + require.Equal(t, storepb.InboxMessage_MEMO_COMMENT, inbox.Message.Type) + require.Equal(t, store.UNREAD, inbox.Status) + } + + // Get next page + offset := 2 + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ + ReceiverID: &user.ID, + MessageType: &memoCommentType, + Status: &unreadStatus, + Limit: &limit, + Offset: &offset, + }) + require.NoError(t, err) + require.Len(t, inboxes, 1) // Only 1 remaining (3 unread total, got 2, now 1 left) + + ts.Close() +} + +func TestInboxMultipleReceivers(t *testing.T) { + 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) + + // Create inbox for user1 + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user1.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // Create inbox for user2 + _, err = ts.CreateInbox(ctx, &store.Inbox{ + SenderID: 0, + ReceiverID: user2.ID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{Type: storepb.InboxMessage_MEMO_COMMENT}, + }) + require.NoError(t, err) + + // User1 should only see their inbox + inboxes, err := ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user1.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user1.ID, inboxes[0].ReceiverID) + + // User2 should only see their inbox + inboxes, err = ts.ListInboxes(ctx, &store.FindInbox{ReceiverID: &user2.ID}) + require.NoError(t, err) + require.Len(t, inboxes, 1) + require.Equal(t, user2.ID, inboxes[0].ReceiverID) + + ts.Close() +} diff --git a/store/test/memo_relation_test.go b/store/test/memo_relation_test.go index dd05134ef..32ccaf0b1 100644 --- a/store/test/memo_relation_test.go +++ b/store/test/memo_relation_test.go @@ -239,3 +239,432 @@ func TestMemoRelationDifferentTypes(t *testing.T) { ts.Close() } + +func TestMemoRelationUpsertSameRelation(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo", + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relation + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Upsert the same relation again (should not create duplicate) + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Verify only one relation exists + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + + ts.Close() +} + +func TestMemoRelationDeleteByType(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-1", + CreatorID: user.ID, + Content: "related memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-2", + CreatorID: user.ID, + Content: "related memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create reference relations + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo1.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Create comment relation + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo2.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // Delete only reference type relations + refType := store.MemoRelationReference + err = ts.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &mainMemo.ID, + Type: &refType, + }) + require.NoError(t, err) + + // Verify only comment relation remains + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + require.Equal(t, store.MemoRelationComment, relations[0].Type) + + ts.Close() +} + +func TestMemoRelationDeleteByMemoID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-1", + CreatorID: user.ID, + Content: "memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + memo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-2", + CreatorID: user.ID, + Content: "memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo", + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relations for both memos + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memo1.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memo2.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Delete all relations for memo1 + err = ts.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{ + MemoID: &memo1.ID, + }) + require.NoError(t, err) + + // Verify memo1's relations are gone + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memo1.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 0) + + // Verify memo2's relations still exist + relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memo2.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + + ts.Close() +} + +func TestMemoRelationListByRelatedMemoID(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a memo that will be referenced by others + targetMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "target-memo", + CreatorID: user.ID, + Content: "target memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create memos that reference the target + referrer1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "referrer-1", + CreatorID: user.ID, + Content: "referrer 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + referrer2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "referrer-2", + CreatorID: user.ID, + Content: "referrer 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relations pointing to target + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: referrer1.ID, + RelatedMemoID: targetMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: referrer2.ID, + RelatedMemoID: targetMemo.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // List by related memo ID (find all memos that reference the target) + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + RelatedMemoID: &targetMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 2) + + ts.Close() +} + +func TestMemoRelationListCombinedFilters(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-1", + CreatorID: user.ID, + Content: "related memo 1 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + relatedMemo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-2", + CreatorID: user.ID, + Content: "related memo 2 content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create multiple relations + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo1.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo2.ID, + Type: store.MemoRelationComment, + }) + require.NoError(t, err) + + // List with MemoID and Type filter + refType := store.MemoRelationReference + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + Type: &refType, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + require.Equal(t, relatedMemo1.ID, relations[0].RelatedMemoID) + + // List with MemoID, RelatedMemoID, and Type filter + commentType := store.MemoRelationComment + relations, err = ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + RelatedMemoID: &relatedMemo2.ID, + Type: &commentType, + }) + require.NoError(t, err) + require.Len(t, relations, 1) + + ts.Close() +} + +func TestMemoRelationListEmpty(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-no-relations", + CreatorID: user.ID, + Content: "memo with no relations", + Visibility: store.Public, + }) + require.NoError(t, err) + + // List relations for memo with none + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 0) + + ts.Close() +} + +func TestMemoRelationBidirectional(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + memoA, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-a", + CreatorID: user.ID, + Content: "memo A content", + Visibility: store.Public, + }) + require.NoError(t, err) + + memoB, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "memo-b", + CreatorID: user.ID, + Content: "memo B content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create relation A -> B + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memoA.ID, + RelatedMemoID: memoB.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Create relation B -> A (reverse direction) + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: memoB.ID, + RelatedMemoID: memoA.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + + // Verify A -> B exists + relationsFromA, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memoA.ID, + }) + require.NoError(t, err) + require.Len(t, relationsFromA, 1) + require.Equal(t, memoB.ID, relationsFromA[0].RelatedMemoID) + + // Verify B -> A exists + relationsFromB, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &memoB.ID, + }) + require.NoError(t, err) + require.Len(t, relationsFromB, 1) + require.Equal(t, memoA.ID, relationsFromB[0].RelatedMemoID) + + ts.Close() +} + +func TestMemoRelationMultipleRelationsToSameMemo(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + mainMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "main-memo", + CreatorID: user.ID, + Content: "main memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + // Create multiple memos that all relate to the main memo + for i := 1; i <= 5; i++ { + relatedMemo, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "related-memo-" + string(rune('0'+i)), + CreatorID: user.ID, + Content: "related memo content", + Visibility: store.Public, + }) + require.NoError(t, err) + + _, err = ts.UpsertMemoRelation(ctx, &store.MemoRelation{ + MemoID: mainMemo.ID, + RelatedMemoID: relatedMemo.ID, + Type: store.MemoRelationReference, + }) + require.NoError(t, err) + } + + // Verify all 5 relations exist + relations, err := ts.ListMemoRelations(ctx, &store.FindMemoRelation{ + MemoID: &mainMemo.ID, + }) + require.NoError(t, err) + require.Len(t, relations, 5) + + ts.Close() +} diff --git a/store/test/user_setting_test.go b/store/test/user_setting_test.go index 99a40f775..49927d07b 100644 --- a/store/test/user_setting_test.go +++ b/store/test/user_setting_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" @@ -308,3 +309,332 @@ func TestUserSettingShortcuts(t *testing.T) { ts.Close() } + +func TestUserSettingGetUserByPATHash(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT with a known hash + patHash := "test-pat-hash-12345" + pat := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-test-1", + TokenHash: patHash, + Description: "Test PAT for lookup", + } + err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat) + require.NoError(t, err) + + // Lookup user by PAT hash + result, err := ts.GetUserByPATHash(ctx, patHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.NotNil(t, result.User) + require.Equal(t, user.Username, result.User.Username) + require.NotNil(t, result.PAT) + require.Equal(t, "pat-test-1", result.PAT.TokenId) + require.Equal(t, "Test PAT for lookup", result.PAT.Description) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashNotFound(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + _, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Lookup non-existent PAT hash + result, err := ts.GetUserByPATHash(ctx, "non-existent-hash") + require.Error(t, err) + require.Nil(t, result) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashMultipleUsers(t *testing.T) { + 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) + + // Create PATs for both users + pat1Hash := "user1-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user1.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-user1", + TokenHash: pat1Hash, + Description: "User 1 PAT", + }) + require.NoError(t, err) + + pat2Hash := "user2-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user2.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-user2", + TokenHash: pat2Hash, + Description: "User 2 PAT", + }) + require.NoError(t, err) + + // Lookup user1's PAT + result1, err := ts.GetUserByPATHash(ctx, pat1Hash) + require.NoError(t, err) + require.Equal(t, user1.ID, result1.UserID) + require.Equal(t, user1.Username, result1.User.Username) + + // Lookup user2's PAT + result2, err := ts.GetUserByPATHash(ctx, pat2Hash) + require.NoError(t, err) + require.Equal(t, user2.ID, result2.UserID) + require.Equal(t, user2.Username, result2.User.Username) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashMultiplePATsSameUser(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create multiple PATs for the same user + pat1Hash := "first-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-1", + TokenHash: pat1Hash, + Description: "First PAT", + }) + require.NoError(t, err) + + pat2Hash := "second-pat-hash" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-2", + TokenHash: pat2Hash, + Description: "Second PAT", + }) + require.NoError(t, err) + + // Both PATs should resolve to the same user + result1, err := ts.GetUserByPATHash(ctx, pat1Hash) + require.NoError(t, err) + require.Equal(t, user.ID, result1.UserID) + require.Equal(t, "pat-1", result1.PAT.TokenId) + + result2, err := ts.GetUserByPATHash(ctx, pat2Hash) + require.NoError(t, err) + require.Equal(t, user.ID, result2.UserID) + require.Equal(t, "pat-2", result2.PAT.TokenId) + + ts.Close() +} + +func TestUserSettingUpdatePATLastUsed(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT + patHash := "pat-hash-for-update" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-update-test", + TokenHash: patHash, + Description: "PAT for update test", + }) + require.NoError(t, err) + + // Update last used timestamp + now := timestamppb.Now() + err = ts.UpdatePATLastUsed(ctx, user.ID, "pat-update-test", now) + require.NoError(t, err) + + // Verify the update + pats, err := ts.GetUserPersonalAccessTokens(ctx, user.ID) + require.NoError(t, err) + require.Len(t, pats, 1) + require.NotNil(t, pats[0].LastUsedAt) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashWithExpiredToken(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT with expiration info + patHash := "pat-hash-with-expiry" + expiresAt := timestamppb.Now() + pat := &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-expiry-test", + TokenHash: patHash, + Description: "PAT with expiry", + ExpiresAt: expiresAt, + } + err = ts.AddUserPersonalAccessToken(ctx, user.ID, pat) + require.NoError(t, err) + + // Should still be able to look up by hash (expiry check is done at auth level) + result, err := ts.GetUserByPATHash(ctx, patHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.NotNil(t, result.PAT.ExpiresAt) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashAfterRemoval(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create a PAT + patHash := "pat-hash-to-remove" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-remove-test", + TokenHash: patHash, + Description: "PAT to be removed", + }) + require.NoError(t, err) + + // Verify it exists + result, err := ts.GetUserByPATHash(ctx, patHash) + require.NoError(t, err) + require.NotNil(t, result) + + // Remove the PAT + err = ts.RemoveUserPersonalAccessToken(ctx, user.ID, "pat-remove-test") + require.NoError(t, err) + + // Should no longer be found + result, err = ts.GetUserByPATHash(ctx, patHash) + require.Error(t, err) + require.Nil(t, result) + + ts.Close() +} + +func TestUserSettingGetUserByPATHashSpecialCharacters(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create PATs with special characters in hash (simulating real hash values) + testCases := []struct { + tokenID string + tokenHash string + }{ + {"pat-special-1", "abc123+/=XYZ"}, + {"pat-special-2", "sha256:abcdef1234567890"}, + {"pat-special-3", "$2a$10$N9qo8uLOickgx2ZMRZoMy"}, + } + + for _, tc := range testCases { + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: tc.tokenID, + TokenHash: tc.tokenHash, + Description: "PAT with special chars", + }) + require.NoError(t, err) + + // Verify lookup works with special characters + result, err := ts.GetUserByPATHash(ctx, tc.tokenHash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tc.tokenID, result.PAT.TokenId) + } + + ts.Close() +} + +func TestUserSettingGetUserByPATHashLargeTokenCount(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create many PATs for the same user + tokenCount := 10 + hashes := make([]string, tokenCount) + for i := 0; i < tokenCount; i++ { + hashes[i] = "pat-hash-" + string(rune('A'+i)) + "-large-test" + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-large-" + string(rune('A'+i)), + TokenHash: hashes[i], + Description: "PAT for large count test", + }) + require.NoError(t, err) + } + + // Verify each hash can be looked up + for i, hash := range hashes { + result, err := ts.GetUserByPATHash(ctx, hash) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, user.ID, result.UserID) + require.Equal(t, "pat-large-"+string(rune('A'+i)), result.PAT.TokenId) + } + + ts.Close() +} + +func TestUserSettingMultipleSettingTypes(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + user, err := createTestingHostUser(ctx, ts) + require.NoError(t, err) + + // Create GENERAL setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_GENERAL, + Value: &storepb.UserSetting_General{General: &storepb.GeneralUserSetting{Locale: "ja"}}, + }) + require.NoError(t, err) + + // Create SHORTCUTS setting + _, err = ts.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: user.ID, + Key: storepb.UserSetting_SHORTCUTS, + Value: &storepb.UserSetting_Shortcuts{Shortcuts: &storepb.ShortcutsUserSetting{ + Shortcuts: []*storepb.ShortcutsUserSetting_Shortcut{ + {Id: "s1", Title: "Shortcut 1"}, + }, + }}, + }) + require.NoError(t, err) + + // Add a PAT + err = ts.AddUserPersonalAccessToken(ctx, user.ID, &storepb.PersonalAccessTokensUserSetting_PersonalAccessToken{ + TokenId: "pat-multi", + TokenHash: "hash-multi", + }) + require.NoError(t, err) + + // List all settings for user + settings, err := ts.ListUserSettings(ctx, &store.FindUserSetting{UserID: &user.ID}) + require.NoError(t, err) + require.Len(t, settings, 3) + + // Verify each setting type + generalSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_GENERAL}) + require.NoError(t, err) + require.Equal(t, "ja", generalSetting.GetGeneral().Locale) + + shortcutsSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_SHORTCUTS}) + require.NoError(t, err) + require.Len(t, shortcutsSetting.GetShortcuts().Shortcuts, 1) + + patsSetting, err := ts.GetUserSetting(ctx, &store.FindUserSetting{UserID: &user.ID, Key: storepb.UserSetting_PERSONAL_ACCESS_TOKENS}) + require.NoError(t, err) + require.Len(t, patsSetting.GetPersonalAccessTokens().Tokens, 1) + + ts.Close() +}