|
| 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 | +} |
0 commit comments