package api
import (
"errors"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// The tracker's vocabulary is the gate. fossil creates a ticket column
// on demand, so an unchecked field map would turn every ticket write
// into a schema migration — and one typo would leave a tracker with an
// "assigne" column forever.
func TestOnlyFieldsTheTrackerDefinesAreWritable(t *testing.T) {
known := map[string]bool{"assignee": true, "closer": true}
got, err := extraTicketFields(known, map[string]string{"assignee": "dan"})
if err != nil {
t.Fatalf("FAIL: a field the tracker carries was refused: %v", err)
}
if got["assignee"] != "dan" {
t.Errorf("FAIL: assignee = %q, want dan", got["assignee"])
}
_, err = extraTicketFields(known, map[string]string{"assigne": "dan"})
if !errors.Is(err, ops.ErrNotFound) {
t.Errorf("FAIL: a column the tracker does not define was accepted: %v", err)
}
}
// A field with its own input is refused here. One value with two doors
// is a disagreement waiting for its first save.
func TestAFieldWithItsOwnInputIsRefusedOnTheGenericDoor(t *testing.T) {
known := map[string]bool{"status": true, "title": true, "assignee": true}
for _, name := range []string{"status", "title", "comment", "foundin"} {
_, err := extraTicketFields(known, map[string]string{name: "x"})
if !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: %q was accepted on the generic door: %v", name, err)
}
}
}
// What fossil withholds, quarry does not author. private_contact is
// labelled "Not publicly visible" in fossil's own template; writing it
// through a forge form would put an address into a permanent,
// synchronized record from a page that never showed it.
func TestWithheldFieldsAreNeverAuthored(t *testing.T) {
known := map[string]bool{"private_contact": true, "contact": true}
for name := range known {
_, err := extraTicketFields(known, map[string]string{name: "[email protected]"})
if !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: %q was authorable: %v", name, err)
}
}
}
// An empty value CLEARS the field. It is the only way to unset one, and
// a form that could set but never clear would trap a wrong assignee on
// a ticket forever.
func TestAnEmptyValueClearsRatherThanBeingDropped(t *testing.T) {
got, err := extraTicketFields(map[string]bool{"assignee": true},
map[string]string{"assignee": ""})
if err != nil {
t.Fatal(err)
}
v, ok := got["assignee"]
if !ok || v != "" {
t.Errorf("FAIL: clearing a field produced %q, present=%v", v, ok)
}
}
// A name that is not a fossil column shape is refused before it is
// looked up: anything else would have to be quoted in the tracker's own
// SQL, which is a column no tracker has.
func TestOnlyPlainColumnNames(t *testing.T) {
for _, bad := range []string{"", "1st", "As-signee", "a b", "assignee;drop",
strings.Repeat("a", 41)} {
if plainFieldName(bad) {
t.Errorf("FAIL: %q reads as a column name", bad)
}
}
for _, ok := range []string{"assignee", "found_in", "a1", "closer"} {
if !plainFieldName(ok) {
t.Errorf("FAIL: %q does not read as a column name", ok)
}
}
}
// One change sets a triage step's worth of fields, not a schema dump.
func TestOneChangeIsBounded(t *testing.T) {
known, in := map[string]bool{}, map[string]string{}
for i := 0; i < maxExtraFields+1; i++ {
n := "f" + string(rune('a'+i%26)) + string(rune('a'+i/26))
known[n], in[n] = true, "x"
}
if _, err := extraTicketFields(known, in); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: an oversized change was accepted: %v", err)
}
}
// A tracker with no tickets authors nothing through this door, and that
// is the honest answer: there is no schema to read, and inventing
// columns on a fresh tracker is what the check exists to stop.
func TestAnEmptyTrackerAuthorsNoExtraFields(t *testing.T) {
if _, err := extraTicketFields(nil, map[string]string{"assignee": "dan"}); !errors.Is(err, ops.ErrNotFound) {
t.Errorf("FAIL: an empty tracker accepted a column: %v", err)
}
// And a call with nothing to set is not an error.
if got, err := extraTicketFields(nil, nil); err != nil || got != nil {
t.Errorf("FAIL: an empty map = %v, %v", got, err)
}
}