feat: items-editor (#5)

* format readme

* update logo

* format html

* add logo to docs

* repository for document and document tokens

* add attachments type and repository

* autogenerate types via scripts

* use autogenerated types

* attachment type updates

* add insured and quantity fields for items

* implement HasID interface for entities

* implement label updates for items

* implement service update method

* WIP item update client side actions

* check err on attachment

* finish types for basic items editor

* remove unused var

* house keeping
This commit is contained in:
Hayden 2022-09-12 14:47:27 -08:00 committed by GitHub
parent fbc364dcd2
commit 95ab14b866
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 15626 additions and 1791 deletions

View file

@ -8,9 +8,9 @@ import (
func UserFactory() types.UserCreate {
f := faker.NewFaker()
return types.UserCreate{
Name: f.RandomString(10),
Email: f.RandomEmail(),
Password: f.RandomString(10),
IsSuperuser: f.RandomBool(),
Name: f.Str(10),
Email: f.Email(),
Password: f.Str(10),
IsSuperuser: f.Bool(),
}
}

View file

@ -0,0 +1,62 @@
package repo
import "github.com/google/uuid"
// HasID is an interface to entities that have an ID uuid.UUID field and a GetID() method.
// This interface is fulfilled by all entities generated by entgo.io/ent via a custom template
type HasID interface {
GetID() uuid.UUID
}
// IDSet is a utility set-like type for working with sets of uuid.UUIDs within a repository
// instance. Most useful for comparing lists of UUIDs for processing relationship
// IDs and remove/adding relationships as required.
//
// # See how ItemRepo uses it to manage the Labels-To-Items relationship
//
// NOTE: may be worth moving this to a more generic package/set implementation
// or use a 3rd party set library, but this is good enough for now
type IDSet struct {
mp map[uuid.UUID]struct{}
}
func NewIDSet(l int) *IDSet {
return &IDSet{
mp: make(map[uuid.UUID]struct{}, l),
}
}
func EntitiesToIDSet[T HasID](entities []T) *IDSet {
s := NewIDSet(len(entities))
for _, e := range entities {
s.Add(e.GetID())
}
return s
}
func (t *IDSet) Slice() []uuid.UUID {
s := make([]uuid.UUID, 0, len(t.mp))
for k := range t.mp {
s = append(s, k)
}
return s
}
func (t *IDSet) Add(ids ...uuid.UUID) {
for _, id := range ids {
t.mp[id] = struct{}{}
}
}
func (t *IDSet) Has(id uuid.UUID) bool {
_, ok := t.mp[id]
return ok
}
func (t *IDSet) Len() int {
return len(t.mp)
}
func (t *IDSet) Remove(id uuid.UUID) {
delete(t.mp, id)
}

View file

@ -0,0 +1,47 @@
package repo
import (
"context"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/ent/document"
"github.com/hay-kot/content/backend/ent/group"
"github.com/hay-kot/content/backend/internal/types"
)
// DocumentRepository is a repository for Document entity
type DocumentRepository struct {
db *ent.Client
}
func (r *DocumentRepository) Create(ctx context.Context, gid uuid.UUID, doc types.DocumentCreate) (*ent.Document, error) {
return r.db.Document.Create().
SetGroupID(gid).
SetTitle(doc.Title).
SetPath(doc.Path).
Save(ctx)
}
func (r *DocumentRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]*ent.Document, error) {
return r.db.Document.Query().
Where(document.HasGroupWith(group.ID(gid))).
All(ctx)
}
func (r *DocumentRepository) Get(ctx context.Context, id uuid.UUID) (*ent.Document, error) {
return r.db.Document.Query().
Where(document.ID(id)).
Only(ctx)
}
func (r *DocumentRepository) Update(ctx context.Context, id uuid.UUID, doc types.DocumentUpdate) (*ent.Document, error) {
return r.db.Document.UpdateOneID(id).
SetTitle(doc.Title).
SetPath(doc.Path).
Save(ctx)
}
func (r *DocumentRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.Document.DeleteOneID(id).Exec(ctx)
}

View file

@ -0,0 +1,202 @@
package repo
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/internal/types"
"github.com/stretchr/testify/assert"
)
func TestDocumentRepository_Create(t *testing.T) {
type args struct {
ctx context.Context
gid uuid.UUID
doc types.DocumentCreate
}
tests := []struct {
name string
args args
want *ent.Document
wantErr bool
}{
{
name: "create document",
args: args{
ctx: context.Background(),
gid: tGroup.ID,
doc: types.DocumentCreate{
Title: "test document",
Path: "/test/document",
},
},
want: &ent.Document{
Title: "test document",
Path: "/test/document",
},
wantErr: false,
},
{
name: "create document with empty title",
args: args{
ctx: context.Background(),
gid: tGroup.ID,
doc: types.DocumentCreate{
Title: "",
Path: "/test/document",
},
},
want: nil,
wantErr: true,
},
{
name: "create document with empty path",
args: args{
ctx: context.Background(),
gid: tGroup.ID,
doc: types.DocumentCreate{
Title: "test document",
Path: "",
},
},
want: nil,
wantErr: true,
},
}
ids := make([]uuid.UUID, 0, len(tests))
t.Cleanup(func() {
for _, id := range ids {
err := tRepos.Docs.Delete(context.Background(), id)
assert.NoError(t, err)
}
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tRepos.Docs.Create(tt.args.ctx, tt.args.gid, tt.args.doc)
if (err != nil) != tt.wantErr {
t.Errorf("DocumentRepository.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, got)
return
}
assert.Equal(t, tt.want.Title, got.Title)
assert.Equal(t, tt.want.Path, got.Path)
ids = append(ids, got.ID)
})
}
}
func useDocs(t *testing.T, num int) []*ent.Document {
t.Helper()
results := make([]*ent.Document, 0, num)
ids := make([]uuid.UUID, 0, num)
for i := 0; i < num; i++ {
doc, err := tRepos.Docs.Create(context.Background(), tGroup.ID, types.DocumentCreate{
Title: fk.Str(10),
Path: fk.Path(),
})
assert.NoError(t, err)
assert.NotNil(t, doc)
results = append(results, doc)
ids = append(ids, doc.ID)
}
t.Cleanup(func() {
for _, id := range ids {
err := tRepos.Docs.Delete(context.Background(), id)
if err != nil {
assert.True(t, ent.IsNotFound(err))
}
}
})
return results
}
func TestDocumentRepository_GetAll(t *testing.T) {
entities := useDocs(t, 10)
for _, entity := range entities {
assert.NotNil(t, entity)
}
all, err := tRepos.Docs.GetAll(context.Background(), tGroup.ID)
assert.NoError(t, err)
assert.Len(t, all, 10)
for _, entity := range all {
assert.NotNil(t, entity)
for _, e := range entities {
if e.ID == entity.ID {
assert.Equal(t, e.Title, entity.Title)
assert.Equal(t, e.Path, entity.Path)
}
}
}
}
func TestDocumentRepository_Get(t *testing.T) {
entities := useDocs(t, 10)
for _, entity := range entities {
got, err := tRepos.Docs.Get(context.Background(), entity.ID)
assert.NoError(t, err)
assert.Equal(t, entity.ID, got.ID)
assert.Equal(t, entity.Title, got.Title)
assert.Equal(t, entity.Path, got.Path)
}
}
func TestDocumentRepository_Update(t *testing.T) {
entities := useDocs(t, 10)
for _, entity := range entities {
got, err := tRepos.Docs.Get(context.Background(), entity.ID)
assert.NoError(t, err)
assert.Equal(t, entity.ID, got.ID)
assert.Equal(t, entity.Title, got.Title)
assert.Equal(t, entity.Path, got.Path)
}
for _, entity := range entities {
updateData := types.DocumentUpdate{
Title: fk.Str(10),
Path: fk.Path(),
}
updated, err := tRepos.Docs.Update(context.Background(), entity.ID, updateData)
assert.NoError(t, err)
assert.Equal(t, entity.ID, updated.ID)
assert.Equal(t, updateData.Title, updated.Title)
assert.Equal(t, updateData.Path, updated.Path)
}
}
func TestDocumentRepository_Delete(t *testing.T) {
entities := useDocs(t, 10)
for _, entity := range entities {
err := tRepos.Docs.Delete(context.Background(), entity.ID)
assert.NoError(t, err)
_, err = tRepos.Docs.Get(context.Background(), entity.ID)
assert.Error(t, err)
}
}

View file

@ -0,0 +1,41 @@
package repo
import (
"context"
"time"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/ent/documenttoken"
"github.com/hay-kot/content/backend/internal/types"
)
// DocumentTokensRepository is a repository for Document entity
type DocumentTokensRepository struct {
db *ent.Client
}
func (r *DocumentTokensRepository) Create(ctx context.Context, data types.DocumentTokenCreate) (*ent.DocumentToken, error) {
result, err := r.db.DocumentToken.Create().
SetDocumentID(data.DocumentID).
SetToken(data.TokenHash).
SetExpiresAt(data.ExpiresAt).
Save(ctx)
if err != nil {
return nil, err
}
return r.db.DocumentToken.Query().
Where(documenttoken.ID(result.ID)).
WithDocument().
Only(ctx)
}
func (r *DocumentTokensRepository) PurgeExpiredTokens(ctx context.Context) (int, error) {
return r.db.DocumentToken.Delete().Where(documenttoken.ExpiresAtLT(time.Now())).Exec(ctx)
}
func (r *DocumentTokensRepository) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.DocumentToken.DeleteOneID(id).Exec(ctx)
}

View file

@ -0,0 +1,149 @@
package repo
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/ent/documenttoken"
"github.com/hay-kot/content/backend/internal/types"
"github.com/stretchr/testify/assert"
)
func TestDocumentTokensRepository_Create(t *testing.T) {
entities := useDocs(t, 1)
doc := entities[0]
expires := fk.Time()
type args struct {
ctx context.Context
data types.DocumentTokenCreate
}
tests := []struct {
name string
args args
want *ent.DocumentToken
wantErr bool
}{
{
name: "create document token",
args: args{
ctx: context.Background(),
data: types.DocumentTokenCreate{
DocumentID: doc.ID,
TokenHash: []byte("token"),
ExpiresAt: expires,
},
},
want: &ent.DocumentToken{
Edges: ent.DocumentTokenEdges{
Document: doc,
},
Token: []byte("token"),
ExpiresAt: expires,
},
wantErr: false,
},
{
name: "create document token with empty token",
args: args{
ctx: context.Background(),
data: types.DocumentTokenCreate{
DocumentID: doc.ID,
TokenHash: []byte(""),
ExpiresAt: expires,
},
},
want: nil,
wantErr: true,
},
{
name: "create document token with empty document id",
args: args{
ctx: context.Background(),
data: types.DocumentTokenCreate{
DocumentID: uuid.Nil,
TokenHash: []byte("token"),
ExpiresAt: expires,
},
},
want: nil,
wantErr: true,
},
}
ids := make([]uuid.UUID, 0, len(tests))
t.Cleanup(func() {
for _, id := range ids {
_ = tRepos.DocTokens.Delete(context.Background(), id)
}
})
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tRepos.DocTokens.Create(tt.args.ctx, tt.args.data)
if (err != nil) != tt.wantErr {
t.Errorf("DocumentTokensRepository.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
assert.Equal(t, tt.want.Token, got.Token)
assert.WithinDuration(t, tt.want.ExpiresAt, got.ExpiresAt, time.Duration(1)*time.Second)
assert.Equal(t, tt.want.Edges.Document.ID, got.Edges.Document.ID)
})
}
}
func useDocTokens(t *testing.T, num int) []*ent.DocumentToken {
entity := useDocs(t, 1)[0]
results := make([]*ent.DocumentToken, 0, num)
ids := make([]uuid.UUID, 0, num)
t.Cleanup(func() {
for _, id := range ids {
_ = tRepos.DocTokens.Delete(context.Background(), id)
}
})
for i := 0; i < num; i++ {
e, err := tRepos.DocTokens.Create(context.Background(), types.DocumentTokenCreate{
DocumentID: entity.ID,
TokenHash: []byte(fk.Str(10)),
ExpiresAt: fk.Time(),
})
assert.NoError(t, err)
results = append(results, e)
ids = append(ids, e.ID)
}
return results
}
func TestDocumentTokensRepository_PurgeExpiredTokens(t *testing.T) {
entities := useDocTokens(t, 2)
// set expired token
tRepos.DocTokens.db.DocumentToken.Update().
Where(documenttoken.ID(entities[0].ID)).
SetExpiresAt(time.Now().Add(-time.Hour)).
ExecX(context.Background())
count, err := tRepos.DocTokens.PurgeExpiredTokens(context.Background())
assert.NoError(t, err)
assert.Equal(t, 1, count)
all, err := tRepos.DocTokens.db.DocumentToken.Query().All(context.Background())
assert.NoError(t, err)
assert.Len(t, all, 1)
assert.Equal(t, entities[1].ID, all[0].ID)
}

View file

@ -0,0 +1,44 @@
package repo
import (
"context"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/ent/attachment"
)
// AttachmentRepo is a repository for Attachments table that links Items to Documents
// While also specifying the type of the attachment. This _ONLY_ provides basic Create Update
// And Delete operations. For accessing the actual documents, use the Items repository since it
// provides the attachments with the documents.
type AttachmentRepo struct {
db *ent.Client
}
func (r *AttachmentRepo) Create(ctx context.Context, itemId, docId uuid.UUID, typ attachment.Type) (*ent.Attachment, error) {
return r.db.Attachment.Create().
SetType(typ).
SetDocumentID(docId).
SetItemID(itemId).
Save(ctx)
}
func (r *AttachmentRepo) Get(ctx context.Context, id uuid.UUID) (*ent.Attachment, error) {
return r.db.Attachment.
Query().
Where(attachment.ID(id)).
WithItem().
WithDocument().
Only(ctx)
}
func (r *AttachmentRepo) Update(ctx context.Context, itemId uuid.UUID, typ attachment.Type) (*ent.Attachment, error) {
return r.db.Attachment.UpdateOneID(itemId).
SetType(typ).
Save(ctx)
}
func (r *AttachmentRepo) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.Attachment.DeleteOneID(id).Exec(ctx)
}

View file

@ -0,0 +1,133 @@
package repo
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/ent/attachment"
"github.com/stretchr/testify/assert"
)
func TestAttachmentRepo_Create(t *testing.T) {
doc := useDocs(t, 1)[0]
item := useItems(t, 1)[0]
ids := []uuid.UUID{doc.ID, item.ID}
t.Cleanup(func() {
for _, id := range ids {
_ = tRepos.Attachments.Delete(context.Background(), id)
}
})
type args struct {
ctx context.Context
itemId uuid.UUID
docId uuid.UUID
typ attachment.Type
}
tests := []struct {
name string
args args
want *ent.Attachment
wantErr bool
}{
{
name: "create attachment",
args: args{
ctx: context.Background(),
itemId: item.ID,
docId: doc.ID,
typ: attachment.TypePhoto,
},
want: &ent.Attachment{
Type: attachment.TypePhoto,
},
},
{
name: "create attachment with invalid item id",
args: args{
ctx: context.Background(),
itemId: uuid.New(),
docId: doc.ID,
typ: "blarg",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tRepos.Attachments.Create(tt.args.ctx, tt.args.itemId, tt.args.docId, tt.args.typ)
if (err != nil) != tt.wantErr {
t.Errorf("AttachmentRepo.Create() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
assert.Equal(t, tt.want.Type, got.Type)
withItems, err := tRepos.Attachments.Get(tt.args.ctx, got.ID)
assert.NoError(t, err)
assert.Equal(t, tt.args.itemId, withItems.Edges.Item.ID)
assert.Equal(t, tt.args.docId, withItems.Edges.Document.ID)
ids = append(ids, got.ID)
})
}
}
func useAttachments(t *testing.T, n int) []*ent.Attachment {
t.Helper()
doc := useDocs(t, 1)[0]
item := useItems(t, 1)[0]
ids := make([]uuid.UUID, 0, n)
t.Cleanup(func() {
for _, id := range ids {
_ = tRepos.Attachments.Delete(context.Background(), id)
}
})
attachments := make([]*ent.Attachment, n)
for i := 0; i < n; i++ {
attachment, err := tRepos.Attachments.Create(context.Background(), item.ID, doc.ID, attachment.TypePhoto)
assert.NoError(t, err)
attachments[i] = attachment
ids = append(ids, attachment.ID)
}
return attachments
}
func TestAttachmentRepo_Update(t *testing.T) {
entity := useAttachments(t, 1)[0]
for _, typ := range []attachment.Type{"photo", "manual", "warranty", "attachment"} {
t.Run(string(typ), func(t *testing.T) {
_, err := tRepos.Attachments.Update(context.Background(), entity.ID, typ)
assert.NoError(t, err)
updated, err := tRepos.Attachments.Get(context.Background(), entity.ID)
assert.NoError(t, err)
assert.Equal(t, typ, updated.Type)
})
}
}
func TestAttachmentRepo_Delete(t *testing.T) {
entity := useAttachments(t, 1)[0]
err := tRepos.Attachments.Delete(context.Background(), entity.ID)
assert.NoError(t, err)
_, err = tRepos.Attachments.Get(context.Background(), entity.ID)
assert.Error(t, err)
}

View file

@ -21,9 +21,13 @@ func (e *ItemsRepository) GetOne(ctx context.Context, id uuid.UUID) (*ent.Item,
WithLabel().
WithLocation().
WithGroup().
WithAttachments(func(aq *ent.AttachmentQuery) {
aq.WithDocument()
}).
Only(ctx)
}
// GetAll returns all the items in the database with the Labels and Locations eager loaded.
func (e *ItemsRepository) GetAll(ctx context.Context, gid uuid.UUID) ([]*ent.Item, error) {
return e.db.Item.Query().
Where(item.HasGroupWith(group.ID(gid))).
@ -72,11 +76,31 @@ func (e *ItemsRepository) Update(ctx context.Context, data types.ItemUpdate) (*e
SetSoldNotes(data.SoldNotes).
SetNotes(data.Notes).
SetLifetimeWarranty(data.LifetimeWarranty).
SetInsured(data.Insured).
SetWarrantyExpires(data.WarrantyExpires).
SetWarrantyDetails(data.WarrantyDetails)
SetWarrantyDetails(data.WarrantyDetails).
SetQuantity(data.Quantity)
err := q.Exec(ctx)
currentLabels, err := e.db.Item.Query().Where(item.ID(data.ID)).QueryLabel().All(ctx)
if err != nil {
return nil, err
}
set := EntitiesToIDSet(currentLabels)
for _, l := range data.LabelIDs {
if set.Has(l) {
set.Remove(l)
continue
}
q.AddLabelIDs(l)
}
if set.Len() > 0 {
q.RemoveLabelIDs(set.Slice()...)
}
err = q.Exec(ctx)
if err != nil {
return nil, err
}

View file

@ -5,6 +5,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent"
"github.com/hay-kot/content/backend/internal/types"
"github.com/stretchr/testify/assert"
@ -12,12 +13,12 @@ import (
func itemFactory() types.ItemCreate {
return types.ItemCreate{
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
}
}
func useItems(t *testing.T, len int) ([]*ent.Item, func()) {
func useItems(t *testing.T, len int) []*ent.Item {
t.Helper()
location, err := tRepos.Locations.Create(context.Background(), tGroup.ID, locationFactory())
@ -33,17 +34,17 @@ func useItems(t *testing.T, len int) ([]*ent.Item, func()) {
items[i] = item
}
return items, func() {
t.Cleanup(func() {
for _, item := range items {
err := tRepos.Items.Delete(context.Background(), item.ID)
assert.NoError(t, err)
_ = tRepos.Items.Delete(context.Background(), item.ID)
}
}
})
return items
}
func TestItemsRepository_GetOne(t *testing.T) {
entity, cleanup := useItems(t, 3)
defer cleanup()
entity := useItems(t, 3)
for _, item := range entity {
result, err := tRepos.Items.GetOne(context.Background(), item.ID)
@ -54,8 +55,7 @@ func TestItemsRepository_GetOne(t *testing.T) {
func TestItemsRepository_GetAll(t *testing.T) {
length := 10
expected, cleanup := useItems(t, length)
defer cleanup()
expected := useItems(t, length)
results, err := tRepos.Items.GetAll(context.Background(), tGroup.ID)
assert.NoError(t, err)
@ -119,7 +119,7 @@ func TestItemsRepository_Create_Location(t *testing.T) {
}
func TestItemsRepository_Delete(t *testing.T) {
entities, _ := useItems(t, 3)
entities := useItems(t, 3)
for _, item := range entities {
err := tRepos.Items.Delete(context.Background(), item.ID)
@ -131,9 +131,68 @@ func TestItemsRepository_Delete(t *testing.T) {
assert.Empty(t, results)
}
func TestItemsRepository_Update_Labels(t *testing.T) {
entity := useItems(t, 1)[0]
labels := useLabels(t, 3)
labelsIDs := []uuid.UUID{labels[0].ID, labels[1].ID, labels[2].ID}
type args struct {
labelIds []uuid.UUID
}
tests := []struct {
name string
args args
want []uuid.UUID
}{
{
name: "add all labels",
args: args{
labelIds: labelsIDs,
},
want: labelsIDs,
},
{
name: "update with one label",
args: args{
labelIds: labelsIDs[:1],
},
want: labelsIDs[:1],
},
{
name: "add one new label to existing single label",
args: args{
labelIds: labelsIDs[1:],
},
want: labelsIDs[1:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Apply all labels to entity
updateData := types.ItemUpdate{
ID: entity.ID,
Name: entity.Name,
LocationID: entity.Edges.Location.ID,
LabelIDs: tt.args.labelIds,
}
updated, err := tRepos.Items.Update(context.Background(), updateData)
assert.NoError(t, err)
assert.Len(t, tt.want, len(updated.Edges.Label))
for _, label := range updated.Edges.Label {
assert.Contains(t, tt.want, label.ID)
}
})
}
}
func TestItemsRepository_Update(t *testing.T) {
entities, cleanup := useItems(t, 3)
defer cleanup()
entities := useItems(t, 3)
entity := entities[0]
@ -141,20 +200,20 @@ func TestItemsRepository_Update(t *testing.T) {
ID: entity.ID,
Name: entity.Name,
LocationID: entity.Edges.Location.ID,
SerialNumber: fk.RandomString(10),
SerialNumber: fk.Str(10),
LabelIDs: nil,
ModelNumber: fk.RandomString(10),
Manufacturer: fk.RandomString(10),
ModelNumber: fk.Str(10),
Manufacturer: fk.Str(10),
PurchaseTime: time.Now(),
PurchaseFrom: fk.RandomString(10),
PurchaseFrom: fk.Str(10),
PurchasePrice: 300.99,
SoldTime: time.Now(),
SoldTo: fk.RandomString(10),
SoldTo: fk.Str(10),
SoldPrice: 300.99,
SoldNotes: fk.RandomString(10),
Notes: fk.RandomString(10),
SoldNotes: fk.Str(10),
Notes: fk.Str(10),
WarrantyExpires: time.Now(),
WarrantyDetails: fk.RandomString(10),
WarrantyDetails: fk.Str(10),
LifetimeWarranty: true,
}

View file

@ -11,12 +11,12 @@ import (
func labelFactory() types.LabelCreate {
return types.LabelCreate{
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
}
}
func useLabels(t *testing.T, len int) ([]*ent.Label, func()) {
func useLabels(t *testing.T, len int) []*ent.Label {
t.Helper()
labels := make([]*ent.Label, len)
@ -28,17 +28,17 @@ func useLabels(t *testing.T, len int) ([]*ent.Label, func()) {
labels[i] = item
}
return labels, func() {
t.Cleanup(func() {
for _, item := range labels {
err := tRepos.Labels.Delete(context.Background(), item.ID)
assert.NoError(t, err)
_ = tRepos.Labels.Delete(context.Background(), item.ID)
}
}
})
return labels
}
func TestLabelRepository_Get(t *testing.T) {
labels, cleanup := useLabels(t, 1)
defer cleanup()
labels := useLabels(t, 1)
label := labels[0]
// Get by ID
@ -48,8 +48,7 @@ func TestLabelRepository_Get(t *testing.T) {
}
func TestLabelRepositoryGetAll(t *testing.T) {
_, cleanup := useLabels(t, 10)
defer cleanup()
useLabels(t, 10)
all, err := tRepos.Labels.GetAll(context.Background(), tGroup.ID)
assert.NoError(t, err)
@ -75,8 +74,8 @@ func TestLabelRepository_Update(t *testing.T) {
updateData := types.LabelUpdate{
ID: loc.ID,
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
}
update, err := tRepos.Labels.Update(context.Background(), updateData)

View file

@ -10,8 +10,8 @@ import (
func locationFactory() types.LocationCreate {
return types.LocationCreate{
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
}
}
@ -31,14 +31,14 @@ func TestLocationRepository_Get(t *testing.T) {
func TestLocationRepositoryGetAllWithCount(t *testing.T) {
ctx := context.Background()
result, err := tRepos.Locations.Create(ctx, tGroup.ID, types.LocationCreate{
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
})
assert.NoError(t, err)
_, err = tRepos.Items.Create(ctx, tGroup.ID, types.ItemCreate{
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
LocationID: result.ID,
})
@ -74,8 +74,8 @@ func TestLocationRepository_Update(t *testing.T) {
updateData := types.LocationUpdate{
ID: loc.ID,
Name: fk.RandomString(10),
Description: fk.RandomString(100),
Name: fk.Str(10),
Description: fk.Str(100),
}
update, err := tRepos.Locations.Update(context.Background(), updateData)

View file

@ -13,10 +13,10 @@ import (
func userFactory() types.UserCreate {
return types.UserCreate{
Name: fk.RandomString(10),
Email: fk.RandomEmail(),
Password: fk.RandomString(10),
IsSuperuser: fk.RandomBool(),
Name: fk.Str(10),
Email: fk.Email(),
Password: fk.Str(10),
IsSuperuser: fk.Bool(),
GroupID: tGroup.ID,
}
}
@ -109,8 +109,8 @@ func TestUserRepo_Update(t *testing.T) {
assert.NoError(t, err)
updateData := types.UserUpdate{
Name: fk.RandomString(10),
Email: fk.RandomEmail(),
Name: fk.Str(10),
Email: fk.Email(),
}
// Update

View file

@ -4,21 +4,27 @@ import "github.com/hay-kot/content/backend/ent"
// AllRepos is a container for all the repository interfaces
type AllRepos struct {
Users *UserRepository
AuthTokens *TokenRepository
Groups *GroupRepository
Locations *LocationRepository
Labels *LabelRepository
Items *ItemsRepository
Users *UserRepository
AuthTokens *TokenRepository
Groups *GroupRepository
Locations *LocationRepository
Labels *LabelRepository
Items *ItemsRepository
Docs *DocumentRepository
DocTokens *DocumentTokensRepository
Attachments *AttachmentRepo
}
func EntAllRepos(db *ent.Client) *AllRepos {
return &AllRepos{
Users: &UserRepository{db},
AuthTokens: &TokenRepository{db},
Groups: &GroupRepository{db},
Locations: &LocationRepository{db},
Labels: &LabelRepository{db},
Items: &ItemsRepository{db},
Users: &UserRepository{db},
AuthTokens: &TokenRepository{db},
Groups: &GroupRepository{db},
Locations: &LocationRepository{db},
Labels: &LabelRepository{db},
Items: &ItemsRepository{db},
Docs: &DocumentRepository{db},
DocTokens: &DocumentTokensRepository{db},
Attachments: &AttachmentRepo{db},
}
}

View file

@ -16,6 +16,9 @@ func NewServices(repos *repo.AllRepos) *AllServices {
Admin: &AdminService{repos},
Location: &LocationService{repos},
Labels: &LabelService{repos},
Items: &ItemService{repos},
Items: &ItemService{
repo: repos,
filepath: "/tmp/content",
},
}
}

View file

@ -22,6 +22,7 @@ var (
tRepos *repo.AllRepos
tUser *ent.User
tGroup *ent.Group
tSvc *AllServices
)
func bootstrap() {
@ -36,10 +37,10 @@ func bootstrap() {
}
tUser, err = tRepos.Users.Create(ctx, types.UserCreate{
Name: fk.RandomString(10),
Email: fk.RandomEmail(),
Password: fk.RandomString(10),
IsSuperuser: fk.RandomBool(),
Name: fk.Str(10),
Email: fk.Email(),
Password: fk.Str(10),
IsSuperuser: fk.Bool(),
GroupID: tGroup.ID,
})
if err != nil {
@ -62,6 +63,7 @@ func TestMain(m *testing.M) {
tClient = client
tRepos = repo.EntAllRepos(tClient)
tSvc = NewServices(tRepos)
defer client.Close()
bootstrap()

View file

@ -5,6 +5,19 @@ import (
"github.com/hay-kot/content/backend/internal/types"
)
func ToItemAttachment(attachment *ent.Attachment) *types.ItemAttachment {
return &types.ItemAttachment{
ID: attachment.ID,
CreatedAt: attachment.CreatedAt,
UpdatedAt: attachment.UpdatedAt,
Document: types.DocumentOut{
ID: attachment.Edges.Document.ID,
Title: attachment.Edges.Document.Title,
Path: attachment.Edges.Document.Path,
},
}
}
func ToItemSummary(item *ent.Item) *types.ItemSummary {
var location *types.LocationSummary
if item.Edges.Location != nil {
@ -23,6 +36,14 @@ func ToItemSummary(item *ent.Item) *types.ItemSummary {
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
Quantity: item.Quantity,
Insured: item.Insured,
// Warranty
LifetimeWarranty: item.LifetimeWarranty,
WarrantyExpires: item.WarrantyExpires,
WarrantyDetails: item.WarrantyDetails,
// Edges
Location: location,
Labels: labels,
@ -53,8 +74,14 @@ func ToItemSummaryErr(item *ent.Item, err error) (*types.ItemSummary, error) {
}
func ToItemOut(item *ent.Item) *types.ItemOut {
var attachments []*types.ItemAttachment
if item.Edges.Attachments != nil {
attachments = MapEach(item.Edges.Attachments, ToItemAttachment)
}
return &types.ItemOut{
ItemSummary: *ToItemSummary(item),
Attachments: attachments,
}
}

View file

@ -3,8 +3,12 @@ package services
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/ent/attachment"
"github.com/hay-kot/content/backend/internal/repo"
"github.com/hay-kot/content/backend/internal/services/mappers"
"github.com/hay-kot/content/backend/internal/types"
@ -13,6 +17,9 @@ import (
type ItemService struct {
repo *repo.AllRepos
// filepath is the root of the storage location that will be used to store all files from.
filepath string
}
func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID) (*types.ItemOut, error) {
@ -41,6 +48,7 @@ func (svc *ItemService) GetAll(ctx context.Context, gid uuid.UUID) ([]*types.Ite
return itemsOut, nil
}
func (svc *ItemService) Create(ctx context.Context, gid uuid.UUID, data types.ItemCreate) (*types.ItemOut, error) {
item, err := svc.repo.Items.Create(ctx, gid, data)
if err != nil {
@ -49,6 +57,7 @@ func (svc *ItemService) Create(ctx context.Context, gid uuid.UUID, data types.It
return mappers.ToItemOut(item), nil
}
func (svc *ItemService) Delete(ctx context.Context, gid uuid.UUID, id uuid.UUID) error {
item, err := svc.repo.Items.GetOne(ctx, id)
if err != nil {
@ -66,8 +75,76 @@ func (svc *ItemService) Delete(ctx context.Context, gid uuid.UUID, id uuid.UUID)
return nil
}
func (svc *ItemService) Update(ctx context.Context, gid uuid.UUID, data types.ItemUpdate) (*types.ItemOut, error) {
panic("implement me")
item, err := svc.repo.Items.GetOne(ctx, data.ID)
if err != nil {
return nil, err
}
if item.Edges.Group.ID != gid {
return nil, ErrNotOwner
}
item, err = svc.repo.Items.Update(ctx, data)
if err != nil {
return nil, err
}
return mappers.ToItemOut(item), nil
}
func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) string {
return filepath.Join(svc.filepath, gid.String(), itemId.String(), filename)
}
// AddAttachment adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment
// Table and Items table. The file provided via the reader is stored on the file system based on the provided
// relative path during construction of the service.
func (svc *ItemService) AddAttachment(ctx context.Context, gid, itemId uuid.UUID, filename string, file io.Reader) (*types.ItemOut, error) {
// Get the Item
item, err := svc.repo.Items.GetOne(ctx, itemId)
if err != nil {
return nil, err
}
if item.Edges.Group.ID != gid {
return nil, ErrNotOwner
}
// Create the document
doc, err := svc.repo.Docs.Create(ctx, gid, types.DocumentCreate{
Title: filename,
Path: svc.attachmentPath(gid, itemId, filename),
})
if err != nil {
return nil, err
}
// Create the attachment
_, err = svc.repo.Attachments.Create(ctx, itemId, doc.ID, attachment.TypeAttachment)
if err != nil {
return nil, err
}
// Read the contents and write them to a file on the file system
err = os.MkdirAll(filepath.Dir(doc.Path), os.ModePerm)
if err != nil {
return nil, err
}
f, err := os.Create(doc.Path)
if err != nil {
log.Err(err).Msg("failed to create file")
return nil, err
}
_, err = io.Copy(f, file)
if err != nil {
return nil, err
}
return svc.GetOne(ctx, gid, itemId)
}
func (svc *ItemService) CsvImport(ctx context.Context, gid uuid.UUID, data [][]string) error {

View file

@ -2,13 +2,16 @@ package services
import (
"context"
"os"
"path"
"strings"
"testing"
"github.com/google/uuid"
"github.com/hay-kot/content/backend/internal/types"
"github.com/stretchr/testify/assert"
)
func TestItemService_CsvImport(t *testing.T) {
data := loadcsv()
svc := &ItemService{
@ -55,6 +58,14 @@ func TestItemService_CsvImport(t *testing.T) {
labelNames = append(labelNames, label.Name)
}
ids := []uuid.UUID{}
t.Cleanup((func() {
for _, id := range ids {
err := svc.repo.Items.Delete(context.Background(), id)
assert.NoError(t, err)
}
}))
for _, item := range items {
assert.Contains(t, locNames, item.Location.Name)
for _, label := range item.Labels {
@ -79,6 +90,55 @@ func TestItemService_CsvImport(t *testing.T) {
assert.Equal(t, csvRow.parsedSoldPrice(), item.SoldPrice)
}
}
}
}
func TestItemService_AddAttachment(t *testing.T) {
temp := os.TempDir()
svc := &ItemService{
repo: tRepos,
filepath: temp,
}
loc, err := tSvc.Location.Create(context.Background(), tGroup.ID, types.LocationCreate{
Description: "test",
Name: "test",
})
assert.NoError(t, err)
assert.NotNil(t, loc)
itmC := types.ItemCreate{
Name: fk.Str(10),
Description: fk.Str(10),
LocationID: loc.ID,
}
itm, err := svc.Create(context.Background(), tGroup.ID, itmC)
assert.NoError(t, err)
assert.NotNil(t, itm)
t.Cleanup(func() {
err := svc.repo.Items.Delete(context.Background(), itm.ID)
assert.NoError(t, err)
})
contents := fk.Str(1000)
reader := strings.NewReader(contents)
// Setup
afterAttachment, err := svc.AddAttachment(context.Background(), tGroup.ID, itm.ID, "testfile.txt", reader)
assert.NoError(t, err)
assert.NotNil(t, afterAttachment)
// Check that the file exists
storedPath := afterAttachment.Attachments[0].Document.Path
// {root}/{group}/{item}/{attachment}
assert.Equal(t, path.Join(temp, tGroup.ID.String(), itm.ID.String(), "testfile.txt"), storedPath)
// Check that the file contents are correct
bts, err := os.ReadFile(storedPath)
assert.NoError(t, err)
assert.Equal(t, contents, string(bts))
}

View file

@ -0,0 +1,31 @@
package types
import (
"time"
"github.com/google/uuid"
)
type DocumentOut struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Path string
}
type DocumentCreate struct {
Title string `json:"name"`
Path string `json:"path"`
}
type DocumentUpdate = DocumentCreate
type DocumentToken struct {
Raw string `json:"raw"`
ExpiresAt time.Time `json:"expiresAt"`
}
type DocumentTokenCreate struct {
TokenHash []byte `json:"tokenHash"`
DocumentID uuid.UUID `json:"documentId"`
ExpiresAt time.Time `json:"expiresAt"`
}

View file

@ -19,6 +19,8 @@ type ItemUpdate struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Quantity int `json:"quantity"`
Insured bool `json:"insured"`
// Edges
LocationID uuid.UUID `json:"locationId"`
@ -37,12 +39,12 @@ type ItemUpdate struct {
// Purchase
PurchaseTime time.Time `json:"purchaseTime"`
PurchaseFrom string `json:"purchaseFrom"`
PurchasePrice float64 `json:"purchasePrice"`
PurchasePrice float64 `json:"purchasePrice,string"`
// Sold
SoldTime time.Time `json:"soldTime"`
SoldTo string `json:"soldTo"`
SoldPrice float64 `json:"soldPrice"`
SoldPrice float64 `json:"soldPrice,string"`
SoldNotes string `json:"soldNotes"`
// Extras
@ -56,6 +58,8 @@ type ItemSummary struct {
Description string `json:"description"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Quantity int `json:"quantity"`
Insured bool `json:"insured"`
// Edges
Location *LocationSummary `json:"location"`
@ -74,12 +78,12 @@ type ItemSummary struct {
// Purchase
PurchaseTime time.Time `json:"purchaseTime"`
PurchaseFrom string `json:"purchaseFrom"`
PurchasePrice float64 `json:"purchasePrice"`
PurchasePrice float64 `json:"purchasePrice,string"`
// Sold
SoldTime time.Time `json:"soldTime"`
SoldTo string `json:"soldTo"`
SoldPrice float64 `json:"soldPrice"`
SoldPrice float64 `json:"soldPrice,string"`
SoldNotes string `json:"soldNotes"`
// Extras
@ -88,6 +92,14 @@ type ItemSummary struct {
type ItemOut struct {
ItemSummary
Attachments []*ItemAttachment `json:"attachments"`
// Future
// Fields []*FieldSummary `json:"fields"`
}
type ItemAttachment struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Document DocumentOut `json:"document"`
}