diff --git a/app/resources/item/db.go b/app/resources/item/db.go new file mode 100644 index 000000000..5610d4da1 --- /dev/null +++ b/app/resources/item/db.go @@ -0,0 +1,129 @@ +package item + +import ( + "context" + "time" + + "github.com/Southclaws/dt" + "github.com/Southclaws/fault" + "github.com/Southclaws/fault/fctx" + "github.com/Southclaws/fault/fmsg" + "github.com/Southclaws/fault/ftag" + "github.com/rs/xid" + + "github.com/Southclaws/storyden/app/resources/account" + "github.com/Southclaws/storyden/app/resources/datagraph" + "github.com/Southclaws/storyden/internal/ent" + "github.com/Southclaws/storyden/internal/ent/item" +) + +type database struct { + db *ent.Client +} + +func New(db *ent.Client) Repository { + return &database{db} +} + +func (d *database) Create( + ctx context.Context, + owner account.AccountID, + name string, + slug string, + desc string, + opts ...Option, +) (*datagraph.Item, error) { + create := d.db.Item.Create() + mutate := create.Mutation() + + mutate.SetOwnerID(xid.ID(owner)) + mutate.SetName(name) + mutate.SetSlug(slug) + mutate.SetDescription(desc) + + for _, fn := range opts { + fn(mutate) + } + + item, err := create.Save(ctx) + if err != nil { + if ent.IsConstraintError(err) { + return nil, fault.Wrap(err, + fctx.With(ctx), + ftag.With(ftag.AlreadyExists), + fmsg.WithDesc("already exists", "The item URL slug must be unique and the specified slug is already in use."), + ) + } + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return d.Get(ctx, datagraph.ItemSlug(item.Slug)) +} + +func (d *database) List(ctx context.Context, filters ...Filter) ([]*datagraph.Item, error) { + q := d.db.Item. + Query(). + WithOwner() + + for _, fn := range filters { + fn(q) + } + + cols, err := q.All(ctx) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + all, err := dt.MapErr(cols, datagraph.ItemFromModel) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return all, nil +} + +func (d *database) Get(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) { + item, err := d.db.Item. + Query(). + Where(item.Slug(string(slug))). + WithOwner(). + Only(ctx) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + r, err := datagraph.ItemFromModel(item) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return r, nil +} + +func (d *database) Update(ctx context.Context, id datagraph.ItemID, opts ...Option) (*datagraph.Item, error) { + create := d.db.Item.UpdateOneID(xid.ID(id)) + mutate := create.Mutation() + + for _, fn := range opts { + fn(mutate) + } + + c, err := create.Save(ctx) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return d.Get(ctx, datagraph.ItemSlug(c.Slug)) +} + +func (d *database) Archive(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) { + update := d.db.Item.Update().Where(item.Slug(string(slug))) + update.SetDeletedAt(time.Now()) + + _, err := update.Save(ctx) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return d.Get(ctx, slug) +} diff --git a/app/resources/item/item.go b/app/resources/item/item.go index 022817081..d84c41cc1 100644 --- a/app/resources/item/item.go +++ b/app/resources/item/item.go @@ -15,7 +15,7 @@ type ( Filter func(*ent.ItemQuery) ) -type ItemRepository interface { +type Repository interface { Create(ctx context.Context, owner account.AccountID, name string, @@ -27,9 +27,9 @@ type ItemRepository interface { List(ctx context.Context, filters ...Filter) ([]*datagraph.Item, error) Get(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) - Update(ctx context.Context, slug datagraph.ItemSlug, opts ...Option) (*datagraph.Item, error) + Update(ctx context.Context, slug datagraph.ItemID, opts ...Option) (*datagraph.Item, error) - Delete(ctx context.Context, slug datagraph.ItemSlug) error + Archive(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) } func WithID(id datagraph.ItemID) Option { @@ -62,6 +62,12 @@ func WithDescription(v string) Option { } } +func WithProperties(v any) Option { + return func(c *ent.ItemMutation) { + c.SetProperties(v) + } +} + func WithParentClusterAdd(id xid.ID) Option { return func(c *ent.ItemMutation) { c.AddClusterIDs(id) diff --git a/app/resources/resources.go b/app/resources/resources.go index 71689e46e..116149f6a 100644 --- a/app/resources/resources.go +++ b/app/resources/resources.go @@ -10,6 +10,7 @@ import ( "github.com/Southclaws/storyden/app/resources/cluster" "github.com/Southclaws/storyden/app/resources/cluster_traversal" "github.com/Southclaws/storyden/app/resources/collection" + "github.com/Southclaws/storyden/app/resources/item" "github.com/Southclaws/storyden/app/resources/notification" "github.com/Southclaws/storyden/app/resources/post_search" "github.com/Southclaws/storyden/app/resources/rbac" @@ -38,6 +39,7 @@ func Build() fx.Option { collection.New, cluster.New, cluster_traversal.New, + item.New, ), ) } diff --git a/app/services/item/item.go b/app/services/item/item.go index d0ba6a64a..10d77cb59 100644 --- a/app/services/item/item.go +++ b/app/services/item/item.go @@ -3,13 +3,26 @@ package item import ( "context" + "github.com/Southclaws/fault" + "github.com/Southclaws/fault/fctx" + "github.com/Southclaws/fault/ftag" "github.com/Southclaws/opt" + "github.com/Southclaws/storyden/app/resources/account" "github.com/Southclaws/storyden/app/resources/datagraph" + "github.com/Southclaws/storyden/app/resources/item" + "github.com/Southclaws/storyden/app/services/authentication" ) -type ItemManager interface { - Create(ctx context.Context) (*datagraph.Item, error) +var errNotAuthorised = fault.Wrap(fault.New("not authorised"), ftag.With(ftag.PermissionDenied)) + +type Manager interface { + Create(ctx context.Context, + owner account.AccountID, + name string, + slug string, + desc string, + opts ...item.Option) (*datagraph.Item, error) Get(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) Update(ctx context.Context, slug datagraph.ItemSlug, p Partial) (*datagraph.Item, error) Archive(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) @@ -20,4 +33,79 @@ type Partial struct { Slug opt.Optional[string] ImageURL opt.Optional[string] Description opt.Optional[string] + Properties opt.Optional[any] +} + +type service struct { + cr item.Repository +} + +func New(cr item.Repository) Manager { + return &service{cr: cr} +} + +func (s *service) Create(ctx context.Context, + owner account.AccountID, + name string, + slug string, + desc string, + opts ...item.Option, +) (*datagraph.Item, error) { + itm, err := s.cr.Create(ctx, owner, name, slug, desc, opts...) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return itm, nil +} + +func (s *service) Get(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) { + itm, err := s.cr.Get(ctx, slug) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return itm, nil +} + +func (s *service) Update(ctx context.Context, slug datagraph.ItemSlug, p Partial) (*datagraph.Item, error) { + accountID, err := authentication.GetAccountID(ctx) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + itm, err := s.cr.Get(ctx, slug) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + if !itm.Owner.Admin { + if itm.Owner.ID != accountID { + return nil, fault.Wrap(errNotAuthorised, fctx.With(ctx)) + } + } + + opts := []item.Option{} + + p.Name.Call(func(value string) { opts = append(opts, item.WithName(value)) }) + p.Slug.Call(func(value string) { opts = append(opts, item.WithSlug(value)) }) + p.ImageURL.Call(func(value string) { opts = append(opts, item.WithImageURL(value)) }) + p.Description.Call(func(value string) { opts = append(opts, item.WithDescription(value)) }) + p.Properties.Call(func(value any) { opts = append(opts, item.WithProperties(value)) }) + + itm, err = s.cr.Update(ctx, itm.ID, opts...) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return itm, nil +} + +func (s *service) Archive(ctx context.Context, slug datagraph.ItemSlug) (*datagraph.Item, error) { + itm, err := s.cr.Archive(ctx, slug) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return itm, nil } diff --git a/app/services/services.go b/app/services/services.go index d44642e10..b4c737974 100644 --- a/app/services/services.go +++ b/app/services/services.go @@ -13,6 +13,7 @@ import ( "github.com/Southclaws/storyden/app/services/clustertree" "github.com/Southclaws/storyden/app/services/collection" "github.com/Southclaws/storyden/app/services/icon" + "github.com/Southclaws/storyden/app/services/item" "github.com/Southclaws/storyden/app/services/onboarding" "github.com/Southclaws/storyden/app/services/react" "github.com/Southclaws/storyden/app/services/reply" @@ -42,5 +43,6 @@ func Build() fx.Option { thread_url.Build(), fx.Provide(avatar_gen.New), fx.Provide(cluster.New, clustertree.New), + fx.Provide(item.New), ) } diff --git a/app/transports/openapi/bindings/items.go b/app/transports/openapi/bindings/items.go index 68ca43bd1..8fad63ac0 100644 --- a/app/transports/openapi/bindings/items.go +++ b/app/transports/openapi/bindings/items.go @@ -3,32 +3,57 @@ package bindings import ( "context" - "github.com/Southclaws/storyden/app/resources/collection" + "github.com/Southclaws/fault" + "github.com/Southclaws/fault/fctx" + "github.com/Southclaws/storyden/app/resources/datagraph" - collection_svc "github.com/Southclaws/storyden/app/services/collection" + item_repo "github.com/Southclaws/storyden/app/resources/item" + "github.com/Southclaws/storyden/app/services/authentication" + item_svc "github.com/Southclaws/storyden/app/services/item" "github.com/Southclaws/storyden/internal/openapi" ) type Items struct { - collection_repo collection.Repository - collection_svc collection_svc.Service + im item_svc.Manager } func NewItems( - collection_repo collection.Repository, - collection_svc collection_svc.Service, + im item_svc.Manager, ) Items { return Items{ - collection_repo: collection_repo, - collection_svc: collection_svc, + im: im, } } -func (i *Items) ItemList(ctx context.Context, request openapi.ItemListRequestObject) (openapi.ItemListResponseObject, error) { - return nil, nil +func (i *Items) ItemCreate(ctx context.Context, request openapi.ItemCreateRequestObject) (openapi.ItemCreateResponseObject, error) { + session, err := authentication.GetAccountID(ctx) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + opts := []item_repo.Option{} + + if v := request.Body.Properties; v != nil { + opts = append(opts, item_repo.WithProperties(*v)) + } + + itm, err := i.im.Create(ctx, + session, + request.Body.Name, + request.Body.Slug, + request.Body.Description, + opts..., + ) + if err != nil { + return nil, fault.Wrap(err, fctx.With(ctx)) + } + + return openapi.ItemCreate200JSONResponse{ + ItemCreateOKJSONResponse: openapi.ItemCreateOKJSONResponse(serialiseItem(itm)), + }, nil } -func (i *Items) ItemCreate(ctx context.Context, request openapi.ItemCreateRequestObject) (openapi.ItemCreateResponseObject, error) { +func (i *Items) ItemList(ctx context.Context, request openapi.ItemListRequestObject) (openapi.ItemListResponseObject, error) { return nil, nil } diff --git a/tests/item/item_test.go b/tests/item/item_test.go index 0c202d0de..abdd827ec 100644 --- a/tests/item/item_test.go +++ b/tests/item/item_test.go @@ -1,17 +1,97 @@ package item_test import ( - "fmt" - "net/http" - "net/http/httptest" + "context" "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/fx" + + "github.com/Southclaws/storyden/app/resources/account" + "github.com/Southclaws/storyden/app/resources/seed" + "github.com/Southclaws/storyden/app/transports/openapi/bindings" + "github.com/Southclaws/storyden/internal/integration" + "github.com/Southclaws/storyden/internal/integration/e2e" + "github.com/Southclaws/storyden/internal/openapi" ) -func TestMain(m *testing.M) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hello, client") +func TestItemsHappyPath(t *testing.T) { + t.Parallel() + + integration.Test(t, nil, e2e.Setup(), fx.Invoke(func( + lc fx.Lifecycle, + ctx context.Context, + cl *openapi.ClientWithResponses, + cj *bindings.CookieJar, + ar account.Repository, + ) { + lc.Append(fx.StartHook(func() { + r := require.New(t) + a := assert.New(t) + + ctx, acc := e2e.WithAccount(ctx, ar, seed.Account_001_Odin) + + name1 := "test-item-1" + slug1 := name1 + uuid.NewString() + item1, err := cl.ItemCreateWithResponse(ctx, openapi.ItemInitialProps{ + Name: name1, + Slug: slug1, + Description: "testing items api", + }, e2e.WithSession(ctx, cj)) + r.NoError(err) + r.NotNil(item1) + r.Equal(200, item1.StatusCode()) + + a.Equal(name1, item1.JSON200.Name) + a.Equal(slug1, item1.JSON200.Slug) + a.Equal("testing items api", item1.JSON200.Description) + a.Equal(acc.ID.String(), string(item1.JSON200.Owner.Id)) + })) })) - defer ts.Close() +} + +func TestItemsErrors(t *testing.T) { + t.Parallel() - fmt.Println("DONE", ts.URL) + integration.Test(t, nil, e2e.Setup(), fx.Invoke(func( + lc fx.Lifecycle, + ctx context.Context, + cl *openapi.ClientWithResponses, + cj *bindings.CookieJar, + ar account.Repository, + ) { + lc.Append(fx.StartHook(func() { + r := require.New(t) + a := assert.New(t) + + ctx, _ := e2e.WithAccount(ctx, ar, seed.Account_001_Odin) + + create401, err := cl.ItemCreateWithResponse(ctx, openapi.ItemInitialProps{}) + r.NoError(err) + r.NotNil(create401) + a.Equal(401, create401.StatusCode()) + + create400, err := cl.ItemCreateWithResponse(ctx, openapi.ItemInitialProps{}, e2e.WithSession(ctx, cj)) + r.NoError(err) + r.NotNil(create400) + a.Equal(400, create400.StatusCode()) + + get404, err := cl.ItemGetWithResponse(ctx, "nonexistent") + r.NoError(err) + r.NotNil(get404) + a.Equal(404, get404.StatusCode()) + + update401, err := cl.ItemUpdateWithResponse(ctx, "nonexistent", openapi.ItemMutableProps{}) + r.NoError(err) + r.NotNil(update401) + a.Equal(401, update401.StatusCode()) + + update404, err := cl.ItemUpdateWithResponse(ctx, "nonexistent", openapi.ItemMutableProps{}, e2e.WithSession(ctx, cj)) + r.NoError(err) + r.NotNil(update404) + a.Equal(404, update404.StatusCode()) + })) + })) }