mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Filter params were pasted in one at a time, so a value spelling another param's placeholder was rewritten inside its own quotes and the second value's quotes closed the first one's string: a bound value could add conditions to the filter. They are now substituted in one pass over the filter as written. An org admin administers, and a member reads the schema of, only a Base opened for its org (StoreKeyTenant): a Base deployed for no org belongs to whoever deployed it. The org plugin names the org of every Base it opens. A saved user's record never carries org authority: @request.auth.admin and @request.auth.role answer only for the record a verified token mints, and the two fields are system and hidden. The emptiness figures of an aggregate are columns of its one query, so a grouped request counts the groups it shows and runs one query however many figures it asks for. A stream the host routed to its org's Base is served by that live Base, not the instance its grant was minted on.
354 lines
13 KiB
Go
354 lines
13 KiB
Go
package apis_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tests"
|
|
"github.com/hanzoai/base/tools/types"
|
|
)
|
|
|
|
const (
|
|
// users, [email protected] (id 4q1xlclmfloku33)
|
|
aggregateMemberToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfdXNlcnNfYXV0aF8iLCJleHAiOjI1MjQ2MDQ0NjEsImlkIjoiNHExeGxjbG1mbG9rdTMzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.AuFTIzCsdLEy-5adFzpjZzbqAdTP6Iu9B1wPBAxLBgo"
|
|
aggregateMemberId = "4q1xlclmfloku33"
|
|
// _superusers, [email protected]
|
|
aggregateSuperToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJoYmNfMzE0MjYzNTgyMyIsImV4cCI6MjUyNDYwNDQ2MSwiaWQiOiJzeXdiaGVjbmg0NnJobTAiLCJyZWZyZXNoYWJsZSI6dHJ1ZSwidHlwZSI6ImF1dGgifQ.CXBf8BazmUeg2RnJW8OEs1UFYF41rbCMOa6YZa4wZio"
|
|
)
|
|
|
|
// seedDeals builds a deals collection whose list rule admits a member's own
|
|
// deals, four deals (three of them the member's), and two members of one deal.
|
|
func seedDeals(t testing.TB, app *tests.TestApp) {
|
|
t.Helper()
|
|
|
|
companies := core.NewBaseCollection("firms")
|
|
companies.Fields.Add(&core.NumberField{Name: "employees"})
|
|
if err := app.Save(companies); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
deals := core.NewBaseCollection("deals")
|
|
deals.ListRule = types.Pointer("owner = @request.auth.id")
|
|
deals.Fields.Add(
|
|
&core.TextField{Name: "title"},
|
|
&core.TextField{Name: "owner"},
|
|
&core.TextField{Name: "secret", Hidden: true},
|
|
&core.NumberField{Name: "amount"},
|
|
&core.RatingField{Name: "rating"},
|
|
&core.BoolField{Name: "won"},
|
|
&core.SelectField{Name: "stage", MaxSelect: 1, Values: []string{"new", "won", "lost"}},
|
|
&core.DateField{Name: "closed"},
|
|
&core.CurrencyField{Name: "money"},
|
|
&core.EmailsField{Name: "emails"},
|
|
&core.RelationField{Name: "company", CollectionId: companies.Id, MaxSelect: 1},
|
|
)
|
|
if err := app.Save(deals); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
members := core.NewBaseCollection("deal_members")
|
|
members.Fields.Add(
|
|
&core.RelationField{Name: "deal", CollectionId: deals.Id, MaxSelect: 1},
|
|
&core.TextField{Name: "label"},
|
|
)
|
|
if err := app.Save(members); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rows := []map[string]any{
|
|
{"title": "A", "owner": aggregateMemberId, "amount": 100, "rating": 5, "won": true, "stage": "new",
|
|
"closed": "2026-10-01 00:00:00.000Z", "money": types.Currency{AmountMicros: 1_500_000, CurrencyCode: "USD"},
|
|
"emails": types.Emails{PrimaryEmail: "[email protected]"}},
|
|
{"title": "B", "owner": aggregateMemberId, "amount": 50, "rating": 3, "won": false, "stage": "new",
|
|
"closed": "2026-09-01 00:00:00.000Z"},
|
|
{"title": "C", "owner": aggregateMemberId, "amount": 30, "won": true, "stage": "won",
|
|
"money": types.Currency{AmountMicros: 9_000_000, CurrencyCode: "EUR"}, "emails": types.Emails{PrimaryEmail: "[email protected]"}},
|
|
{"title": "D", "owner": "someone-else", "amount": 5, "rating": 1, "won": false, "stage": "lost",
|
|
"closed": "2026-11-01 00:00:00.000Z", "emails": types.Emails{PrimaryEmail: "[email protected]"}},
|
|
}
|
|
|
|
var first string
|
|
for _, row := range rows {
|
|
r := core.NewRecord(deals)
|
|
for k, v := range row {
|
|
r.Set(k, v)
|
|
}
|
|
if err := app.Save(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first == "" {
|
|
first = r.Id
|
|
}
|
|
}
|
|
|
|
for _, label := range []string{"one", "done"} {
|
|
r := core.NewRecord(members)
|
|
r.Set("deal", first)
|
|
r.Set("label", label)
|
|
if err := app.Save(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func aggregateURL(params map[string]string) string {
|
|
v := url.Values{}
|
|
for k, x := range params {
|
|
v.Set(k, x)
|
|
}
|
|
return "/v1/collections/deals/aggregate?" + v.Encode()
|
|
}
|
|
|
|
func TestRecordsAggregate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
everyOp := "count,sum(amount),avg(amount),min(amount),max(amount),countEmpty(emails),countNotEmpty(emails)," +
|
|
"percentEmpty(emails),percentNotEmpty(emails),countUnique(stage),countTrue(won),countFalse(won)," +
|
|
"sum(money.amountMicros),min(closed),max(closed),avg(rating)"
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "every operation, as a superuser",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": everyOp}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`"count":4`,
|
|
`"sum(amount)":185`,
|
|
`"avg(amount)":46.25`,
|
|
`"min(amount)":5`,
|
|
`"max(amount)":100`,
|
|
`"countEmpty(emails)":1`,
|
|
`"countNotEmpty(emails)":3`,
|
|
`"percentEmpty(emails)":25`,
|
|
`"percentNotEmpty(emails)":75`,
|
|
`"countUnique(stage)":3`,
|
|
`"countTrue(won)":2`,
|
|
`"countFalse(won)":2`,
|
|
`"sum(money.amountMicros)":10500000`,
|
|
`"min(closed)":"2026-09-01 00:00:00.000Z"`,
|
|
`"max(closed)":"2026-11-01 00:00:00.000Z"`,
|
|
`"avg(rating)":2.25`,
|
|
`"groups":[]`,
|
|
},
|
|
},
|
|
{
|
|
Name: "grouped by a select, largest group first",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "count,sum(amount),countEmpty(emails)", "group": "stage"}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`"groups":[{"count":2,"countEmpty(emails)":1,"sum(amount)":150,"value":"new"},` +
|
|
`{"count":1,"countEmpty(emails)":0,"sum(amount)":5,"value":"lost"},` +
|
|
`{"count":1,"countEmpty(emails)":0,"sum(amount)":30,"value":"won"}]`,
|
|
`"total":{"count":4,"countEmpty(emails)":1,"sum(amount)":185}`,
|
|
},
|
|
},
|
|
{
|
|
Name: "grouped by a composite part",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "count", "group": "money.currencyCode"}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`{"count":2,"value":null}`,
|
|
`{"count":1,"value":"EUR"}`,
|
|
`{"count":1,"value":"USD"}`,
|
|
},
|
|
},
|
|
{
|
|
Name: "a member counts only what its list rule admits",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "count,sum(amount)"}),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{
|
|
`"total":{"count":3,"sum(amount)":180}`,
|
|
},
|
|
},
|
|
{
|
|
Name: "narrowed by filter and where",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{
|
|
"ops": "count,sum(amount)",
|
|
"filter": `amount > 10`,
|
|
"where": `{"field":"stage","op":"is","value":"new"}`,
|
|
}),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"total":{"count":2,"sum(amount)":150}`},
|
|
},
|
|
{
|
|
Name: "a record joined twice is counted once",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{
|
|
"ops": "count,sum(amount)",
|
|
"filter": `deal_members_via_deal.label ?~ "one"`,
|
|
}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"total":{"count":1,"sum(amount)":100}`},
|
|
},
|
|
{
|
|
Name: "a figure through a relation is refused",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "sum(company.employees)"}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
{
|
|
Name: "a hidden field is refused to a member",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "countEmpty(secret)"}),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
{
|
|
Name: "a hidden field is a superuser's",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "countEmpty(secret)"}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"countEmpty(secret)":4`},
|
|
},
|
|
{
|
|
Name: "a sum of text is refused",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "sum(title)"}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
{
|
|
Name: "an unknown operation is refused",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "median(amount)"}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
{
|
|
Name: "a where that does not compile is refused",
|
|
Method: http.MethodGet,
|
|
URL: aggregateURL(map[string]string{"ops": "count", "where": `{"field":"nope","op":"is","value":1}`}),
|
|
Headers: map[string]string{"Authorization": aggregateSuperToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
{
|
|
Name: "a guest is refused where only a superuser may list",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/collections/demo2/aggregate?ops=count",
|
|
ExpectedStatus: 403,
|
|
ExpectedContent: []string{`"status":403`},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
s.BeforeTestFunc = func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
|
seedDeals(t, app)
|
|
if c, err := app.FindCollectionByNameOrId("demo2"); err == nil {
|
|
c.ListRule = nil
|
|
if err := app.Save(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
s.Test(t)
|
|
}
|
|
}
|
|
|
|
func TestRecordsListWhere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenarios := []tests.ApiScenario{
|
|
{
|
|
Name: "where narrows a member's list, still under its list rule",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/collections/deals/records?where=" + url.QueryEscape(`{"field":"won","op":"is","value":true}`),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"totalItems":2`, `"title":"A"`, `"title":"C"`},
|
|
},
|
|
{
|
|
Name: "where beside a filter",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/collections/deals/records?filter=" + url.QueryEscape(`amount < 60`) + "&where=" + url.QueryEscape(`{"field":"emails","op":"empty"}`),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`"totalItems":1`, `"title":"B"`},
|
|
},
|
|
{
|
|
Name: "a where that does not compile is refused",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/collections/deals/records?where=" + url.QueryEscape(`{"field":"title","op":"gt","value":"x"}`),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
{
|
|
Name: "a where cannot name a hidden field",
|
|
Method: http.MethodGet,
|
|
URL: "/v1/collections/deals/records?where=" + url.QueryEscape(`{"field":"secret","op":"empty"}`),
|
|
Headers: map[string]string{"Authorization": aggregateMemberToken},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{`"status":400`},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
s.BeforeTestFunc = func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
|
seedDeals(t, app)
|
|
}
|
|
s.Test(t)
|
|
}
|
|
}
|
|
|
|
// Every group a grouped figure reports is counted by the query that chose the
|
|
// groups. With more values than the 200 a request reports, a separate count of
|
|
// empties would rank its own 200 groups: here every bucket has one empty row, so
|
|
// it would keep the "a" buckets (first by name) and report the busier "z" buckets
|
|
// shown in the answer as having none.
|
|
func TestRecordsAggregateEmptinessFollowsTheGroupsShown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
scenario := tests.ApiScenario{
|
|
Method: http.MethodGet,
|
|
URL: "/v1/collections/tallies/aggregate?ops=" + url.QueryEscape("count,countEmpty(note)") + "&group=bucket",
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{`{"count":2,"countEmpty(note)":1,"value":"z0001"}`},
|
|
BeforeTestFunc: func(t testing.TB, app *tests.TestApp, e *core.ServeEvent) {
|
|
c := core.NewBaseCollection("tallies")
|
|
c.ListRule = types.Pointer("")
|
|
c.Fields.Add(&core.TextField{Name: "bucket"}, &core.TextField{Name: "note"})
|
|
if err := app.Save(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
add := func(bucket, note string) {
|
|
r := core.NewRecord(c)
|
|
r.Set("bucket", bucket)
|
|
r.Set("note", note)
|
|
if err := app.Save(r); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for i := 1; i <= 150; i++ {
|
|
z := "z" + strconv.Itoa(10000 + i)[1:]
|
|
add(z, "")
|
|
add(z, "kept")
|
|
}
|
|
for i := 1; i <= 250; i++ {
|
|
add("a"+strconv.Itoa(10000 + i)[1:], "")
|
|
}
|
|
},
|
|
}
|
|
scenario.Test(t)
|
|
}
|