Files
geo/server/internal/tenant/app/media_supply_favorites_test.go
T
root 67a6cbcd9e feat(media-supply): persist favorites with multi-group membership
Move media favorites from client state to PostgreSQL with server-side
search and pagination.

- Store favorites keyed by (tenant, user, group, resource) so a resource
  can belong to multiple groups; the 500-resource cap counts distinct
  resources, not memberships.
- Add favorite-group CRUD and group/global removal routes, with silent
  cleanup of delisted or invisible resources on read.
- Serve favorite resource details server-side, 10 per page, with name/ID
  search; favorites-page removal is group-scoped, resources-page removal
  is global.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:21:59 +08:00

380 lines
11 KiB
Go

package app
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/geo-platform/tenant-api/internal/shared/auth"
)
type favoriteOwner struct {
tenantID int64
userID int64
}
type memoryMediaSupplyFavoriteStore struct {
mu sync.Mutex
states map[favoriteOwner]mediaSupplyFavoriteState
}
func newMemoryMediaSupplyFavoriteStore() *memoryMediaSupplyFavoriteStore {
return &memoryMediaSupplyFavoriteStore{states: make(map[favoriteOwner]mediaSupplyFavoriteState)}
}
func (s *memoryMediaSupplyFavoriteStore) List(
_ context.Context,
tenantID int64,
userID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
state, ok := s.states[favoriteOwner{tenantID: tenantID, userID: userID}]
if !ok {
return mediaSupplyFavoriteState{}, nil
}
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) CreateGroup(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
name string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
for _, group := range state.Groups {
if group.Name == name {
if resourceID > 0 {
return s.putResourceLocked(owner, state, group.ID, resourceID)
}
return cloneFavoriteState(state), nil
}
}
state.Groups = append(state.Groups, MediaSupplyFavoriteGroup{
ID: groupID,
Name: name,
ResourceIDs: []int64{},
UpdatedAt: favoriteTestTime(),
})
if resourceID > 0 {
for index, group := range state.Groups {
if group.ID == groupID {
group.ResourceIDs = append(group.ResourceIDs, resourceID)
group.UpdatedAt = favoriteTestTime()
}
state.Groups[index] = group
}
}
state.Revision++
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) DeleteGroup(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
next := state.Groups[:0]
changed := false
for _, group := range state.Groups {
if group.ID != groupID {
next = append(next, group)
} else {
changed = true
}
}
state.Groups = next
if changed {
state.Revision++
}
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) PutResource(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
return s.putResourceLocked(owner, state, groupID, resourceID)
}
func (s *memoryMediaSupplyFavoriteStore) putResourceLocked(
owner favoriteOwner,
state mediaSupplyFavoriteState,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
groupExists := false
resourceIDs := make(map[int64]struct{})
for _, group := range state.Groups {
if group.ID == groupID {
groupExists = true
}
for _, id := range group.ResourceIDs {
resourceIDs[id] = struct{}{}
if id == resourceID {
if group.ID == groupID {
return cloneFavoriteState(state), nil
}
}
}
}
if !groupExists {
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteGroupMissing
}
_, resourceExists := resourceIDs[resourceID]
if !resourceExists && len(resourceIDs) >= mediaSupplyFavoriteResourceLimit {
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteResourceLimit
}
for index, group := range state.Groups {
if group.ID == groupID {
group.ResourceIDs = append(group.ResourceIDs, resourceID)
group.UpdatedAt = favoriteTestTime()
}
state.Groups[index] = group
}
state.Revision++
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) DeleteResourceFromGroup(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
for index, group := range state.Groups {
if group.ID != groupID {
continue
}
next := removeFavoriteResourceID(group.ResourceIDs, resourceID)
if len(next) != len(group.ResourceIDs) {
group.ResourceIDs = next
group.UpdatedAt = favoriteTestTime()
state.Groups[index] = group
state.Revision++
s.states[owner] = state
return cloneFavoriteState(state), nil
}
s.states[owner] = state
return cloneFavoriteState(state), nil
}
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteGroupMissing
}
func (s *memoryMediaSupplyFavoriteStore) DeleteResource(
_ context.Context,
tenantID int64,
userID int64,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
changed := false
for index, group := range state.Groups {
if len(group.ResourceIDs) != len(removeFavoriteResourceID(group.ResourceIDs, resourceID)) {
changed = true
}
group.ResourceIDs = removeFavoriteResourceID(group.ResourceIDs, resourceID)
state.Groups[index] = group
}
if changed {
state.Revision++
}
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func TestMediaSupplyFavoriteGroupsPersistAcrossServiceInstances(t *testing.T) {
store := newMemoryMediaSupplyFavoriteStore()
ctx := auth.WithActor(context.Background(), auth.Actor{TenantID: 11, UserID: 101})
writer := &MediaSupplyService{favoriteStore: store}
initial, err := writer.ListFavoriteGroups(ctx)
require.NoError(t, err)
require.False(t, initial.Initialized)
require.Len(t, initial.Groups, 1)
require.Equal(t, "default", initial.Groups[0].ID)
require.Equal(t, "默认分组", initial.Groups[0].Name)
require.Empty(t, initial.Groups[0].ResourceIDs)
require.False(t, initial.Groups[0].UpdatedAt.IsZero())
withDefaultFavorite, err := writer.PutFavoriteResource(ctx, "default", 101)
require.NoError(t, err)
require.True(t, withDefaultFavorite.Initialized)
require.Equal(t, []int64{101}, withDefaultFavorite.Groups[0].ResourceIDs)
resourceID := int64(202)
withGroup, err := writer.CreateFavoriteGroup(ctx, CreateMediaSupplyFavoriteGroupRequest{
Name: "重点媒体",
ResourceID: &resourceID,
})
require.NoError(t, err)
require.Len(t, withGroup.Groups, 2)
groupID := withGroup.Groups[1].ID
require.NotEmpty(t, groupID)
require.NotEqual(t, "default", groupID)
require.Equal(t, []int64{202}, withGroup.Groups[1].ResourceIDs)
idempotentCreate, err := writer.CreateFavoriteGroup(ctx, CreateMediaSupplyFavoriteGroupRequest{
Name: "重点媒体",
})
require.NoError(t, err)
require.Equal(t, withGroup, idempotentCreate)
multiGroup, err := writer.PutFavoriteResource(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, []int64{101}, multiGroup.Groups[0].ResourceIDs)
require.Equal(t, []int64{202, 101}, multiGroup.Groups[1].ResourceIDs)
idempotentPut, err := writer.PutFavoriteResource(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, multiGroup, idempotentPut)
reader := &MediaSupplyService{favoriteStore: store}
readBack, err := reader.ListFavoriteGroups(ctx)
require.NoError(t, err)
require.Equal(t, multiGroup, readBack)
removedFromGroup, err := writer.DeleteFavoriteResourceFromGroup(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, []int64{101}, removedFromGroup.Groups[0].ResourceIDs)
require.Equal(t, []int64{202}, removedFromGroup.Groups[1].ResourceIDs)
restored, err := writer.PutFavoriteResource(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, []int64{101}, restored.Groups[0].ResourceIDs)
require.Equal(t, []int64{202, 101}, restored.Groups[1].ResourceIDs)
removed, err := writer.DeleteFavoriteResource(ctx, 101)
require.NoError(t, err)
require.Empty(t, removed.Groups[0].ResourceIDs)
require.Equal(t, []int64{202}, removed.Groups[1].ResourceIDs)
idempotentDelete, err := writer.DeleteFavoriteResource(ctx, 101)
require.NoError(t, err)
require.Equal(t, removed, idempotentDelete)
deletedGroup, err := writer.DeleteFavoriteGroup(ctx, groupID)
require.NoError(t, err)
idempotentGroupDelete, err := writer.DeleteFavoriteGroup(ctx, groupID)
require.NoError(t, err)
require.Equal(t, deletedGroup, idempotentGroupDelete)
otherUserCtx := auth.WithActor(context.Background(), auth.Actor{TenantID: 11, UserID: 102})
otherUser, err := reader.ListFavoriteGroups(otherUserCtx)
require.NoError(t, err)
require.False(t, otherUser.Initialized)
require.Empty(t, otherUser.Groups[0].ResourceIDs)
}
func TestMediaSupplyFavoriteResourceLimit(t *testing.T) {
store := newMemoryMediaSupplyFavoriteStore()
owner := favoriteOwner{tenantID: 11, userID: 101}
resourceIDs := make([]int64, mediaSupplyFavoriteResourceLimit)
for index := range resourceIDs {
resourceIDs[index] = int64(index + 1)
}
store.states[owner] = mediaSupplyFavoriteState{
Groups: []MediaSupplyFavoriteGroup{
{
ID: "default",
Name: "默认分组",
ResourceIDs: resourceIDs,
UpdatedAt: favoriteTestTime(),
},
{
ID: "secondary",
Name: "第二分组",
ResourceIDs: []int64{},
UpdatedAt: favoriteTestTime(),
},
},
Initialized: true,
Revision: 1,
}
service := &MediaSupplyService{favoriteStore: store}
ctx := auth.WithActor(context.Background(), auth.Actor{TenantID: owner.tenantID, UserID: owner.userID})
multiGroup, err := service.PutFavoriteResource(ctx, "secondary", 1)
require.NoError(t, err)
require.Contains(t, multiGroup.Groups[0].ResourceIDs, int64(1))
require.Equal(t, []int64{1}, multiGroup.Groups[1].ResourceIDs)
_, err = service.PutFavoriteResource(ctx, "default", 501)
require.Error(t, err)
}
func ensureMemoryFavoriteState(state mediaSupplyFavoriteState) mediaSupplyFavoriteState {
if state.Initialized {
return state
}
return mediaSupplyFavoriteState{
Groups: []MediaSupplyFavoriteGroup{{
ID: "default",
Name: "默认分组",
ResourceIDs: []int64{},
UpdatedAt: favoriteTestTime(),
}},
Initialized: true,
Revision: 1,
}
}
func cloneFavoriteState(state mediaSupplyFavoriteState) mediaSupplyFavoriteState {
state.Groups = cloneFavoriteGroups(state.Groups)
return state
}
func cloneFavoriteGroups(groups []MediaSupplyFavoriteGroup) []MediaSupplyFavoriteGroup {
cloned := make([]MediaSupplyFavoriteGroup, 0, len(groups))
for _, group := range groups {
group.ResourceIDs = append([]int64(nil), group.ResourceIDs...)
cloned = append(cloned, group)
}
return cloned
}
func removeFavoriteResourceID(ids []int64, resourceID int64) []int64 {
next := make([]int64, 0, len(ids))
for _, id := range ids {
if id != resourceID {
next = append(next, id)
}
}
return next
}
func favoriteTestTime() time.Time {
return time.Date(2026, time.July, 10, 10, 30, 0, 0, time.UTC)
}