Skip to content

Commit ec04f63

Browse files
author
Serge Pauli
committed
test: add recursive Contract guardrail coverage and default max_depth policy
1 parent 9c17ab4 commit ec04f63

14 files changed

+369
-51
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,9 @@ Rules:
448448
- `reentrant: true` is required for returning to an already visited model in the preset graph.
449449
- `max_depth` limits how many times the same model may appear on one traversal path.
450450
- You can set `max_depth` on the relation and, if needed, override it on a specific preset field (`field.max_depth` has priority).
451-
- If the cycle is not allowed (`reentrant: false`) or depth is exceeded, startup validation fails with a clear error.
451+
- If `reentrant: false`, startup validation fails with a clear error on cyclic re-entry.
452+
- If `max_depth` is exceeded, traversal is capped at that depth (no startup failure).
453+
- If `max_depth` is omitted for a reentrant cycle, default `max_depth=3` is applied and logged as a warning.
452454

453455
Why it matters:
454456

RELEASE_CHECKLIST.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ Use this checklist for `v1.0.0` and future releases.
1212
## 2. Quality gates
1313

1414
- [x] Run full test suite: `go test ./...`.
15-
- [ ] Run quick startup test from `README.md` (at least one option).
16-
- [ ] Validate startup failure behavior for broken YAML (negative test).
17-
- [ ] Verify recursive relation guardrails (`reentrant`, `max_depth`) with at least one real config case.
18-
- [ ] Check that `/api/index` and `/api/count` both return expected responses on smoke dataset.
15+
- [x] Run quick startup test from `README.md` (at least one option).
16+
- [x] Validate startup failure behavior for broken YAML (negative test).
17+
- [x] Verify recursive relation guardrails (`reentrant`, `max_depth`) with at least one real config case.
18+
- [x] Check that `/api/index` and `/api/count` both return expected responses on smoke dataset.
1919

2020
## 3. Docs and release notes
2121

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package itests
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"reflect"
10+
"testing"
11+
"time"
12+
13+
"YrestAPI/internal/db"
14+
)
15+
16+
func Test_Index_Contract_RecursiveChain_RespectsMaxDepth(t *testing.T) {
17+
if testBaseURL == "" || httpSrv == nil {
18+
t.Fatal("bootstrap not ready: HTTP server/baseURL missing")
19+
}
20+
21+
const maxDepth = 3 // max recursive relation hops from the root node
22+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
23+
defer cancel()
24+
25+
var firstID, lastID int
26+
if err := db.Pool.QueryRow(ctx, `SELECT id FROM contracts ORDER BY id ASC LIMIT 1`).Scan(&firstID); err != nil {
27+
t.Fatalf("failed to fetch first contract id: %v", err)
28+
}
29+
if err := db.Pool.QueryRow(ctx, `SELECT id FROM contracts ORDER BY id DESC LIMIT 1`).Scan(&lastID); err != nil {
30+
t.Fatalf("failed to fetch last contract id: %v", err)
31+
}
32+
33+
wantPrev := expectedChainPrevNumbers(t, ctx, lastID, maxDepth)
34+
gotPrev := fetchChainNumbers(t, "chain_prev", lastID, "prev")
35+
if !reflect.DeepEqual(gotPrev, wantPrev) {
36+
t.Fatalf("chain_prev mismatch: got %v, want %v", gotPrev, wantPrev)
37+
}
38+
39+
wantNext := expectedChainNextNumbers(t, ctx, firstID, maxDepth)
40+
gotNext := fetchChainNumbers(t, "chain_next", firstID, "next")
41+
if !reflect.DeepEqual(gotNext, wantNext) {
42+
t.Fatalf("chain_next mismatch: got %v, want %v", gotNext, wantNext)
43+
}
44+
}
45+
46+
func expectedChainPrevNumbers(t *testing.T, ctx context.Context, startID, maxDepth int) []string {
47+
t.Helper()
48+
rows, err := db.Pool.Query(ctx, `
49+
WITH RECURSIVE chain AS (
50+
SELECT id, number, prev_contract_id, 1 AS depth
51+
FROM contracts
52+
WHERE id = $1
53+
UNION ALL
54+
SELECT c.id, c.number, c.prev_contract_id, chain.depth + 1
55+
FROM contracts c
56+
JOIN chain ON c.id = chain.prev_contract_id
57+
WHERE chain.depth < $2
58+
)
59+
SELECT number
60+
FROM chain
61+
ORDER BY depth ASC
62+
`, startID, maxDepth+1)
63+
if err != nil {
64+
t.Fatalf("failed to build expected prev-chain: %v", err)
65+
}
66+
defer rows.Close()
67+
return scanStrings(t, rows)
68+
}
69+
70+
func expectedChainNextNumbers(t *testing.T, ctx context.Context, startID, maxDepth int) []string {
71+
t.Helper()
72+
rows, err := db.Pool.Query(ctx, `
73+
WITH RECURSIVE chain AS (
74+
SELECT id, number, 1 AS depth
75+
FROM contracts
76+
WHERE id = $1
77+
UNION ALL
78+
SELECT c.id, c.number, chain.depth + 1
79+
FROM contracts c
80+
JOIN chain ON c.prev_contract_id = chain.id
81+
WHERE chain.depth < $2
82+
)
83+
SELECT number
84+
FROM chain
85+
ORDER BY depth ASC
86+
`, startID, maxDepth+1)
87+
if err != nil {
88+
t.Fatalf("failed to build expected next-chain: %v", err)
89+
}
90+
defer rows.Close()
91+
return scanStrings(t, rows)
92+
}
93+
94+
func fetchChainNumbers(t *testing.T, preset string, rootID int, relKey string) []string {
95+
t.Helper()
96+
payload := map[string]any{
97+
"model": "Contract",
98+
"preset": preset,
99+
"filters": map[string]any{
100+
"id__eq": rootID,
101+
},
102+
"limit": 1,
103+
}
104+
body, _ := json.Marshal(payload)
105+
106+
req, err := http.NewRequest(http.MethodPost, testBaseURL+"/api/index", bytes.NewReader(body))
107+
if err != nil {
108+
t.Fatalf("build request failed: %v", err)
109+
}
110+
req.Header.Set("Content-Type", "application/json")
111+
112+
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
113+
if err != nil {
114+
t.Fatalf("POST /api/index failed: %v", err)
115+
}
116+
defer resp.Body.Close()
117+
118+
respBody, _ := io.ReadAll(resp.Body)
119+
if resp.StatusCode != http.StatusOK {
120+
t.Fatalf("expected 200 OK, got %d. body=%s", resp.StatusCode, string(respBody))
121+
}
122+
123+
var raw any
124+
if err := json.Unmarshal(respBody, &raw); err != nil {
125+
t.Fatalf("invalid JSON response: %v; body=%s", err, string(respBody))
126+
}
127+
items, err := extractItemsArray(raw)
128+
if err != nil {
129+
t.Fatalf("extract items failed: %v; body=%s", err, string(respBody))
130+
}
131+
if len(items) != 1 {
132+
t.Fatalf("expected 1 item, got %d; body=%s", len(items), string(respBody))
133+
}
134+
135+
numbers := make([]string, 0, 4)
136+
cur := items[0]
137+
first := true
138+
for {
139+
if first {
140+
id, ok := asInt(cur["id"])
141+
if !ok || id <= 0 {
142+
t.Fatalf("root node id has unexpected type/value: %T (%v), node=%#v, body=%s", cur["id"], cur["id"], cur, string(respBody))
143+
}
144+
first = false
145+
} else if cur["id"] != nil {
146+
id, ok := asInt(cur["id"])
147+
if !ok || id <= 0 {
148+
t.Fatalf("nested node id has unexpected type/value: %T (%v), node=%#v, body=%s", cur["id"], cur["id"], cur, string(respBody))
149+
}
150+
}
151+
152+
areaAny, exists := cur["area"]
153+
if !exists || areaAny == nil {
154+
t.Fatalf("node area missing: node=%#v, body=%s", cur, string(respBody))
155+
}
156+
area, ok := areaAny.(map[string]any)
157+
if !ok {
158+
t.Fatalf("node area must be object, got %T (%v), node=%#v, body=%s", areaAny, areaAny, cur, string(respBody))
159+
}
160+
areaID, ok := asInt(area["id"])
161+
if !ok || areaID <= 0 {
162+
t.Fatalf("area.id has unexpected type/value: %T (%v), area=%#v, node=%#v", area["id"], area["id"], area, cur)
163+
}
164+
areaName, ok := area["name"].(string)
165+
if !ok || areaName == "" {
166+
t.Fatalf("area.name has unexpected type/value: %T (%v), area=%#v, node=%#v", area["name"], area["name"], area, cur)
167+
}
168+
169+
number, ok := cur["number"].(string)
170+
if !ok {
171+
t.Fatalf("node number has unexpected type: %T (%v), node=%#v, body=%s", cur["number"], cur["number"], cur, string(respBody))
172+
}
173+
numbers = append(numbers, number)
174+
175+
next, exists := cur[relKey]
176+
if !exists || next == nil {
177+
break
178+
}
179+
node, ok := next.(map[string]any)
180+
if !ok {
181+
t.Fatalf("%s node must be object or null, got %T (%v)", relKey, next, next)
182+
}
183+
cur = node
184+
}
185+
return numbers
186+
}
187+
188+
func scanStrings(t *testing.T, rows interface {
189+
Next() bool
190+
Scan(dest ...any) error
191+
Err() error
192+
Close()
193+
}) []string {
194+
t.Helper()
195+
var out []string
196+
for rows.Next() {
197+
var value string
198+
if err := rows.Scan(&value); err != nil {
199+
t.Fatalf("scan value: %v", err)
200+
}
201+
out = append(out, value)
202+
}
203+
if err := rows.Err(); err != nil {
204+
t.Fatalf("rows err: %v", err)
205+
}
206+
return out
207+
}
208+
209+
func asInt(v any) (int, bool) {
210+
switch n := v.(type) {
211+
case int:
212+
return n, true
213+
case int32:
214+
return int(n), true
215+
case int64:
216+
return int(n), true
217+
case float64:
218+
return int(n), true
219+
default:
220+
return 0, false
221+
}
222+
}

internal/itests/registry_sanity_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,17 @@ func Test_Registry_Sanity_OnComplexRelations(t *testing.T) {
2929
t.Fatalf("Department.parent must be reentrant with max_depth, got: %#v", p)
3030
}
3131

32+
contract := model.Registry["Contract"]
33+
if contract == nil {
34+
t.Fatalf("Contract model missing in registry")
35+
}
36+
if rel := contract.Relations["area"]; rel == nil || rel.Type != "belongs_to" || rel.Model != "Area" {
37+
t.Fatalf("Contract.area must be belongs_to Area, got: %#v", rel)
38+
}
39+
if rel := contract.Relations["prev"]; rel == nil || rel.Type != "has_one" || !rel.Reentrant || rel.MaxDepth == 0 {
40+
t.Fatalf("Contract.prev must be reentrant has_one with max_depth, got: %#v", rel)
41+
}
42+
if rel := contract.Relations["next"]; rel == nil || rel.Type != "has_one" || !rel.Reentrant || rel.MaxDepth == 0 {
43+
t.Fatalf("Contract.next must be reentrant has_one with max_depth, got: %#v", rel)
44+
}
3245
}

internal/model/BuildAliasMap.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import (
1616
// Правила:
1717
// - Разрешённые типы связей для пути: has_one, has_many, belongs_to.
1818
// - re-entry считается "возвратом в уже встречавшуюся модель по пути":
19-
// нужен rel.Reentrant == true и repeats+1 <= effMax, где effMax = field.MaxDepth (для полей его тут нет) или rel.MaxDepth,
20-
// если effMax <= 0 — трактуем как 1 (только одно посещение модели на пути; без возвратов).
19+
// нужен rel.Reentrant == true и repeats+1 <= effMax, где effMax = rel.MaxDepth
20+
// (или defaultReentrantMaxDepth, если max_depth не задан явно).
2121
func BuildAliasMap(model *Model, preset *DataPreset, filters map[string]interface{}, sorts []string) (*AliasMap, error) {
2222
if model == nil {
2323
return nil, fmt.Errorf("BuildAliasMap: model is nil")
@@ -106,10 +106,7 @@ func ensureAliasPath(root *Model, am *AliasMap, fullPath string, nextIdx *int) e
106106
if _, exists := am.PathToAlias[path]; !exists {
107107
repeats := countModelIn(stack, nextModel)
108108
if repeats > 0 {
109-
effMax := rel.MaxDepth
110-
if effMax <= 0 {
111-
effMax = 1 // посещений модели на одном пути по умолчанию
112-
}
109+
effMax, _ := resolveMaxDepth(0, rel.MaxDepth)
113110
if !rel.Reentrant {
114111
return fmt.Errorf("not reentrant: returning to model %s via %q at %q", rel.Model, seg, path)
115112
}

internal/model/CreateAliasMap.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func buildPresetAliasMapsForModel(m *Model, visited map[*Model]bool) error {
100100

101101
// Собирает все relation-пути ("a", "a.b", "a.b.c") из NestedPreset-полей данного пресета.
102102
// Учитывает политику ре-энтри по МОДЕЛИ: rel.Reentrant и лимит посещений модели effMax.
103-
// По умолчанию effMax=1 (только одно посещение модели на пути; без возвратов).
103+
// При отсутствии field.max_depth и relation.max_depth используется defaultReentrantMaxDepth.
104104
func collectPresetRelationPaths(root *Model, presetName string) ([]string, error) {
105105
set := make(map[string]struct{})
106106
var dfs func(curr *Model, pName, currPath string, stack []*Model) error
@@ -131,13 +131,7 @@ func collectPresetRelationPaths(root *Model, presetName string) ([]string, error
131131
}
132132
}
133133
if repeats > 0 {
134-
eff := f.MaxDepth
135-
if eff <= 0 {
136-
eff = rel.MaxDepth
137-
}
138-
if eff <= 0 {
139-
eff = 1
140-
} // трактуем как "макс. посещений модели на пути"
134+
eff, _ := resolveMaxDepth(f.MaxDepth, rel.MaxDepth) // трактуем как "макс. посещений модели на пути"
141135
if !rel.Reentrant || repeats >= eff {
142136
// путь добавили, но глубже НЕ идём
143137
continue

internal/model/PresetInheritance_test.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ presets:
8080
}
8181
}
8282

83-
84-
8583
// Сравнение списков полей с учётом только значимых для теста атрибутов.
8684
// Если есть служебные поля/ссылки — игнорим их.
8785
func fieldsEqual(got, want []Field) bool {
@@ -103,6 +101,7 @@ func fieldsEqual(got, want []Field) bool {
103101
}
104102
return reflect.DeepEqual(gg, ww)
105103
}
104+
106105
// keyOf как в резолвере: alias приоритетнее, иначе source
107106
func keyOfField(f Field) string {
108107
if s := strings.TrimSpace(f.Alias); s != "" {
@@ -391,7 +390,7 @@ presets:
391390
preset: mini
392391
`
393392

394-
contragentY := `
393+
contragentY := `
395394
table: contragents
396395
relations:
397396
contracts:
@@ -455,13 +454,13 @@ presets:
455454
`
456455
// файлы
457456
write(t, dir, "Contract.yml", contractY)
458-
write(t, dir, "Contragent.yml", contragentY)
457+
write(t, dir, "Contragent.yml", contragentY)
459458
mustLoadAndExpectValidateErr(t, dir, "not reentrant")
460459
}
461460

462-
// Превышение лимита глубины: позволен один ре-энтри, но цепочка делает два
463-
// Contract.card -> contragent.mini -> contracts.list2 -> contragent.mini (второй заход в mini)
464-
func TestValidatePresets_ReentrantDepthExceeded(t *testing.T) {
461+
// Превышение лимита глубины больше не является ошибкой валидации:
462+
// обход должен остановиться на лимите и успешно завершиться.
463+
func TestValidatePresets_ReentrantDepthExceeded_IsCapped(t *testing.T) {
465464
dir := t.TempDir()
466465

467466
contractY := `
@@ -508,7 +507,7 @@ presets:
508507
write(t, dir, "Contract.yml", contractY)
509508
write(t, dir, "Contragent.yml", contragentY)
510509

511-
mustLoadAndExpectValidateErr(t, dir, "exceed max_depth")
510+
mustLoadAndValidate(t, dir)
512511
}
513512

514513
// Требование: поле с NestedPreset должно быть type=preset
@@ -617,7 +616,6 @@ presets:
617616
mustLoadAndExpectValidateErr(t, dir, "unsupported type")
618617
}
619618

620-
621619
// Ошибка: отсутствующий nested preset
622620
func TestValidatePresets_MissingNestedPreset(t *testing.T) {
623621
dir := t.TempDir()

internal/model/max_depth.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package model
2+
3+
const defaultReentrantMaxDepth = 3
4+
5+
// resolveMaxDepth returns an effective max_depth.
6+
// Priority: field.max_depth > relation.max_depth > defaultReentrantMaxDepth.
7+
// The second return value indicates that the default value was used.
8+
func resolveMaxDepth(fieldMax, relMax int) (int, bool) {
9+
if fieldMax > 0 {
10+
return fieldMax, false
11+
}
12+
if relMax > 0 {
13+
return relMax, false
14+
}
15+
return defaultReentrantMaxDepth, true
16+
}

0 commit comments

Comments
 (0)