laskoviymishka commented on code in PR #1324:
URL: https://github.com/apache/iceberg-go/pull/1324#discussion_r3491578665


##########
catalog/rest/scan_planning.go:
##########
@@ -113,6 +189,48 @@ func (r *Catalog) WaitForPlan(ctx context.Context, ident 
table.Identifier, planI
        return CompletedPlanningResult{}, fmt.Errorf("%w: wait for plan", 
iceberg.ErrNotImplemented)
 }
 
+func (r *Catalog) scanPlanningPath(ep endpoint, ident table.Identifier, extra 
...string) ([]string, error) {
+       ns, tbl, err := r.splitIdentForPath(ident)
+       if err != nil {
+               return nil, err
+       }
+
+       return ep.reqPath(append([]string{ns, tbl}, extra...)...)
+}
+
+func scanPlanningHeaders(idempotencyKey, accessDelegation *string, 
includeIdempotency bool) (map[string]string, error) {
+       headers := make(map[string]string, 2)
+       if includeIdempotency {
+               key, err := idempotencyHeaderValue(idempotencyKey)
+               if err != nil {
+                       return nil, err
+               }
+               headers[headerIdempotencyKey] = key
+       }
+       if accessDelegation != nil {
+               headers[headerIcebergAccessDelegation] = *accessDelegation
+       }
+       if len(headers) == 0 {
+               return nil, nil
+       }
+
+       return headers, nil
+}
+
+func idempotencyHeaderValue(idempotencyKey *string) (string, error) {
+       if idempotencyKey == nil {
+               // Plan and task POSTs always send an idempotency key. There is 
no
+               // transport retry here, so nil means a fresh key for this call.
+               return uuid.NewString(), nil

Review Comment:
   `uuid.NewString()` gives us a UUIDv4, but the spec pins this header to 
"UUIDv7 in string form (RFC 9562)" and Java generates it via 
`UUIDUtil.generateUuidV7()`. Servers that key the idempotency window off the 
embedded timestamp will treat our v4 keys as undefined, so cross-client dedup 
against a Java-based server is broken.
   
   `google/uuid` v1.6.0 is already in go.mod and exposes `uuid.NewV7()` — I'd 
swap to that and propagate the error it returns.
   
   While we're here: Java only sends idempotency headers when the server 
advertises a key lifetime (`config.idempotencyKeyLifetime()`), whereas we send 
one on every plan and task POST. The header's optional, so this isn't wrong, 
but strict servers could reject the unsolicited header. Worth a follow-up to 
gate it on config, even if not in this PR.



##########
catalog/rest/scan_planning.go:
##########
@@ -80,29 +88,97 @@ func (r *Catalog) PlanFiles(ctx context.Context, req 
table.ScanPlanningRequest)
 // --- Low-level client methods -----------------------------------------------
 
 // PlanTableScan submits a scan plan. The result is either completed inline,
-// submitted (returns a plan-id to poll), or failed.
+// submitted (returns a plan-id to poll), or failed. A failed planning response
+// decodes as (resp, nil); callers must branch on resp.Status.
 func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, 
req PlanTableScanRequest) (PlanTableScanResponse, error) {
-       return PlanTableScanResponse{}, fmt.Errorf("%w: plan table scan", 
iceberg.ErrNotImplemented)
+       if err := r.endpoints.check(endpointPlanTableScan); err != nil {
+               return PlanTableScanResponse{}, err
+       }
+
+       path, err := r.scanPlanningPath(endpointPlanTableScan, ident)
+       if err != nil {
+               return PlanTableScanResponse{}, err
+       }
+
+       headers, err := scanPlanningHeaders(req.IdempotencyKey, 
req.AccessDelegation, true)
+       if err != nil {
+               return PlanTableScanResponse{}, err
+       }
+
+       return doPost[PlanTableScanRequest, PlanTableScanResponse](
+               ctx, r.baseURI, path, req, r.cl,
+               map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable}, 
withHeaders(headers))
 }
 
 // FetchPlanningResult polls a previously submitted plan. opts.AccessDelegation
 // is sent as the X-Iceberg-Access-Delegation header so an async poll can still
 // receive plan-scoped storage credentials: the spec defines data-access on 
this
 // endpoint, and the completed-async result is where those credentials are 
vended.
 func (r *Catalog) FetchPlanningResult(ctx context.Context, ident 
table.Identifier, planID string, opts FetchPlanningResultOptions) 
(FetchPlanningResultResponse, error) {
-       return FetchPlanningResultResponse{}, fmt.Errorf("%w: fetch planning 
result", iceberg.ErrNotImplemented)
+       if err := r.endpoints.check(endpointFetchPlanResult); err != nil {
+               return FetchPlanningResultResponse{}, err
+       }
+
+       path, err := r.scanPlanningPath(endpointFetchPlanResult, ident, planID)
+       if err != nil {
+               return FetchPlanningResultResponse{}, err
+       }
+
+       headers, err := scanPlanningHeaders(nil, opts.AccessDelegation, false)
+       if err != nil {
+               return FetchPlanningResultResponse{}, err
+       }
+
+       return doGet[FetchPlanningResultResponse](
+               ctx, r.baseURI, path, r.cl,
+               map[int]error{http.StatusNotFound: ErrPlanExpired}, 
withHeaders(headers))

Review Comment:
   This maps every 404 to `ErrPlanExpired`, but the spec's 
`fetchPlanningResult` 404 can be `NoSuchPlanIdException`, 
`NoSuchTableException`, or `NoSuchNamespaceException`. A dropped table comes 
back as "plan expired," which is the one distinction `WaitForPlan` actually 
needs — "plan expired, retry with a new plan" vs. "table's gone, abort."
   
   I'd settle this before the polling layer lands on top of it, since fixing it 
later means reworking the retry contract. The clean version is inspecting the 
body's `error.type` before mapping — either a pre-decode pass or a typed 
override hook in `handleNon200` that can split on body content. wdyt?



##########
catalog/rest/scan_planning.go:
##########
@@ -80,29 +88,97 @@ func (r *Catalog) PlanFiles(ctx context.Context, req 
table.ScanPlanningRequest)
 // --- Low-level client methods -----------------------------------------------
 
 // PlanTableScan submits a scan plan. The result is either completed inline,
-// submitted (returns a plan-id to poll), or failed.
+// submitted (returns a plan-id to poll), or failed. A failed planning response
+// decodes as (resp, nil); callers must branch on resp.Status.
 func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, 
req PlanTableScanRequest) (PlanTableScanResponse, error) {

Review Comment:
   I think this contract is going to bite callers. Returning `(resp, nil)` for 
a `failed` plan means anyone using the `if err != nil` idiom treats a failure 
as success, then reads `resp.PlanID` (nil for failed) and panics. The doc 
comment is honest about it, but the comment is the only thing standing between 
this and a nil-deref downstream.
   
   I'd lean toward returning an error on `failed` — something like 
`fmt.Errorf("%w: plan failed: %s", ErrRESTError, resp.Error.Message)`, or a 
typed error wrapping the `PlanningError` so callers can still get at the 
structured detail. The `completed`/`submitted` split stays in the response.
   
   Either way I'd settle it here rather than after `WaitForPlan` and the 
scanner are written against the current shape — and the same note needs to go 
on `FetchPlanningResult`, which has the identical `(resp, nil)` contract and is 
exactly what the poller will loop on. Thoughts?



##########
catalog/rest/rest.go:
##########
@@ -287,7 +295,48 @@ func (s *sessionTransport) RoundTrip(r *http.Request) 
(*http.Response, error) {
        return s.RoundTripper.RoundTrip(r)
 }
 
-func do[T any](ctx context.Context, method string, baseURI *url.URL, path 
[]string, cl *http.Client, override map[int]error, allowNoContent bool) (ret T, 
err error) {
+// reqConfig holds the optional per-request knobs shared by do and doPost:
+// headers to set, session-default headers to suppress, and whether an HTTP 204
+// is a valid empty result.
+type reqConfig struct {
+       headers         map[string]string
+       suppressHeaders []string
+       allowNoContent  bool
+}
+
+type reqOption func(*reqConfig)
+
+// withHeaders sets per-request headers. They take precedence over session
+// defaults because sessionTransport.RoundTrip skips any default whose key is
+// already present on the request.
+func withHeaders(headers map[string]string) reqOption {
+       return func(c *reqConfig) { c.headers = headers }
+}
+
+// withSuppressedHeaders prevents the named session-default headers from being
+// sent on this request. See suppressRequestHeaders for the mechanism.
+func withSuppressedHeaders(names ...string) reqOption {

Review Comment:
   Both options assign rather than merge, so two `withSuppressedHeaders(...)` 
on one call silently drops all but the last. Not a bug today since each is 
passed once, but it's a trap for the next caller who reasonably assumes options 
compose.
   
   I'd make `withSuppressedHeaders` append (`c.suppressHeaders = 
append(c.suppressHeaders, names...)`) and merge maps in `withHeaders`.



##########
catalog/rest/rest.go:
##########
@@ -389,6 +440,24 @@ func doPostAllowNoContent[Payload, Result any](ctx 
context.Context, baseURI *url
        return ret, err
 }
 
+func setRequestHeaders(req *http.Request, headers map[string]string) {
+       for k, v := range headers {
+               req.Header.Set(k, v)
+       }
+}
+
+// suppressRequestHeaders prevents the named session-default headers from being
+// sent on this request. It writes a present-but-empty header entry rather than
+// calling Header.Del: sessionTransport.RoundTrip decides whether to apply a
+// default by testing key *presence* (not value), so a present-but-empty key
+// both blocks the default and emits nothing, whereas deleting the key would 
let
+// the default reappear. Keep RoundTrip's presence check in sync with this.
+func suppressRequestHeaders(req *http.Request, headers []string) {
+       for _, h := range headers {
+               req.Header[http.CanonicalHeaderKey(h)] = nil

Review Comment:
   This leans on net/http not writing a header whose value slice is nil — 
correct today, but it's an internal wire invariant, not a documented contract, 
and the comment calls it "present-but-empty" when it's actually nil. If a 
runtime change ever altered that, the suppression breaks silently and 
`X-Iceberg-Access-Delegation` reappears on cancel and task-fetch.
   
   Two ways to firm this up. The lighter one is `req.Header.Set(h, "")` plus a 
comment — present, empty string, still skipped by the presence check, no 
reliance on nil-slice behavior. The sturdier one is moving suppression into 
`reqConfig` as an explicit suppress-set that `RoundTrip` consults directly, 
since `RoundTrip` already owns session-default logic — then there's no 
two-sided "keep in sync" invariant at all. I'd lean toward the latter given a 
follow-up PR will be poking at this. Thoughts?



##########
catalog/rest/scan_planning.go:
##########
@@ -80,29 +88,97 @@ func (r *Catalog) PlanFiles(ctx context.Context, req 
table.ScanPlanningRequest)
 // --- Low-level client methods -----------------------------------------------
 
 // PlanTableScan submits a scan plan. The result is either completed inline,
-// submitted (returns a plan-id to poll), or failed.
+// submitted (returns a plan-id to poll), or failed. A failed planning response
+// decodes as (resp, nil); callers must branch on resp.Status.
 func (r *Catalog) PlanTableScan(ctx context.Context, ident table.Identifier, 
req PlanTableScanRequest) (PlanTableScanResponse, error) {
-       return PlanTableScanResponse{}, fmt.Errorf("%w: plan table scan", 
iceberg.ErrNotImplemented)
+       if err := r.endpoints.check(endpointPlanTableScan); err != nil {
+               return PlanTableScanResponse{}, err
+       }
+
+       path, err := r.scanPlanningPath(endpointPlanTableScan, ident)
+       if err != nil {
+               return PlanTableScanResponse{}, err
+       }
+
+       headers, err := scanPlanningHeaders(req.IdempotencyKey, 
req.AccessDelegation, true)
+       if err != nil {
+               return PlanTableScanResponse{}, err
+       }
+
+       return doPost[PlanTableScanRequest, PlanTableScanResponse](
+               ctx, r.baseURI, path, req, r.cl,
+               map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable}, 
withHeaders(headers))
 }
 
 // FetchPlanningResult polls a previously submitted plan. opts.AccessDelegation
 // is sent as the X-Iceberg-Access-Delegation header so an async poll can still
 // receive plan-scoped storage credentials: the spec defines data-access on 
this
 // endpoint, and the completed-async result is where those credentials are 
vended.
 func (r *Catalog) FetchPlanningResult(ctx context.Context, ident 
table.Identifier, planID string, opts FetchPlanningResultOptions) 
(FetchPlanningResultResponse, error) {
-       return FetchPlanningResultResponse{}, fmt.Errorf("%w: fetch planning 
result", iceberg.ErrNotImplemented)
+       if err := r.endpoints.check(endpointFetchPlanResult); err != nil {
+               return FetchPlanningResultResponse{}, err
+       }
+
+       path, err := r.scanPlanningPath(endpointFetchPlanResult, ident, planID)
+       if err != nil {
+               return FetchPlanningResultResponse{}, err
+       }
+
+       headers, err := scanPlanningHeaders(nil, opts.AccessDelegation, false)
+       if err != nil {
+               return FetchPlanningResultResponse{}, err
+       }
+
+       return doGet[FetchPlanningResultResponse](
+               ctx, r.baseURI, path, r.cl,
+               map[int]error{http.StatusNotFound: ErrPlanExpired}, 
withHeaders(headers))
 }
 
 // CancelPlanning cancels a server-side plan. Callers should cancel on context
-// cancellation using a detached context with a short timeout.
+// cancellation using a detached context with a short timeout. The spec 
supports
+// idempotency and access-delegation headers on cancel; this low-level method
+// deliberately defers those until a cancel options type is added, and 
suppresses
+// the session-default access-delegation header (cancel vends no credentials). 
A
+// 404 (already-expired or unknown plan) is not special-cased: cancel is
+// best-effort, so the generic REST error is acceptable.
 func (r *Catalog) CancelPlanning(ctx context.Context, ident table.Identifier, 
planID string) error {
-       return fmt.Errorf("%w: cancel planning", iceberg.ErrNotImplemented)
+       if err := r.endpoints.check(endpointCancelPlanning); err != nil {
+               return err
+       }
+
+       path, err := r.scanPlanningPath(endpointCancelPlanning, ident, planID)
+       if err != nil {
+               return err
+       }
+
+       _, err = doDelete[struct{}](
+               ctx, r.baseURI, path, r.cl, nil,
+               withSuppressedHeaders(headerIcebergAccessDelegation))
+
+       return err
 }
 
 // FetchScanTasks fetches the scan tasks for a plan-task handle returned by a
 // completed plan.
 func (r *Catalog) FetchScanTasks(ctx context.Context, ident table.Identifier, 
req FetchScanTasksRequest) (FetchScanTasksResponse, error) {
-       return FetchScanTasksResponse{}, fmt.Errorf("%w: fetch scan tasks", 
iceberg.ErrNotImplemented)
+       if err := r.endpoints.check(endpointFetchScanTasks); err != nil {
+               return FetchScanTasksResponse{}, err
+       }
+
+       path, err := r.scanPlanningPath(endpointFetchScanTasks, ident)
+       if err != nil {
+               return FetchScanTasksResponse{}, err
+       }
+
+       headers, err := scanPlanningHeaders(req.IdempotencyKey, nil, true)
+       if err != nil {
+               return FetchScanTasksResponse{}, err
+       }
+
+       return doPost[FetchScanTasksRequest, FetchScanTasksResponse](
+               ctx, r.baseURI, path, req, r.cl,
+               map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable},

Review Comment:
   Same shape as the `FetchPlanningResult` 404, the other direction: here every 
404 becomes `catalog.ErrNoSuchTable`, but the spec's `fetchScanTasks` 404 also 
covers `NoSuchPlanTaskException`. A caller fanning out over plan-task handles 
will read `ErrNoSuchTable` and conclude the table vanished when really a task 
handle expired. If we go the `error.type` inspection route for the 
`FetchPlanningResult` 404, the same hook covers this — and we'd want an 
`ErrNoSuchPlanTask` sentinel to map onto.



##########
catalog/rest/rest.go:
##########
@@ -223,9 +229,11 @@ const emptyStringHash = 
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991
 
 func (s *sessionTransport) RoundTrip(r *http.Request) (*http.Response, error) {
        for k, v := range s.defaultHeaders {
-               // Skip Content-Type if it's already set in the request
-               // to avoid duplicate headers (e.g., when using PostForm)
-               if http.CanonicalHeaderKey(k) == "Content-Type" && 
r.Header.Get("Content-Type") != "" {
+               // Per-request headers override session defaults. The check is 
on key
+               // *presence*, not value, so suppressRequestHeaders can opt out 
of a
+               // default by setting a present-but-empty entry. Keep this in 
sync with
+               // suppressRequestHeaders.
+               if _, ok := r.Header[http.CanonicalHeaderKey(k)]; ok {

Review Comment:
   This broadens the old behavior: it used to skip only Content-Type, now it 
skips any session default whose key is already present. That's almost certainly 
what we want, but the oauth2 Content-Type correctness that the old special-case 
guaranteed is now a side-effect of the general rule. I'd just note in the 
comment that the rule applies to any per-request header sharing a key with a 
session default, so the next person doesn't re-add a Content-Type special case.



##########
catalog/rest/scan_planning.go:
##########
@@ -54,21 +59,24 @@ var ErrPlanExpired = fmt.Errorf("%w: scan plan expired", 
ErrRESTError)
 // SupportsPlanTableScan reports whether the server advertised the synchronous
 // plan endpoint.
 func (r *Catalog) SupportsPlanTableScan() bool {
-       return false
+       return r.endpoints.contains(endpointPlanTableScan)
 }
 
 // SupportsFullRemoteScanPlanning reports whether the server advertised all 
four
 // scan-planning endpoints (plan, fetch-result, cancel, fetch-tasks).
 func (r *Catalog) SupportsFullRemoteScanPlanning() bool {
-       return false
+       return r.SupportsPlanTableScan() &&
+               r.endpoints.contains(endpointFetchPlanResult) &&
+               r.endpoints.contains(endpointCancelPlanning) &&
+               r.endpoints.contains(endpointFetchScanTasks)
 }
 
 // --- table.ScanPlanner implementation ---------------------------------------
 
 // SupportsRemoteScanPlanning reports whether this catalog can complete a 
remote
 // plan end-to-end; backed by the split capability checks above.
 func (r *Catalog) SupportsRemoteScanPlanning() bool {
-       return false
+       return r.SupportsFullRemoteScanPlanning()

Review Comment:
   This requires all four endpoints, where Java's minimal check only needs the 
submit endpoint for the sync-only case (`RESTSessionCatalog.java:641`). A 
server that advertises only `plan` — completed-inline, no polling — passes Java 
but gets rejected here and falls back to client-side planning.
   
   The doc comment ("complete a plan end-to-end") is consistent with requiring 
all four, so this isn't a contradiction so much as a semantics call: is 
`SupportsRemoteScanPlanning` "can I do the full async dance" or "can the server 
plan at all"? I'd lean toward the latter to match Java — return 
`SupportsPlanTableScan()`, and let callers needing fanout check 
`SupportsFullRemoteScanPlanning()` explicitly. wdyt?



##########
catalog/rest/rest_internal_test.go:
##########
@@ -914,22 +914,22 @@ func TestResponseBodyLeak(t *testing.T) {
                baseURI, err := url.Parse(srv.URL)
                require.NoError(t, err)
 
-               _, err = do[struct{}](context.Background(), http.MethodGet, 
baseURI, []string{"test"}, client, nil, false)
+               _, err = do[struct{}](context.Background(), http.MethodGet, 
baseURI, []string{"test"}, client, nil)

Review Comment:
   The generalized presence-check override in `RoundTrip` is only tested 
transitively through the scan-planning end-to-end tests. Since it's the most 
consequential behavior change in the PR, I'd add a direct unit test here: build 
a `sessionTransport` with a default header, send a request that already sets 
that header, call `RoundTrip`, and assert the default wasn't appended. That 
pins the invariant independently of the scan-planning callers.



##########
catalog/rest/scan_planning_test.go:
##########
@@ -94,3 +183,298 @@ func TestPlanTableScanResponseRejectsUnknownStatus(t 
*testing.T) {
        err := json.Unmarshal([]byte(`{"status":"bogus"}`), &resp)
        require.ErrorIs(t, err, ErrRESTError)
 }
+
+func TestFetchPlanningResultResponseRejectsMalformedResponses(t *testing.T) {

Review Comment:
   The `cancelled` arm in `FetchPlanningResultResponse.UnmarshalJSON` is 
accepted silently with no test, so a refactor routing `cancelled` into the 
`default` error case would slip through — and `cancelled` is specifically valid 
for poll results, unlike `PlanTableScanResponse`. A subtest unmarshaling 
`{"status":"cancelled"}` asserting NoError + `PlanStatusCancelled` would pin it.
   
   Same gap on the round-trip side: `TestFetchPlanningResultRequest` only 
covers `completed`, while `TestPlanTableScanRequest` has a `failed` arm. A 
`failed`-status HTTP case here would exercise the validation through the real 
client path, not just direct `json.Unmarshal`.



##########
catalog/rest/scan_planning_test.go:
##########
@@ -94,3 +183,298 @@ func TestPlanTableScanResponseRejectsUnknownStatus(t 
*testing.T) {
        err := json.Unmarshal([]byte(`{"status":"bogus"}`), &resp)
        require.ErrorIs(t, err, ErrRESTError)
 }
+
+func TestFetchPlanningResultResponseRejectsMalformedResponses(t *testing.T) {
+       t.Parallel()
+
+       t.Run("failed without error", func(t *testing.T) {
+               t.Parallel()
+
+               var resp FetchPlanningResultResponse
+               err := json.Unmarshal([]byte(`{"status":"failed"}`), &resp)
+               require.ErrorIs(t, err, ErrRESTError)
+       })
+
+       t.Run("unknown status", func(t *testing.T) {
+               t.Parallel()
+
+               var resp FetchPlanningResultResponse
+               err := json.Unmarshal([]byte(`{"status":"bogus"}`), &resp)
+               require.ErrorIs(t, err, ErrRESTError)
+       })
+}
+
+func TestPlanTableScanGeneratesIdempotencyKeyAndUsesDefaultAccessDelegation(t 
*testing.T) {
+       t.Parallel()
+
+       cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan}, 
func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan", func(w 
http.ResponseWriter, req *http.Request) {
+                       got := req.Header.Get(headerIdempotencyKey)
+                       require.NotEmpty(t, got)
+                       _, err := uuid.Parse(got)
+                       require.NoError(t, err)
+                       assert.Equal(t, []string{defaultAccessDelegation}, 
req.Header.Values(headerIcebergAccessDelegation))
+
+                       _, err = 
w.Write([]byte(`{"status":"completed","plan-id":"plan-1"}`))
+                       require.NoError(t, err)
+               })
+       })
+
+       _, err := cat.PlanTableScan(context.Background(), 
table.Identifier{"db", "tbl"}, PlanTableScanRequest{})
+       require.NoError(t, err)
+}
+
+func TestPlanTableScanRejectsInvalidIdempotencyKey(t *testing.T) {
+       t.Parallel()
+
+       badKey := "not-a-uuid"
+       cat := &Catalog{endpoints: 
newEndpointSet([]endpoint{endpointPlanTableScan})}
+
+       _, err := cat.PlanTableScan(context.Background(), 
table.Identifier{"db", "tbl"}, PlanTableScanRequest{
+               IdempotencyKey: &badKey,
+       })
+       require.ErrorIs(t, err, iceberg.ErrInvalidArgument)
+}
+
+func TestPlanTableScanRequest(t *testing.T) {
+       t.Parallel()
+
+       cases := []struct {
+               name       string
+               response   string
+               wantStatus PlanStatus
+               wantPlanID *string
+               wantError  string
+       }{
+               {
+                       name:       "completed",
+                       response:   
`{"status":"completed","plan-id":"plan-1","plan-tasks":["task-1"]}`,
+                       wantStatus: PlanStatusCompleted,
+                       wantPlanID: stringPtr("plan-1"),
+               },
+               {
+                       name:       "submitted",
+                       response:   `{"status":"submitted","plan-id":"plan-2"}`,
+                       wantStatus: PlanStatusSubmitted,
+                       wantPlanID: stringPtr("plan-2"),
+               },
+               {
+                       name:       "failed",
+                       response:   
`{"status":"failed","error":{"message":"boom","type":"ServerError","code":500}}`,
+                       wantStatus: PlanStatusFailed,
+                       wantError:  "boom",
+               },
+       }
+
+       for _, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       t.Parallel()
+
+                       idempotencyKey := "11111111-1111-1111-1111-111111111111"
+                       accessDelegation := "remote-signing"
+                       snapshotID := int64(22)
+                       cat := newScanPlanningTestCatalog(t, 
[]endpoint{endpointPlanTableScan}, func(mux *http.ServeMux) {
+                               
mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan", func(w http.ResponseWriter, 
req *http.Request) {
+                                       require.Equal(t, http.MethodPost, 
req.Method)
+                                       assert.Equal(t, idempotencyKey, 
req.Header.Get(headerIdempotencyKey))
+                                       assert.Equal(t, 
[]string{accessDelegation}, req.Header.Values(headerIcebergAccessDelegation))
+
+                                       body, err := io.ReadAll(req.Body)
+                                       require.NoError(t, err)
+                                       assert.JSONEq(t, `{
+                                               "snapshot-id": 22,
+                                               "select": ["id", "data"],
+                                               "filter": {"type": 
"always-true"}
+                                       }`, string(body))
+
+                                       _, err = w.Write([]byte(tc.response))
+                                       require.NoError(t, err)
+                               })
+                       })
+
+                       resp, err := cat.PlanTableScan(context.Background(), 
table.Identifier{"db", "tbl"}, PlanTableScanRequest{
+                               IdempotencyKey:   &idempotencyKey,
+                               AccessDelegation: &accessDelegation,
+                               SnapshotID:       &snapshotID,
+                               Select:           []string{"id", "data"},
+                               Filter:           
json.RawMessage(`{"type":"always-true"}`),
+                       })
+                       require.NoError(t, err)
+                       assert.Equal(t, tc.wantStatus, resp.Status)
+                       if tc.wantPlanID != nil {
+                               require.NotNil(t, resp.PlanID)
+                               assert.Equal(t, *tc.wantPlanID, *resp.PlanID)
+                       }
+                       if tc.wantError != "" {
+                               require.NotNil(t, resp.Error)
+                               assert.Equal(t, tc.wantError, 
resp.Error.Message)
+                       }
+               })
+       }
+}
+
+func TestFetchPlanningResultRequest(t *testing.T) {
+       t.Parallel()
+
+       accessDelegation := "remote-signing"
+       cat := newScanPlanningTestCatalog(t, 
[]endpoint{endpointFetchPlanResult}, func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan/plan-123", 
func(w http.ResponseWriter, req *http.Request) {
+                       require.Equal(t, http.MethodGet, req.Method)
+                       assert.Equal(t, []string{accessDelegation}, 
req.Header.Values(headerIcebergAccessDelegation))
+
+                       _, err := 
w.Write([]byte(`{"status":"completed","plan-tasks":["task-1"]}`))
+                       require.NoError(t, err)
+               })
+       })
+
+       resp, err := cat.FetchPlanningResult(context.Background(), 
table.Identifier{"db", "tbl"}, "plan-123", FetchPlanningResultOptions{
+               AccessDelegation: &accessDelegation,
+       })
+       require.NoError(t, err)
+       assert.Equal(t, PlanStatusCompleted, resp.Status)
+       assert.Equal(t, []string{"task-1"}, resp.PlanTasks)
+}
+
+func TestFetchPlanningResultUsesDefaultAccessDelegation(t *testing.T) {
+       t.Parallel()
+
+       cat := newScanPlanningTestCatalog(t, 
[]endpoint{endpointFetchPlanResult}, func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan/plan-123", 
func(w http.ResponseWriter, req *http.Request) {
+                       require.Equal(t, http.MethodGet, req.Method)
+                       assert.Equal(t, []string{defaultAccessDelegation}, 
req.Header.Values(headerIcebergAccessDelegation))
+
+                       _, err := w.Write([]byte(`{"status":"submitted"}`))
+                       require.NoError(t, err)
+               })
+       })
+
+       resp, err := cat.FetchPlanningResult(context.Background(), 
table.Identifier{"db", "tbl"}, "plan-123", FetchPlanningResultOptions{})
+       require.NoError(t, err)
+       assert.Equal(t, PlanStatusSubmitted, resp.Status)
+}
+
+func TestFetchPlanningResultMapsNotFoundToPlanExpired(t *testing.T) {
+       t.Parallel()
+
+       cat := newScanPlanningTestCatalog(t, 
[]endpoint{endpointFetchPlanResult}, func(mux *http.ServeMux) {
+               
mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan/expired-plan", func(w 
http.ResponseWriter, req *http.Request) {
+                       require.Equal(t, http.MethodGet, req.Method)
+                       w.WriteHeader(http.StatusNotFound)
+               })
+       })
+
+       _, err := cat.FetchPlanningResult(context.Background(), 
table.Identifier{"db", "tbl"}, "expired-plan", FetchPlanningResultOptions{})
+       require.ErrorIs(t, err, ErrPlanExpired)
+}
+
+func TestCancelPlanningRequest(t *testing.T) {
+       t.Parallel()
+
+       cat := newScanPlanningTestCatalog(t, 
[]endpoint{endpointCancelPlanning}, func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan/plan-123", 
func(w http.ResponseWriter, req *http.Request) {
+                       require.Equal(t, http.MethodDelete, req.Method)
+                       assert.Empty(t, req.Header.Values(headerIdempotencyKey))
+                       assert.Empty(t, 
req.Header.Values(headerIcebergAccessDelegation))
+                       w.WriteHeader(http.StatusNoContent)
+               })
+       })
+
+       require.NoError(t, cat.CancelPlanning(context.Background(), 
table.Identifier{"db", "tbl"}, "plan-123"))
+}
+
+func TestFetchScanTasksRequest(t *testing.T) {
+       t.Parallel()
+
+       idempotencyKey := "22222222-2222-2222-2222-222222222222"
+       cat := newScanPlanningTestCatalog(t, 
[]endpoint{endpointFetchScanTasks}, func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/tasks", func(w 
http.ResponseWriter, req *http.Request) {
+                       require.Equal(t, http.MethodPost, req.Method)
+                       assert.Equal(t, idempotencyKey, 
req.Header.Get(headerIdempotencyKey))
+                       assert.Empty(t, 
req.Header.Values(headerIcebergAccessDelegation))
+
+                       body, err := io.ReadAll(req.Body)
+                       require.NoError(t, err)
+                       assert.JSONEq(t, `{"plan-task":"task-1"}`, string(body))
+
+                       _, err = w.Write([]byte(`{
+                               "plan-tasks": ["child-task"],
+                               "file-scan-tasks": [{}],
+                               "delete-files": [{}]
+                       }`))
+                       require.NoError(t, err)
+               })
+       })
+
+       resp, err := cat.FetchScanTasks(context.Background(), 
table.Identifier{"db", "tbl"}, FetchScanTasksRequest{
+               IdempotencyKey: &idempotencyKey,
+               PlanTask:       "task-1",
+       })
+       require.NoError(t, err)
+       assert.Equal(t, []string{"child-task"}, resp.PlanTasks)
+       assert.Len(t, resp.FileScanTasks, 1)
+       assert.Len(t, resp.DeleteFiles, 1)
+}
+
+func TestScanPlanningEndpointGating(t *testing.T) {

Review Comment:
   This runs all four endpoint checks in one body, so a failing `require` on 
the first masks the other three. `TestScanPlanningCapabilities` right above it 
uses `t.Run` subtests — I'd match that here so each endpoint reports 
independently.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to