package store_test import ( "context" "testing" "atlas9.dev/c/core" "atlas9.dev/c/core/assert" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/lib/access" "atlas9.dev/c/demo/store" "atlas9.dev/c/demo/todos" ) type todoTestHarness struct { Context context.Context Store todos.Store NewTenants func(t *testing.T, n int) []core.ID NewUsers func(t *testing.T, n int) []core.ID AddMember func(t *testing.T, tenant, user core.ID) } func testTodoStore(t *testing.T, setup func(t *testing.T) todoTestHarness) { newList := func(tenant core.ID, name string) *todos.List { return &todos.List{ID: core.NewID("list"), Tenant: tenant, Name: name} } newItem := func(tenant core.ID, title string) *todos.Item { return &todos.Item{ID: core.NewID("todo"), Tenant: tenant, Title: title, Status: todos.StatusOpen} } t.Run("CreateList and GetList", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] in := newList(tenant, "Groceries") assert.Ok(t, h.Store.CreateList(ctx, in)) var got todos.List assert.Ok(t, h.Store.GetList(ctx, tenant, in.ID, &got)) assert.Eq(t, got, *in) }) t.Run("UpdateList", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] in := newList(tenant, "Old") assert.Ok(t, h.Store.CreateList(ctx, in)) in.Name = "New" assert.Ok(t, h.Store.UpdateList(ctx, in)) var got todos.List assert.Ok(t, h.Store.GetList(ctx, tenant, in.ID, &got)) assert.Eq(t, got.Name, "New") }) t.Run("UpdateList not found", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] err := h.Store.UpdateList(h.Context, newList(tenant, "Nope")) assert.Eq(t, err, core.ErrNotFound) }) t.Run("GetList not found", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] var got todos.List err := h.Store.GetList(h.Context, tenant, core.NewID("list"), &got) assert.Eq(t, err, core.ErrNotFound) }) t.Run("GetList tenant isolation", func(t *testing.T) { h := setup(t) ctx := h.Context tenants := h.NewTenants(t, 2) in := newList(tenants[0], "Mine") assert.Ok(t, h.Store.CreateList(ctx, in)) var got todos.List err := h.Store.GetList(ctx, tenants[1], in.ID, &got) assert.Eq(t, err, core.ErrNotFound) }) t.Run("DeleteList", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] in := newList(tenant, "Doomed") assert.Ok(t, h.Store.CreateList(ctx, in)) assert.Ok(t, h.Store.DeleteList(ctx, tenant, in.ID)) var got todos.List err := h.Store.GetList(ctx, tenant, in.ID, &got) assert.Eq(t, err, core.ErrNotFound) }) t.Run("ListLists", func(t *testing.T) { h := setup(t) ctx := h.Context tenants := h.NewTenants(t, 2) assert.Ok(t, h.Store.CreateList(ctx, newList(tenants[0], "A"))) assert.Ok(t, h.Store.CreateList(ctx, newList(tenants[0], "B"))) assert.Ok(t, h.Store.CreateList(ctx, newList(tenants[1], "Other"))) var page core.Page[todos.List] assert.Ok(t, h.Store.ListLists(ctx, tenants[0], core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 2) }) t.Run("CreateItem and GetItem", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] in := newItem(tenant, "Buy milk") in.Notes = "2%" assert.Ok(t, h.Store.CreateItem(ctx, in)) var got todos.Item assert.Ok(t, h.Store.GetItem(ctx, tenant, in.ID, &got)) assert.Eq(t, got, *in) }) t.Run("UpdateItem", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] in := newItem(tenant, "Buy milk") assert.Ok(t, h.Store.CreateItem(ctx, in)) in.Title = "Buy oat milk" in.Status = todos.StatusDone assert.Ok(t, h.Store.UpdateItem(ctx, in)) var got todos.Item assert.Ok(t, h.Store.GetItem(ctx, tenant, in.ID, &got)) assert.Eq(t, got.Title, "Buy oat milk") assert.Eq(t, got.Status, todos.StatusDone) }) t.Run("UpdateItem not found", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] err := h.Store.UpdateItem(h.Context, newItem(tenant, "Nope")) assert.Eq(t, err, core.ErrNotFound) }) t.Run("DeleteItem", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] in := newItem(tenant, "Doomed") assert.Ok(t, h.Store.CreateItem(ctx, in)) assert.Ok(t, h.Store.DeleteItem(ctx, tenant, in.ID)) var got todos.Item err := h.Store.GetItem(ctx, tenant, in.ID, &got) assert.Eq(t, err, core.ErrNotFound) }) t.Run("Assignee must be tenant member", func(t *testing.T) { h := setup(t) ctx := h.Context tenants := h.NewTenants(t, 2) user := h.NewUsers(t, 1)[0] h.AddMember(t, tenants[1], user) // user is a member of tenants[1], not tenants[0] in := newItem(tenants[0], "Assigned") in.Assignee = user err := h.Store.CreateItem(ctx, in) assert.Eq(t, err, todos.ErrAssigneeNotMember) h.AddMember(t, tenants[0], user) assert.Ok(t, h.Store.CreateItem(ctx, in)) var got todos.Item assert.Ok(t, h.Store.GetItem(ctx, tenants[0], in.ID, &got)) assert.Eq(t, got.Assignee, user) }) t.Run("UpdateItem assignee must be tenant member", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] user := h.NewUsers(t, 1)[0] in := newItem(tenant, "Assigned") assert.Ok(t, h.Store.CreateItem(ctx, in)) in.Assignee = user err := h.Store.UpdateItem(ctx, in) assert.Eq(t, err, todos.ErrAssigneeNotMember) }) t.Run("AddToList and ListItems", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] list1 := newList(tenant, "One") list2 := newList(tenant, "Two") assert.Ok(t, h.Store.CreateList(ctx, list1)) assert.Ok(t, h.Store.CreateList(ctx, list2)) item1 := newItem(tenant, "Shared") item2 := newItem(tenant, "Only in one") assert.Ok(t, h.Store.CreateItem(ctx, item1)) assert.Ok(t, h.Store.CreateItem(ctx, item2)) // item1 belongs to both lists assert.Ok(t, h.Store.AddToList(ctx, tenant, list1.ID, item1.ID)) assert.Ok(t, h.Store.AddToList(ctx, tenant, list2.ID, item1.ID)) assert.Ok(t, h.Store.AddToList(ctx, tenant, list1.ID, item2.ID)) var page core.Page[todos.Item] assert.Ok(t, h.Store.ListItems(ctx, tenant, list1.ID, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 2) page = core.Page[todos.Item]{} assert.Ok(t, h.Store.ListItems(ctx, tenant, list2.ID, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 1) assert.Eq(t, page.Items[0].ID, item1.ID) // empty list ID returns all items in the tenant page = core.Page[todos.Item]{} assert.Ok(t, h.Store.ListItems(ctx, tenant, core.ID{}, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 2) }) t.Run("AddToList idempotent", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] list := newList(tenant, "One") item := newItem(tenant, "Once") assert.Ok(t, h.Store.CreateList(ctx, list)) assert.Ok(t, h.Store.CreateItem(ctx, item)) assert.Ok(t, h.Store.AddToList(ctx, tenant, list.ID, item.ID)) assert.Ok(t, h.Store.AddToList(ctx, tenant, list.ID, item.ID)) var page core.Page[todos.Item] assert.Ok(t, h.Store.ListItems(ctx, tenant, list.ID, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 1) }) t.Run("AddToList cross-tenant", func(t *testing.T) { h := setup(t) ctx := h.Context tenants := h.NewTenants(t, 2) list := newList(tenants[0], "Mine") item := newItem(tenants[1], "Theirs") assert.Ok(t, h.Store.CreateList(ctx, list)) assert.Ok(t, h.Store.CreateItem(ctx, item)) err := h.Store.AddToList(ctx, tenants[0], list.ID, item.ID) assert.Eq(t, err, core.ErrNotFound) }) t.Run("RemoveFromList", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] list := newList(tenant, "One") item := newItem(tenant, "In and out") assert.Ok(t, h.Store.CreateList(ctx, list)) assert.Ok(t, h.Store.CreateItem(ctx, item)) assert.Ok(t, h.Store.AddToList(ctx, tenant, list.ID, item.ID)) assert.Ok(t, h.Store.RemoveFromList(ctx, tenant, list.ID, item.ID)) var page core.Page[todos.Item] assert.Ok(t, h.Store.ListItems(ctx, tenant, list.ID, core.PageReq{}, &page)) assert.Eq(t, len(page.Items), 0) // the item itself survives removal from the list var got todos.Item assert.Ok(t, h.Store.GetItem(ctx, tenant, item.ID, &got)) }) t.Run("DeleteList keeps items", func(t *testing.T) { h := setup(t) ctx := h.Context tenant := h.NewTenants(t, 1)[0] list := newList(tenant, "Doomed") item := newItem(tenant, "Survivor") assert.Ok(t, h.Store.CreateList(ctx, list)) assert.Ok(t, h.Store.CreateItem(ctx, item)) assert.Ok(t, h.Store.AddToList(ctx, tenant, list.ID, item.ID)) assert.Ok(t, h.Store.DeleteList(ctx, tenant, list.ID)) var got todos.Item assert.Ok(t, h.Store.GetItem(ctx, tenant, item.ID, &got)) }) t.Run("CreateList: no access", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] err := h.Store.CreateList(t.Context(), newList(tenant, "Nope")) assert.Eq(t, err, iam.ErrForbidden) }) t.Run("CreateItem: no access", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] err := h.Store.CreateItem(t.Context(), newItem(tenant, "Nope")) assert.Eq(t, err, iam.ErrForbidden) }) t.Run("ListItems: no access", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] var page core.Page[todos.Item] err := h.Store.ListItems(t.Context(), tenant, core.ID{}, core.PageReq{}, &page) assert.Eq(t, err, iam.ErrForbidden) }) t.Run("AddToList: no access", func(t *testing.T) { h := setup(t) tenant := h.NewTenants(t, 1)[0] err := h.Store.AddToList(t.Context(), tenant, core.NewID("list"), core.NewID("todo")) assert.Eq(t, err, iam.ErrForbidden) }) } func TestSqliteTodoStore(t *testing.T) { testTodoStore(t, func(t *testing.T) todoTestHarness { t.Helper() db := setupTestDB(t) tx, err := db.Begin() assert.Ok(t, err) t.Cleanup(func() { tx.Rollback() }) w := dbi.WrapTx(tx) guard := access.ContextGuard{} ctx := access.Put(t.Context(), access.Admin()) tenantStore := store.NewSqliteTenantStore(w, guard) userStore := store.NewSqliteUserStore(w, guard) return todoTestHarness{ Context: ctx, Store: store.NewSqliteTodoStore(w, guard), NewTenants: func(t *testing.T, n int) []core.ID { t.Helper() ids := generateIDs(n) for _, id := range ids { err := tenantStore.Create(ctx, &iam.Tenant{ID: id, Name: "T"}) assert.Ok(t, err) } return ids }, NewUsers: func(t *testing.T, n int) []core.ID { t.Helper() ids := generateIDs(n) for _, id := range ids { err := userStore.Save(ctx, &iam.User{ID: id, Email: id.String() + "@test.com"}) assert.Ok(t, err) } return ids }, AddMember: func(t *testing.T, tenant, user core.ID) { insertMemberGrant(t, w, ctx, tenant, user) }, } }) }