466 lines
15 KiB
Go
466 lines
15 KiB
Go
package test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
func TestGetUserStats_TagCount(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Create test service
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
|
|
// Create a test host user
|
|
user, err := ts.CreateHostUser(ctx, "test-user")
|
|
require.NoError(t, err)
|
|
|
|
// Create user context for authentication
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
|
|
// Create a memo with a single tag
|
|
memo, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "test-memo-1",
|
|
CreatorID: user.ID,
|
|
Content: "This is a test memo with #test tag",
|
|
Visibility: store.Public,
|
|
Payload: &storepb.MemoPayload{
|
|
Tags: []string{"test", "test"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, memo)
|
|
|
|
// Test GetUserStats
|
|
userName := fmt.Sprintf("users/%s", user.Username)
|
|
response, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
|
|
Name: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, response)
|
|
require.Equal(t, fmt.Sprintf("users/%s/stats", user.Username), response.Name)
|
|
|
|
// A memo contributes at most once to an exact tag count, even if its payload
|
|
// accidentally contains the same derived membership more than once.
|
|
require.Contains(t, response.TagCount, "test")
|
|
require.Equal(t, int32(1), response.TagCount["test"], "Tag count should be 1 for a single occurrence")
|
|
|
|
// Create another memo with the same tag
|
|
memo2, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "test-memo-2",
|
|
CreatorID: user.ID,
|
|
Content: "Another memo with #test tag",
|
|
Visibility: store.Public,
|
|
Payload: &storepb.MemoPayload{
|
|
Tags: []string{"test"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, memo2)
|
|
|
|
// Test GetUserStats again
|
|
response2, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
|
|
Name: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, response2)
|
|
|
|
// Check that the tag count is exactly 2, not 3
|
|
require.Contains(t, response2.TagCount, "test")
|
|
require.Equal(t, int32(2), response2.TagCount["test"], "Tag count should be 2 for two occurrences")
|
|
|
|
// Test with a new unique tag
|
|
memo3, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "test-memo-3",
|
|
CreatorID: user.ID,
|
|
Content: "Memo with #unique tag",
|
|
Visibility: store.Public,
|
|
Payload: &storepb.MemoPayload{
|
|
Tags: []string{"unique"},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, memo3)
|
|
|
|
// Test GetUserStats for the new tag
|
|
response3, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
|
|
Name: userName,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, response3)
|
|
|
|
// Check that the unique tag count is exactly 1
|
|
require.Contains(t, response3.TagCount, "unique")
|
|
require.Equal(t, int32(1), response3.TagCount["unique"], "New tag count should be 1 for first occurrence")
|
|
|
|
// The original test tag should still be 2
|
|
require.Contains(t, response3.TagCount, "test")
|
|
require.Equal(t, int32(2), response3.TagCount["test"], "Original tag count should remain 2")
|
|
|
|
_, err = ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
|
|
Name: "users/1",
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "user not found")
|
|
}
|
|
|
|
func TestGetUserStats_TagCountPreservesExactIdentity(t *testing.T) {
|
|
ctx := context.Background()
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
const (
|
|
composedTag = "caf\u00e9"
|
|
decomposedTag = "cafe\u0301"
|
|
)
|
|
|
|
user, err := ts.CreateHostUser(ctx, "exact-tag-stats-user")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
|
|
_, err = ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "exact-tag-stats-memo",
|
|
CreatorID: user.ID,
|
|
Content: "Exact tag membership fixture",
|
|
Visibility: store.Public,
|
|
Payload: &storepb.MemoPayload{
|
|
Tags: []string{"book", "book/fiction", "book", "Work", "work", composedTag, decomposedTag},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
response, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{
|
|
Name: fmt.Sprintf("users/%s", user.Username),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, map[string]int32{
|
|
"book": 1,
|
|
"book/fiction": 1,
|
|
"Work": 1,
|
|
"work": 1,
|
|
composedTag: 1,
|
|
decomposedTag: 1,
|
|
}, response.TagCount)
|
|
}
|
|
|
|
func TestGetUserStats_MemoUpdatedTimestamps(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
|
|
user, err := ts.CreateHostUser(ctx, "ts-user")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
|
|
memo, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "ts-memo-1",
|
|
CreatorID: user.ID,
|
|
Content: "first content",
|
|
Visibility: store.Public,
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, memo)
|
|
|
|
// SQLite UpdateMemo only sets fields explicitly passed (created_ts default
|
|
// fires on INSERT only). So bump updated_ts explicitly to simulate an edit
|
|
// happening after creation.
|
|
newContent := "second content"
|
|
newUpdatedTs := memo.UpdatedTs + 100
|
|
require.NoError(t, ts.Store.UpdateMemo(ctx, &store.UpdateMemo{
|
|
ID: memo.ID,
|
|
Content: &newContent,
|
|
UpdatedTs: &newUpdatedTs,
|
|
}))
|
|
|
|
userName := fmt.Sprintf("users/%s", user.Username)
|
|
resp, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{Name: userName})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
|
|
require.Len(t, resp.MemoCreatedTimestamps, 1, "should have one created timestamp")
|
|
require.Len(t, resp.MemoUpdatedTimestamps, 1, "should have one updated timestamp")
|
|
|
|
require.Equal(t, memo.CreatedTs, resp.MemoCreatedTimestamps[0].AsTime().Unix())
|
|
require.Equal(t, newUpdatedTs, resp.MemoUpdatedTimestamps[0].AsTime().Unix())
|
|
require.Greater(
|
|
t,
|
|
resp.MemoUpdatedTimestamps[0].AsTime().Unix(),
|
|
resp.MemoCreatedTimestamps[0].AsTime().Unix(),
|
|
"updated_ts should be after created_ts after an edit",
|
|
)
|
|
}
|
|
|
|
func TestGetUserStats_PinnedMemoUsesCanonicalResourceName(t *testing.T) {
|
|
ctx := context.Background()
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
|
|
user, err := ts.CreateHostUser(ctx, "pinned-stats-user")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
memo, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "pinned-stats-memo",
|
|
CreatorID: user.ID,
|
|
Content: "pinned",
|
|
Visibility: store.Public,
|
|
})
|
|
require.NoError(t, err)
|
|
pinned := true
|
|
require.NoError(t, ts.Store.UpdateMemo(ctx, &store.UpdateMemo{ID: memo.ID, Pinned: &pinned}))
|
|
|
|
resp, err := ts.Service.GetUserStats(userCtx, &v1pb.GetUserStatsRequest{Name: fmt.Sprintf("users/%s", user.Username)})
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"memos/pinned-stats-memo"}, resp.PinnedMemos)
|
|
}
|
|
|
|
func TestListAllUserStats_FilterExcludesPrivateMemos(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
|
|
user, err := ts.CreateHostUser(ctx, "stats-filter-user")
|
|
require.NoError(t, err)
|
|
userCtx := ts.CreateUserContext(ctx, user.ID)
|
|
|
|
_, err = ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "stats-filter-public",
|
|
CreatorID: user.ID,
|
|
Content: "public memo",
|
|
Visibility: store.Public,
|
|
Payload: &storepb.MemoPayload{Tags: []string{"public", "public"}},
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: "stats-filter-private",
|
|
CreatorID: user.ID,
|
|
Content: "private memo",
|
|
Visibility: store.Private,
|
|
Payload: &storepb.MemoPayload{Tags: []string{"private", "private"}},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
unfilteredResp, err := ts.Service.ListAllUserStats(userCtx, &v1pb.ListAllUserStatsRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, unfilteredResp.Stats, 1)
|
|
require.Equal(t, int32(1), unfilteredResp.Stats[0].TagCount["public"])
|
|
require.Equal(t, int32(1), unfilteredResp.Stats[0].TagCount["private"])
|
|
|
|
filteredResp, err := ts.Service.ListAllUserStats(userCtx, &v1pb.ListAllUserStatsRequest{
|
|
Filter: `visibility in ["PUBLIC", "PROTECTED"]`,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, filteredResp.Stats, 1)
|
|
require.Equal(t, int32(1), filteredResp.Stats[0].TagCount["public"])
|
|
require.NotContains(t, filteredResp.Stats[0].TagCount, "private")
|
|
}
|
|
|
|
func TestUserStatsUseMemoLocalAccess(t *testing.T) {
|
|
ctx := context.Background()
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
|
|
owner, err := ts.CreateRegularUser(ctx, "stats-access-owner")
|
|
require.NoError(t, err)
|
|
member, err := ts.CreateRegularUser(ctx, "stats-access-member")
|
|
require.NoError(t, err)
|
|
applicationAdmin, err := ts.CreateHostUser(ctx, "stats-access-app-admin")
|
|
require.NoError(t, err)
|
|
|
|
space, err := ts.Store.CreateSpace(ctx, &store.Space{
|
|
UID: "stats-access-space",
|
|
Title: "Stats access",
|
|
}, owner.ID)
|
|
require.NoError(t, err)
|
|
_, err = ts.InviteAndAcceptSpaceMember(ctx, &store.SpaceMember{
|
|
SpaceID: space.ID,
|
|
UserID: member.ID,
|
|
Role: store.SpaceMemberRoleUser,
|
|
}, owner.ID)
|
|
require.NoError(t, err)
|
|
|
|
fixtures := []struct {
|
|
uid string
|
|
visibility store.Visibility
|
|
spaceID *int32
|
|
tag string
|
|
}{
|
|
{uid: "stats-access-public", visibility: store.Public, tag: "public"},
|
|
{uid: "stats-access-protected", visibility: store.Protected, tag: "protected"},
|
|
{uid: "stats-access-private", visibility: store.Private, tag: "private"},
|
|
{uid: "stats-access-members", visibility: store.SpaceAudience, spaceID: &space.ID, tag: "space"},
|
|
}
|
|
for _, fixture := range fixtures {
|
|
_, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: fixture.uid,
|
|
CreatorID: owner.ID,
|
|
Content: fixture.tag,
|
|
Visibility: fixture.visibility,
|
|
SpaceID: fixture.spaceID,
|
|
Payload: &storepb.MemoPayload{Tags: []string{fixture.tag}},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
ownerName := fmt.Sprintf("users/%s", owner.Username)
|
|
tests := []struct {
|
|
name string
|
|
requestCtx context.Context
|
|
wantTags []string
|
|
denyTags []string
|
|
}{
|
|
{
|
|
name: "owner",
|
|
requestCtx: ts.CreateUserContext(ctx, owner.ID),
|
|
wantTags: []string{"public", "protected", "private", "space"},
|
|
},
|
|
{
|
|
name: "space member",
|
|
requestCtx: ts.CreateUserContext(ctx, member.ID),
|
|
wantTags: []string{"public", "protected", "space"},
|
|
denyTags: []string{"private"},
|
|
},
|
|
{
|
|
name: "nonmember application admin",
|
|
requestCtx: ts.CreateUserContext(ctx, applicationAdmin.ID),
|
|
wantTags: []string{"public", "protected"},
|
|
denyTags: []string{"private", "space"},
|
|
},
|
|
{
|
|
name: "anonymous",
|
|
requestCtx: ctx,
|
|
wantTags: []string{"public"},
|
|
denyTags: []string{"protected", "private", "space"},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
response, err := ts.Service.GetUserStats(test.requestCtx, &v1pb.GetUserStatsRequest{Name: ownerName})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(len(test.wantTags)), response.TotalMemoCount)
|
|
for _, tag := range test.wantTags {
|
|
require.Equal(t, int32(1), response.TagCount[tag], "expected tag %q to be visible", tag)
|
|
}
|
|
for _, tag := range test.denyTags {
|
|
require.NotContains(t, response.TagCount, tag)
|
|
}
|
|
})
|
|
}
|
|
|
|
memberStats, err := ts.Service.ListAllUserStats(ts.CreateUserContext(ctx, member.ID), &v1pb.ListAllUserStatsRequest{})
|
|
require.NoError(t, err)
|
|
require.Len(t, memberStats.Stats, 1)
|
|
require.Equal(t, int32(3), memberStats.Stats[0].TotalMemoCount)
|
|
require.Contains(t, memberStats.Stats[0].TagCount, "space")
|
|
require.NotContains(t, memberStats.Stats[0].TagCount, "private")
|
|
}
|
|
|
|
func TestUserStatsSpaceFilter(t *testing.T) {
|
|
ctx := context.Background()
|
|
ts := NewTestService(t)
|
|
defer ts.Cleanup()
|
|
|
|
owner, err := ts.CreateRegularUser(ctx, "stats-scope-owner")
|
|
require.NoError(t, err)
|
|
member, err := ts.CreateRegularUser(ctx, "stats-scope-member")
|
|
require.NoError(t, err)
|
|
outsider, err := ts.CreateRegularUser(ctx, "stats-scope-outsider")
|
|
require.NoError(t, err)
|
|
|
|
spaceA, err := ts.Store.CreateSpace(ctx, &store.Space{UID: "stats-scope-a", Title: "A"}, owner.ID)
|
|
require.NoError(t, err)
|
|
_, err = ts.InviteAndAcceptSpaceMember(ctx, &store.SpaceMember{
|
|
SpaceID: spaceA.ID,
|
|
UserID: member.ID,
|
|
Role: store.SpaceMemberRoleUser,
|
|
}, owner.ID)
|
|
require.NoError(t, err)
|
|
spaceB, err := ts.Store.CreateSpace(ctx, &store.Space{UID: "stats-scope-b", Title: "B"}, owner.ID)
|
|
require.NoError(t, err)
|
|
|
|
spaceAName := "spaces/" + spaceA.UID
|
|
fixtures := []struct {
|
|
uid string
|
|
creatorID int32
|
|
visibility store.Visibility
|
|
spaceID *int32
|
|
tag string
|
|
}{
|
|
{uid: "stats-scope-unassigned", creatorID: owner.ID, visibility: store.Public, tag: "unassigned"},
|
|
{uid: "stats-scope-a-members", creatorID: owner.ID, visibility: store.SpaceAudience, spaceID: &spaceA.ID, tag: "a-members"},
|
|
{uid: "stats-scope-a-private", creatorID: owner.ID, visibility: store.Private, spaceID: &spaceA.ID, tag: "a-private"},
|
|
{uid: "stats-scope-a-member-private", creatorID: member.ID, visibility: store.Private, spaceID: &spaceA.ID, tag: "member-private"},
|
|
{uid: "stats-scope-b-public", creatorID: owner.ID, visibility: store.Public, spaceID: &spaceB.ID, tag: "b-public"},
|
|
}
|
|
for _, fixture := range fixtures {
|
|
_, err := ts.Store.CreateMemo(ctx, &store.Memo{
|
|
UID: fixture.uid,
|
|
CreatorID: fixture.creatorID,
|
|
Content: fixture.tag,
|
|
Visibility: fixture.visibility,
|
|
SpaceID: fixture.spaceID,
|
|
Payload: &storepb.MemoPayload{Tags: []string{fixture.tag}},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
ownerName := fmt.Sprintf("users/%s", owner.Username)
|
|
ownerCtx := ts.CreateUserContext(ctx, owner.ID)
|
|
ownerSpaceStats, err := ts.Service.GetUserStats(ownerCtx, &v1pb.GetUserStatsRequest{
|
|
Name: ownerName,
|
|
Filter: fmt.Sprintf(`space == %q`, spaceAName),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(2), ownerSpaceStats.TotalMemoCount)
|
|
require.Contains(t, ownerSpaceStats.TagCount, "a-members")
|
|
require.Contains(t, ownerSpaceStats.TagCount, "a-private")
|
|
require.NotContains(t, ownerSpaceStats.TagCount, "unassigned")
|
|
require.NotContains(t, ownerSpaceStats.TagCount, "b-public")
|
|
|
|
ownerUnassignedStats, err := ts.Service.GetUserStats(ownerCtx, &v1pb.GetUserStatsRequest{
|
|
Name: ownerName,
|
|
Filter: `space == null`,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(1), ownerUnassignedStats.TotalMemoCount)
|
|
require.Contains(t, ownerUnassignedStats.TagCount, "unassigned")
|
|
|
|
memberSpaceStats, err := ts.Service.ListAllUserStats(ts.CreateUserContext(ctx, member.ID), &v1pb.ListAllUserStatsRequest{
|
|
Filter: fmt.Sprintf(`space == %q`, spaceAName),
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, memberSpaceStats.Stats, 2)
|
|
visibleTags := map[string]int32{}
|
|
for _, stats := range memberSpaceStats.Stats {
|
|
for tag, count := range stats.TagCount {
|
|
visibleTags[tag] += count
|
|
}
|
|
}
|
|
require.Equal(t, map[string]int32{"a-members": 1, "member-private": 1}, visibleTags)
|
|
|
|
_, err = ts.Service.ListAllUserStats(ts.CreateUserContext(ctx, outsider.ID), &v1pb.ListAllUserStatsRequest{
|
|
Filter: fmt.Sprintf(`space == %q`, spaceAName),
|
|
})
|
|
require.Equal(t, codes.NotFound, status.Code(err))
|
|
|
|
_, err = ts.Service.ListAllUserStats(ctx, &v1pb.ListAllUserStatsRequest{
|
|
Filter: fmt.Sprintf(`space == %q`, spaceAName),
|
|
})
|
|
require.Equal(t, codes.Unauthenticated, status.Code(err))
|
|
|
|
_, err = ts.Service.GetUserStats(ownerCtx, &v1pb.GetUserStatsRequest{
|
|
Name: ownerName,
|
|
Filter: `space != null`,
|
|
})
|
|
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
|
}
|