package cabana_test import ( "fmt" "net/http" "reflect" "strings" "testing" ) // TestRowStateOrder: states are sent in the fixed order deleted, negative, // disabled, whatever order the hook answers in, and a repeated state is sent // once (D-12). func TestRowStateOrder(t *testing.T) { env, gdb := newRosterEnv(t) // The fixture answers disabled twice, then negative, then deleted. all := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "All", Banned: true, DeletedAt: rosterDeleted()}) two := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Two", Banned: true}) one := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "One"}) body, raw := rosterList(t, env, "") want := map[string][]string{ fmt.Sprint(all): {"deleted", "negative", "disabled"}, fmt.Sprint(two): {"negative", "disabled"}, fmt.Sprint(one): {"disabled"}, } if !reflect.DeepEqual(body.Meta.RowStates, want) { t.Fatalf("row_states = %v, want %v\n%s", body.Meta.RowStates, want, raw) } } // TestRowStateUnknownDropped: a value outside the fixed set is never sent // (T-12.1-07); the known states of the same row stay. func TestRowStateUnknownDropped(t *testing.T) { env, gdb := newRosterEnv(t) logs := captureLog(t) only := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true}) mixed := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Banned: true}) body, raw := rosterList(t, env, "") if strings.Contains(raw, "starred") { t.Fatalf("an unknown state was sent: %s", raw) } if _, ok := body.Meta.RowStates[fmt.Sprint(only)]; ok { t.Fatalf("a row whose only state is unknown is listed: %v", body.Meta.RowStates) } if got := body.Meta.RowStates[fmt.Sprint(mixed)]; !reflect.DeepEqual(got, []string{"negative", "disabled"}) { t.Fatalf("known states next to an unknown one = %v", got) } if dropped := logs.matching("cabana: unknown list row state dropped"); len(dropped) != 2 || !strings.Contains(dropped[0], "state=starred") { t.Fatalf("server log = %q", dropped) } } // TestRowStateLengthMismatch: a hook result that is not aligned with the page // fails the list with the generic 500. func TestRowStateLengthMismatch(t *testing.T) { env, gdb := newRosterEnv(t) rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada"}) rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterShort}) rec := env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople, "", "bearer") got := rosterError(t, rec) if got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "row states") || strings.Contains(rec.Body.String(), "Ada") { t.Fatalf("500 body = %s", rec.Body.String()) } // A page without the misaligned answer still lists. env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"?search=Ada", "", "bearer") } // TestRowStateHookError: a hook error fails the list with the generic 500 and // no error text. func TestRowStateHookError(t *testing.T) { env, gdb := newRosterEnv(t) logs := captureLog(t) rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: rosterStateErr}) rec := env.expect(t, http.StatusInternalServerError, http.MethodGet, rosterPeople, "", "bearer") if got := rosterError(t, rec); got.Code != "error" || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "state table") { t.Fatalf("500 body = %s", rec.Body.String()) } if failed := logs.matching("cabana: list row states failed"); len(failed) != 1 || !strings.Contains(failed[0], "controller=acme.roster.people") { t.Fatalf("server log = %q", failed) } } // TestRowStateAbsentKey: meta has no row_states key when no row has a state, // when the page is empty and when the controller has no hook. func TestRowStateAbsentKey(t *testing.T) { env, gdb := newRosterEnv(t) rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true}) for _, query := range []string{"", "?search=nobody-matches-this"} { rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer") if strings.Contains(rec.Body.String(), "row_states") { t.Fatalf("row_states sent for %q: %s", query, rec.Body.String()) } } demo, demoDB := newActEnv(t) actInsert(t, demoDB, "plain", "acme") rec := demo.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets", "", "bearer") if strings.Contains(rec.Body.String(), "row_states") { t.Fatalf("a controller without the hook sent row_states: %s", rec.Body.String()) } // Row states are meta, never a column of the row. body, _ := rosterList(t, env, "") for _, row := range body.Data { if _, ok := row["row_states"]; ok { t.Fatalf("a row carries row_states: %v", row) } } } // TestRowStateOncePerPage: the hook is called once per served page with that // page's records, and not at all for an empty page. func TestRowStateOncePerPage(t *testing.T) { env, gdb := newRosterEnv(t) for i := range 21 { rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: fmt.Sprintf("Person %02d", i)}) } rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Foreign"}) env.spy.takeStates() first, _ := rosterList(t, env, "") second, _ := rosterList(t, env, "?page=2") rosterList(t, env, "?search=nobody-matches-this") if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{20, 1}) { t.Fatalf("ListRowStates calls = %v, want one call of 20 and one of 1", calls) } if len(first.Meta.RowStates) != 20 || len(second.Meta.RowStates) != 1 { t.Fatalf("row_states sizes = %d and %d", len(first.Meta.RowStates), len(second.Meta.RowStates)) } // The keys of a page are the ids of that page's rows. for _, row := range second.Data { id, _ := row["id"].(float64) if _, ok := second.Meta.RowStates[fmt.Sprint(uint(id))]; !ok { t.Fatalf("page 2 row %v has no state entry: %v", row["id"], second.Meta.RowStates) } } }