Files
krow_backend/go-api/internal/service/service_test.go
2026-08-24 13:06:29 +05:30

218 lines
6.8 KiB
Go

package service
import (
"net/url"
"testing"
"github.com/krow/krow-backend/go-api/internal/domain"
)
// These exercise query parsing and validation without a database, so the
// contract's defaults are pinned even when PostgreSQL is not available.
func resource(t *testing.T, path string) *domain.Resource {
t.Helper()
res, ok := domain.ResourceByPath[path]
if !ok {
t.Fatalf("no resource for path %q", path)
}
return res
}
func parse(t *testing.T, path, query string) (domain.ListParams, error) {
t.Helper()
values, err := url.ParseQuery(query)
if err != nil {
t.Fatalf("bad test query %q: %v", query, err)
}
return New(resource(t, path), nil).ParseList(values)
}
// Each default is the literal argument at the frontend call site
// (api-contract.md §8.1), not a generic value.
func TestParseListDefaults(t *testing.T) {
for _, tc := range []struct {
path string
limit int
sort string
desc bool
}{
{"job-postings", 100, "created_date", true},
{"job-applications", 200, "ai_score", true},
{"worker-profiles", 500, "krow_score", true},
{"shift-records", 500, "created_date", true},
{"user-activity", 500, "created_date", true},
{"courses", 200, "created_date", true},
} {
p, err := parse(t, tc.path, "")
if err != nil {
t.Fatalf("%s: %v", tc.path, err)
}
if p.Limit != tc.limit {
t.Errorf("%s limit = %d, want %d", tc.path, p.Limit, tc.limit)
}
if p.Sort != tc.sort || p.Desc != tc.desc {
t.Errorf("%s sort = %q desc=%v, want %q desc=%v", tc.path, p.Sort, p.Desc, tc.sort, tc.desc)
}
}
}
// An explicitly empty ?sort= means no ordering, matching `if (!sort) return records`.
func TestParseListEmptySortMeansUnordered(t *testing.T) {
p, err := parse(t, "job-postings", "sort=")
if err != nil {
t.Fatal(err)
}
if p.Sort != "" {
t.Errorf("sort = %q, want empty", p.Sort)
}
}
func TestParseListLimitClampAndRejection(t *testing.T) {
p, err := parse(t, "job-postings", "limit=99999")
if err != nil {
t.Fatal(err)
}
if p.Limit != MaxLimit {
t.Errorf("limit = %d, want it clamped to %d", p.Limit, MaxLimit)
}
// A zero limit is legitimate: store.js's slice(0, 0) returns nothing.
if p, err := parse(t, "job-postings", "limit=0"); err != nil || p.Limit != 0 {
t.Errorf("limit=0 -> %d, %v", p.Limit, err)
}
for _, bad := range []string{"limit=-1", "limit=abc", "offset=-3", "offset=x"} {
if _, err := parse(t, "job-postings", bad); err == nil {
t.Errorf("%s was accepted", bad)
}
}
}
func TestParseListRejectsUnknownSortAndFilter(t *testing.T) {
if _, err := parse(t, "job-postings", "sort=-nope"); err == nil {
t.Error("unknown sort field was accepted")
}
if _, err := parse(t, "job-postings", "nope=1"); err == nil {
t.Error("unknown filter field was accepted")
}
// Arrays and JSON are not comparable for equality, so they are not filterable.
if _, err := parse(t, "job-postings", "responsibilities=x"); err == nil {
t.Error("an array column was accepted as a filter")
}
if _, err := parse(t, "job-postings", "vetting_criteria=x"); err == nil {
t.Error("a jsonb column was accepted as a filter")
}
}
// A repeated parameter is membership, matching `Array.isArray(want)`.
func TestParseListRepeatedParameterIsMembership(t *testing.T) {
p, err := parse(t, "job-applications", "status=hired&status=interview")
if err != nil {
t.Fatal(err)
}
if len(p.Filters) != 1 {
t.Fatalf("filters = %d, want 1", len(p.Filters))
}
if len(p.Filters[0].Values) != 2 {
t.Errorf("values = %v, want two", p.Filters[0].Values)
}
}
// sort, limit and offset are reserved; no column collides with them.
func TestReservedParametersAreNotFilters(t *testing.T) {
p, err := parse(t, "job-applications", "sort=-ai_score&limit=5&offset=2&status=hired")
if err != nil {
t.Fatal(err)
}
if len(p.Filters) != 1 || p.Filters[0].Column.Name != "status" {
t.Errorf("filters = %#v, want only status", p.Filters)
}
if p.Limit != 5 || p.Offset != 2 {
t.Errorf("limit/offset = %d/%d, want 5/2", p.Limit, p.Offset)
}
for _, r := range []string{"sort", "limit", "offset"} {
if _, isColumn := resource(t, "job-applications").Column(r); isColumn {
t.Errorf("a column named %q collides with a reserved parameter", r)
}
}
}
func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
svc := New(resource(t, "job-postings"), nil)
if _, err := svc.validate(domain.Record{}, true); err == nil {
t.Error("a create with no title was accepted")
}
if _, err := svc.validate(domain.Record{"title": " "}, true); err == nil {
t.Error("a blank title was accepted")
}
if _, err := svc.validate(domain.Record{"title": "X", "bogus": 1}, true); err == nil {
t.Error("an unknown field was accepted")
}
if _, err := svc.validate(domain.Record{"title": "X", "status": "archived"}, true); err == nil {
t.Error("an invalid enum value was accepted")
}
if _, err := svc.validate(domain.Record{"title": "X", "status": "active"}, true); err != nil {
t.Errorf("a valid payload was rejected: %v", err)
}
// Server-owned fields are stripped, not rejected.
out, err := svc.validate(domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
if err != nil {
t.Fatalf("server-owned fields caused a rejection: %v", err)
}
if _, present := out["id"]; present {
t.Error("id survived validation")
}
if _, present := out["org_id"]; present {
t.Error("org_id survived validation")
}
// An update needs no required fields — it is a partial by definition.
if _, err := svc.validate(domain.Record{"location": "Here"}, false); err != nil {
t.Errorf("a partial update was rejected: %v", err)
}
}
func TestIsUUID(t *testing.T) {
valid := []string{
"00000000-0000-0000-0000-000000000000",
"9A88DEBC-76E5-572C-A7E7-6EB5F43A6705",
}
for _, v := range valid {
if !isUUID(v) {
t.Errorf("%q rejected", v)
}
}
invalid := []string{"", "not-a-uuid", "00000000000000000000000000000000",
"00000000-0000-0000-0000-00000000000g", "00000000-0000-0000-0000-0000000000000"}
for _, v := range invalid {
if isUUID(v) {
t.Errorf("%q accepted", v)
}
}
}
// Every resource must declare a sort column that actually exists, and a limit
// in range — a typo in the generated metadata would otherwise only surface as a
// 400 at runtime.
func TestEveryResourceIsCoherent(t *testing.T) {
for _, res := range domain.AllResources {
field := res.DefaultSort
if len(field) > 0 && field[0] == '-' {
field = field[1:]
}
if !res.Sortable(field) {
t.Errorf("%s: default sort %q is not a column", res.Name, res.DefaultSort)
}
if res.DefaultLimit < 1 || res.DefaultLimit > MaxLimit {
t.Errorf("%s: default limit %d is out of range", res.Name, res.DefaultLimit)
}
if _, ok := res.Column("id"); !ok {
t.Errorf("%s: no id column, so the sort tiebreaker cannot apply", res.Name)
}
if _, ok := res.Column("org_id"); !ok {
t.Errorf("%s: no org_id column, so it cannot be scoped", res.Name)
}
}
}