stash/pkg/sqlite/group_test.go

1900 lines
45 KiB
Go

//go:build integration
// +build integration
package sqlite_test
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/intslice"
)
func loadGroupRelationships(ctx context.Context, expected models.Group, actual *models.Group) error {
if expected.URLs.Loaded() {
if err := actual.LoadURLs(ctx, db.Group); err != nil {
return err
}
}
if expected.TagIDs.Loaded() {
if err := actual.LoadTagIDs(ctx, db.Group); err != nil {
return err
}
}
if expected.ContainingGroups.Loaded() {
if err := actual.LoadContainingGroupIDs(ctx, db.Group); err != nil {
return err
}
}
if expected.SubGroups.Loaded() {
if err := actual.LoadSubGroupIDs(ctx, db.Group); err != nil {
return err
}
}
return nil
}
func Test_GroupStore_Create(t *testing.T) {
var (
name = "name"
url = "url"
aliases = "alias1, alias2"
director = "director"
rating = 60
duration = 34
synopsis = "synopsis"
date, _ = models.ParseDate("2003-02-01")
containingGroupDescription = "containingGroupDescription"
subGroupDescription = "subGroupDescription"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
name string
newObject models.Group
wantErr bool
}{
{
"full",
models.Group{
Name: name,
Duration: &duration,
Date: &date,
Rating: &rating,
StudioID: &studioIDs[studioIdxWithGroup],
Director: director,
Synopsis: synopsis,
URLs: models.NewRelatedStrings([]string{url}),
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
}),
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription},
}),
Aliases: aliases,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
false,
},
{
"invalid tag id",
models.Group{
Name: name,
TagIDs: models.NewRelatedIDs([]int{invalidID}),
},
true,
},
{
"invalid containing group id",
models.Group{
Name: name,
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
},
true,
},
{
"invalid sub group id",
models.Group{
Name: name,
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
},
true,
},
}
qb := db.Group
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
p := tt.newObject
if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {
t.Errorf("GroupStore.Create() error = %v, wantErr = %v", err, tt.wantErr)
}
if tt.wantErr {
assert.Zero(p.ID)
return
}
assert.NotZero(p.ID)
copy := tt.newObject
copy.ID = p.ID
// load relationships
if err := loadGroupRelationships(ctx, copy, &p); err != nil {
t.Errorf("loadGroupRelationships() error = %v", err)
return
}
assert.Equal(copy, p)
// ensure can find the group
found, err := qb.Find(ctx, p.ID)
if err != nil {
t.Errorf("GroupStore.Find() error = %v", err)
}
if !assert.NotNil(found) {
return
}
// load relationships
if err := loadGroupRelationships(ctx, copy, found); err != nil {
t.Errorf("loadGroupRelationships() error = %v", err)
return
}
assert.Equal(copy, *found)
return
})
}
}
func Test_groupQueryBuilder_Update(t *testing.T) {
var (
name = "name"
url = "url"
aliases = "alias1, alias2"
director = "director"
rating = 60
duration = 34
synopsis = "synopsis"
date, _ = models.ParseDate("2003-02-01")
containingGroupDescription = "containingGroupDescription"
subGroupDescription = "subGroupDescription"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
name string
updatedObject models.Group
wantErr bool
}{
{
"full",
models.Group{
ID: groupIDs[groupIdxWithTag],
Name: name,
Duration: &duration,
Date: &date,
Rating: &rating,
StudioID: &studioIDs[studioIdxWithGroup],
Director: director,
Synopsis: synopsis,
URLs: models.NewRelatedStrings([]string{url}),
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
}),
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription},
}),
Aliases: aliases,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
false,
},
{
"clear tag ids",
models.Group{
ID: groupIDs[groupIdxWithTag],
Name: name,
TagIDs: models.NewRelatedIDs([]int{}),
},
false,
},
{
"clear containing ids",
models.Group{
ID: groupIDs[groupIdxWithParent],
Name: name,
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
},
false,
},
{
"clear sub ids",
models.Group{
ID: groupIDs[groupIdxWithChild],
Name: name,
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
},
false,
},
{
"invalid studio id",
models.Group{
ID: groupIDs[groupIdxWithScene],
Name: name,
StudioID: &invalidID,
},
true,
},
{
"invalid tag id",
models.Group{
ID: groupIDs[groupIdxWithScene],
Name: name,
TagIDs: models.NewRelatedIDs([]int{invalidID}),
},
true,
},
{
"invalid containing group id",
models.Group{
ID: groupIDs[groupIdxWithScene],
Name: name,
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
},
true,
},
{
"invalid sub group id",
models.Group{
ID: groupIDs[groupIdxWithScene],
Name: name,
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}),
},
true,
},
}
qb := db.Group
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
actual := tt.updatedObject
expected := tt.updatedObject
if err := qb.Update(ctx, &actual); (err != nil) != tt.wantErr {
t.Errorf("groupQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
s, err := qb.Find(ctx, actual.ID)
if err != nil {
t.Errorf("groupQueryBuilder.Find() error = %v", err)
}
// load relationships
if err := loadGroupRelationships(ctx, expected, s); err != nil {
t.Errorf("loadGroupRelationships() error = %v", err)
return
}
assert.Equal(expected, *s)
})
}
}
var clearGroupPartial = models.GroupPartial{
// leave mandatory fields
Aliases: models.OptionalString{Set: true, Null: true},
Synopsis: models.OptionalString{Set: true, Null: true},
Director: models.OptionalString{Set: true, Null: true},
Duration: models.OptionalInt{Set: true, Null: true},
URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
Date: models.OptionalDate{Set: true, Null: true},
Rating: models.OptionalInt{Set: true, Null: true},
StudioID: models.OptionalInt{Set: true, Null: true},
TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},
ContainingGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet},
SubGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet},
}
func emptyGroup(idx int) models.Group {
return models.Group{
ID: groupIDs[idx],
Name: groupNames[idx],
TagIDs: models.NewRelatedIDs([]int{}),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
}
}
func Test_groupQueryBuilder_UpdatePartial(t *testing.T) {
var (
name = "name"
url = "url"
aliases = "alias1, alias2"
director = "director"
rating = 60
duration = 34
synopsis = "synopsis"
date, _ = models.ParseDate("2003-02-01")
containingGroupDescription = "containingGroupDescription"
subGroupDescription = "subGroupDescription"
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
name string
id int
partial models.GroupPartial
want models.Group
wantErr bool
}{
{
"full",
groupIDs[groupIdxWithScene],
models.GroupPartial{
Name: models.NewOptionalString(name),
Director: models.NewOptionalString(director),
Synopsis: models.NewOptionalString(synopsis),
Aliases: models.NewOptionalString(aliases),
URLs: &models.UpdateStrings{
Values: []string{url},
Mode: models.RelationshipUpdateModeSet,
},
Date: models.NewOptionalDate(date),
Duration: models.NewOptionalInt(duration),
Rating: models.NewOptionalInt(rating),
StudioID: models.NewOptionalInt(studioIDs[studioIdxWithGroup]),
CreatedAt: models.NewOptionalTime(createdAt),
UpdatedAt: models.NewOptionalTime(updatedAt),
TagIDs: &models.UpdateIDs{
IDs: []int{tagIDs[tagIdx1WithGroup], tagIDs[tagIdx1WithDupName]},
Mode: models.RelationshipUpdateModeSet,
},
ContainingGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription},
{GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription},
},
Mode: models.RelationshipUpdateModeSet,
},
SubGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription},
{GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription},
},
Mode: models.RelationshipUpdateModeSet,
},
},
models.Group{
ID: groupIDs[groupIdxWithScene],
Name: name,
Director: director,
Synopsis: synopsis,
Aliases: aliases,
URLs: models.NewRelatedStrings([]string{url}),
Date: &date,
Duration: &duration,
Rating: &rating,
StudioID: &studioIDs[studioIdxWithGroup],
CreatedAt: createdAt,
UpdatedAt: updatedAt,
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription},
{GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription},
}),
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription},
{GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription},
}),
},
false,
},
{
"clear all",
groupIDs[groupIdxWithScene],
clearGroupPartial,
emptyGroup(groupIdxWithScene),
false,
},
{
"clear tag ids",
groupIDs[groupIdxWithTag],
clearGroupPartial,
emptyGroup(groupIdxWithTag),
false,
},
{
"clear group relationships",
groupIDs[groupIdxWithParentAndChild],
clearGroupPartial,
emptyGroup(groupIdxWithParentAndChild),
false,
},
{
"add containing group",
groupIDs[groupIdxWithParent],
models.GroupPartial{
ContainingGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
},
Mode: models.RelationshipUpdateModeAdd,
},
},
models.Group{
ID: groupIDs[groupIdxWithParent],
Name: groupNames[groupIdxWithParent],
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithChild]},
{GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription},
}),
},
false,
},
{
"add sub group",
groupIDs[groupIdxWithChild],
models.GroupPartial{
SubGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription},
},
Mode: models.RelationshipUpdateModeAdd,
},
},
models.Group{
ID: groupIDs[groupIdxWithChild],
Name: groupNames[groupIdxWithChild],
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithParent]},
{GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription},
}),
},
false,
},
{
"remove containing group",
groupIDs[groupIdxWithParent],
models.GroupPartial{
ContainingGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithChild]},
},
Mode: models.RelationshipUpdateModeRemove,
},
},
models.Group{
ID: groupIDs[groupIdxWithParent],
Name: groupNames[groupIdxWithParent],
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
},
false,
},
{
"remove sub group",
groupIDs[groupIdxWithChild],
models.GroupPartial{
SubGroups: &models.UpdateGroupDescriptions{
Groups: []models.GroupIDDescription{
{GroupID: groupIDs[groupIdxWithParent]},
},
Mode: models.RelationshipUpdateModeRemove,
},
},
models.Group{
ID: groupIDs[groupIdxWithChild],
Name: groupNames[groupIdxWithChild],
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}),
},
false,
},
{
"invalid id",
invalidID,
models.GroupPartial{},
models.Group{},
true,
},
}
for _, tt := range tests {
qb := db.Group
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
got, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
if (err != nil) != tt.wantErr {
t.Errorf("groupQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
// load relationships
if err := loadGroupRelationships(ctx, tt.want, got); err != nil {
t.Errorf("loadGroupRelationships() error = %v", err)
return
}
assert.Equal(tt.want, *got)
s, err := qb.Find(ctx, tt.id)
if err != nil {
t.Errorf("groupQueryBuilder.Find() error = %v", err)
}
// load relationships
if err := loadGroupRelationships(ctx, tt.want, s); err != nil {
t.Errorf("loadGroupRelationships() error = %v", err)
return
}
assert.Equal(tt.want, *s)
})
}
}
func TestGroupFindByName(t *testing.T) {
withTxn(func(ctx context.Context) error {
mqb := db.Group
name := groupNames[groupIdxWithScene] // find a group by name
group, err := mqb.FindByName(ctx, name, false)
if err != nil {
t.Errorf("Error finding groups: %s", err.Error())
}
assert.Equal(t, groupNames[groupIdxWithScene], group.Name)
name = groupNames[groupIdxWithDupName] // find a group by name nocase
group, err = mqb.FindByName(ctx, name, true)
if err != nil {
t.Errorf("Error finding groups: %s", err.Error())
}
// groupIdxWithDupName and groupIdxWithScene should have similar names ( only diff should be Name vs NaMe)
//group.Name should match with groupIdxWithScene since its ID is before moveIdxWithDupName
assert.Equal(t, groupNames[groupIdxWithScene], group.Name)
//group.Name should match with groupIdxWithDupName if the check is not case sensitive
assert.Equal(t, strings.ToLower(groupNames[groupIdxWithDupName]), strings.ToLower(group.Name))
return nil
})
}
func TestGroupFindByNames(t *testing.T) {
withTxn(func(ctx context.Context) error {
var names []string
mqb := db.Group
names = append(names, groupNames[groupIdxWithScene]) // find groups by names
groups, err := mqb.FindByNames(ctx, names, false)
if err != nil {
t.Errorf("Error finding groups: %s", err.Error())
}
assert.Len(t, groups, 1)
assert.Equal(t, groupNames[groupIdxWithScene], groups[0].Name)
groups, err = mqb.FindByNames(ctx, names, true) // find groups by names nocase
if err != nil {
t.Errorf("Error finding groups: %s", err.Error())
}
assert.Len(t, groups, 2) // groupIdxWithScene and groupIdxWithDupName
assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[0].Name))
assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[1].Name))
return nil
})
}
func groupsToIDs(i []*models.Group) []int {
ret := make([]int, len(i))
for i, v := range i {
ret[i] = v.ID
}
return ret
}
func TestGroupQuery(t *testing.T) {
var (
frontImage = "front_image"
backImage = "back_image"
)
tests := []struct {
name string
findFilter *models.FindFilterType
filter *models.GroupFilterType
includeIdxs []int
excludeIdxs []int
wantErr bool
}{
{
"is missing front image",
nil,
&models.GroupFilterType{
IsMissing: &frontImage,
},
// just ensure that it doesn't error
nil,
nil,
false,
},
{
"is missing back image",
nil,
&models.GroupFilterType{
IsMissing: &backImage,
},
// just ensure that it doesn't error
nil,
nil,
false,
},
}
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
results, _, err := db.Group.Query(ctx, tt.filter, tt.findFilter)
if (err != nil) != tt.wantErr {
t.Errorf("GroupQueryBuilder.Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
ids := groupsToIDs(results)
include := indexesToIDs(performerIDs, tt.includeIdxs)
exclude := indexesToIDs(performerIDs, tt.excludeIdxs)
for _, i := range include {
assert.Contains(ids, i)
}
for _, e := range exclude {
assert.NotContains(ids, e)
}
})
}
}
func TestGroupQueryStudio(t *testing.T) {
withTxn(func(ctx context.Context) error {
mqb := db.Group
studioCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGroup]),
},
Modifier: models.CriterionModifierIncludes,
}
groupFilter := models.GroupFilterType{
Studios: &studioCriterion,
}
groups, _, err := mqb.Query(ctx, &groupFilter, nil)
if err != nil {
t.Errorf("Error querying group: %s", err.Error())
}
assert.Len(t, groups, 1)
// ensure id is correct
assert.Equal(t, groupIDs[groupIdxWithStudio], groups[0].ID)
studioCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithGroup]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getGroupStringValue(groupIdxWithStudio, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
groups, _, err = mqb.Query(ctx, &groupFilter, &findFilter)
if err != nil {
t.Errorf("Error querying group: %s", err.Error())
}
assert.Len(t, groups, 0)
return nil
})
}
func TestGroupQueryURL(t *testing.T) {
const sceneIdx = 1
groupURL := getGroupStringValue(sceneIdx, urlField)
urlCriterion := models.StringCriterionInput{
Value: groupURL,
Modifier: models.CriterionModifierEquals,
}
filter := models.GroupFilterType{
URL: &urlCriterion,
}
verifyFn := func(n *models.Group) {
t.Helper()
urls := n.URLs.List()
var url string
if len(urls) > 0 {
url = urls[0]
}
verifyString(t, url, urlCriterion)
}
verifyGroupQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierNotEquals
verifyGroupQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierMatchesRegex
urlCriterion.Value = "group_.*1_URL"
verifyGroupQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex
verifyGroupQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierIsNull
urlCriterion.Value = ""
verifyGroupQuery(t, filter, verifyFn)
urlCriterion.Modifier = models.CriterionModifierNotNull
verifyGroupQuery(t, filter, verifyFn)
}
func TestGroupQueryURLExcludes(t *testing.T) {
withRollbackTxn(func(ctx context.Context) error {
mqb := db.Group
// create group with two URLs
group := models.Group{
Name: "TestGroupQueryURLExcludes",
URLs: models.NewRelatedStrings([]string{
"aaa",
"bbb",
}),
}
err := mqb.Create(ctx, &group)
if err != nil {
return fmt.Errorf("Error creating group: %w", err)
}
// query for groups that exclude the URL "aaa"
urlCriterion := models.StringCriterionInput{
Value: "aaa",
Modifier: models.CriterionModifierExcludes,
}
nameCriterion := models.StringCriterionInput{
Value: group.Name,
Modifier: models.CriterionModifierEquals,
}
filter := models.GroupFilterType{
URL: &urlCriterion,
Name: &nameCriterion,
}
groups := queryGroups(ctx, t, &filter, nil)
assert.Len(t, groups, 0, "Expected no groups to be found")
// query for groups that exclude the URL "ccc"
urlCriterion.Value = "ccc"
groups = queryGroups(ctx, t, &filter, nil)
if assert.Len(t, groups, 1, "Expected one group to be found") {
assert.Equal(t, group.Name, groups[0].Name)
}
return nil
})
}
func verifyGroupQuery(t *testing.T, filter models.GroupFilterType, verifyFn func(s *models.Group)) {
withTxn(func(ctx context.Context) error {
t.Helper()
sqb := db.Group
groups := queryGroups(ctx, t, &filter, nil)
for _, group := range groups {
if err := group.LoadURLs(ctx, sqb); err != nil {
t.Errorf("Error loading group relationships: %v", err)
}
}
// assume it should find at least one
assert.Greater(t, len(groups), 0)
for _, m := range groups {
verifyFn(m)
}
return nil
})
}
func queryGroups(ctx context.Context, t *testing.T, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) []*models.Group {
sqb := db.Group
groups, _, err := sqb.Query(ctx, groupFilter, findFilter)
if err != nil {
t.Errorf("Error querying group: %s", err.Error())
}
return groups
}
func TestGroupQueryTags(t *testing.T) {
withTxn(func(ctx context.Context) error {
tagCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithGroup]),
strconv.Itoa(tagIDs[tagIdx1WithGroup]),
},
Modifier: models.CriterionModifierIncludes,
}
groupFilter := models.GroupFilterType{
Tags: &tagCriterion,
}
// ensure ids are correct
groups := queryGroups(ctx, t, &groupFilter, nil)
assert.Len(t, groups, 3)
for _, group := range groups {
assert.True(t, group.ID == groupIDs[groupIdxWithTag] || group.ID == groupIDs[groupIdxWithTwoTags] || group.ID == groupIDs[groupIdxWithThreeTags])
}
tagCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithGroup]),
strconv.Itoa(tagIDs[tagIdx2WithGroup]),
},
Modifier: models.CriterionModifierIncludesAll,
}
groups = queryGroups(ctx, t, &groupFilter, nil)
if assert.Len(t, groups, 2) {
assert.Equal(t, sceneIDs[groupIdxWithTwoTags], groups[0].ID)
assert.Equal(t, sceneIDs[groupIdxWithThreeTags], groups[1].ID)
}
tagCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithGroup]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getSceneStringValue(groupIdxWithTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
groups = queryGroups(ctx, t, &groupFilter, &findFilter)
assert.Len(t, groups, 0)
return nil
})
}
func TestGroupQueryTagCount(t *testing.T) {
const tagCount = 1
tagCountCriterion := models.IntCriterionInput{
Value: tagCount,
Modifier: models.CriterionModifierEquals,
}
verifyGroupsTagCount(t, tagCountCriterion)
tagCountCriterion.Modifier = models.CriterionModifierNotEquals
verifyGroupsTagCount(t, tagCountCriterion)
tagCountCriterion.Modifier = models.CriterionModifierGreaterThan
verifyGroupsTagCount(t, tagCountCriterion)
tagCountCriterion.Modifier = models.CriterionModifierLessThan
verifyGroupsTagCount(t, tagCountCriterion)
}
func verifyGroupsTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Group
groupFilter := models.GroupFilterType{
TagCount: &tagCountCriterion,
}
groups := queryGroups(ctx, t, &groupFilter, nil)
assert.Greater(t, len(groups), 0)
for _, group := range groups {
ids, err := sqb.GetTagIDs(ctx, group.ID)
if err != nil {
return err
}
verifyInt(t, len(ids), tagCountCriterion)
}
return nil
})
}
func TestGroupQuerySorting(t *testing.T) {
sort := "scenes_count"
direction := models.SortDirectionEnumDesc
findFilter := models.FindFilterType{
Sort: &sort,
Direction: &direction,
}
withTxn(func(ctx context.Context) error {
groups := queryGroups(ctx, t, nil, &findFilter)
// scenes should be in same order as indexes
firstGroup := groups[0]
assert.Equal(t, groupIDs[groupIdxWithScene], firstGroup.ID)
// sort in descending order
direction = models.SortDirectionEnumAsc
groups = queryGroups(ctx, t, nil, &findFilter)
lastGroup := groups[len(groups)-1]
assert.Equal(t, groupIDs[groupIdxWithParentAndScene], lastGroup.ID)
return nil
})
}
func TestGroupQuerySortOrderIndex(t *testing.T) {
sort := "sub_group_order"
direction := models.SortDirectionEnumDesc
findFilter := models.FindFilterType{
Sort: &sort,
Direction: &direction,
}
groupFilter := models.GroupFilterType{
ContainingGroups: &models.HierarchicalMultiCriterionInput{
Value: intslice.IntSliceToStringSlice([]int{groupIdxWithChild}),
Modifier: models.CriterionModifierIncludes,
},
}
withTxn(func(ctx context.Context) error {
// just ensure there are no errors
_, _, err := db.Group.Query(ctx, &groupFilter, &findFilter)
if err != nil {
t.Errorf("Error querying group: %s", err.Error())
}
_, _, err = db.Group.Query(ctx, nil, &findFilter)
if err != nil {
t.Errorf("Error querying group: %s", err.Error())
}
return nil
})
}
func TestGroupUpdateFrontImage(t *testing.T) {
if err := withRollbackTxn(func(ctx context.Context) error {
qb := db.Group
// create group to test against
const name = "TestGroupUpdateGroupImages"
group := models.Group{
Name: name,
}
err := qb.Create(ctx, &group)
if err != nil {
return fmt.Errorf("Error creating group: %s", err.Error())
}
return testUpdateImage(t, ctx, group.ID, qb.UpdateFrontImage, qb.GetFrontImage)
}); err != nil {
t.Error(err.Error())
}
}
func TestGroupUpdateBackImage(t *testing.T) {
if err := withRollbackTxn(func(ctx context.Context) error {
qb := db.Group
// create group to test against
const name = "TestGroupUpdateGroupImages"
group := models.Group{
Name: name,
}
err := qb.Create(ctx, &group)
if err != nil {
return fmt.Errorf("Error creating group: %s", err.Error())
}
return testUpdateImage(t, ctx, group.ID, qb.UpdateBackImage, qb.GetBackImage)
}); err != nil {
t.Error(err.Error())
}
}
func TestGroupQueryContainingGroups(t *testing.T) {
const nameField = "Name"
type criterion struct {
valueIdxs []int
modifier models.CriterionModifier
depth int
}
tests := []struct {
name string
c criterion
q string
includeIdxs []int
}{
{
"includes",
criterion{
[]int{groupIdxWithChild},
models.CriterionModifierIncludes,
0,
},
"",
[]int{groupIdxWithParent},
},
{
"excludes",
criterion{
[]int{groupIdxWithChild},
models.CriterionModifierExcludes,
0,
},
getGroupStringValue(groupIdxWithParent, nameField),
nil,
},
{
"includes (all levels)",
criterion{
[]int{groupIdxWithGrandChild},
models.CriterionModifierIncludes,
-1,
},
"",
[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},
},
{
"includes (1 level)",
criterion{
[]int{groupIdxWithGrandChild},
models.CriterionModifierIncludes,
1,
},
"",
[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},
},
{
"is null",
criterion{
nil,
models.CriterionModifierIsNull,
0,
},
getGroupStringValue(groupIdxWithParent, nameField),
nil,
},
{
"not null",
criterion{
nil,
models.CriterionModifierNotNull,
0,
},
"",
[]int{groupIdxWithParentAndChild, groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndScene},
},
}
qb := db.Group
for _, tt := range tests {
valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)
expectedIDs := indexesToIDs(groupIDs, tt.includeIdxs)
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
groupFilter := &models.GroupFilterType{
ContainingGroups: &models.HierarchicalMultiCriterionInput{
Value: intslice.IntSliceToStringSlice(valueIDs),
Modifier: tt.c.modifier,
},
}
if tt.c.depth != 0 {
groupFilter.ContainingGroups.Depth = &tt.c.depth
}
findFilter := models.FindFilterType{}
if tt.q != "" {
findFilter.Q = &tt.q
}
groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
if err != nil {
t.Errorf("GroupStore.Query() error = %v", err)
return
}
// get ids of groups
groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
assert.ElementsMatch(t, expectedIDs, groupIDs)
})
}
}
func TestGroupQuerySubGroups(t *testing.T) {
const nameField = "Name"
type criterion struct {
valueIdxs []int
modifier models.CriterionModifier
depth int
}
tests := []struct {
name string
c criterion
q string
expectedIdxs []int
}{
{
"includes",
criterion{
[]int{groupIdxWithParent},
models.CriterionModifierIncludes,
0,
},
"",
[]int{groupIdxWithChild},
},
{
"excludes",
criterion{
[]int{groupIdxWithParent},
models.CriterionModifierExcludes,
0,
},
getGroupStringValue(groupIdxWithChild, nameField),
nil,
},
{
"includes (all levels)",
criterion{
[]int{groupIdxWithGrandParent},
models.CriterionModifierIncludes,
-1,
},
"",
[]int{groupIdxWithGrandChild, groupIdxWithParentAndChild},
},
{
"includes (1 level)",
criterion{
[]int{groupIdxWithGrandParent},
models.CriterionModifierIncludes,
1,
},
"",
[]int{groupIdxWithGrandChild, groupIdxWithParentAndChild},
},
{
"is null",
criterion{
nil,
models.CriterionModifierIsNull,
0,
},
getGroupStringValue(groupIdxWithChild, nameField),
nil,
},
{
"not null",
criterion{
nil,
models.CriterionModifierNotNull,
0,
},
"",
[]int{groupIdxWithGrandChild, groupIdxWithChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},
},
}
qb := db.Group
for _, tt := range tests {
valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs)
expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
groupFilter := &models.GroupFilterType{
SubGroups: &models.HierarchicalMultiCriterionInput{
Value: intslice.IntSliceToStringSlice(valueIDs),
Modifier: tt.c.modifier,
},
}
if tt.c.depth != 0 {
groupFilter.SubGroups.Depth = &tt.c.depth
}
findFilter := models.FindFilterType{}
if tt.q != "" {
findFilter.Q = &tt.q
}
groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
if err != nil {
t.Errorf("GroupStore.Query() error = %v", err)
return
}
// get ids of groups
groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
assert.ElementsMatch(t, expectedIDs, groupIDs)
})
}
}
func TestGroupQueryContainingGroupCount(t *testing.T) {
const nameField = "Name"
tests := []struct {
name string
value int
modifier models.CriterionModifier
q string
expectedIdxs []int
}{
{
"equals",
1,
models.CriterionModifierEquals,
"",
[]int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene},
},
{
"not equals",
1,
models.CriterionModifierNotEquals,
getGroupStringValue(groupIdxWithParent, nameField),
nil,
},
{
"less than",
1,
models.CriterionModifierLessThan,
getGroupStringValue(groupIdxWithParent, nameField),
nil,
},
{
"greater than",
0,
models.CriterionModifierGreaterThan,
"",
[]int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene},
},
}
qb := db.Group
for _, tt := range tests {
expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
groupFilter := &models.GroupFilterType{
ContainingGroupCount: &models.IntCriterionInput{
Value: tt.value,
Modifier: tt.modifier,
},
}
findFilter := models.FindFilterType{}
if tt.q != "" {
findFilter.Q = &tt.q
}
groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
if err != nil {
t.Errorf("GroupStore.Query() error = %v", err)
return
}
// get ids of groups
groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
assert.ElementsMatch(t, expectedIDs, groupIDs)
})
}
}
func TestGroupQuerySubGroupCount(t *testing.T) {
const nameField = "Name"
tests := []struct {
name string
value int
modifier models.CriterionModifier
q string
expectedIdxs []int
}{
{
"equals",
1,
models.CriterionModifierEquals,
"",
[]int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},
},
{
"not equals",
1,
models.CriterionModifierNotEquals,
getGroupStringValue(groupIdxWithChild, nameField),
nil,
},
{
"less than",
1,
models.CriterionModifierLessThan,
getGroupStringValue(groupIdxWithChild, nameField),
nil,
},
{
"greater than",
0,
models.CriterionModifierGreaterThan,
"",
[]int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene},
},
}
qb := db.Group
for _, tt := range tests {
expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
groupFilter := &models.GroupFilterType{
SubGroupCount: &models.IntCriterionInput{
Value: tt.value,
Modifier: tt.modifier,
},
}
findFilter := models.FindFilterType{}
if tt.q != "" {
findFilter.Q = &tt.q
}
groups, _, err := qb.Query(ctx, groupFilter, &findFilter)
if err != nil {
t.Errorf("GroupStore.Query() error = %v", err)
return
}
// get ids of groups
groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID })
assert.ElementsMatch(t, expectedIDs, groupIDs)
})
}
}
func TestGroupFindInAncestors(t *testing.T) {
tests := []struct {
name string
ancestorIdxs []int
idxs []int
expectedIdxs []int
}{
{
"basic",
[]int{groupIdxWithGrandParent},
[]int{groupIdxWithGrandChild},
[]int{groupIdxWithGrandChild},
},
{
"same",
[]int{groupIdxWithScene},
[]int{groupIdxWithScene},
[]int{groupIdxWithScene},
},
{
"no matches",
[]int{groupIdxWithGrandParent},
[]int{groupIdxWithScene},
nil,
},
}
qb := db.Group
for _, tt := range tests {
ancestorIDs := indexesToIDs(groupIDs, tt.ancestorIdxs)
ids := indexesToIDs(groupIDs, tt.idxs)
expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs)
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
found, err := qb.FindInAncestors(ctx, ancestorIDs, ids)
if err != nil {
t.Errorf("GroupStore.FindInAncestors() error = %v", err)
return
}
// get ids of groups
assert.ElementsMatch(t, found, expectedIDs)
})
}
}
func TestGroupReorderSubGroups(t *testing.T) {
tests := []struct {
name string
subGroupLen int
idxsToMove []int
insertLoc int
insertAfter bool
// order of elements, using original indexes
expectedIdxs []int
}{
{
"move single back before",
5,
[]int{2},
1,
false,
[]int{0, 2, 1, 3, 4},
},
{
"move single forward before",
5,
[]int{2},
4,
false,
[]int{0, 1, 3, 2, 4},
},
{
"move multiple back before",
5,
[]int{3, 2, 4},
0,
false,
[]int{3, 2, 4, 0, 1},
},
{
"move multiple forward before",
5,
[]int{2, 1, 0},
4,
false,
[]int{3, 2, 1, 0, 4},
},
{
"move single back after",
5,
[]int{2},
0,
true,
[]int{0, 2, 1, 3, 4},
},
{
"move single forward after",
5,
[]int{2},
4,
true,
[]int{0, 1, 3, 4, 2},
},
{
"move multiple back after",
5,
[]int{3, 2, 4},
0,
false,
[]int{0, 3, 2, 4, 1},
},
{
"move multiple forward after",
5,
[]int{2, 1, 0},
4,
false,
[]int{3, 4, 2, 1, 0},
},
}
qb := db.Group
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
// create the group
group := models.Group{
Name: "TestGroupReorderSubGroups",
}
if err := qb.Create(ctx, &group); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
// and sub-groups
idxToId := make([]int, tt.subGroupLen)
for i := 0; i < tt.subGroupLen; i++ {
subGroup := models.Group{
Name: fmt.Sprintf("SubGroup %d", i),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: group.ID},
}),
}
if err := qb.Create(ctx, &subGroup); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
idxToId[i] = subGroup.ID
}
// reorder
idsToMove := indexesToIDs(idxToId, tt.idxsToMove)
insertID := idxToId[tt.insertLoc]
if err := qb.ReorderSubGroups(ctx, group.ID, idsToMove, insertID, tt.insertAfter); err != nil {
t.Errorf("GroupStore.ReorderSubGroups() error = %v", err)
return
}
// validate the new order
gd, err := qb.GetSubGroupDescriptions(ctx, group.ID)
if err != nil {
t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err)
return
}
// get ids of groups
newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })
newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })
assert.ElementsMatch(t, tt.expectedIdxs, newIdxs)
})
}
}
func TestGroupAddSubGroups(t *testing.T) {
tests := []struct {
name string
existingSubGroupLen int
insertGroupsLen int
insertLoc int
// order of elements, using original indexes
expectedIdxs []int
}{
{
"append single",
4,
1,
999,
[]int{0, 1, 2, 3, 4},
},
{
"insert single middle",
4,
1,
2,
[]int{0, 1, 4, 2, 3},
},
{
"insert single start",
4,
1,
0,
[]int{4, 0, 1, 2, 3},
},
{
"append multiple",
4,
2,
999,
[]int{0, 1, 2, 3, 4, 5},
},
{
"insert multiple middle",
4,
2,
2,
[]int{0, 1, 4, 5, 2, 3},
},
{
"insert multiple start",
4,
2,
0,
[]int{4, 5, 0, 1, 2, 3},
},
}
qb := db.Group
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
// create the group
group := models.Group{
Name: "TestGroupReorderSubGroups",
}
if err := qb.Create(ctx, &group); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
// and sub-groups
idxToId := make([]int, tt.existingSubGroupLen+tt.insertGroupsLen)
for i := 0; i < tt.existingSubGroupLen; i++ {
subGroup := models.Group{
Name: fmt.Sprintf("Existing SubGroup %d", i),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: group.ID},
}),
}
if err := qb.Create(ctx, &subGroup); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
idxToId[i] = subGroup.ID
}
// and sub-groups to insert
for i := 0; i < tt.insertGroupsLen; i++ {
subGroup := models.Group{
Name: fmt.Sprintf("Inserted SubGroup %d", i),
}
if err := qb.Create(ctx, &subGroup); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
idxToId[i+tt.existingSubGroupLen] = subGroup.ID
}
// convert ids to description
idDescriptions := make([]models.GroupIDDescription, tt.insertGroupsLen)
for i, id := range idxToId[tt.existingSubGroupLen:] {
idDescriptions[i] = models.GroupIDDescription{GroupID: id}
}
// add
if err := qb.AddSubGroups(ctx, group.ID, idDescriptions, &tt.insertLoc); err != nil {
t.Errorf("GroupStore.AddSubGroups() error = %v", err)
return
}
// validate the new order
gd, err := qb.GetSubGroupDescriptions(ctx, group.ID)
if err != nil {
t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err)
return
}
// get ids of groups
newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })
newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })
assert.ElementsMatch(t, tt.expectedIdxs, newIdxs)
})
}
}
func TestGroupRemoveSubGroups(t *testing.T) {
tests := []struct {
name string
subGroupLen int
removeIdxs []int
// order of elements, using original indexes
expectedIdxs []int
}{
{
"remove last",
4,
[]int{3},
[]int{0, 1, 2},
},
{
"remove first",
4,
[]int{0},
[]int{1, 2, 3},
},
{
"remove middle",
4,
[]int{2},
[]int{0, 1, 3},
},
{
"remove multiple",
4,
[]int{1, 3},
[]int{0, 2},
},
{
"remove all",
4,
[]int{0, 1, 2, 3},
[]int{},
},
}
qb := db.Group
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
// create the group
group := models.Group{
Name: "TestGroupReorderSubGroups",
}
if err := qb.Create(ctx, &group); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
// and sub-groups
idxToId := make([]int, tt.subGroupLen)
for i := 0; i < tt.subGroupLen; i++ {
subGroup := models.Group{
Name: fmt.Sprintf("Existing SubGroup %d", i),
ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: group.ID},
}),
}
if err := qb.Create(ctx, &subGroup); err != nil {
t.Errorf("GroupStore.Create() error = %v", err)
return
}
idxToId[i] = subGroup.ID
}
idsToRemove := indexesToIDs(idxToId, tt.removeIdxs)
if err := qb.RemoveSubGroups(ctx, group.ID, idsToRemove); err != nil {
t.Errorf("GroupStore.RemoveSubGroups() error = %v", err)
return
}
// validate the new order
gd, err := qb.GetSubGroupDescriptions(ctx, group.ID)
if err != nil {
t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err)
return
}
// get ids of groups
newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID })
newIdxs := sliceutil.Map(newIDs, func(id int) int { return slices.Index(idxToId, id) })
assert.ElementsMatch(t, tt.expectedIdxs, newIdxs)
})
}
}
func TestGroupFindSubGroupIDs(t *testing.T) {
tests := []struct {
name string
containingGroupIdx int
subIdxs []int
expectedIdxs []int
}{
{
"overlap",
groupIdxWithGrandChild,
[]int{groupIdxWithParentAndChild, groupIdxWithGrandParent},
[]int{groupIdxWithParentAndChild},
},
{
"non-overlap",
groupIdxWithGrandChild,
[]int{groupIdxWithGrandParent},
[]int{},
},
{
"none",
groupIdxWithScene,
[]int{groupIdxWithDupName},
[]int{},
},
{
"invalid",
invalidID,
[]int{invalidID},
[]int{},
},
}
qb := db.Group
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
subIDs := indexesToIDs(groupIDs, tt.subIdxs)
id := indexToID(groupIDs, tt.containingGroupIdx)
found, err := qb.FindSubGroupIDs(ctx, id, subIDs)
if err != nil {
t.Errorf("GroupStore.FindSubGroupIDs() error = %v", err)
return
}
// get ids of groups
foundIdxs := sliceutil.Map(found, func(id int) int { return slices.Index(groupIDs, id) })
assert.ElementsMatch(t, tt.expectedIdxs, foundIdxs)
})
}
}
// TODO Update
// TODO Destroy - ensure image is destroyed
// TODO Find
// TODO Count
// TODO All
// TODO Query