diff --git a/internal/domain/list_test.go b/internal/domain/list_test.go index eeee2cd..f232973 100644 --- a/internal/domain/list_test.go +++ b/internal/domain/list_test.go @@ -11,6 +11,12 @@ import ( "github.com/stretchr/testify/require" ) +var ( + _ Transactor = (*fakeTransactor)(nil) + _ ListRepository = (*mockListRepository)(nil) +) + +type txCtxKey struct{} type fakeTransactor struct { calls int err error @@ -21,9 +27,14 @@ func (f *fakeTransactor) WithinTx(ctx context.Context, fn func(ctx context.Conte if f.err != nil { return f.err } - return fn(ctx) + return fn(context.WithValue(ctx, txCtxKey{}, true)) } +var inTx = mock.MatchedBy(func(ctx context.Context) bool { + v, _ := ctx.Value(txCtxKey{}).(bool) + return v +}) + type mockListRepository struct { mock.Mock } @@ -55,6 +66,17 @@ func (m *mockListRepository) RemoveUserFromList(ctx context.Context, listID stri return args.Error(0) } +func (m *mockListRepository) OrderLists(ctx context.Context, userID string, listIDs []string) error { + args := m.Called(ctx, userID, listIDs) + return args.Error(0) +} + +func (m *mockListRepository) LockUsersLists(ctx context.Context, userID string) ([]string, error) { + args := m.Called(ctx, userID) + ids, _ := args.Get(0).([]string) + return ids, args.Error(1) +} + func (m *mockListRepository) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) { args := m.Called(ctx, listID, userID) return args.Bool(0), args.Error(1) @@ -101,6 +123,7 @@ func newListService(t *testing.T) (*ListService, *mockListRepository, *fakeTrans t.Helper() repo := &mockListRepository{} + repo.Test(t) t.Cleanup(func() { repo.AssertExpectations(t) }) tx := &fakeTransactor{} @@ -108,224 +131,630 @@ func newListService(t *testing.T) (*ListService, *mockListRepository, *fakeTrans return NewListService(tx, repo), repo, tx } +func assertCallOrder(t *testing.T, repo *mockListRepository, want ...string) { + t.Helper() + + var got []string + for _, c := range repo.Calls { + got = append(got, c.Method) + } + assert.Equal(t, want, got) +} + func TestListService_CreateList(t *testing.T) { - t.Run("creates the list and adds the owner", func(t *testing.T) { + ctx := context.Background() + + t.Run("creates the list and adds the creator in one transaction", func(t *testing.T) { svc, repo, tx := newListService(t) created := &List{ID: "list-1", Name: "Groceries", CreatedAt: time.Now()} - repo.On("CreateList", mock.Anything, "Groceries").Return(created, nil) - repo.On("AddUserToList", mock.Anything, "list-1", "user-1").Return(nil) + repo.On("CreateList", inTx, "Groceries").Return(created, nil) + repo.On("AddUserToList", inTx, "list-1", "user-1").Return(nil) - list, err := svc.CreateList(context.Background(), "user-1", "Groceries") + list, err := svc.CreateList(ctx, "user-1", "Groceries") require.NoError(t, err) assert.Equal(t, created, list) assert.Equal(t, 1, tx.calls) - }) - - t.Run("empty name is rejected before any repo call", func(t *testing.T) { - svc, _, tx := newListService(t) - - list, err := svc.CreateList(context.Background(), "user-1", "") - - assert.Nil(t, list) - assert.ErrorIs(t, err, ErrListNameMissing) - assert.Zero(t, tx.calls) + assertCallOrder(t, repo, "CreateList", "AddUserToList") }) t.Run("insert error aborts before adding the user", func(t *testing.T) { svc, repo, _ := newListService(t) repoErr := errors.New("insert failed") - repo.On("CreateList", mock.Anything, "Groceries").Return(nil, repoErr) + repo.On("CreateList", inTx, "Groceries").Return(nil, repoErr) - list, err := svc.CreateList(context.Background(), "user-1", "Groceries") + list, err := svc.CreateList(ctx, "user-1", "Groceries") assert.Nil(t, list) assert.ErrorIs(t, err, repoErr) - repo.AssertNotCalled(t, "AddUserToList", mock.Anything, mock.Anything, mock.Anything) + assertCallOrder(t, repo, "CreateList") }) - t.Run("transaction error is returned", func(t *testing.T) { - svc, _, tx := newListService(t) + t.Run("membership insert error fails the whole operation", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repoErr := errors.New("foreign key violation") + repo.On("CreateList", inTx, "Groceries").Return(&List{ID: "list-1", Name: "Groceries"}, nil) + repo.On("AddUserToList", inTx, "list-1", "user-1").Return(repoErr) + + list, err := svc.CreateList(ctx, "user-1", "Groceries") + + assert.Nil(t, list, "no half-created list may be returned") + assert.ErrorIs(t, err, repoErr) + }) + + t.Run("transaction begin error is returned", func(t *testing.T) { + svc, repo, tx := newListService(t) tx.err = errors.New("could not begin transaction") - list, err := svc.CreateList(context.Background(), "user-1", "Groceries") + list, err := svc.CreateList(ctx, "user-1", "Groceries") assert.Nil(t, list) assert.ErrorIs(t, err, tx.err) + assertCallOrder(t, repo) }) } func TestListService_DeleteList(t *testing.T) { - t.Run("deletes when the user is a member", func(t *testing.T) { - svc, repo, _ := newListService(t) + svc, repo, _ := newListService(t) - repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) - repo.On("DeleteList", mock.Anything, "list-1").Return(nil) + repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) + repo.On("DeleteList", mock.Anything, "list-1").Return(nil) - assert.NoError(t, svc.DeleteList(context.Background(), "user-1", "list-1")) - }) + err := svc.DeleteList(context.Background(), "user-1", "list-1") - t.Run("refuses when the user is not a member", func(t *testing.T) { - svc, repo, _ := newListService(t) - - repo.On("IsUserInList", mock.Anything, "list-1", "intruder").Return(false, nil) - - err := svc.DeleteList(context.Background(), "intruder", "list-1") - - assert.ErrorIs(t, err, ErrUserNotInList) - repo.AssertNotCalled(t, "DeleteList", mock.Anything, mock.Anything) - }) - - t.Run("membership check error is propagated", func(t *testing.T) { - svc, repo, _ := newListService(t) - - repoErr := errors.New("connection reset") - repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(false, repoErr) - - err := svc.DeleteList(context.Background(), "user-1", "list-1") - - assert.ErrorIs(t, err, repoErr) - assert.NotErrorIs(t, err, ErrUserNotInList) - repo.AssertNotCalled(t, "DeleteList", mock.Anything, mock.Anything) - }) + require.NoError(t, err) + assertCallOrder(t, repo, "IsUserInList", "DeleteList") } func TestListService_GetLists(t *testing.T) { + ctx := context.Background() + + t.Run("returns the user's lists", func(t *testing.T) { + svc, repo, _ := newListService(t) + + want := []List{{ID: "list-1", Name: "Groceries"}, {ID: "list-2", Name: "Reading"}} + repo.On("GetLists", mock.Anything, "user-1").Return(want, nil) + + lists, err := svc.GetLists(ctx, "user-1") + + require.NoError(t, err) + assert.Equal(t, want, lists) + }) + + t.Run("user without lists gets an empty result", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repo.On("GetLists", mock.Anything, "user-1").Return([]List{}, nil) + + lists, err := svc.GetLists(ctx, "user-1") + + require.NoError(t, err) + assert.Empty(t, lists) + }) + + t.Run("repository error is propagated", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repoErr := errors.New("connection reset") + repo.On("GetLists", mock.Anything, "user-1").Return(nil, repoErr) + + lists, err := svc.GetLists(ctx, "user-1") + + assert.Nil(t, lists) + assert.ErrorIs(t, err, repoErr) + }) +} + +func TestListService_AddUserToList(t *testing.T) { svc, repo, _ := newListService(t) - want := []List{{ID: "list-1", Name: "Groceries"}, {ID: "list-2", Name: "Reading"}} - repo.On("GetLists", mock.Anything, "user-1").Return(want, nil) + repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) + repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(nil) - lists, err := svc.GetLists(context.Background(), "user-1") + err := svc.AddUserToList(context.Background(), "user-1", "list-1", "user-2") require.NoError(t, err) - assert.Equal(t, want, lists) + assertCallOrder(t, repo, "IsUserInList", "AddUserToList") +} + +func TestListService_RemoveUserFromList(t *testing.T) { + ctx := context.Background() + + t.Run("member removes another member", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) + repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(nil) + + err := svc.RemoveUserFromList(ctx, "user-1", "list-1", "user-2") + + require.NoError(t, err) + assertCallOrder(t, repo, "IsUserInList", "RemoveUserFromList") + }) + + t.Run("member leaves the list", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) + repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-1").Return(nil) + + err := svc.RemoveUserFromList(ctx, "user-1", "list-1", "user-1") + + require.NoError(t, err) + }) +} + +func TestListService_OrderLists(t *testing.T) { + ctx := context.Background() + + t.Run("stores the client's order inside one transaction", func(t *testing.T) { + svc, repo, tx := newListService(t) + + repo.On("LockUsersLists", inTx, "user-1").Return([]string{"list-a", "list-b", "list-c"}, nil) + repo.On("OrderLists", inTx, "user-1", []string{"list-c", "list-a", "list-b"}).Return(nil) + + err := svc.OrderLists(ctx, "user-1", []string{"list-c", "list-a", "list-b"}) + + require.NoError(t, err) + assert.Equal(t, 1, tx.calls) + assertCallOrder(t, repo, "LockUsersLists", "OrderLists") + }) + + t.Run("ids are compared case-insensitively", func(t *testing.T) { + svc, repo, _ := newListService(t) + + server := []string{ + "3f2a8c1e-7d4b-4e21-9a6f-2b1c0d9e8f7a", + "9b1d4e6f-0a2c-4b3d-8e5f-6a7b8c9d0e1f", + } + client := []string{ + "9B1D4E6F-0A2C-4B3D-8E5F-6A7B8C9D0E1F", + "3F2A8C1E-7D4B-4E21-9A6F-2B1C0D9E8F7A", + } + repo.On("LockUsersLists", inTx, "user-1").Return(server, nil) + // The client's spelling is passed on unchanged; Postgres' uuid cast + // doesn't care about case. + repo.On("OrderLists", inTx, "user-1", client).Return(nil) + + err := svc.OrderLists(ctx, "user-1", client) + + require.NoError(t, err) + }) + + t.Run("stale ids are rejected without writing", func(t *testing.T) { + tests := []struct { + name string + server []string + client []string + }{ + {name: "client is missing a list", server: []string{"a", "b", "c"}, client: []string{"a", "b"}}, + {name: "client has an extra list", server: []string{"a", "b"}, client: []string{"a", "b", "c"}}, + {name: "client has an unknown list", server: []string{"a", "b"}, client: []string{"a", "x"}}, + {name: "client repeats a list", server: []string{"a", "b"}, client: []string{"a", "a"}}, + {name: "user has no lists anymore", server: []string{}, client: []string{"a"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, repo, _ := newListService(t) + + repo.On("LockUsersLists", inTx, "user-1").Return(tt.server, nil) + + err := svc.OrderLists(ctx, "user-1", tt.client) + + assert.ErrorIs(t, err, ErrStaleListIDs) + assertCallOrder(t, repo, "LockUsersLists") + }) + } + }) + + t.Run("lock error is propagated", func(t *testing.T) { + svc, repo, _ := newListService(t) + + lockErr := errors.New("lock timeout") + repo.On("LockUsersLists", inTx, "user-1").Return(nil, lockErr) + + err := svc.OrderLists(ctx, "user-1", []string{"list-a"}) + + assert.ErrorIs(t, err, lockErr) + assert.NotErrorIs(t, err, ErrStaleListIDs) + assertCallOrder(t, repo, "LockUsersLists") + }) + + t.Run("update error is propagated", func(t *testing.T) { + svc, repo, _ := newListService(t) + + updateErr := errors.New("update failed") + repo.On("LockUsersLists", inTx, "user-1").Return([]string{"list-a"}, nil) + repo.On("OrderLists", inTx, "user-1", []string{"list-a"}).Return(updateErr) + + err := svc.OrderLists(ctx, "user-1", []string{"list-a"}) + + assert.ErrorIs(t, err, updateErr) + }) + + t.Run("transaction begin error is returned", func(t *testing.T) { + svc, repo, tx := newListService(t) + + tx.err = errors.New("could not begin transaction") + + err := svc.OrderLists(ctx, "user-1", []string{"list-a"}) + + assert.ErrorIs(t, err, tx.err) + assertCallOrder(t, repo) + }) } func TestListService_CreateListItem(t *testing.T) { - t.Run("creates the item for a member", func(t *testing.T) { - svc, repo, _ := newListService(t) + svc, repo, _ := newListService(t) - created := &ListItem{ID: "item-1", ListID: "list-1", Title: "Milk"} - repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) - repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(created, nil) + created := &ListItem{ID: "item-1", ListID: "list-1", Title: "Milk", CreatedAt: time.Now()} + repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) + repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(created, nil) - item, err := svc.CreateListItem(context.Background(), "user-1", "list-1", "Milk") + item, err := svc.CreateListItem(context.Background(), "user-1", "list-1", "Milk") - require.NoError(t, err) - assert.Equal(t, created, item) - }) + require.NoError(t, err) + assert.Equal(t, created, item) + assertCallOrder(t, repo, "IsUserInList", "CreateListItem") +} - t.Run("refuses for a non-member", func(t *testing.T) { - svc, repo, _ := newListService(t) +func TestListService_DeleteListItem(t *testing.T) { + svc, repo, _ := newListService(t) - repo.On("IsUserInList", mock.Anything, "list-1", "intruder").Return(false, nil) + repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil) + repo.On("DeleteListItem", mock.Anything, "item-1").Return(nil) - item, err := svc.CreateListItem(context.Background(), "intruder", "list-1", "Milk") + err := svc.DeleteListItem(context.Background(), "user-1", "item-1") - assert.Nil(t, item) - assert.ErrorIs(t, err, ErrUserNotInList) - repo.AssertNotCalled(t, "CreateListItem", mock.Anything, mock.Anything, mock.Anything) - }) + require.NoError(t, err) + assertCallOrder(t, repo, "IsUserInListByItemID", "DeleteListItem") +} + +func TestListService_UpdateListItem(t *testing.T) { + svc, repo, _ := newListService(t) + + repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil) + repo.On("UpdateListItem", mock.Anything, "item-1", "Oat milk", true).Return(nil) + + err := svc.UpdateListItem(context.Background(), "user-1", "item-1", "Oat milk", true) + + require.NoError(t, err) + assertCallOrder(t, repo, "IsUserInListByItemID", "UpdateListItem") +} + +func TestListService_SetListItemTitle(t *testing.T) { + svc, repo, _ := newListService(t) + + repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil) + repo.On("SetListItemTitle", mock.Anything, "item-1", "Oat milk").Return(nil) + + err := svc.SetListItemTitle(context.Background(), "user-1", "item-1", "Oat milk") + + require.NoError(t, err) + assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemTitle") } func TestListService_SetListItemCompleted(t *testing.T) { - t.Run("checks membership through the item id", func(t *testing.T) { - svc, repo, _ := newListService(t) + tests := []struct { + name string + completed bool + }{ + {name: "marks the item as completed", completed: true}, + {name: "marks the item as open again", completed: false}, + } - repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil) - repo.On("SetListItemCompleted", mock.Anything, "item-1", true).Return(nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, repo, _ := newListService(t) - assert.NoError(t, svc.SetListItemCompleted(context.Background(), "user-1", "item-1", true)) - }) + repo.On("IsUserInListByItemID", mock.Anything, "item-1", "user-1").Return(true, nil) + repo.On("SetListItemCompleted", mock.Anything, "item-1", tt.completed).Return(nil) - t.Run("refuses for a non-member", func(t *testing.T) { - svc, repo, _ := newListService(t) + err := svc.SetListItemCompleted(context.Background(), "user-1", "item-1", tt.completed) - repo.On("IsUserInListByItemID", mock.Anything, "item-1", "intruder").Return(false, nil) + require.NoError(t, err) + assertCallOrder(t, repo, "IsUserInListByItemID", "SetListItemCompleted") + }) + } +} - err := svc.SetListItemCompleted(context.Background(), "intruder", "item-1", false) +func TestListService_GetListItems(t *testing.T) { + svc, repo, _ := newListService(t) - assert.ErrorIs(t, err, ErrUserNotInList) - repo.AssertNotCalled(t, "SetListItemCompleted", mock.Anything, mock.Anything, mock.Anything) - }) + want := []ListItem{ + {ID: "item-1", ListID: "list-1", Title: "Milk"}, + {ID: "item-2", ListID: "list-1", Title: "Bread", IsCompleted: true}, + } + repo.On("IsUserInList", mock.Anything, "list-1", "user-1").Return(true, nil) + repo.On("GetListItems", mock.Anything, "list-1").Return(want, nil) + + items, err := svc.GetListItems(context.Background(), "user-1", "list-1") + + require.NoError(t, err) + assert.Equal(t, want, items) + assertCallOrder(t, repo, "IsUserInList", "GetListItems") +} + +type guardedOp struct { + name string + // byItem is true when membership is resolved through a list item id + // (IsUserInListByItemID) instead of a list id (IsUserInList). + byItem bool + // expectRepo registers the delegated repository call, returning err. + expectRepo func(repo *mockListRepository, err error) + // call invokes the service method on behalf of userID. + call func(svc *ListService, userID string) error +} + +func (op guardedOp) guardMethod() string { + if op.byItem { + return "IsUserInListByItemID" + } + return "IsUserInList" +} + +func (op guardedOp) expectGuard(repo *mockListRepository, userID string, inList bool, err error) { + if op.byItem { + repo.On("IsUserInListByItemID", mock.Anything, "item-1", userID).Return(inList, err) + return + } + repo.On("IsUserInList", mock.Anything, "list-1", userID).Return(inList, err) +} + +func guardedOps() []guardedOp { + ctx := context.Background() + + return []guardedOp{ + { + name: "DeleteList", + expectRepo: func(repo *mockListRepository, err error) { + repo.On("DeleteList", mock.Anything, "list-1").Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.DeleteList(ctx, userID, "list-1") + }, + }, + { + name: "AddUserToList", + expectRepo: func(repo *mockListRepository, err error) { + repo.On("AddUserToList", mock.Anything, "list-1", "user-2").Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.AddUserToList(ctx, userID, "list-1", "user-2") + }, + }, + { + name: "RemoveUserFromList", + expectRepo: func(repo *mockListRepository, err error) { + repo.On("RemoveUserFromList", mock.Anything, "list-1", "user-2").Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.RemoveUserFromList(ctx, userID, "list-1", "user-2") + }, + }, + { + name: "CreateListItem", + expectRepo: func(repo *mockListRepository, err error) { + repo.On("CreateListItem", mock.Anything, "list-1", "Milk").Return(nil, err) + }, + call: func(svc *ListService, userID string) error { + item, err := svc.CreateListItem(ctx, userID, "list-1", "Milk") + if item != nil { + return errors.New("expected no item on failure") + } + return err + }, + }, + { + name: "GetListItems", + expectRepo: func(repo *mockListRepository, err error) { + repo.On("GetListItems", mock.Anything, "list-1").Return(nil, err) + }, + call: func(svc *ListService, userID string) error { + items, err := svc.GetListItems(ctx, userID, "list-1") + if items != nil { + return errors.New("expected no items on failure") + } + return err + }, + }, + { + name: "DeleteListItem", + byItem: true, + expectRepo: func(repo *mockListRepository, err error) { + repo.On("DeleteListItem", mock.Anything, "item-1").Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.DeleteListItem(ctx, userID, "item-1") + }, + }, + { + name: "UpdateListItem", + byItem: true, + expectRepo: func(repo *mockListRepository, err error) { + repo.On("UpdateListItem", mock.Anything, "item-1", "Oat milk", true).Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.UpdateListItem(ctx, userID, "item-1", "Oat milk", true) + }, + }, + { + name: "SetListItemTitle", + byItem: true, + expectRepo: func(repo *mockListRepository, err error) { + repo.On("SetListItemTitle", mock.Anything, "item-1", "Oat milk").Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.SetListItemTitle(ctx, userID, "item-1", "Oat milk") + }, + }, + { + name: "SetListItemCompleted", + byItem: true, + expectRepo: func(repo *mockListRepository, err error) { + repo.On("SetListItemCompleted", mock.Anything, "item-1", true).Return(err) + }, + call: func(svc *ListService, userID string) error { + return svc.SetListItemCompleted(ctx, userID, "item-1", true) + }, + }, + } +} + +func TestListService_AccessControl(t *testing.T) { + for _, op := range guardedOps() { + t.Run(op.name, func(t *testing.T) { + t.Run("non-member is refused before any write", func(t *testing.T) { + svc, repo, _ := newListService(t) + + op.expectGuard(repo, "intruder", false, nil) + + err := op.call(svc, "intruder") + + assert.ErrorIs(t, err, ErrUserNotInList) + assertCallOrder(t, repo, op.guardMethod()) + }) + + t.Run("membership check error is propagated, not masked", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repoErr := errors.New("connection reset") + op.expectGuard(repo, "user-1", false, repoErr) + + err := op.call(svc, "user-1") + + assert.ErrorIs(t, err, repoErr) + assert.NotErrorIs(t, err, ErrUserNotInList) + assertCallOrder(t, repo, op.guardMethod()) + }) + + t.Run("repository error is propagated", func(t *testing.T) { + svc, repo, _ := newListService(t) + + repoErr := errors.New("write failed") + op.expectGuard(repo, "user-1", true, nil) + op.expectRepo(repo, repoErr) + + err := op.call(svc, "user-1") + + assert.ErrorIs(t, err, repoErr) + assertCallOrder(t, repo, op.guardMethod(), op.name) + }) + }) + } } func TestListService_ValidationErrors(t *testing.T) { + ctx := context.Background() + tests := []struct { name string call func(svc *ListService) error wantErr error }{ + { + name: "CreateList without name", + call: func(svc *ListService) error { + _, err := svc.CreateList(ctx, "user-1", "") + return err + }, + wantErr: ErrListNameMissing, + }, { name: "DeleteList without list id", - call: func(svc *ListService) error { return svc.DeleteList(context.Background(), "user-1", "") }, + call: func(svc *ListService) error { return svc.DeleteList(ctx, "user-1", "") }, wantErr: ErrListIDMissing, }, { - name: "AddUserToList without list id", - call: func(svc *ListService) error { - return svc.AddUserToList(context.Background(), "user-1", "", "user-2") - }, + name: "AddUserToList without list id", + call: func(svc *ListService) error { return svc.AddUserToList(ctx, "user-1", "", "user-2") }, wantErr: ErrListIDMissing, }, { - name: "AddUserToList without user id", - call: func(svc *ListService) error { - return svc.AddUserToList(context.Background(), "user-1", "list-1", "") - }, + name: "AddUserToList without user id", + call: func(svc *ListService) error { return svc.AddUserToList(ctx, "user-1", "list-1", "") }, wantErr: ErrUserIDMissing, }, { - name: "RemoveUserFromList without user id", - call: func(svc *ListService) error { - return svc.RemoveUserFromList(context.Background(), "user-1", "list-1", "") - }, + name: "RemoveUserFromList without list id", + call: func(svc *ListService) error { return svc.RemoveUserFromList(ctx, "user-1", "", "user-2") }, + wantErr: ErrListIDMissing, + }, + { + name: "RemoveUserFromList without user id", + call: func(svc *ListService) error { return svc.RemoveUserFromList(ctx, "user-1", "list-1", "") }, wantErr: ErrUserIDMissing, }, + { + name: "OrderLists without user id", + call: func(svc *ListService) error { return svc.OrderLists(ctx, "", []string{"list-1"}) }, + wantErr: ErrUserIDMissing, + }, + { + name: "OrderLists with nil list ids", + call: func(svc *ListService) error { return svc.OrderLists(ctx, "user-1", nil) }, + wantErr: ErrListIDMissing, + }, + { + name: "OrderLists with empty list ids", + call: func(svc *ListService) error { return svc.OrderLists(ctx, "user-1", []string{}) }, + wantErr: ErrListIDMissing, + }, + { + name: "CreateListItem without list id", + call: func(svc *ListService) error { + _, err := svc.CreateListItem(ctx, "user-1", "", "Milk") + return err + }, + wantErr: ErrListIDMissing, + }, { name: "CreateListItem without title", call: func(svc *ListService) error { - _, err := svc.CreateListItem(context.Background(), "user-1", "list-1", "") + _, err := svc.CreateListItem(ctx, "user-1", "list-1", "") return err }, wantErr: ErrListItemTitleMissing, }, { - name: "DeleteListItem without item id", - call: func(svc *ListService) error { - return svc.DeleteListItem(context.Background(), "user-1", "") - }, + name: "DeleteListItem without item id", + call: func(svc *ListService) error { return svc.DeleteListItem(ctx, "user-1", "") }, wantErr: ErrListItemIDMissing, }, { - name: "UpdateListItem without title", - call: func(svc *ListService) error { - return svc.UpdateListItem(context.Background(), "user-1", "item-1", "", false) - }, + name: "UpdateListItem without item id", + call: func(svc *ListService) error { return svc.UpdateListItem(ctx, "user-1", "", "Milk", false) }, + wantErr: ErrListItemIDMissing, + }, + { + name: "UpdateListItem without title", + call: func(svc *ListService) error { return svc.UpdateListItem(ctx, "user-1", "item-1", "", false) }, wantErr: ErrListItemTitleMissing, }, { - name: "SetListItemTitle without item id", - call: func(svc *ListService) error { - return svc.SetListItemTitle(context.Background(), "user-1", "", "Milk") - }, + name: "SetListItemTitle without item id", + call: func(svc *ListService) error { return svc.SetListItemTitle(ctx, "user-1", "", "Milk") }, wantErr: ErrListItemIDMissing, }, { - name: "SetListItemCompleted without item id", - call: func(svc *ListService) error { - return svc.SetListItemCompleted(context.Background(), "user-1", "", true) - }, + name: "SetListItemTitle without title", + call: func(svc *ListService) error { return svc.SetListItemTitle(ctx, "user-1", "item-1", "") }, + wantErr: ErrListItemTitleMissing, + }, + { + name: "SetListItemCompleted without item id", + call: func(svc *ListService) error { return svc.SetListItemCompleted(ctx, "user-1", "", true) }, wantErr: ErrListItemIDMissing, }, { name: "GetListItems without list id", call: func(svc *ListService) error { - _, err := svc.GetListItems(context.Background(), "user-1", "") + _, err := svc.GetListItems(ctx, "user-1", "") return err }, wantErr: ErrListIDMissing, @@ -334,9 +763,41 @@ func TestListService_ValidationErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc, _, _ := newListService(t) + svc, repo, tx := newListService(t) - assert.ErrorIs(t, tt.call(svc), tt.wantErr) + err := tt.call(svc) + + assert.ErrorIs(t, err, tt.wantErr) + assert.Zero(t, tx.calls, "no transaction may be started") + assertCallOrder(t, repo) + }) + } +} + +func TestIsPermutation(t *testing.T) { + tests := []struct { + name string + a []string + b []string + want bool + }{ + {name: "both empty", a: nil, b: []string{}, want: true}, + {name: "same order", a: []string{"a", "b", "c"}, b: []string{"a", "b", "c"}, want: true}, + {name: "reordered", a: []string{"a", "b", "c"}, b: []string{"c", "a", "b"}, want: true}, + {name: "different case", a: []string{"ABC", "def"}, b: []string{"DEF", "abc"}, want: true}, + {name: "first is shorter", a: []string{"a", "b"}, b: []string{"a", "b", "c"}, want: false}, + {name: "second is shorter", a: []string{"a", "b", "c"}, b: []string{"a", "b"}, want: false}, + {name: "same length, different element", a: []string{"a", "b"}, b: []string{"a", "c"}, want: false}, + {name: "duplicate in first", a: []string{"a", "a"}, b: []string{"a", "b"}, want: false}, + {name: "duplicate in second", a: []string{"a", "b"}, b: []string{"a", "a"}, want: false}, + {name: "same duplicate on both sides", a: []string{"a", "a"}, b: []string{"a", "a"}, want: false}, + {name: "duplicate differing only in case", a: []string{"a", "b"}, b: []string{"a", "A"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isPermutation(tt.a, tt.b)) + assert.Equal(t, tt.want, isPermutation(tt.b, tt.a), "must be symmetric") }) } }