From cbbb8e7fc4a02e055e3831a3311c45eddc8cea45 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 12:01:48 +0530 Subject: [PATCH 01/39] feat: let a mapping refuse a provider's answer and classify failed checks [#57] Some upstreams, PMFBY among them, answer every call with a 200 and signal failure in the body. Without a way to recognise that, the response half mapped a refusal as if it were an answer. A mapping file can now declare a reject section, checked after the call and before the response half runs, and any check can carry its own error code and HTTP status. The step uses Reject only when the mapper offers it, so existing mappers and plugins need no change, and a check without a code keeps returning the generic bad-request code it did before. --- .../internal/common/reject_test.go | 90 +++++++++ .../implementation/internal/common/serve.go | 27 +++ .../implementation/jsonmapper/jsonmapper.go | 85 ++++++--- .../implementation/jsonmapper/reject_test.go | 180 ++++++++++++++++++ 4 files changed, 359 insertions(+), 23 deletions(-) create mode 100644 pkg/plugin/implementation/internal/common/reject_test.go create mode 100644 pkg/plugin/implementation/jsonmapper/reject_test.go diff --git a/pkg/plugin/implementation/internal/common/reject_test.go b/pkg/plugin/implementation/internal/common/reject_test.go new file mode 100644 index 000000000..39198c96c --- /dev/null +++ b/pkg/plugin/implementation/internal/common/reject_test.go @@ -0,0 +1,90 @@ +// reject: a mapper that can refuse a provider's 200. +package common + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" +) + +// rejectingMapper is stubMapper plus Reject, recording what Reject was given. +type rejectingMapper struct { + *stubMapper + rejectErr error + rejectInput any +} + +func (m *rejectingMapper) Reject(_ context.Context, _ string, input any) error { + m.rejectInput = input + return m.rejectErr +} + +// assertStatus fails unless err is a CodedErr carrying status. +func assertStatus(t *testing.T, err error, status int) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != status { + t.Fatalf("error = %v, want a coded %d", err, status) + } +} + +// rejectStep is a step whose provider answers body with a 200. +func rejectStep(t *testing.T, mapper definition.Mapper, body string) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, body) + })) + t.Cleanup(provider.Close) + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) +} + +func TestReject_MapperRefuses_ReturnsItsErrorAndSkipsResponseMapping(t *testing.T) { + stub := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)} + mapper := &rejectingMapper{stubMapper: stub, + rejectErr: model.NewCodedErr(http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", errors.New("refused"))} + + stepCtx, err := runStep(t, rejectStep(t, mapper, `{"responseCode":0,"responseMessage":"no"}`), selectBody) + assertStatus(t, err, http.StatusBadGateway) + if stub.responseInput != nil { + t.Error("the response half ran on a refused answer") + } + if len(stepCtx.ResponseBody) != 0 { + t.Errorf("a refused answer produced a response body: %s", stepCtx.ResponseBody) + } +} + +func TestReject_MapperAccepts_MapsResponse(t *testing.T) { + stub := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)} + mapper := &rejectingMapper{stubMapper: stub} + + stepCtx, err := runStep(t, rejectStep(t, mapper, `{"responseCode":1}`), selectBody) + if err != nil { + t.Fatal(err) + } + if string(stepCtx.ResponseBody) != `{"a":1}` { + t.Errorf("response = %s, want the mapped answer", stepCtx.ResponseBody) + } + input, _ := mapper.rejectInput.(map[string]any) + if input["beckn"] == nil || input["response"].(map[string]any)["responseCode"] != 1.0 { + t.Errorf("Reject was given %v, want the beckn request and the decoded answer", mapper.rejectInput) + } +} + +func TestReject_MapperWithoutReject_MapsResponseUnchanged(t *testing.T) { + stub := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)} + + stepCtx, err := runStep(t, rejectStep(t, stub, `{"responseCode":0}`), selectBody) + if err != nil { + t.Fatalf("a mapper with no Reject refused: %v", err) + } + if !strings.Contains(string(stepCtx.ResponseBody), `"a":1`) { + t.Errorf("response = %s, want the mapped answer", stepCtx.ResponseBody) + } +} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 6d34eca71..95ba17931 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -13,6 +13,7 @@ import ( "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" ) // Run serves the request when it is this step's capability, and does nothing @@ -135,6 +136,10 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return fmt.Errorf("provider answered with something that is not JSON: %w", err) } + if err := s.reject(ctx, plan.BindingKey, call.Mappings, beckn, answer, upstreamResponse); err != nil { + return err + } + // The same file's other half. It is handed what each party sent, plus // whatever prerequisites resolved, under _local. becknResponse, err := s.mapper.Transform(ctx, call.Mappings, definition.DirectionResponse, map[string]any{ @@ -158,6 +163,28 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return nil } +// rejecter is a mapper that can also refuse a provider's answer. Optional, so +// mappers and mapping files that never refuse need nothing new. +type rejecter interface { + Reject(ctx context.Context, mappingRef string, input any) error +} + +// reject checks the provider's answer against the mapping's reject conditions. +// +// A 200 can still be a refusal. The provider's own words go to the log, never +// to the caller: the mapping says what the caller is told. +func (s *Step) reject(ctx context.Context, bindingKey, mappingRef string, beckn, answer any, raw []byte) error { + mapper, ok := s.mapper.(rejecter) + if !ok { + return nil + } + err := mapper.Reject(ctx, mappingRef, map[string]any{"beckn": beckn, "response": answer}) + if err != nil { + log.Warnf(ctx, "%s refused the request: %s", bindingKey, s.redactString(util.Explain(raw))) + } + return err +} + // buildRequest produces what the provider is sent. // // Whatever the mapping produces IS the request: a body for a method that takes diff --git a/pkg/plugin/implementation/jsonmapper/jsonmapper.go b/pkg/plugin/implementation/jsonmapper/jsonmapper.go index c86982de1..0e97d38d0 100644 --- a/pkg/plugin/implementation/jsonmapper/jsonmapper.go +++ b/pkg/plugin/implementation/jsonmapper/jsonmapper.go @@ -59,6 +59,10 @@ type mappingFile struct { // Required are the preconditions this binding-action imposes on a payload, // verified before either half runs. Absent means none. Required []requirement `yaml:"required"` + // Reject are the conditions a provider's answer must meet, verified after + // the call and before the response half runs. For an upstream that says + // "no" with a 200 and a flag in the body. Absent means none. + Reject []requirement `yaml:"reject"` Request string `yaml:"request"` Response string `yaml:"response"` } @@ -77,6 +81,10 @@ type mappingFile struct { type requirement struct { Check string `yaml:"check"` Message string `yaml:"message"` + // Code and Status classify a failed check. Absent means a 400 with the + // generic bad-request code. + Code string `yaml:"code"` + Status int `yaml:"status"` } // Config holds configuration parameters for the mapper. @@ -190,6 +198,8 @@ type cacheEntry struct { directions map[definition.Direction]*compiledMapping // checks are the file's preconditions, in the order it declared them. checks []*compiledRequirement + // rejects are the conditions on the provider's answer, likewise in order. + rejects []*compiledRequirement // err is a failure that applies to the whole file -- it could not be // fetched, or not parsed -- as opposed to one action failing to compile. err error @@ -203,9 +213,19 @@ type cacheEntry struct { type compiledRequirement struct { expression jsonata.Expression message string + code string + status int err error } +// failure is the error a failed check returns, classified as the file says. +func (r *compiledRequirement) failure() error { + if r.status == 0 { + return model.NewBadReqErr(r.code, errors.New(r.message)) + } + return model.NewCodedErr(r.status, r.code, errors.New(r.message)) +} + // compiledMapping is one half of a mapping, or the failure that stopped it // compiling. Failures are held per half deliberately: a typo in the response // mapping is no reason for the request half to stop working, and finding out on @@ -325,7 +345,22 @@ func (m *Mapper) Verify(ctx context.Context, mappingRef string, input any) error if err != nil { return err } - if len(entry.checks) == 0 { + return m.check(ctx, mappingRef, entry.checks, input) +} + +// Reject checks the provider's answer against the conditions the mapping +// declares under reject, in the order declared, and returns the first failure. +func (m *Mapper) Reject(ctx context.Context, mappingRef string, input any) error { + entry, err := m.compiled(ctx, mappingRef) + if err != nil { + return err + } + return m.check(ctx, mappingRef, entry.rejects, input) +} + +// check runs one list of conditions over input and returns the first failure. +func (m *Mapper) check(ctx context.Context, mappingRef string, checks []*compiledRequirement, input any) error { + if len(checks) == 0 { return nil } @@ -334,7 +369,7 @@ func (m *Mapper) Verify(ctx context.Context, mappingRef string, input any) error return fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) } - for _, precondition := range entry.checks { + for _, precondition := range checks { if precondition.err != nil { return precondition.err } @@ -358,7 +393,7 @@ func (m *Mapper) Verify(ctx context.Context, mappingRef string, input any) error if !holds { // The mapping's own words: the caller is told what is wrong with // their payload, not that an expression somewhere returned false. - return model.NewBadReqErr("", errors.New(precondition.message)) + return precondition.failure() } } return nil @@ -403,8 +438,8 @@ func (m *Mapper) compiled(ctx context.Context, mappingRef string) (cacheEntry, e if entry, found := m.cached(mappingRef); found { return entry, entry.err } - directions, checks, err := m.fetchAndCompile(ctx, mappingRef) - return m.remember(mappingRef, directions, checks, err), err + compiled, err := m.fetchAndCompile(ctx, mappingRef) + return m.remember(mappingRef, compiled, err), err }) entry, _ := shared.(cacheEntry) return entry, err @@ -425,18 +460,13 @@ func (m *Mapper) cached(mappingRef string) (cacheEntry, bool) { // remember caches a compiled mapping, or the failure that stopped it compiling. // A failure gets the shorter TTL: it should stop hammering a broken reference // without outlasting the fix. -func (m *Mapper) remember(mappingRef string, directions map[definition.Direction]*compiledMapping, - checks []*compiledRequirement, err error) cacheEntry { +func (m *Mapper) remember(mappingRef string, entry cacheEntry, err error) cacheEntry { ttl := m.config.CacheTTL if err != nil { ttl = m.config.NegativeTTL } - entry := cacheEntry{ - directions: directions, - checks: checks, - err: err, - expiresAt: time.Now().Add(ttl), - } + entry.err = err + entry.expiresAt = time.Now().Add(ttl) m.mu.Lock() defer m.mu.Unlock() @@ -485,23 +515,20 @@ func (m *Mapper) cachedCount() int { } // fetchAndCompile retrieves a mapping and turns it into a runnable expression. -func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) ( - map[definition.Direction]*compiledMapping, []*compiledRequirement, error) { +func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) (cacheEntry, error) { body, err := m.fetch(ctx, mappingRef) if err != nil { - return nil, nil, err + return cacheEntry{}, err } file, err := parseMapping(body) if err != nil { - return nil, nil, fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) + return cacheEntry{}, fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) } // Preconditions compile with the halves, so one round trip leaves the whole // file ready and a precondition costs no extra fetch. - checks := make([]*compiledRequirement, 0, len(file.Required)) - for _, declared := range file.Required { - checks = append(checks, m.compileRequirement(ctx, mappingRef, declared)) - } + checks := m.compileRequirements(ctx, mappingRef, file.Required) + rejects := m.compileRequirements(ctx, mappingRef, file.Reject) // Both halves are compiled now rather than on first use, so one fetch leaves // the file ready in both directions. A compile failure is recorded against @@ -509,8 +536,18 @@ func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) ( directions := make(map[definition.Direction]*compiledMapping, 2) directions[definition.DirectionRequest] = m.compileMapping(ctx, mappingRef, definition.DirectionRequest, file.Request) directions[definition.DirectionResponse] = m.compileMapping(ctx, mappingRef, definition.DirectionResponse, file.Response) - log.Debugf(ctx, "JSON mapper compiled mapping: %s (%d precondition(s))", mappingRef, len(checks)) - return directions, checks, nil + log.Debugf(ctx, "JSON mapper compiled mapping: %s (%d precondition(s), %d reject(s))", + mappingRef, len(checks), len(rejects)) + return cacheEntry{directions: directions, checks: checks, rejects: rejects}, nil +} + +// compileRequirements compiles a list of conditions, keeping their order. +func (m *Mapper) compileRequirements(ctx context.Context, mappingRef string, declared []requirement) []*compiledRequirement { + compiled := make([]*compiledRequirement, 0, len(declared)) + for _, requirement := range declared { + compiled = append(compiled, m.compileRequirement(ctx, mappingRef, requirement)) + } + return compiled } // compileRequirement compiles one precondition, keeping any failure local to it. @@ -532,6 +569,8 @@ func (m *Mapper) compileRequirement(ctx context.Context, mappingRef string, decl return &compiledRequirement{ expression: expression, message: declared.Message, + code: declared.Code, + status: declared.Status, } } diff --git a/pkg/plugin/implementation/jsonmapper/reject_test.go b/pkg/plugin/implementation/jsonmapper/reject_test.go new file mode 100644 index 000000000..b81a24b0a --- /dev/null +++ b/pkg/plugin/implementation/jsonmapper/reject_test.go @@ -0,0 +1,180 @@ +package jsonmapper + +// reject_test.go covers the reject section and the code and status a failed +// check can carry. + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// withReject refuses a wrong OTP as the caller's fault and anything else that +// is not responseCode 1 as the provider's. +const withReject = ` +required: + - check: $exists(beckn.phone) + message: "phone is required" + code: SCH_REQUIRED_FIELD_MISSING + - check: $exists(beckn.season) = false or beckn.season = "Kharif" + message: "season must be Kharif" +reject: + - check: response.responseCode = 1 or response.responseMessage != "Invalid OTP" + message: "the OTP is wrong" + code: BIZ_GENERIC_ERROR + status: 400 + - check: response.responseCode = 1 + message: "the provider refused" + code: NET_DOWNSTREAM_UNAVAILABLE + status: 502 +request: | + { "phone": beckn.phone } +response: | + { "ok": true } +` + +// assertCoded fails unless err is a CodedErr with this status and code. +func assertCoded(t *testing.T, err error, status int, code string) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) { + t.Fatalf("error = %v (%T), want a coded %d %s", err, err, status, code) + } + if coded.HTTPStatus() != status || coded.BecknError().Code != code { + t.Errorf("error = %d %s, want %d %s", coded.HTTPStatus(), coded.BecknError().Code, status, code) + } +} + +func rejectInput(code int, message string) map[string]any { + return map[string]any{ + "beckn": map[string]any{"phone": "9876543210"}, + "response": map[string]any{"responseCode": code, "responseMessage": message}, + } +} + +func TestReject_SuccessfulAnswer_ReturnsNil(t *testing.T) { + srv := newMappingServer(t, withReject, nil) + defer srv.Close() + + if err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(1, "ok")); err != nil { + t.Errorf("Reject() = %v, want nil for a successful answer", err) + } +} + +func TestReject_FirstFailingCondition_ReturnsItsCodeAndStatus(t *testing.T) { + srv := newMappingServer(t, withReject, nil) + defer srv.Close() + + err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(0, "Invalid OTP")) + assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") + if !strings.Contains(err.Error(), "the OTP is wrong") { + t.Errorf("error %q should carry the mapping's message", err) + } +} + +func TestReject_LaterCondition_ReturnsItsCodeAndStatus(t *testing.T) { + srv := newMappingServer(t, withReject, nil) + defer srv.Close() + + err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(0, "Service down")) + assertCoded(t, err, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE") + if strings.Contains(err.Error(), "Service down") { + t.Errorf("error %q leaks the provider's own message", err) + } +} + +func TestReject_NoRejectSection_ReturnsNil(t *testing.T) { + srv := newMappingServer(t, withChecks, nil) + defer srv.Close() + + if err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(0, "anything")); err != nil { + t.Errorf("Reject() = %v, want nil when the file declares no reject", err) + } +} + +func TestReject_ConditionNotBoolean_ReturnsError(t *testing.T) { + srv := newMappingServer(t, ` +reject: + - check: response.responseMessage + message: "never shown" +response: | + { "ok": true } +`, nil) + defer srv.Close() + + if err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(1, "text")); err == nil { + t.Error("Reject() accepted a condition that answered a string") + } +} + +func TestReject_ConditionWithoutMessage_ReturnsError(t *testing.T) { + srv := newMappingServer(t, ` +reject: + - check: response.responseCode = 1 +response: | + { "ok": true } +`, nil) + defer srv.Close() + + err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(1, "ok")) + if err == nil || !strings.Contains(err.Error(), "no message") { + t.Errorf("Reject() = %v, want a condition with no message refused", err) + } +} + +func TestReject_ConditionThatWillNotCompile_ReturnsErrorAndKeepsTransform(t *testing.T) { + srv := newMappingServer(t, ` +reject: + - check: response.responseCode = = 1 + message: "broken" +response: | + { "ok": true } +`, nil) + defer srv.Close() + + mapper := newTestMapper(t) + if err := mapper.Reject(context.Background(), ref(srv.URL), rejectInput(1, "ok")); err == nil { + t.Error("Reject() ran a condition that does not compile") + } + if out, err := mapper.Transform(context.Background(), ref(srv.URL), "response", rejectInput(1, "ok")); err != nil || string(out) != `{"ok":true}` { + t.Errorf("Transform() = %s, %v; a broken reject must not take the response half down", out, err) + } +} + +func TestReject_UnfetchableMapping_ReturnsError(t *testing.T) { + if err := newTestMapper(t).Reject(context.Background(), "file:///etc/passwd", rejectInput(1, "ok")); err == nil { + t.Error("Reject() fetched a reference that is not http or https") + } +} + +func TestVerify_RequirementWithCode_ReturnsThatCode(t *testing.T) { + srv := newMappingServer(t, withReject, nil) + defer srv.Close() + + err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), map[string]any{"beckn": map[string]any{}}) + assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") +} + +func TestVerify_RequirementWithoutCode_KeepsDefaultCode(t *testing.T) { + srv := newMappingServer(t, withReject, nil) + defer srv.Close() + + err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), + map[string]any{"beckn": map[string]any{"phone": "1", "season": "Rabi"}}) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") +} + +func TestVerify_RejectSection_NotRunBeforeTheCall(t *testing.T) { + srv := newMappingServer(t, withReject, nil) + defer srv.Close() + + // No response yet: only required runs, so a payload meeting it passes. + if err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), + map[string]any{"beckn": map[string]any{"phone": "1"}}); err != nil { + t.Errorf("Verify() = %v, want reject conditions left for after the call", err) + } +} From 3e278a171ee01144dc9a28fffb0183d7705a909c Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 12:02:08 +0530 Subject: [PATCH 02/39] feat: add tokenHeader auth for upstreams that log in and read a bare token [#57] PMFBY issues a token from its own login endpoint and expects it back, unprefixed, in the Authorization header. tokenQuery already does that login but puts the token in the query string, and oauth2 sends it as a Bearer header, so neither fits. tokenHeader reuses tokenQuery's exchange, caching and redaction and places the token in a configured header, which keeps PMFBY's auth config-only. --- .../implementation/internal/common/auth.go | 32 ++- .../implementation/internal/common/redact.go | 2 +- .../implementation/internal/common/token.go | 4 +- .../internal/common/tokenheader_test.go | 236 ++++++++++++++++++ .../internal/common/util/constant.go | 6 + 5 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 pkg/plugin/implementation/internal/common/tokenheader_test.go diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 4c780112a..cf698b601 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -27,7 +27,7 @@ type AuthProfile struct { // operator guessing which block to look at. Provider string - // One of none, basic, header, query, oauth2 or tokenQuery. + // One of none, basic, header, query, oauth2, tokenQuery or tokenHeader. Scheme string // Variable NAMES, never the values. @@ -116,21 +116,27 @@ func (a *AuthProfile) validate() error { "%s: authScheme oauth2 requires tokenUrl, clientIdEnv and clientSecretEnv", a.Provider) } - case util.AuthSchemeTokenQuery: + case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: // Every one of these is required, and none can be defaulted: the JSON // keys, the response key and the parameter name are all this // provider's spelling, and guessing any of them sends a malformed // request whose rejection says nothing about the cause. if a.TokenURL == "" || a.TokenUserField == "" || a.TokenUserEnv == "" || a.TokenSecretName == "" || a.TokenSecretEnv == "" || - a.TokenResponseField == "" || a.QueryName == "" { - return fmt.Errorf("%s: authScheme tokenQuery requires tokenUrl, "+ - "tokenUserField, tokenUserEnv, tokenSecretField, tokenSecretEnv, "+ - "tokenResponseField and queryName", a.Provider) + a.TokenResponseField == "" { + return fmt.Errorf("%s: authScheme %s requires tokenUrl, "+ + "tokenUserField, tokenUserEnv, tokenSecretField, tokenSecretEnv "+ + "and tokenResponseField", a.Provider, a.Scheme) + } + if a.Scheme == util.AuthSchemeTokenQuery && a.QueryName == "" { + return fmt.Errorf("%s: authScheme tokenQuery requires queryName", a.Provider) + } + if a.Scheme == util.AuthSchemeTokenHeader && a.HeaderName == "" { + return fmt.Errorf("%s: authScheme tokenHeader requires headerName", a.Provider) } if a.TokenTTLRaw == "" { - return fmt.Errorf("%s: authScheme tokenQuery requires tokenTtl, "+ - "because the token response carries no expiry to read", a.Provider) + return fmt.Errorf("%s: authScheme %s requires tokenTtl, "+ + "because the token response carries no expiry to read", a.Provider, a.Scheme) } ttl, err := time.ParseDuration(a.TokenTTLRaw) if err != nil { @@ -152,7 +158,7 @@ func (a *AuthProfile) validate() error { default: return fmt.Errorf( "%s: unknown authScheme %q: must be none, basic, header, query, "+ - "oauth2 or tokenQuery", a.Provider, a.Scheme) + "oauth2, tokenQuery or tokenHeader", a.Provider, a.Scheme) } return nil } @@ -307,6 +313,14 @@ func (s *Step) authenticate(auth *authenticator, req *http.Request) error { } req.Header.Set("Authorization", "Bearer "+token) + case util.AuthSchemeTokenHeader: + // Exchanged like tokenQuery, placed like header: the bare token. + token, err := s.providerToken(req.Context(), auth) + if err != nil { + return err + } + req.Header.Set(cfg.HeaderName, token) + case util.AuthSchemeTokenQuery: // Exchanged like oauth2, placed like query: the same held token, put // where this provider reads it from. diff --git a/pkg/plugin/implementation/internal/common/redact.go b/pkg/plugin/implementation/internal/common/redact.go index 1faa93be1..000c62f5b 100644 --- a/pkg/plugin/implementation/internal/common/redact.go +++ b/pkg/plugin/implementation/internal/common/redact.go @@ -138,7 +138,7 @@ func (a *authenticator) secretForms() []string { forms = append(forms, encoded) } return forms - case util.AuthSchemeTokenQuery: + case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: // oauth2's two halves, with query's escaping: the secret goes to the // token endpoint, and the token it returns travels in a QUERY STRING -- // which is the exposed placement, so its escaped form has to be diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index 88b09f5d0..18e9dd8b7 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -87,7 +87,7 @@ type tokenResponse struct { // issuer that failed. Unclassified they would surface as 500, telling a peer // this adapter broke when it did not. func (s *Step) exchangeToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { - if auth.cfg.Scheme == util.AuthSchemeTokenQuery { + if auth.cfg.Scheme == util.AuthSchemeTokenQuery || auth.cfg.Scheme == util.AuthSchemeTokenHeader { return s.exchangeQueryToken(ctx, auth) } return s.exchangeOAuth2Token(ctx, auth) @@ -103,7 +103,7 @@ func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (str cfg := auth.cfg user, secret := os.Getenv(cfg.TokenUserEnv), os.Getenv(cfg.TokenSecretEnv) if user == "" || secret == "" { - return "", 0, s.missingCredential(ctx, cfg.Provider, util.AuthSchemeTokenQuery, + return "", 0, s.missingCredential(ctx, cfg.Provider, cfg.Scheme, cfg.TokenUserEnv+" and "+cfg.TokenSecretEnv) } diff --git a/pkg/plugin/implementation/internal/common/tokenheader_test.go b/pkg/plugin/implementation/internal/common/tokenheader_test.go new file mode 100644 index 000000000..b8df5ed5d --- /dev/null +++ b/pkg/plugin/implementation/internal/common/tokenheader_test.go @@ -0,0 +1,236 @@ +// tokenHeader: tokenQuery's exchange, the bare token sent in a configured header. +package common + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// tokenHeaderProfile is tokenQuery's profile under the header scheme. queryName +// is cleared to prove it is not needed. +func tokenHeaderProfile(tokenURL string) AuthProfile { + profile := tokenQueryProfile(tokenURL) + profile.Scheme = util.AuthSchemeTokenHeader + profile.QueryName = "" + profile.HeaderName = "Authorization" + return profile +} + +// tokenHeaderStep records the Authorization header and query each provider call +// arrived with. status, when non-zero, is what the provider answers. +func tokenHeaderStep(t *testing.T, tokenURL string, headers, queries *[]string, status *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *headers = append(*headers, r.Header.Get("Authorization")) + *queries = append(*queries, r.URL.RawQuery) + if status != nil && *status != 0 { + w.WriteHeader(*status) + return + } + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)}, + func(c *Config) { + profile := tokenHeaderProfile(tokenURL) + if err := profile.validate(); err != nil { + t.Fatalf("profile does not validate: %v", err) + } + c.setProviderAuth(profile) + }) +} + +func TestTokenHeader_ValidLogin_SendsBareTokenInHeaderNotQuery(t *testing.T) { + t.Setenv("TEST_TQ_USER", "pmfby-user") + t.Setenv("TEST_TQ_SECRET", "pmfby-password") + + ts := &queryTokenServer{token: "the-token"} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if len(headers) != 1 || headers[0] != "the-token" { + t.Errorf("provider saw Authorization %q, want the bare token, no Bearer prefix", headers) + } + if strings.Contains(queries[0], "the-token") { + t.Errorf("provider saw query %q; the token belongs in the header only", queries[0]) + } + if ts.gotUser != "pmfby-user" || ts.gotSecret != "pmfby-password" || ts.gotType != "application/json" { + t.Errorf("login saw %q/%q as %q, want the configured pair as JSON", ts.gotUser, ts.gotSecret, ts.gotType) + } +} + +func TestTokenHeader_RepeatedCalls_LogsInOnce(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + ts := &queryTokenServer{} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + for i := 0; i < 3; i++ { + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("request %d: %v", i, err) + } + } + if got := ts.calls.Load(); got != 1 { + t.Errorf("login called %d times for 3 requests, want 1", got) + } +} + +func TestTokenHeader_ProviderRejectsToken_LogsInAgainNextCall(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + ts := &queryTokenServer{} + var headers, queries []string + status := http.StatusUnauthorized + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, &status) + + if _, err := runStep(t, step, selectBody); err == nil { + t.Fatal("Run() succeeded against a 401, want an error") + } + status = 0 + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("second Run() = %v, want it to recover with a fresh token", err) + } + if got := ts.calls.Load(); got != 2 || headers[1] != "tok-2" { + t.Errorf("login called %d times, second call sent %q; want 2 and tok-2", got, headers[1]) + } +} + +func TestTokenHeader_LoginFails_Returns502WithoutCallingProvider(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "wrong") + + ts := &queryTokenServer{status: http.StatusUnauthorized, body: `{"message":"invalid credentials"}`} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + _, err := runStep(t, step, selectBody) + assertStatus(t, err, http.StatusBadGateway) + if len(headers) != 0 { + t.Errorf("provider was called %d times without a token, want 0", len(headers)) + } +} + +func TestTokenHeader_LoginResponseWithoutToken_Returns502(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + ts := &queryTokenServer{field: "accessToken"} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + _, err := runStep(t, step, selectBody) + assertStatus(t, err, http.StatusBadGateway) +} + +func TestTokenHeader_CredentialUnset_FailsPermanentlyWithoutLogin(t *testing.T) { + t.Setenv("TEST_TQ_USER", "") + t.Setenv("TEST_TQ_SECRET", "") + + ts := &queryTokenServer{} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + + _, err := runStep(t, step, selectBody) + if err == nil || ts.calls.Load() != 0 || len(headers) != 0 { + t.Fatalf("err %v, %d logins, %d provider calls; want an error and no calls", err, ts.calls.Load(), len(headers)) + } + if strings.Contains(err.Error(), "TEST_TQ_USER") { + t.Errorf("error %q names the environment variable", err) + } + if !strings.Contains(err.Error(), util.AuthSchemeTokenHeader) { + t.Errorf("error %q should name the scheme", err) + } +} + +func TestTokenHeader_AfterCall_RedactsSecretAndToken(t *testing.T) { + t.Setenv("TEST_TQ_USER", "an-identifier") + t.Setenv("TEST_TQ_SECRET", "the-secret-value") + + ts := &queryTokenServer{token: "the-token-value"} + var headers, queries []string + step := tokenHeaderStep(t, ts.start(t), &headers, &queries, nil) + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatal(err) + } + + redacted := step.redactString("secret the-secret-value token the-token-value user an-identifier") + if strings.Contains(redacted, "the-secret-value") || strings.Contains(redacted, "the-token-value") { + t.Errorf("redacted %q still carries a credential", redacted) + } + if !strings.Contains(redacted, "an-identifier") { + t.Errorf("redacted %q hides the identifier, which is not a secret", redacted) + } +} + +func TestTokenHeader_IncompleteProfile_RefusedAtStartup(t *testing.T) { + for name, blank := range map[string]func(*AuthProfile){ + "tokenUrl": func(p *AuthProfile) { p.TokenURL = "" }, + "tokenUserField": func(p *AuthProfile) { p.TokenUserField = "" }, + "tokenUserEnv": func(p *AuthProfile) { p.TokenUserEnv = "" }, + "tokenSecretField": func(p *AuthProfile) { p.TokenSecretName = "" }, + "tokenSecretEnv": func(p *AuthProfile) { p.TokenSecretEnv = "" }, + "tokenResponseField": func(p *AuthProfile) { p.TokenResponseField = "" }, + "tokenTtl": func(p *AuthProfile) { p.TokenTTLRaw = "" }, + "headerName": func(p *AuthProfile) { p.HeaderName = "" }, + "tokenTtl not a duration": func(p *AuthProfile) { p.TokenTTLRaw = "ten minutes" }, + "tokenTtl within skew": func(p *AuthProfile) { p.TokenTTLRaw = "30s" }, + } { + t.Run(name, func(t *testing.T) { + profile := tokenHeaderProfile("https://login.example") + profile.Provider = "pmfby" + blank(&profile) + err := profile.validate() + if err == nil { + t.Fatal("validate() accepted an incomplete tokenHeader profile") + } + if !strings.Contains(err.Error(), "pmfby") { + t.Errorf("error %q should name the provider", err) + } + }) + } +} + +func TestTokenQuery_WithoutQueryName_StillRefused(t *testing.T) { + profile := tokenQueryProfile("https://login.example") + profile.QueryName = "" + if err := profile.validate(); err == nil || !strings.Contains(err.Error(), "queryName") { + t.Errorf("validate() = %v, want tokenQuery refused without queryName", err) + } +} + +func TestParseProviderAuth_TokenHeaderBlock_BuildsProfile(t *testing.T) { + profiles, err := ParseProviderAuth(map[string]string{ + "authScheme-pmfby": "tokenHeader", + "tokenUrl-pmfby": "https://login.example", + "tokenUserField-pmfby": "userName", + "tokenUserEnv-pmfby": "PMFBY_USER", + "tokenSecretField-pmfby": "password", + "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", + "tokenResponseField-pmfby": "token", + "tokenTtl-pmfby": "10m", + "headerName-pmfby": "Authorization", + }) + if err != nil { + t.Fatal(err) + } + profile := profiles["pmfby"] + if profile == nil || profile.Scheme != util.AuthSchemeTokenHeader { + t.Fatalf("profiles = %+v, want a tokenHeader profile for pmfby", profiles) + } + if err := profile.validate(); err != nil { + t.Errorf("validate() = %v, want the parsed profile accepted", err) + } +} diff --git a/pkg/plugin/implementation/internal/common/util/constant.go b/pkg/plugin/implementation/internal/common/util/constant.go index df4cb1d12..9e40e65fd 100644 --- a/pkg/plugin/implementation/internal/common/util/constant.go +++ b/pkg/plugin/implementation/internal/common/util/constant.go @@ -80,6 +80,12 @@ const ( // Inherits query's exposure -- the token reaches proxy logs and transport // errors -- so it is redacted the same way. See redact. AuthSchemeTokenQuery = "tokenQuery" + + // tokenQuery's exchange, header's placement: the token that comes back is + // sent as-is in the configured header, with no "Bearer" prefix. For an + // upstream with its own login endpoint that reads the bare token from a + // header -- PMFBY. + AuthSchemeTokenHeader = "tokenHeader" ) // CodeUpstreamUnavailable: the provider could not be reached or failed. Not From 227f6ffd97ba0baf9e48dfe700056f130aed7dc1 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 12:02:09 +0530 Subject: [PATCH 03/39] feat: add the Grievance plugin with PMFBY init, confirm and status mappings [#57] Farmers need to file a PMFBY grievance and check on it. The plugin is a thin package over the shared step, like MandiPrice, and holds no PMFBY logic: init sends an OTP, confirm files the grievance with it, and status reads the ticket back, all described by the mapping files. The mappings follow the PMFBY Postman collection for the ticket and status calls (AddKRPHNCIPGrievenceSupportTicket, receiptSourceID) and the design doc for the OTP step, which the collection does not cover yet. The OTP is never echoed, and the status answer is an allow-list that drops the farmer's personal details. --- config/mappings/pmfby/grievance.confirm.yaml | 86 +++ config/mappings/pmfby/grievance.init.yaml | 49 ++ config/mappings/pmfby/grievance.status.yaml | 66 ++ config/provider-adapter.yaml | 30 + install/build-plugins.sh | 1 + .../implementation/Grievance/Grievance.go | 27 + .../implementation/Grievance/cmd/plugin.go | 108 +++ .../Grievance/cmd/plugin_test.go | 171 +++++ .../implementation/Grievance/mappings_test.go | 677 ++++++++++++++++++ 9 files changed, 1215 insertions(+) create mode 100644 config/mappings/pmfby/grievance.confirm.yaml create mode 100644 config/mappings/pmfby/grievance.init.yaml create mode 100644 config/mappings/pmfby/grievance.status.yaml create mode 100644 pkg/plugin/implementation/Grievance/Grievance.go create mode 100644 pkg/plugin/implementation/Grievance/cmd/plugin.go create mode 100644 pkg/plugin/implementation/Grievance/cmd/plugin_test.go create mode 100644 pkg/plugin/implementation/Grievance/mappings_test.go diff --git a/config/mappings/pmfby/grievance.confirm.yaml b/config/mappings/pmfby/grievance.confirm.yaml new file mode 100644 index 000000000..e6b4f90cd --- /dev/null +++ b/config/mappings/pmfby/grievance.confirm.yaml @@ -0,0 +1,86 @@ +# PMFBY, openagrinet:PMFBYGrievance, confirm -> POST /AddKRPHNCIPGrievenceSupportTicket. +# Both directions, one file. +# +# Step two of three: file the grievance with the OTP init sent. The OTP goes to +# PMFBY and nowhere else -- it is never echoed back. The commitment becomes +# ACTIVE and carries the ticket number the farmer quotes on status. +# +# "Grievence" is PMFBY's spelling, kept where PMFBY reads it. + +required: + - check: | + ( + $ca := beckn.message.contract.commitments[0].commitmentAttributes; + $exists($ca.applicantPhone) and $exists($ca.otp) and $exists($ca.applicationNo) + and $exists($ca.cropYear) and $exists($ca.season) + and $exists($ca.grievanceCategory.code) and $exists($ca.grievanceDescription) + ) + message: "filing a grievance needs applicantPhone, otp, applicationNo, cropYear, season, grievanceCategory.code and grievanceDescription" + code: SCH_REQUIRED_FIELD_MISSING + - check: | + $exists($match(beckn.message.contract.commitments[0].commitmentAttributes.grievanceCategory.code, /^[0-9]+\.[0-9]+$/)[0]) + message: "grievanceCategory.code must be ., e.g. 3.10" + - check: | + beckn.message.contract.commitments[0].commitmentAttributes.season in ["Kharif", "Rabi", "Zaid"] + message: "season must be Kharif, Rabi or Zaid" + +# Wrong OTP first: it is the farmer's to fix, so a 400. Any other refusal is +# PMFBY's, so a 502. "= false" rather than $not: an absent message must read as +# "not an OTP error", and this engine answers null for string functions on it. +reject: + - check: | + response.responseCode = 1 + or ($exists(response.responseMessage) and $contains($lowercase(response.responseMessage), "otp")) = false + message: "the OTP is wrong or has expired" + code: BIZ_GENERIC_ERROR + status: 400 + - check: response.responseCode = 1 + message: "PMFBY did not register the grievance" + code: NET_DOWNSTREAM_UNAVAILABLE + status: 502 + +request: | + ( + $ca := beckn.message.contract.commitments[0].commitmentAttributes; + $category := $split($ca.grievanceCategory.code, "."); + $seasons := { "Kharif": 1, "Rabi": 2, "Zaid": 3 }; + /* PMFBY's fixed id for this channel as the source of the grievance. */ + $receiptSourceID := 134306; + { + "requestorMobileNo": $ca.applicantPhone, + "otp": $ca.otp, + "complaintDate": $now("[Y0001]-[M01]-[D01]", "+0530"), + "receiptSourceID": $receiptSourceID, + "ticketCategoryID": $number($category[0]), + "ticketSubCategoryID": $number($category[1]), + "requestYear": $string($ca.cropYear), + "requestSeason": $lookup($seasons, $ca.season), + "applicationNo": $ca.applicationNo, + "grievenceDescription": $trim($ca.grievanceDescription) + } + ) + +# filedOn restates the IST date the request just sent; PMFBY echoes none. +response: | + ( + $commitment := beckn.message.contract.commitments[0]; + $ca := $commitment.commitmentAttributes; + { + "context": $merge([beckn.context, { "action": "on_confirm", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": "ACTIVE" } }, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "ticketNo": response.responseDynamic.GrievenceSupportTicketNo, + "caseStatus": "REGISTERED", + "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530"), + "source": "PMFBY" + } + }])] + }]) + } + } + ) diff --git a/config/mappings/pmfby/grievance.init.yaml b/config/mappings/pmfby/grievance.init.yaml new file mode 100644 index 000000000..22383ab6e --- /dev/null +++ b/config/mappings/pmfby/grievance.init.yaml @@ -0,0 +1,49 @@ +# PMFBY, openagrinet:PMFBYGrievance, init -> POST /SendOTP. Both directions, one file. +# +# Step one of three: send an OTP to the farmer's phone. The commitment stays +# DRAFT; confirm carries the OTP back with the grievance itself. + +required: + - check: | + $exists(beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone) + message: "requesting an OTP needs commitmentAttributes.applicantPhone" + code: SCH_REQUIRED_FIELD_MISSING + +# PMFBY answers 200 whatever happened; responseCode 1 is the only success. +reject: + - check: response.responseCode = 1 + message: "PMFBY did not send the OTP" + code: NET_DOWNSTREAM_UNAVAILABLE + status: 502 + +request: | + { + "requestorMobileNo": beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone + } + +# The phone is masked rather than echoed: enough for the farmer to recognise +# which number, not enough to read it back. +response: | + ( + $commitment := beckn.message.contract.commitments[0]; + $ca := $commitment.commitmentAttributes; + $phone := $ca.applicantPhone; + { + "context": $merge([beckn.context, { "action": "on_init", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": "DRAFT" } }, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "otpChallenge": { + "sentTo": $substring($phone, 0, 2) & "XXXXXX" & $substring($phone, -2), + "expiresAt": $fromMillis($toMillis($now()) + response.responseDynamic.otpValidityInSeconds * 1000) + } + } + }])] + }]) + } + } + ) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml new file mode 100644 index 000000000..5d3da79d9 --- /dev/null +++ b/config/mappings/pmfby/grievance.status.yaml @@ -0,0 +1,66 @@ +# PMFBY, openagrinet:PMFBYGrievance, status -> POST /GetGrievenceTicketsStatus. +# Both directions, one file. +# +# Step three: read a filed grievance back. PMFBY matches the ticket to the phone +# it was filed from, so both are required. +# +# The response is an allow-list. PMFBY's record also carries the farmer's name, +# address and policy details; none of it is mapped. + +required: + - check: | + ( + $ca := beckn.message.contract.commitments[0].commitmentAttributes; + $exists($ca.applicantPhone) and $exists($ca.ticketNo) + ) + message: "checking a grievance needs applicantPhone and ticketNo" + code: SCH_REQUIRED_FIELD_MISSING + +reject: + - check: response.responseCode = 1 + message: "PMFBY did not return the grievance" + code: NET_DOWNSTREAM_UNAVAILABLE + status: 502 + +request: | + ( + $ca := beckn.message.contract.commitments[0].commitmentAttributes; + { + "requestorMobileNo": $ca.applicantPhone, + "GrievenceSupportTicketNo": $ca.ticketNo + } + ) + +# An unknown ticket is an ordinary answer with no grievance details, not an +# error: responseDynamic is empty and only the ticket number goes back. +response: | + ( + $commitment := beckn.message.contract.commitments[0]; + $ca := $commitment.commitmentAttributes; + $ticket := response.responseDynamic; + { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": "ACTIVE" } }, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "ticketNo": $ca.ticketNo, + "caseStatus": $exists($ticket.TicketStatus) + ? $uppercase($replace($ticket.TicketStatus, " ", "_")), + "grievanceCategory": $exists($ticket.TicketCategoryID) ? { + "code": $string($ticket.TicketCategoryID) & "." & $string($ticket.TicketSubCategoryID), + "name": $ticket.TicketCategoryName & " / " & $ticket.TicketSubCategoryName + }, + "grievanceDescription": $ticket.GrievenceDescription, + "filedOn": $ticket.ComplaintDate, + "officerReply": $ticket.latestRemark != "" ? $ticket.latestRemark, + "source": "PMFBY" + } + }])] + }]) + } + } + ) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index e2684cb31..7f25ebd86 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -505,6 +505,35 @@ modules: # concurrency for this capability. searchConcurrency: 1 + # ------------------------------------------------------------------ + # Grievance -- filing a grievance against a scheme and checking it: + # init sends an OTP, confirm files the grievance, status reads it back. + # ------------------------------------------------------------------ + - id: Grievance + config: + bindingKeys: "pmfby|openagrinet:PMFBYGrievance" + + # BOTH, OR THE ADAPTER DOES NOT START. A grievance names its + # capability on the commitment, not on a resource, so without + # capabilityCodeAt every grievance request passes through unserved. + providerIdAt: "message.contract.commitments[].offer.provider.id" + capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type" + + # PMFBY issues its own token from its login endpoint + # (.../krphapi/FGMS/NICUsersLogin) and reads it back as the bare + # Authorization header, no "Bearer" -- tokenQuery's exchange, + # header's placement. tokenUrl is a per-deployment placeholder. + pmfby: + authScheme: tokenHeader + tokenUrl: <> + tokenUserField: appAccessUID + tokenUserEnv: PMFBY_ACCESS_UID + tokenSecretField: appAccessPWD + tokenSecretEnv: PMFBY_ACCESS_PWD + tokenResponseField: token + tokenTtl: 10m + headerName: Authorization + # Declaring a step above is not enough: THIS list is what runs. A step # that appears under providerSteps but not here never executes, and the # request falls through to the 404 above -- which looks like a registry @@ -516,6 +545,7 @@ modules: - MandiPrice # its binding key, or pass through - KnowledgeAdvisory # its binding key, or pass through - AgricultureFacility # its binding key, or pass through + - Grievance # its binding key, or pass through - signAck # signs whatever the step answered with # ---------------------------------------------------------------------------- diff --git a/install/build-plugins.sh b/install/build-plugins.sh index 4e505cc05..519a22822 100755 --- a/install/build-plugins.sh +++ b/install/build-plugins.sh @@ -34,6 +34,7 @@ plugins=( "jsonmapper" "WeatherObservation" "MandiPrice" + "Grievance" "KnowledgeAdvisory" "AgricultureFacility" "manifestloader" diff --git a/pkg/plugin/implementation/Grievance/Grievance.go b/pkg/plugin/implementation/Grievance/Grievance.go new file mode 100644 index 000000000..727abd016 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/Grievance.go @@ -0,0 +1,27 @@ +// Package Grievance serves the network's grievance capabilities: filing a +// grievance against a government scheme and checking on it. +// +// One package for every scheme. What differs between PMFBY and the next portal +// -- endpoints, field names, how it says "no", how it authenticates -- is the +// registry's, the mapping's and the adapter config's, so nothing here names a +// provider. Like MandiPrice, this package is a name over internal/common. +package Grievance + +import ( + "context" + + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// Config is upstream's, unchanged. Aliased here so a domain plugin's cmd package +// need not know where the machinery lives. +type Config = common.Config + +// New creates the grievance step. +// +// No prerequisites: everything a grievance call sends is in the payload. +func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, + cfg *Config) (definition.Step, func() error, error) { + return common.New(ctx, registry, mapper, nil, cfg) +} diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go new file mode 100644 index 000000000..0f2fcc056 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -0,0 +1,108 @@ +// Command plugin builds the grievance provider step as a loadable plugin. +// +// The filename of the built .so is the id a deployment names in providerSteps, +// so this package is grievance's whole public surface: a config map in, a step out. +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// grievanceProvider implements definition.ProviderStepProvider. +type grievanceProvider struct{} + +// newStepFunc creates a new step. Indirected for tests. +var newStepFunc = Grievance.New + +// parseConfig turns the plugin config map into a typed Config. Anything absent +// is left zero: Grievance.New applies the defaults and validates the auth scheme, +// so those rules live in one place. +func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Config, error) { + cfg := &Grievance.Config{ + BindingKeys: splitList(config["bindingKeys"]), + // Absent means the Beckn v2 convention. See common.Config for why + // this is a default rather than something to set. + ProviderIDAt: config["providerIdAt"], + CapabilityCodeAt: config["capabilityCodeAt"], + } + + if raw, exists := config["maxResponseBytes"]; exists && raw != "" { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid maxResponseBytes value '%s': %w", raw, err) + } + if value <= 0 { + return nil, fmt.Errorf("maxResponseBytes must be positive, got %d", value) + } + cfg.MaxResponseBytes = value + } + + // One credential profile per provider, read from the flattened + // authScheme- settings. Shared with the other capability + // plugins: each used to copy the same field list, so a scheme added in one + // had to be remembered in three. + auth, err := common.ParseProviderAuth(config) + if err != nil { + return nil, err + } + cfg.AuthByProvider = auth + + return cfg, nil +} + +// New creates a new grievance provider step instance. +func (p grievanceProvider) New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, config map[string]string) (definition.Step, func() error, error) { + if ctx == nil { + return nil, nil, errors.New("context cannot be nil") + } + + cfg, err := p.parseConfig(config) + if err != nil { + log.Errorf(ctx, err, "Failed to parse grievance configuration") + return nil, nil, fmt.Errorf("failed to parse grievance configuration: %w", err) + } + + step, closer, err := newStepFunc(ctx, registry, mapper, cfg) + if err != nil { + log.Errorf(ctx, err, "Failed to create grievance step") + return nil, nil, err + } + + log.Infof(ctx, "Grievance step created successfully") + return step, closer, nil +} + +// splitList reads a comma-separated config value, which is how a list reaches a +// plugin -- the config is map[string]string. Blanks are dropped and spaces +// trimmed, so a trailing comma or a wrapped line is not a config error. +// +// A comma is unambiguous here: a binding key separates its own halves with a +// pipe. +func splitList(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + var out []string + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// Provider is the exported plugin instance. +var Provider = grievanceProvider{} + +// Compile-time proof the provider satisfies the interface the manager asserts +// against. A mismatch is otherwise a runtime cast failure at startup. +var _ definition.ProviderStepProvider = Provider diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go new file mode 100644 index 000000000..a022d3591 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +type stubRegistry struct{} + +func (stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return nil, nil +} + +type stubMapper struct{} + +func (stubMapper) Verify(context.Context, string, any) error { return nil } + +func (stubMapper) Transform(context.Context, string, definition.Direction, any) ([]byte, error) { + return nil, nil +} + +const ( + pmfbyBindingKey = "pmfby|openagrinet:PMFBYGrievance" + providerIDAt = "message.contract.commitments[].offer.provider.id" + capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" +) + +// pmfbyConfig is the block config/provider-adapter.yaml ships, as the plugin +// receives it: flattened. +func pmfbyConfig() map[string]string { + return map[string]string{ + "bindingKeys": pmfbyBindingKey, + "providerIdAt": providerIDAt, + "capabilityCodeAt": capabilityCodeAt, + "authScheme-pmfby": "tokenHeader", + "tokenUrl-pmfby": "https://pmfby.example/krphapi/FGMS/NICUsersLogin", + "tokenUserField-pmfby": "appAccessUID", + "tokenUserEnv-pmfby": "PMFBY_USER", + "tokenSecretField-pmfby": "appAccessPWD", + "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", + "tokenResponseField-pmfby": "token", + "tokenTtl-pmfby": "10m", + "headerName-pmfby": "Authorization", + } +} + +func TestParseConfig_PMFBYBlock_ReadsEverySetting(t *testing.T) { + got, err := grievanceProvider{}.parseConfig(pmfbyConfig()) + if err != nil { + t.Fatal(err) + } + want := &Grievance.Config{ + BindingKeys: []string{pmfbyBindingKey}, + ProviderIDAt: providerIDAt, + CapabilityCodeAt: capabilityCodeAt, + AuthByProvider: map[string]*common.AuthProfile{"pmfby": { + Provider: "pmfby", Scheme: "tokenHeader", TokenURL: "https://pmfby.example/krphapi/FGMS/NICUsersLogin", + TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", + TokenSecretName: "appAccessPWD", TokenSecretEnv: "PMFBY_PASSWORD", + TokenResponseField: "token", TokenTTLRaw: "10m", HeaderName: "Authorization", + }}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("parseConfig() = %+v, want %+v", got, want) + } +} + +func TestParseConfig_ResponseCap_ValidatesValue(t *testing.T) { + for raw, wantErr := range map[string]string{ + "lots": "invalid maxResponseBytes value 'lots'", + "0": "maxResponseBytes must be positive", + "-1": "maxResponseBytes must be positive", + "": "", + "2048": "", + } { + t.Run(raw, func(t *testing.T) { + _, err := grievanceProvider{}.parseConfig(map[string]string{"maxResponseBytes": raw}) + if wantErr == "" && err != nil { + t.Errorf("parseConfig() = %v, want no error", err) + } + if wantErr != "" && (err == nil || !strings.Contains(err.Error(), wantErr)) { + t.Errorf("parseConfig() = %v, want %q", err, wantErr) + } + }) + } +} + +func TestParseConfig_MisspelledAuthSetting_Refused(t *testing.T) { + config := pmfbyConfig() + config["tokenUrll-pmfby"] = "https://typo.example" + if _, err := (grievanceProvider{}).parseConfig(config); err == nil { + t.Error("parseConfig() accepted a misspelled credential setting") + } +} + +func TestSplitList_CommaSeparated_TrimsAndDropsBlanks(t *testing.T) { + for raw, want := range map[string][]string{ + "": nil, + " ": nil, + pmfbyBindingKey + ", b|openagrinet:Two ,,": {pmfbyBindingKey, "b|openagrinet:Two"}, + } { + if got := splitList(raw); !reflect.DeepEqual(got, want) { + t.Errorf("splitList(%q) = %v, want %v", raw, got, want) + } + } +} + +func TestNew_ShippedPMFBYConfig_BuildsStep(t *testing.T) { + step, closer, err := grievanceProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, pmfbyConfig()) + if err != nil { + t.Fatalf("New() = %v, want the shipped config to load", err) + } + if step == nil || closer == nil { + t.Fatal("New() returned no step or no closer") + } + if err := closer(); err != nil { + t.Errorf("closer() = %v", err) + } +} + +func TestNew_InvalidConfig_Refused(t *testing.T) { + for name, edit := range map[string]func(map[string]string){ + "no binding keys": func(c map[string]string) { delete(c, "bindingKeys") }, + "providerIdAt alone": func(c map[string]string) { delete(c, "capabilityCodeAt") }, + "capabilityCodeAt alone": func(c map[string]string) { delete(c, "providerIdAt") }, + "no auth block for pmfby": func(c map[string]string) { delete(c, "authScheme-pmfby") }, + "unknown auth scheme": func(c map[string]string) { c["authScheme-pmfby"] = "loginHeader" }, + "tokenHeader without ttl": func(c map[string]string) { delete(c, "tokenTtl-pmfby") }, + "tokenHeader without login": func(c map[string]string) { delete(c, "tokenUrl-pmfby") }, + "tokenHeader without header": func(c map[string]string) { delete(c, "headerName-pmfby") }, + "auth for unserved provider": func(c map[string]string) { c["authScheme-pmkisan"] = "none" }, + "bad response cap": func(c map[string]string) { c["maxResponseBytes"] = "lots" }, + } { + t.Run(name, func(t *testing.T) { + config := pmfbyConfig() + edit(config) + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, config); err == nil { + t.Error("New() accepted an invalid config") + } + }) + } +} + +func TestNew_NilContext_Refused(t *testing.T) { + //nolint:staticcheck // deliberately passing a nil context to assert the guard. + if _, _, err := (grievanceProvider{}).New(nil, stubRegistry{}, stubMapper{}, pmfbyConfig()); err == nil { + t.Error("New() accepted a nil context") + } +} + +func TestNew_StepConstructorFails_PropagatesError(t *testing.T) { + original := newStepFunc + wanted := errors.New("upstream refused the config") + newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper, + *Grievance.Config) (definition.Step, func() error, error) { + return nil, nil, wanted + } + defer func() { newStepFunc = original }() + + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, pmfbyConfig()); !errors.Is(err, wanted) { + t.Errorf("New() = %v, want it to wrap %v", err, wanted) + } +} diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go new file mode 100644 index 000000000..4d35d43d9 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -0,0 +1,677 @@ +package Grievance_test + +// mappings_test.go runs the shipped PMFBY mapping files through the real mapper +// and the real grievance step, against a stub PMFBY that logs in, sends an OTP, +// files a ticket and reads it back. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +const ( + mappingsDir = "../../../../config/mappings/pmfby" + shippedBindingKey = "pmfby|openagrinet:PMFBYGrievance" + stubToken = "stub-login-token" + validOTP = "123456" + knownTicket = "100626000099001" + phone = "9876543210" + sendOTPPath = "/SendOTP" + insertPath = "/AddKRPHNCIPGrievenceSupportTicket" + statusPath = "/GetGrievenceTicketsStatus" + loginPath = "/NICUsersLogin" +) + +// knownTicketRecord is PMFBY's record for knownTicket, personal data included. +const knownTicketRecord = `{"responseCode":1,"responseMessage":"Fetched","responseDynamic":{ + "GrievenceSupportTicketNo":"100626000099001","TicketStatus":"Under Review","TicketStatusID":109301, + "ComplaintDate":"2026-10-04","ApplicationNo":"040108251010160770605", + "GrievenceDescription":"Claim not received","TicketCategoryID":3,"TicketSubCategoryID":10, + "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received", + "RequestYear":2025,"RequestSeason":1,"latestRemark":"Forwarded to insurer", + "FarmerName":"Ramesh","RequestorMobileNo":"9876543210","InsurancePolicyNo":"P-1"}}` + +// pmfbyStub answers like PMFBY: 200 for everything, success or failure in +// responseCode. Every field left zero gives PMFBY's ordinary answer. +type pmfbyStub struct { + down bool // 503 on every call but the login + loginDown bool // 401 on the login + answers map[string]string // overrides the answer for a path + calls map[string]int // per path, login included + bodies map[string]map[string]any +} + +func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p.calls[r.URL.Path]++ + if r.URL.Path == loginPath { + var login map[string]string + _ = json.NewDecoder(r.Body).Decode(&login) + if p.loginDown || login["appAccessUID"] != "user" || login["appAccessPWD"] != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + fmt.Fprintf(w, `{"token":%q}`, stubToken) + return + } + if p.down { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if r.Header.Get("Authorization") != stubToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + p.bodies[r.URL.Path] = body + + if answer, overridden := p.answers[r.URL.Path]; overridden { + fmt.Fprint(w, answer) + return + } + switch r.URL.Path { + case sendOTPPath: + fmt.Fprint(w, `{"responseCode":1,"responseMessage":"OTP sent","responseDynamic":{"otpValidityInSeconds":300}}`) + case insertPath: + if body["otp"] != validOTP { + fmt.Fprint(w, `{"responseCode":0,"responseMessage":"Invalid OTP","responseDynamic":null}`) + return + } + fmt.Fprintf(w, `{"responseCode":1,"responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q}}`, knownTicket) + case statusPath: + if body["GrievenceSupportTicketNo"] != knownTicket { + fmt.Fprint(w, `{"responseCode":1,"responseMessage":"No record found","responseDynamic":null}`) + return + } + fmt.Fprint(w, knownTicketRecord) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +type stubRegistry struct{ plan *model.ProviderRecord } + +func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderRecord, error) { + return s.plan, nil +} + +// harness is the real step over the shipped mappings, wired to a stub PMFBY. +// One harness serves several requests, so a held token carries across them. +type harness struct { + pmfby *pmfbyStub + step definition.Step +} + +// newHarness builds the step. statusRetries is the registry's retryMax for +// status; init and confirm get 0, as the seed ships them. +func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int) *harness { + t.Helper() + t.Setenv("PMFBY_USER", "user") + t.Setenv("PMFBY_PASSWORD", "secret") + pmfby.calls = map[string]int{} + pmfby.bodies = map[string]map[string]any{} + + mappings := httptest.NewServer(http.FileServer(http.Dir(mappingsDir))) + t.Cleanup(mappings.Close) + upstream := httptest.NewServer(pmfby) + t.Cleanup(upstream.Close) + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = closeMapper() }) + + action := func(path, file string, retries int) model.ActionPlan { + return model.ActionPlan{Method: http.MethodPost, Path: path, + Mappings: mappings.URL + "/" + file, RetryMax: retries} + } + registry := &stubRegistry{plan: &model.ProviderRecord{ + BindingKey: shippedBindingKey, ParticipantID: "pmfby", CapabilityCode: "openagrinet:PMFBYGrievance", + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + "init": action(sendOTPPath, "grievance.init.yaml", 0), + "confirm": action(insertPath, "grievance.confirm.yaml", 0), + "status": action(statusPath, "grievance.status.yaml", statusRetries), + }, + }} + + step, closeStep, err := Grievance.New(context.Background(), registry, mapper, &Grievance.Config{ + BindingKeys: []string{shippedBindingKey}, + ProviderIDAt: "message.contract.commitments[].offer.provider.id", + CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type", + AuthByProvider: map[string]*common.AuthProfile{"pmfby": { + Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, + TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", + TokenSecretName: "appAccessPWD", TokenSecretEnv: "PMFBY_PASSWORD", + TokenResponseField: "token", TokenTTLRaw: "10m", HeaderName: "Authorization", + }}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = closeStep() }) + return &harness{pmfby: pmfby, step: step} +} + +// answer is the part of a Beckn reply these tests read. +type answer struct { + Context map[string]any `json:"context"` + Message struct { + Contract struct { + ID string `json:"id"` + Commitments []struct { + Status struct { + Descriptor struct { + Code string `json:"code"` + } `json:"descriptor"` + } `json:"status"` + Offer map[string]any `json:"offer"` + CommitmentAttributes map[string]any `json:"commitmentAttributes"` + } `json:"commitments"` + } `json:"contract"` + } `json:"message"` +} + +func (a *answer) attributes() map[string]any { + return a.Message.Contract.Commitments[0].CommitmentAttributes +} + +func (a *answer) status() string { + return a.Message.Contract.Commitments[0].Status.Descriptor.Code +} + +// send runs body through the step. A nil answer means the step passed it on. +func (h *harness) send(t *testing.T, body []byte) (*answer, error) { + t.Helper() + stepCtx := &model.StepContext{Context: t.Context(), Body: body} + if err := h.step.Run(stepCtx); err != nil { + return nil, err + } + if len(stepCtx.ResponseBody) == 0 { + return nil, nil + } + var got answer + if err := json.Unmarshal(stepCtx.ResponseBody, &got); err != nil { + t.Fatalf("answer is not JSON: %v\n%s", err, stepCtx.ResponseBody) + } + return &got, nil +} + +// mustSend is send for a request that has to succeed. +func (h *harness) mustSend(t *testing.T, body []byte) *answer { + t.Helper() + got, err := h.send(t, body) + if err != nil { + t.Fatalf("Run() = %v, want success", err) + } + if got == nil { + t.Fatal("the step passed the request on instead of answering it") + } + return got +} + +// request builds a Beckn grievance payload for action with these attributes. +func request(action string, attributes map[string]any) []byte { + attributes["@context"] = "https://schemas.openagrinet.global/schema/PMFBYGrievance/v0.1/context.jsonld" + attributes["@type"] = "openagrinet:PMFBYGrievance" + body, _ := json.Marshal(map[string]any{ + "context": map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"}, + "message": map[string]any{"contract": map[string]any{ + "id": "contract-1", + "commitments": []any{map[string]any{ + "status": map[string]any{"descriptor": map[string]any{"code": "DRAFT"}}, + "resources": []any{map[string]any{"id": "res:pmfby:grievance"}}, + "offer": map[string]any{"id": "offer:pmfby:grievance", "provider": map[string]any{"id": "pmfby"}}, + "commitmentAttributes": attributes, + }}, + }}, + }) + return body +} + +func initAttributes() map[string]any { return map[string]any{"applicantPhone": phone} } + +func confirmAttributes() map[string]any { + return map[string]any{ + "applicantPhone": phone, "otp": validOTP, "applicationNo": "040108251010160770605", + "cropYear": "2025", "season": "Kharif", "grievanceCategory": map[string]any{"code": "3.10"}, + "grievanceDescription": " Claim not received ", + } +} + +func statusAttributes() map[string]any { + return map[string]any{"applicantPhone": phone, "ticketNo": knownTicket} +} + +// assertCoded fails unless err is a CodedErr with this status and code. +func assertCoded(t *testing.T, err error, status int, code string) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) { + t.Fatalf("error = %v, want a coded %d %s", err, status, code) + } + if coded.HTTPStatus() != status || coded.BecknError().Code != code { + t.Errorf("error = %d %s (%v), want %d %s", coded.HTTPStatus(), coded.BecknError().Code, err, status, code) + } +} + +// todayIST is the date PMFBY expects complaintDate in. +func todayIST() string { + return time.Now().In(time.FixedZone("IST", 5*3600+1800)).Format("2006-01-02") +} + +// --- init ------------------------------------------------------------------ + +func TestInit_ValidPhone_ReturnsMaskedOTPChallenge(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + before := time.Now() + got := h.mustSend(t, request("init", initAttributes())) + + if sent := h.pmfby.bodies[sendOTPPath]; len(sent) != 1 || sent["requestorMobileNo"] != phone { + t.Errorf("PMFBY was sent %v, want requestorMobileNo alone", sent) + } + challenge, _ := got.attributes()["otpChallenge"].(map[string]any) + if challenge["sentTo"] != "98XXXXXX10" { + t.Errorf("sentTo = %v, want 98XXXXXX10", challenge["sentTo"]) + } + expiresAt, err := time.Parse(time.RFC3339, fmt.Sprint(challenge["expiresAt"])) + if err != nil { + t.Fatalf("expiresAt %v is not RFC 3339: %v", challenge["expiresAt"], err) + } + if wait := expiresAt.Sub(before); wait < 299*time.Second || wait > 301*time.Second { + t.Errorf("expiresAt is %v away, want the 300s PMFBY reported", wait) + } + if got.Context["action"] != "on_init" || got.status() != "DRAFT" { + t.Errorf("action %v, status %v; want on_init, DRAFT", got.Context["action"], got.status()) + } + if _, echoed := got.attributes()["applicantPhone"]; echoed { + t.Error("applicantPhone must not be echoed") + } +} + +func TestInit_Answer_EchoesContextContractAndType(t *testing.T) { + got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("init", initAttributes())) + + if got.Context["transactionId"] != "txn-1" || got.Context["messageId"] != "msg-1" || got.Context["timestamp"] == nil { + t.Errorf("context = %v, want the request's ids and a fresh timestamp", got.Context) + } + if got.Message.Contract.ID != "contract-1" { + t.Errorf("contract id = %q, want contract-1 carried across the flow", got.Message.Contract.ID) + } + if got.attributes()["@type"] != "openagrinet:PMFBYGrievance" || got.attributes()["@context"] == nil { + t.Errorf("commitmentAttributes = %v, want @type and @context echoed", got.attributes()) + } + if got.Message.Contract.Commitments[0].Offer["id"] != "offer:pmfby:grievance" { + t.Errorf("offer = %v, want the request's offer", got.Message.Contract.Commitments[0].Offer) + } +} + +func TestInit_MissingPhone_Returns400WithoutCallingPMFBY(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, request("init", map[string]any{})) + assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") + if h.pmfby.calls[sendOTPPath] != 0 { + t.Error("PMFBY was called for a request missing a required field") + } +} + +func TestInit_PortalRefuses_Returns502(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{ + sendOTPPath: `{"responseCode":0,"responseMessage":"Mobile 9876543210 not registered"}`}}, 0) + _, err := h.send(t, request("init", initAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if strings.Contains(err.Error(), phone) { + t.Errorf("error %q leaks PMFBY's message", err) + } +} + +func TestInit_PortalDown_Returns502AfterOneAttempt(t *testing.T) { + h := newHarness(t, &pmfbyStub{down: true}, 0) + _, err := h.send(t, request("init", initAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if got := h.pmfby.calls[sendOTPPath]; got != 1 { + t.Errorf("SendOTP called %d times, want 1: a retry sends a second OTP", got) + } +} + +func TestInit_LoginRejected_Returns502WithoutSendingOTP(t *testing.T) { + h := newHarness(t, &pmfbyStub{loginDown: true}, 0) + _, err := h.send(t, request("init", initAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if h.pmfby.calls[sendOTPPath] != 0 { + t.Error("SendOTP was called without a token") + } +} + +// --- confirm --------------------------------------------------------------- + +func TestConfirm_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + got := h.mustSend(t, request("confirm", confirmAttributes())) + + want := map[string]any{ + "requestorMobileNo": phone, "otp": validOTP, "applicationNo": "040108251010160770605", + "requestYear": "2025", "requestSeason": 1.0, "ticketCategoryID": 3.0, "ticketSubCategoryID": 10.0, + "grievenceDescription": "Claim not received", "complaintDate": todayIST(), "receiptSourceID": 134306.0, + } + sent := h.pmfby.bodies[insertPath] + for field, value := range want { + if sent[field] != value { + t.Errorf("PMFBY %s = %v, want %v", field, sent[field], value) + } + } + if len(sent) != len(want) { + t.Errorf("PMFBY was sent %d fields (%v), want exactly %d", len(sent), sent, len(want)) + } + + attributes := got.attributes() + if attributes["ticketNo"] != knownTicket || attributes["caseStatus"] != "REGISTERED" || + attributes["filedOn"] != todayIST() || attributes["source"] != "PMFBY" { + t.Errorf("commitmentAttributes = %v", attributes) + } + if got.Context["action"] != "on_confirm" || got.status() != "ACTIVE" { + t.Errorf("action %v, status %v; want on_confirm, ACTIVE", got.Context["action"], got.status()) + } + for _, private := range []string{"otp", "applicantPhone"} { + if _, echoed := attributes[private]; echoed { + t.Errorf("%s must never be echoed", private) + } + } +} + +func TestConfirm_NumericCropYear_SentAsString(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + attributes["cropYear"] = 2025 + h.mustSend(t, request("confirm", attributes)) + if got := h.pmfby.bodies[insertPath]["requestYear"]; got != "2025" { + t.Errorf("requestYear = %#v, want the string \"2025\" PMFBY takes", got) + } +} + +func TestConfirm_EachSeason_SendsItsCode(t *testing.T) { + for season, code := range map[string]float64{"Kharif": 1, "Rabi": 2, "Zaid": 3} { + t.Run(season, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + attributes["season"] = season + h.mustSend(t, request("confirm", attributes)) + if got := h.pmfby.bodies[insertPath]["requestSeason"]; got != code { + t.Errorf("requestSeason = %v, want %v", got, code) + } + }) + } +} + +func TestConfirm_CategoryCode_SplitsOnTheDot(t *testing.T) { + for category, want := range map[string][2]float64{"3.10": {3, 10}, "12.5": {12, 5}, "1.1": {1, 1}} { + t.Run(category, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + attributes["grievanceCategory"] = map[string]any{"code": category} + h.mustSend(t, request("confirm", attributes)) + sent := h.pmfby.bodies[insertPath] + if sent["ticketCategoryID"] != want[0] || sent["ticketSubCategoryID"] != want[1] { + t.Errorf("sent %v / %v, want %v", sent["ticketCategoryID"], sent["ticketSubCategoryID"], want) + } + }) + } +} + +func TestConfirm_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { + for _, field := range []string{"applicantPhone", "otp", "applicationNo", "cropYear", "season", + "grievanceCategory", "grievanceDescription"} { + t.Run(field, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + delete(attributes, field) + _, err := h.send(t, request("confirm", attributes)) + assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") + if h.pmfby.calls[insertPath] != 0 { + t.Error("PMFBY was called for a request missing a required field") + } + }) + } +} + +func TestConfirm_CategoryWithoutCode_Returns400(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + attributes["grievanceCategory"] = map[string]any{"name": "Claim"} + _, err := h.send(t, request("confirm", attributes)) + assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") +} + +func TestConfirm_MalformedCategory_Returns400WithoutCallingPMFBY(t *testing.T) { + for _, category := range []string{"3", "3.", ".10", "3.10.1", "a.b", "3-10", ""} { + t.Run(category, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + attributes["grievanceCategory"] = map[string]any{"code": category} + _, err := h.send(t, request("confirm", attributes)) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[insertPath] != 0 { + t.Error("PMFBY was called with a malformed category") + } + }) + } +} + +func TestConfirm_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { + for _, season := range []string{"Summer", "kharif", "KHARIF", ""} { + t.Run(season, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := confirmAttributes() + attributes["season"] = season + _, err := h.send(t, request("confirm", attributes)) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[insertPath] != 0 { + t.Error("PMFBY was called with an unknown season") + } + }) + } +} + +func TestConfirm_WrongOrExpiredOTP_Returns400BizGenericError(t *testing.T) { + for _, message := range []string{"Invalid OTP", "OTP expired", "otp mismatch"} { + t.Run(message, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{ + insertPath: fmt.Sprintf(`{"responseCode":0,"responseMessage":%q}`, message)}}, 0) + _, err := h.send(t, request("confirm", confirmAttributes())) + assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") + if strings.Contains(err.Error(), message) { + t.Errorf("error %q leaks PMFBY's message", err) + } + }) + } +} + +func TestConfirm_WrongOTPFromStub_Returns400BizGenericError(t *testing.T) { + attributes := confirmAttributes() + attributes["otp"] = "000000" + _, err := newHarness(t, &pmfbyStub{}, 0).send(t, request("confirm", attributes)) + assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") +} + +func TestConfirm_PortalRefuses_Returns502(t *testing.T) { + for name, answer := range map[string]string{ + "failure code": `{"responseCode":0,"responseMessage":"Service error for 9876543210"}`, + "no message": `{"responseCode":0}`, + "no code": `{"responseMessage":"something"}`, + "code as other value": `{"responseCode":2,"responseMessage":"duplicate"}`, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: answer}}, 0) + _, err := h.send(t, request("confirm", confirmAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if strings.Contains(err.Error(), phone) { + t.Errorf("error %q leaks PMFBY's message", err) + } + }) + } +} + +func TestConfirm_PortalDown_Returns502AfterOneAttempt(t *testing.T) { + h := newHarness(t, &pmfbyStub{down: true}, 0) + _, err := h.send(t, request("confirm", confirmAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if got := h.pmfby.calls[insertPath]; got != 1 { + t.Errorf("InsertGrievenceTicket called %d times, want 1: a retry files a duplicate", got) + } +} + +// --- status ---------------------------------------------------------------- + +func TestStatus_KnownTicket_ReturnsAllowListedDetails(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + got := h.mustSend(t, request("status", statusAttributes())) + + sent := h.pmfby.bodies[statusPath] + if len(sent) != 2 || sent["GrievenceSupportTicketNo"] != knownTicket || sent["requestorMobileNo"] != phone { + t.Errorf("PMFBY was sent %v, want the ticket and the phone alone", sent) + } + attributes := got.attributes() + category, _ := attributes["grievanceCategory"].(map[string]any) + for field, want := range map[string]any{ + "ticketNo": knownTicket, "caseStatus": "UNDER_REVIEW", "filedOn": "2026-10-04", + "grievanceDescription": "Claim not received", "officerReply": "Forwarded to insurer", "source": "PMFBY", + } { + if attributes[field] != want { + t.Errorf("%s = %v, want %v", field, attributes[field], want) + } + } + if category["code"] != "3.10" || category["name"] != "Claim / Claim not received" { + t.Errorf("grievanceCategory = %v", category) + } + if got.Context["action"] != "on_status" || got.status() != "ACTIVE" { + t.Errorf("action %v, status %v; want on_status, ACTIVE", got.Context["action"], got.status()) + } +} + +func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { + got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", statusAttributes())) + allowed := map[string]bool{"@context": true, "@type": true, "ticketNo": true, "caseStatus": true, + "grievanceCategory": true, "grievanceDescription": true, "filedOn": true, "officerReply": true, "source": true} + for field := range got.attributes() { + if !allowed[field] { + t.Errorf("%s is mapped; the response is an allow-list", field) + } + } +} + +func TestStatus_NoRemarkYet_OmitsOfficerReply(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"latestRemark":"Forwarded to insurer"`, `"latestRemark":""`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if _, present := got.attributes()["officerReply"]; present { + t.Errorf("officerReply = %v, want it omitted while PMFBY has none", got.attributes()["officerReply"]) + } +} + +func TestStatus_UnknownTicket_ReturnsTicketNumberAlone(t *testing.T) { + attributes := statusAttributes() + attributes["ticketNo"] = "999" + got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", attributes)) + + for field := range got.attributes() { + if field != "@context" && field != "@type" && field != "ticketNo" && field != "source" { + t.Errorf("%s = %v on an unknown ticket, want no grievance details", field, got.attributes()[field]) + } + } + if got.attributes()["ticketNo"] != "999" { + t.Errorf("ticketNo = %v, want the one asked about", got.attributes()["ticketNo"]) + } +} + +func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { + for _, field := range []string{"applicantPhone", "ticketNo"} { + t.Run(field, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := statusAttributes() + delete(attributes, field) + _, err := h.send(t, request("status", attributes)) + assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") + if h.pmfby.calls[statusPath] != 0 { + t.Error("PMFBY was called for a request missing a required field") + } + }) + } +} + +func TestStatus_PortalRefuses_Returns502(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: `{"responseCode":0,"responseMessage":"error"}`}}, 0) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) +} + +func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { + h := newHarness(t, &pmfbyStub{down: true}, 2) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if got := h.pmfby.calls[statusPath]; got != 3 { + t.Errorf("status called %d times, want 3: a read is safe to retry", got) + } +} + +// --- across the flow ------------------------------------------------------- + +func TestFlow_InitConfirmStatus_LogsInOnce(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + h.mustSend(t, request("init", initAttributes())) + ticket := h.mustSend(t, request("confirm", confirmAttributes())).attributes()["ticketNo"] + got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "ticketNo": ticket})) + + if got.attributes()["caseStatus"] != "UNDER_REVIEW" { + t.Errorf("status of the filed ticket = %v", got.attributes()) + } + if logins := h.pmfby.calls[loginPath]; logins != 1 { + t.Errorf("logged in %d times over three calls, want 1", logins) + } +} + +func TestFlow_AnswerNotJSON_ReturnsErrorWithoutMapping(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{sendOTPPath: `maintenance`}}, 0) + got, err := h.send(t, request("init", initAttributes())) + if err == nil || got != nil { + t.Errorf("Run() = %v, %v; want an error and no answer", got, err) + } +} + +func TestFlow_UnservedAction_Returns400WithoutCallingPMFBY(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, request("select", initAttributes())) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v for an action it does not serve", h.pmfby.calls) + } +} + +func TestFlow_OtherCapability_PassesThroughUntouched(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + body := strings.Replace(string(request("init", initAttributes())), + "openagrinet:PMFBYGrievance", "openagrinet:PMKISANGrievance", 1) + got, err := h.send(t, []byte(body)) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want another capability passed on untouched", got, err) + } + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v for another capability", h.pmfby.calls) + } +} From 7a41690d0461c71ba4ff08febbcff50bd39686ce Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 13:06:30 +0530 Subject: [PATCH 04/39] fix: classify null PMFBY messages and refuse a numeric phone before the OTP is sent [#57] A refusal carrying "responseMessage": null passed the $exists guard and then failed inside $lowercase, so the caller got a 400 adaptation error instead of the 502 a portal refusal should be. Only a string message is now read for "otp". A phone sent as a number passed the presence check, PMFBY sent the OTP, and only then did the reply fail to mask it, so a retry would send a second OTP. init now refuses a non-string phone before calling PMFBY. --- config/mappings/pmfby/grievance.confirm.yaml | 6 +++--- config/mappings/pmfby/grievance.init.yaml | 5 +++++ pkg/plugin/implementation/Grievance/mappings_test.go | 11 +++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/config/mappings/pmfby/grievance.confirm.yaml b/config/mappings/pmfby/grievance.confirm.yaml index e6b4f90cd..01cfc8b07 100644 --- a/config/mappings/pmfby/grievance.confirm.yaml +++ b/config/mappings/pmfby/grievance.confirm.yaml @@ -25,12 +25,12 @@ required: message: "season must be Kharif, Rabi or Zaid" # Wrong OTP first: it is the farmer's to fix, so a 400. Any other refusal is -# PMFBY's, so a 502. "= false" rather than $not: an absent message must read as -# "not an OTP error", and this engine answers null for string functions on it. +# PMFBY's, so a 502. Only a string message can name the OTP: an absent or null +# one reads as "not an OTP error" rather than failing the check. reject: - check: | response.responseCode = 1 - or ($exists(response.responseMessage) and $contains($lowercase(response.responseMessage), "otp")) = false + or ($type(response.responseMessage) = "string" and $contains($lowercase(response.responseMessage), "otp")) = false message: "the OTP is wrong or has expired" code: BIZ_GENERIC_ERROR status: 400 diff --git a/config/mappings/pmfby/grievance.init.yaml b/config/mappings/pmfby/grievance.init.yaml index 22383ab6e..c9ffdb0f2 100644 --- a/config/mappings/pmfby/grievance.init.yaml +++ b/config/mappings/pmfby/grievance.init.yaml @@ -8,6 +8,11 @@ required: $exists(beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone) message: "requesting an OTP needs commitmentAttributes.applicantPhone" code: SCH_REQUIRED_FIELD_MISSING + # Checked before the call: the reply masks the phone as text, so a number + # would fail only after PMFBY had already sent the OTP. + - check: | + $type(beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone) = "string" + message: "commitmentAttributes.applicantPhone must be a string" # PMFBY answers 200 whatever happened; responseCode 1 is the only success. reject: diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 4d35d43d9..793503887 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -332,6 +332,15 @@ func TestInit_MissingPhone_Returns400WithoutCallingPMFBY(t *testing.T) { } } +func TestInit_NumericPhone_Returns400WithoutSendingOTP(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, request("init", map[string]any{"applicantPhone": 9876543210})) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[sendOTPPath] != 0 { + t.Error("SendOTP was called for a phone the reply cannot mask") + } +} + func TestInit_PortalRefuses_Returns502(t *testing.T) { h := newHarness(t, &pmfbyStub{answers: map[string]string{ sendOTPPath: `{"responseCode":0,"responseMessage":"Mobile 9876543210 not registered"}`}}, 0) @@ -515,6 +524,8 @@ func TestConfirm_PortalRefuses_Returns502(t *testing.T) { "failure code": `{"responseCode":0,"responseMessage":"Service error for 9876543210"}`, "no message": `{"responseCode":0}`, "no code": `{"responseMessage":"something"}`, + "null message": `{"responseCode":0,"responseMessage":null}`, + "empty message": `{"responseCode":0,"responseMessage":""}`, "code as other value": `{"responseCode":2,"responseMessage":"duplicate"}`, } { t.Run(name, func(t *testing.T) { From 9e09110064202abdfef4d4f891d500da2eb9a194 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 13:06:36 +0530 Subject: [PATCH 05/39] fix: stop logging the provider's body when a mapping rejects its answer [#57] The reject path logged the provider's raw answer at WARN with only credentials redacted, and PMFBY refusals can echo the farmer's phone number or other personal details. The log now carries the mapping's own message, which says what was refused without quoting the provider. --- pkg/plugin/implementation/internal/common/serve.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 95ba17931..8463235da 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -13,7 +13,6 @@ import ( "github.com/beckn-one/beckn-onix/pkg/log" "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" - "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" ) // Run serves the request when it is this step's capability, and does nothing @@ -136,7 +135,7 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return fmt.Errorf("provider answered with something that is not JSON: %w", err) } - if err := s.reject(ctx, plan.BindingKey, call.Mappings, beckn, answer, upstreamResponse); err != nil { + if err := s.reject(ctx, plan.BindingKey, call.Mappings, beckn, answer); err != nil { return err } @@ -171,16 +170,17 @@ type rejecter interface { // reject checks the provider's answer against the mapping's reject conditions. // -// A 200 can still be a refusal. The provider's own words go to the log, never -// to the caller: the mapping says what the caller is told. -func (s *Step) reject(ctx context.Context, bindingKey, mappingRef string, beckn, answer any, raw []byte) error { +// A 200 can still be a refusal. The provider's body is not logged: a refusal +// can echo the farmer's own details back, and redaction only covers +// credentials. The mapping's message says what was refused. +func (s *Step) reject(ctx context.Context, bindingKey, mappingRef string, beckn, answer any) error { mapper, ok := s.mapper.(rejecter) if !ok { return nil } err := mapper.Reject(ctx, mappingRef, map[string]any{"beckn": beckn, "response": answer}) if err != nil { - log.Warnf(ctx, "%s refused the request: %s", bindingKey, s.redactString(util.Explain(raw))) + log.Warnf(ctx, "%s refused the request: %v", bindingKey, err) } return err } From ca1e14dc3c9dc01efcbb8ff8d5f3f545a87b2f7e Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 13:06:36 +0530 Subject: [PATCH 06/39] refactor: rename exchangeQueryToken to exchangeLoginToken [#57] The exchange now serves tokenHeader as well as tokenQuery, so a name tied to the query placement misled readers about what calls it. --- pkg/plugin/implementation/internal/common/token.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index 18e9dd8b7..e23c80c96 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -88,18 +88,18 @@ type tokenResponse struct { // this adapter broke when it did not. func (s *Step) exchangeToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { if auth.cfg.Scheme == util.AuthSchemeTokenQuery || auth.cfg.Scheme == util.AuthSchemeTokenHeader { - return s.exchangeQueryToken(ctx, auth) + return s.exchangeLoginToken(ctx, auth) } return s.exchangeOAuth2Token(ctx, auth) } -// exchangeQueryToken POSTs a JSON body of configured field names and reads the +// exchangeLoginToken POSTs a JSON body of configured field names and reads the // token out of a configured response field. // // The lifetime comes from config, not from the response: this endpoint says // nothing about how long its token lives. That is the whole reason tokenTtl is // required rather than defaulted -- see AuthProfile. -func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { +func (s *Step) exchangeLoginToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { cfg := auth.cfg user, secret := os.Getenv(cfg.TokenUserEnv), os.Getenv(cfg.TokenSecretEnv) if user == "" || secret == "" { From 0ba5b0d8f8e19c927553be0d48b5120166fba898 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 13:19:37 +0530 Subject: [PATCH 07/39] fix: drop null or empty PMFBY status fields instead of failing or mapping "null" [#57] A status record with "TicketStatus": null failed the whole call in $replace, so the farmer could not read their ticket at all. A null category name or sub-category id came back as "null / ..." or "3.null", and a null description, date or remark was returned as JSON null. Each field is now mapped only when it holds a real value and dropped otherwise, so one missing field no longer costs the rest of the answer. --- config/mappings/pmfby/grievance.status.yaml | 17 +++++--- .../implementation/Grievance/mappings_test.go | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 5d3da79d9..978caf0e7 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -38,6 +38,10 @@ response: | $commitment := beckn.message.contract.commitments[0]; $ca := $commitment.commitmentAttributes; $ticket := response.responseDynamic; + /* PMFBY may send null or "" for a field it has no value for; such a + field is dropped rather than mapped as "null" or failing the call. */ + $isText := function($v) { $type($v) = "string" and $v != "" }; + $isNumber := function($v) { $type($v) = "number" }; { "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), "message": { @@ -48,15 +52,16 @@ response: | "@context": $ca.`@context`, "@type": $ca.`@type`, "ticketNo": $ca.ticketNo, - "caseStatus": $exists($ticket.TicketStatus) + "caseStatus": $isText($ticket.TicketStatus) ? $uppercase($replace($ticket.TicketStatus, " ", "_")), - "grievanceCategory": $exists($ticket.TicketCategoryID) ? { + "grievanceCategory": $isNumber($ticket.TicketCategoryID) and $isNumber($ticket.TicketSubCategoryID) ? { "code": $string($ticket.TicketCategoryID) & "." & $string($ticket.TicketSubCategoryID), - "name": $ticket.TicketCategoryName & " / " & $ticket.TicketSubCategoryName + "name": $isText($ticket.TicketCategoryName) and $isText($ticket.TicketSubCategoryName) + ? $ticket.TicketCategoryName & " / " & $ticket.TicketSubCategoryName }, - "grievanceDescription": $ticket.GrievenceDescription, - "filedOn": $ticket.ComplaintDate, - "officerReply": $ticket.latestRemark != "" ? $ticket.latestRemark, + "grievanceDescription": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription, + "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, + "officerReply": $isText($ticket.latestRemark) ? $ticket.latestRemark, "source": "PMFBY" } }])] diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 793503887..35edb1c93 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -596,6 +596,45 @@ func TestStatus_NoRemarkYet_OmitsOfficerReply(t *testing.T) { } } +func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { + for name, tc := range map[string]struct { + from, to string + absent string + }{ + "null status": {`"TicketStatus":"Under Review"`, `"TicketStatus":null`, "caseStatus"}, + "empty status": {`"TicketStatus":"Under Review"`, `"TicketStatus":""`, "caseStatus"}, + "null sub-category id": {`"TicketSubCategoryID":10`, `"TicketSubCategoryID":null`, "grievanceCategory"}, + "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "grievanceDescription"}, + "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "filedOn"}, + "null remark": {`"latestRemark":"Forwarded to insurer"`, `"latestRemark":null`, "officerReply"}, + } { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, tc.from, tc.to, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if value, present := got.attributes()[tc.absent]; present { + t.Errorf("%s = %v, want it dropped", tc.absent, value) + } + if got.attributes()["ticketNo"] != knownTicket { + t.Errorf("ticketNo = %v, want the rest of the answer intact", got.attributes()["ticketNo"]) + } + }) + } +} + +func TestStatus_NullCategoryName_KeepsCodeDropsName(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + category, _ := got.attributes()["grievanceCategory"].(map[string]any) + if category["code"] != "3.10" { + t.Errorf("code = %v, want 3.10", category["code"]) + } + if name, present := category["name"]; present { + t.Errorf("name = %v, want it dropped rather than \"null / ...\"", name) + } +} + func TestStatus_UnknownTicket_ReturnsTicketNumberAlone(t *testing.T) { attributes := statusAttributes() attributes["ticketNo"] = "999" From 23011964a860d0a5e84c2efc188458677b40c1b4 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 17:03:14 +0530 Subject: [PATCH 08/39] feat: read a binding from the channel when a payload has no contract [#57] The updated grievance design lodges with support, and a SupportAction composes no contract: there is no offer to read the provider from and no commitment to read the type from, so the step passed every support request through unserved. A step can now name a second pair of paths, tried only when the first finds nothing. Grievance sets it to message.support.channels[].providerId and .@type, where the design places them. Both or neither, as with the first pair, and plugins that set neither behave as before. --- config/provider-adapter.yaml | 8 ++++- .../implementation/Grievance/cmd/plugin.go | 4 +++ .../Grievance/cmd/plugin_test.go | 10 +++++++ .../implementation/internal/common/common.go | 30 +++++++++++++++++++ .../implementation/internal/common/serve.go | 3 ++ 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 7f25ebd86..bd2710eaa 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -507,7 +507,7 @@ modules: # ------------------------------------------------------------------ # Grievance -- filing a grievance against a scheme and checking it: - # init sends an OTP, confirm files the grievance, status reads it back. + # init sends an OTP, support files the grievance, status reads it back. # ------------------------------------------------------------------ - id: Grievance config: @@ -519,6 +519,12 @@ modules: providerIdAt: "message.contract.commitments[].offer.provider.id" capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type" + # A support request composes no contract, so it names its provider + # and type on its one channel instead. Tried only when the paths + # above find nothing. Both or neither, like the pair above. + fallbackProviderIdAt: "message.support.channels[].providerId" + fallbackCapabilityCodeAt: "message.support.channels[].@type" + # PMFBY issues its own token from its login endpoint # (.../krphapi/FGMS/NICUsersLogin) and reads it back as the bare # Authorization header, no "Bearer" -- tokenQuery's exchange, diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index 0f2fcc056..7d7a5289b 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -33,6 +33,10 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con // this is a default rather than something to set. ProviderIDAt: config["providerIdAt"], CapabilityCodeAt: config["capabilityCodeAt"], + // Where a support request, which composes no contract, names its + // provider and type. + FallbackProviderIDAt: config["fallbackProviderIdAt"], + FallbackCapabilityCodeAt: config["fallbackCapabilityCodeAt"], } if raw, exists := config["maxResponseBytes"]; exists && raw != "" { diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go index a022d3591..f5ed45c54 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -31,6 +31,9 @@ const ( pmfbyBindingKey = "pmfby|openagrinet:PMFBYGrievance" providerIDAt = "message.contract.commitments[].offer.provider.id" capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" + + fallbackProviderIDAt = "message.support.channels[].providerId" + fallbackCapabilityCodeAt = "message.support.channels[].@type" ) // pmfbyConfig is the block config/provider-adapter.yaml ships, as the plugin @@ -40,6 +43,8 @@ func pmfbyConfig() map[string]string { "bindingKeys": pmfbyBindingKey, "providerIdAt": providerIDAt, "capabilityCodeAt": capabilityCodeAt, + "fallbackProviderIdAt": fallbackProviderIDAt, + "fallbackCapabilityCodeAt": fallbackCapabilityCodeAt, "authScheme-pmfby": "tokenHeader", "tokenUrl-pmfby": "https://pmfby.example/krphapi/FGMS/NICUsersLogin", "tokenUserField-pmfby": "appAccessUID", @@ -61,6 +66,9 @@ func TestParseConfig_PMFBYBlock_ReadsEverySetting(t *testing.T) { BindingKeys: []string{pmfbyBindingKey}, ProviderIDAt: providerIDAt, CapabilityCodeAt: capabilityCodeAt, + + FallbackProviderIDAt: fallbackProviderIDAt, + FallbackCapabilityCodeAt: fallbackCapabilityCodeAt, AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Provider: "pmfby", Scheme: "tokenHeader", TokenURL: "https://pmfby.example/krphapi/FGMS/NICUsersLogin", TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", @@ -131,6 +139,8 @@ func TestNew_InvalidConfig_Refused(t *testing.T) { "no binding keys": func(c map[string]string) { delete(c, "bindingKeys") }, "providerIdAt alone": func(c map[string]string) { delete(c, "capabilityCodeAt") }, "capabilityCodeAt alone": func(c map[string]string) { delete(c, "providerIdAt") }, + "fallback provider alone": func(c map[string]string) { delete(c, "fallbackCapabilityCodeAt") }, + "fallback type alone": func(c map[string]string) { delete(c, "fallbackProviderIdAt") }, "no auth block for pmfby": func(c map[string]string) { delete(c, "authScheme-pmfby") }, "unknown auth scheme": func(c map[string]string) { c["authScheme-pmfby"] = "loginHeader" }, "tokenHeader without ttl": func(c map[string]string) { delete(c, "tokenTtl-pmfby") }, diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 9390a9373..8000a4cd6 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -55,6 +55,13 @@ type Config struct { ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` + // A second place to read a binding key from, tried only when the first + // finds none. For an action whose payload composes no contract: a support + // request carries its provider and type on its channel instead. Both or + // neither; absent means there is no second place. + FallbackProviderIDAt string `yaml:"fallbackProviderIdAt" json:"fallbackProviderIdAt"` + FallbackCapabilityCodeAt string `yaml:"fallbackCapabilityCodeAt" json:"fallbackCapabilityCodeAt"` + // One credential profile per provider, keyed by participant id -- the left // half of a binding key. // @@ -80,6 +87,7 @@ type Config struct { type Step struct { config *Config paths Paths + fallback *Paths prerequisites Prerequisites registry definition.ProviderRecordLookup mapper definition.Mapper @@ -110,10 +118,15 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d if err != nil { return nil, nil, err } + fallback, err := fallbackPaths(cfg) + if err != nil { + return nil, nil, err + } step := &Step{ config: cfg, paths: paths, + fallback: fallback, prerequisites: prerequisites, registry: registry, mapper: mapper, @@ -161,6 +174,23 @@ func BindingPaths(cfg *Config) (Paths, error) { return paths, nil } +// fallbackPaths resolves the second place a binding key may be read from, or +// nil when none is configured. Both halves or neither, for the reason +// BindingPaths gives. +func fallbackPaths(cfg *Config) (*Paths, error) { + if cfg.FallbackProviderIDAt == "" && cfg.FallbackCapabilityCodeAt == "" { + return nil, nil + } + if cfg.FallbackProviderIDAt == "" || cfg.FallbackCapabilityCodeAt == "" { + return nil, errors.New("fallbackProviderIdAt and fallbackCapabilityCodeAt are set together or not at all") + } + paths := Paths{ProviderID: cfg.FallbackProviderIDAt, CapabilityCode: cfg.FallbackCapabilityCodeAt} + if err := paths.Validate(); err != nil { + return nil, fmt.Errorf("fallback %w", err) + } + return &paths, nil +} + // applyDefaults fills in what was left out and rejects what cannot be defaulted. func applyDefaults(cfg *Config) error { // No default: this package serves whatever a domain package points it at, diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 8463235da..8019dc24d 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -23,6 +23,9 @@ import ( // a routing-table change. func (s *Step) Run(ctx *model.StepContext) error { binding, err := BindingFrom(s.paths, ctx.Body) + if errors.Is(err, errNoBinding) && s.fallback != nil { + binding, err = BindingFrom(*s.fallback, ctx.Body) + } if errors.Is(err, errNoBinding) { return nil } From 99c62a82dc0a8ad4274a18d67b485248784f854b Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 17:03:33 +0530 Subject: [PATCH 09/39] feat: lodge PMFBY grievances through support and answer errors as designed [#57] The grievance design moved lodging from confirm to support and fixed the replies and errors each action returns. The mappings follow it: support reads the application number from orderId, the complaint from descriptor and the farmer from channels[0]; status puts the complaint on contract.descriptor and the case record in the attributes. The error table needs answers the mapper cannot give, and the mapper stays unchanged: the reject section and Reject hook are removed from jsonmapper. Instead a mapping half may return a reserved _error object, which the step turns into the reply: - a missing field is refused by the request half with SCH_REQUIRED_FIELD_MISSING, before PMFBY is called - a status reply with no case record is a 202 ACK, BIZ_NO_RESULTS_FOUND - a 4xx from the provider keeps its status and is shown to the response half under _status; support reads PMFBY's 400 as a wrong OTP and answers 400 BIZ_GENERIC_ERROR, and anything a mapping does not explain stays 502 Nothing is read from a failure body: PMFBY's failure format is not documented. SendOTP's reply is not either, so on_init carries no expiresAt. --- config/mappings/pmfby/grievance.confirm.yaml | 86 ---- config/mappings/pmfby/grievance.init.yaml | 45 +- config/mappings/pmfby/grievance.status.yaml | 66 +-- config/mappings/pmfby/grievance.support.yaml | 99 +++++ .../implementation/Grievance/mappings_test.go | 413 +++++++++++------- .../implementation/internal/common/http.go | 19 +- .../implementation/internal/common/refusal.go | 61 +++ .../internal/common/refusal_test.go | 156 +++++++ .../internal/common/reject_test.go | 90 ---- .../implementation/internal/common/serve.go | 58 ++- .../internal/common/tokenheader_test.go | 11 + .../implementation/jsonmapper/jsonmapper.go | 85 +--- .../implementation/jsonmapper/reject_test.go | 180 -------- 13 files changed, 725 insertions(+), 644 deletions(-) delete mode 100644 config/mappings/pmfby/grievance.confirm.yaml create mode 100644 config/mappings/pmfby/grievance.support.yaml create mode 100644 pkg/plugin/implementation/internal/common/refusal.go create mode 100644 pkg/plugin/implementation/internal/common/refusal_test.go delete mode 100644 pkg/plugin/implementation/internal/common/reject_test.go delete mode 100644 pkg/plugin/implementation/jsonmapper/reject_test.go diff --git a/config/mappings/pmfby/grievance.confirm.yaml b/config/mappings/pmfby/grievance.confirm.yaml deleted file mode 100644 index 01cfc8b07..000000000 --- a/config/mappings/pmfby/grievance.confirm.yaml +++ /dev/null @@ -1,86 +0,0 @@ -# PMFBY, openagrinet:PMFBYGrievance, confirm -> POST /AddKRPHNCIPGrievenceSupportTicket. -# Both directions, one file. -# -# Step two of three: file the grievance with the OTP init sent. The OTP goes to -# PMFBY and nowhere else -- it is never echoed back. The commitment becomes -# ACTIVE and carries the ticket number the farmer quotes on status. -# -# "Grievence" is PMFBY's spelling, kept where PMFBY reads it. - -required: - - check: | - ( - $ca := beckn.message.contract.commitments[0].commitmentAttributes; - $exists($ca.applicantPhone) and $exists($ca.otp) and $exists($ca.applicationNo) - and $exists($ca.cropYear) and $exists($ca.season) - and $exists($ca.grievanceCategory.code) and $exists($ca.grievanceDescription) - ) - message: "filing a grievance needs applicantPhone, otp, applicationNo, cropYear, season, grievanceCategory.code and grievanceDescription" - code: SCH_REQUIRED_FIELD_MISSING - - check: | - $exists($match(beckn.message.contract.commitments[0].commitmentAttributes.grievanceCategory.code, /^[0-9]+\.[0-9]+$/)[0]) - message: "grievanceCategory.code must be ., e.g. 3.10" - - check: | - beckn.message.contract.commitments[0].commitmentAttributes.season in ["Kharif", "Rabi", "Zaid"] - message: "season must be Kharif, Rabi or Zaid" - -# Wrong OTP first: it is the farmer's to fix, so a 400. Any other refusal is -# PMFBY's, so a 502. Only a string message can name the OTP: an absent or null -# one reads as "not an OTP error" rather than failing the check. -reject: - - check: | - response.responseCode = 1 - or ($type(response.responseMessage) = "string" and $contains($lowercase(response.responseMessage), "otp")) = false - message: "the OTP is wrong or has expired" - code: BIZ_GENERIC_ERROR - status: 400 - - check: response.responseCode = 1 - message: "PMFBY did not register the grievance" - code: NET_DOWNSTREAM_UNAVAILABLE - status: 502 - -request: | - ( - $ca := beckn.message.contract.commitments[0].commitmentAttributes; - $category := $split($ca.grievanceCategory.code, "."); - $seasons := { "Kharif": 1, "Rabi": 2, "Zaid": 3 }; - /* PMFBY's fixed id for this channel as the source of the grievance. */ - $receiptSourceID := 134306; - { - "requestorMobileNo": $ca.applicantPhone, - "otp": $ca.otp, - "complaintDate": $now("[Y0001]-[M01]-[D01]", "+0530"), - "receiptSourceID": $receiptSourceID, - "ticketCategoryID": $number($category[0]), - "ticketSubCategoryID": $number($category[1]), - "requestYear": $string($ca.cropYear), - "requestSeason": $lookup($seasons, $ca.season), - "applicationNo": $ca.applicationNo, - "grievenceDescription": $trim($ca.grievanceDescription) - } - ) - -# filedOn restates the IST date the request just sent; PMFBY echoes none. -response: | - ( - $commitment := beckn.message.contract.commitments[0]; - $ca := $commitment.commitmentAttributes; - { - "context": $merge([beckn.context, { "action": "on_confirm", "timestamp": $now() }]), - "message": { - "contract": $merge([beckn.message.contract, { - "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "ACTIVE" } }, - "commitmentAttributes": { - "@context": $ca.`@context`, - "@type": $ca.`@type`, - "ticketNo": response.responseDynamic.GrievenceSupportTicketNo, - "caseStatus": "REGISTERED", - "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530"), - "source": "PMFBY" - } - }])] - }]) - } - } - ) diff --git a/config/mappings/pmfby/grievance.init.yaml b/config/mappings/pmfby/grievance.init.yaml index c9ffdb0f2..5b46fc94b 100644 --- a/config/mappings/pmfby/grievance.init.yaml +++ b/config/mappings/pmfby/grievance.init.yaml @@ -1,33 +1,34 @@ # PMFBY, openagrinet:PMFBYGrievance, init -> POST /SendOTP. Both directions, one file. # # Step one of three: send an OTP to the farmer's phone. The commitment stays -# DRAFT; confirm carries the OTP back with the grievance itself. +# DRAFT; support carries the OTP back with the grievance itself. +# required holds format checks, which return SCH_INVALID_FORMAT and let an +# absent field pass. A missing field is refused by the request half under +# _error, which can name SCH_REQUIRED_FIELD_MISSING. required: - - check: | - $exists(beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone) - message: "requesting an OTP needs commitmentAttributes.applicantPhone" - code: SCH_REQUIRED_FIELD_MISSING # Checked before the call: the reply masks the phone as text, so a number # would fail only after PMFBY had already sent the OTP. - check: | - $type(beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone) = "string" + ( + $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; + $not($exists($phone)) or $type($phone) = "string" + ) message: "commitmentAttributes.applicantPhone must be a string" -# PMFBY answers 200 whatever happened; responseCode 1 is the only success. -reject: - - check: response.responseCode = 1 - message: "PMFBY did not send the OTP" - code: NET_DOWNSTREAM_UNAVAILABLE - status: 502 - request: | - { - "requestorMobileNo": beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone - } + ( + $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; + $exists($phone) + ? { "requestorMobileNo": $phone } + : { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", + "message": "requesting an OTP needs commitmentAttributes.applicantPhone" } } + ) -# The phone is masked rather than echoed: enough for the farmer to recognise -# which number, not enough to read it back. +# Built from the request alone: SendOTP's reply is not documented, so nothing +# is read from it, and expiresAt is left out until it is. The phone is masked +# rather than echoed: enough for the farmer to recognise which number, not +# enough to read it back. response: | ( $commitment := beckn.message.contract.commitments[0]; @@ -38,13 +39,15 @@ response: | "message": { "contract": $merge([beckn.message.contract, { "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "DRAFT" } }, + "status": { "descriptor": { "code": "DRAFT", "name": "draft" } }, "commitmentAttributes": { "@context": $ca.`@context`, "@type": $ca.`@type`, + /* Still OnDemand: an OTP acknowledgement carries no case. */ + "informationMode": "OnDemand", + "scheme": $ca.scheme, "otpChallenge": { - "sentTo": $substring($phone, 0, 2) & "XXXXXX" & $substring($phone, -2), - "expiresAt": $fromMillis($toMillis($now()) + response.responseDynamic.otpValidityInSeconds * 1000) + "sentTo": $substring($phone, 0, 2) & "XXXXXX" & $substring($phone, -2) } } }])] diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 978caf0e7..df9de17f8 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -7,34 +7,33 @@ # The response is an allow-list. PMFBY's record also carries the farmer's name, # address and policy details; none of it is mapped. -required: - - check: | - ( - $ca := beckn.message.contract.commitments[0].commitmentAttributes; - $exists($ca.applicantPhone) and $exists($ca.ticketNo) - ) - message: "checking a grievance needs applicantPhone and ticketNo" - code: SCH_REQUIRED_FIELD_MISSING - -reject: - - check: response.responseCode = 1 - message: "PMFBY did not return the grievance" - code: NET_DOWNSTREAM_UNAVAILABLE - status: 502 - +# A missing field is refused by the request half under _error, which can name +# SCH_REQUIRED_FIELD_MISSING. request: | ( $ca := beckn.message.contract.commitments[0].commitmentAttributes; - { + $not($exists($ca.applicantPhone) and $exists($ca.ticketNo)) + ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", + "message": "checking a grievance needs applicantPhone and ticketNo" } } + : { "requestorMobileNo": $ca.applicantPhone, "GrievenceSupportTicketNo": $ca.ticketNo } ) -# An unknown ticket is an ordinary answer with no grievance details, not an -# error: responseDynamic is empty and only the ticket number goes back. +# No case record in the reply is not a failure -- PMFBY answered and has +# nothing on file -- so it is a 202 ACK saying so. Otherwise contract.descriptor +# is the complaint as PMFBY holds it, the attributes are the case record, and a +# field PMFBY does not return is left out. response: | - ( + /* A 4xx from PMFBY arrives under _status; it is PMFBY's refusal, left to + the adapter's 502 rather than read as nothing on file. */ + $exists(_status) + ? {} + : $type(response.responseDynamic) != "object" + ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", + "message": "No grievance matches this ticket and phone number." } } + : ( $commitment := beckn.message.contract.commitments[0]; $ca := $commitment.commitmentAttributes; $ticket := response.responseDynamic; @@ -42,27 +41,38 @@ response: | field is dropped rather than mapped as "null" or failing the call. */ $isText := function($v) { $type($v) = "string" and $v != "" }; $isNumber := function($v) { $type($v) = "number" }; + $seasons := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; + $hasCategory := $isNumber($ticket.TicketCategoryID) and $isNumber($ticket.TicketSubCategoryID); { "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), "message": { "contract": $merge([beckn.message.contract, { + "descriptor": { + "code": $hasCategory + ? $string($ticket.TicketCategoryID) & "." & $string($ticket.TicketSubCategoryID), + "name": $isText($ticket.TicketCategoryName) and $isText($ticket.TicketSubCategoryName) + ? $ticket.TicketCategoryName & " / " & $ticket.TicketSubCategoryName, + "longDesc": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription + }, "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "ACTIVE" } }, + "status": { "descriptor": { "code": "ACTIVE", "name": "active" } }, "commitmentAttributes": { "@context": $ca.`@context`, "@type": $ca.`@type`, + "informationMode": "Direct", + "scheme": $ca.scheme, "ticketNo": $ca.ticketNo, - "caseStatus": $isText($ticket.TicketStatus) - ? $uppercase($replace($ticket.TicketStatus, " ", "_")), - "grievanceCategory": $isNumber($ticket.TicketCategoryID) and $isNumber($ticket.TicketSubCategoryID) ? { - "code": $string($ticket.TicketCategoryID) & "." & $string($ticket.TicketSubCategoryID), - "name": $isText($ticket.TicketCategoryName) and $isText($ticket.TicketSubCategoryName) - ? $ticket.TicketCategoryName & " / " & $ticket.TicketSubCategoryName + "applicationNo": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, + "cropYear": $isNumber($ticket.RequestYear) or $isText($ticket.RequestYear) + ? $string($ticket.RequestYear), + "season": $isNumber($ticket.RequestSeason) ? $lookup($seasons, $string($ticket.RequestSeason)), + "caseStatus": $isText($ticket.TicketStatus) ? { + "code": $uppercase($replace($ticket.TicketStatus, " ", "_")), + "name": $ticket.TicketStatus }, - "grievanceDescription": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription, "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, "officerReply": $isText($ticket.latestRemark) ? $ticket.latestRemark, - "source": "PMFBY" + "source": { "sourceId": "pmfby", "sourceName": "PMFBY Grievance Portal" } } }])] }]) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml new file mode 100644 index 000000000..f855c9a72 --- /dev/null +++ b/config/mappings/pmfby/grievance.support.yaml @@ -0,0 +1,99 @@ +# PMFBY, openagrinet:PMFBYGrievance, support -> POST /AddKRPHNCIPGrievenceSupportTicket. +# Both directions, one file. +# +# Step two of three: lodge the grievance. A support request composes no +# contract: message.support carries the application number in orderId, the +# complaint in descriptor (code "3.10" is category and sub-category), and the +# farmer in channels[0]. The OTP init sent travels on the lodge call until +# PMFBY's verify endpoint is wired in; it is never echoed back. +# +# "Grievence" is PMFBY's spelling, kept where PMFBY reads it. + +# required holds format checks, which return SCH_INVALID_FORMAT and let an +# absent field pass. A missing field is refused by the request half under +# _error, which can name SCH_REQUIRED_FIELD_MISSING. +required: + - check: | + ( + $code := beckn.message.support.descriptor.code; + $not($exists($code)) or $exists($match($code, /^[0-9]+\.[0-9]+$/)[0]) + ) + message: "descriptor.code must be ., e.g. 3.10" + - check: | + ( + $season := beckn.message.support.channels[0].season; + $not($exists($season)) or $season in ["Kharif", "Rabi", "Zaid"] + ) + message: "season must be Kharif, Rabi or Zaid" + +request: | + ( + $s := beckn.message.support; + $ch := $s.channels[0]; + $category := $split($s.descriptor.code, "."); + $seasons := { "Kharif": 1, "Rabi": 2, "Zaid": 3 }; + /* PMFBY's fixed id for this channel as the source of the grievance. */ + $receiptSourceID := 134306; + $complete := $exists($s.orderId) and $exists($s.descriptor.code) and $exists($s.descriptor.longDesc) + and $exists($ch.applicantPhone) and $exists($ch.otp) + and $exists($ch.cropYear) and $exists($ch.season); + $not($complete) + ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", + "message": "lodging a grievance needs orderId, descriptor.code, descriptor.longDesc and, on channels[0], applicantPhone, otp, cropYear and season" } } + : { + "requestorMobileNo": $ch.applicantPhone, + "otp": $ch.otp, + "complaintDate": $now("[Y0001]-[M01]-[D01]", "+0530"), + "receiptSourceID": $receiptSourceID, + "ticketCategoryID": $number($category[0]), + "ticketSubCategoryID": $number($category[1]), + "requestYear": $string($ch.cropYear), + "requestSeason": $lookup($seasons, $ch.season), + "applicationNo": $s.orderId, + "grievenceDescription": $trim($s.descriptor.longDesc) + } + ) + +# A 4xx from PMFBY reaches this half with its status under _status. +# +# orderId and descriptor are the caller's own, echoed, and the +# ticket goes on the channel. caseStatus, filedOn and source are not in PMFBY's +# reply: a freshly lodged grievance is registered, and filedOn restates the IST +# date just sent. +response: | + $exists(_status) + ? ( + /* PMFBY refused the lodge call with a 4xx. A 400 is a wrong or expired + OTP: the farmer's to fix, so a 400 back. Any other status is left to + the adapter's 502. */ + _status = 400 + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": "the OTP is wrong or has expired" } } + : {} + ) + : ( + $s := beckn.message.support; + $ch := $s.channels[0]; + $ticket := response.responseDynamic; + { + "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), + "message": { + "support": { + "orderId": $s.orderId, + "descriptor": $s.descriptor, + "channels": [{ + "@context": $ch.`@context`, + "@type": $ch.`@type`, + "informationMode": "Direct", + "scheme": $ch.scheme, + "ticketNo": $ticket.GrievenceSupportTicketNo, + "ticketId": $type($ticket.GrievenceSupportTicketID) = "number" + or $type($ticket.GrievenceSupportTicketID) = "string" + ? $string($ticket.GrievenceSupportTicketID), + "caseStatus": { "code": "REGISTERED", "name": "Registered" }, + "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530"), + "source": { "sourceId": "pmfby", "sourceName": "PMFBY Grievance Portal" } + }] + } + } + } + ) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 35edb1c93..3721c288b 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -46,12 +46,14 @@ const knownTicketRecord = `{"responseCode":1,"responseMessage":"Fetched","respon "RequestYear":2025,"RequestSeason":1,"latestRemark":"Forwarded to insurer", "FarmerName":"Ramesh","RequestorMobileNo":"9876543210","InsurancePolicyNo":"P-1"}}` -// pmfbyStub answers like PMFBY: 200 for everything, success or failure in -// responseCode. Every field left zero gives PMFBY's ordinary answer. +// pmfbyStub stands in for PMFBY. A refusal is a non-2xx; PMFBY's real failure +// shape is not documented, so nothing here depends on one. Every field left +// zero gives the ordinary answer. type pmfbyStub struct { down bool // 503 on every call but the login loginDown bool // 401 on the login answers map[string]string // overrides the answer for a path + statusFor map[string]int // answers a path with this status and no body calls map[string]int // per path, login included bodies map[string]map[string]any } @@ -76,6 +78,10 @@ func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) return } + if status, set := p.statusFor[r.URL.Path]; set { + w.WriteHeader(status) + return + } raw, _ := io.ReadAll(r.Body) var body map[string]any _ = json.Unmarshal(raw, &body) @@ -90,10 +96,11 @@ func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"responseCode":1,"responseMessage":"OTP sent","responseDynamic":{"otpValidityInSeconds":300}}`) case insertPath: if body["otp"] != validOTP { - fmt.Fprint(w, `{"responseCode":0,"responseMessage":"Invalid OTP","responseDynamic":null}`) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"responseMessage":"Invalid OTP"}`) return } - fmt.Fprintf(w, `{"responseCode":1,"responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q}}`, knownTicket) + fmt.Fprintf(w, `{"responseCode":1,"responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q,"GrievenceSupportTicketID":8842317}}`, knownTicket) case statusPath: if body["GrievenceSupportTicketNo"] != knownTicket { fmt.Fprint(w, `{"responseCode":1,"responseMessage":"No record found","responseDynamic":null}`) @@ -119,8 +126,9 @@ type harness struct { } // newHarness builds the step. statusRetries is the registry's retryMax for -// status; init and confirm get 0, as the seed ships them. -func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int) *harness { +// status; init and support get 0, as the seed ships them. tweak edits the +// shipped config before the step is built. +func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func(*Grievance.Config)) *harness { t.Helper() t.Setenv("PMFBY_USER", "user") t.Setenv("PMFBY_PASSWORD", "secret") @@ -147,22 +155,29 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int) *harness { BaseURL: upstream.URL, Actions: map[string]model.ActionPlan{ "init": action(sendOTPPath, "grievance.init.yaml", 0), - "confirm": action(insertPath, "grievance.confirm.yaml", 0), + "support": action(insertPath, "grievance.support.yaml", 0), "status": action(statusPath, "grievance.status.yaml", statusRetries), }, }} - step, closeStep, err := Grievance.New(context.Background(), registry, mapper, &Grievance.Config{ + cfg := &Grievance.Config{ BindingKeys: []string{shippedBindingKey}, ProviderIDAt: "message.contract.commitments[].offer.provider.id", CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type", + // A support request composes no contract; its channel names both. + FallbackProviderIDAt: "message.support.channels[].providerId", + FallbackCapabilityCodeAt: "message.support.channels[].@type", AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", TokenSecretName: "appAccessPWD", TokenSecretEnv: "PMFBY_PASSWORD", TokenResponseField: "token", TokenTTLRaw: "10m", HeaderName: "Authorization", }}, - }) + } + for _, apply := range tweak { + apply(cfg) + } + step, closeStep, err := Grievance.New(context.Background(), registry, mapper, cfg) if err != nil { t.Fatal(err) } @@ -175,7 +190,8 @@ type answer struct { Context map[string]any `json:"context"` Message struct { Contract struct { - ID string `json:"id"` + ID string `json:"id"` + Descriptor map[string]any `json:"descriptor"` Commitments []struct { Status struct { Descriptor struct { @@ -186,9 +202,19 @@ type answer struct { CommitmentAttributes map[string]any `json:"commitmentAttributes"` } `json:"commitments"` } `json:"contract"` + Support struct { + OrderID string `json:"orderId"` + Descriptor map[string]any `json:"descriptor"` + Channels []map[string]any `json:"channels"` + } `json:"support"` } `json:"message"` } +// channel is the case record on an on_support. +func (a *answer) channel() map[string]any { + return a.Message.Support.Channels[0] +} + func (a *answer) attributes() map[string]any { return a.Message.Contract.Commitments[0].CommitmentAttributes } @@ -227,12 +253,24 @@ func (h *harness) mustSend(t *testing.T, body []byte) *answer { return got } -// request builds a Beckn grievance payload for action with these attributes. +const packContext = "https://openagrinet.github.io/network-specs/api-schemas/PMFBYGrievance/v0.1/context.jsonld" + +// scheme is what every PMFBY payload names, in both directions. +var scheme = map[string]any{"code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana"} + +func becknContext(action string) map[string]any { + return map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"} +} + +// request builds a Beckn grievance payload for init or status: a contract whose +// one commitment carries these attributes. func request(action string, attributes map[string]any) []byte { - attributes["@context"] = "https://schemas.openagrinet.global/schema/PMFBYGrievance/v0.1/context.jsonld" + attributes["@context"] = packContext attributes["@type"] = "openagrinet:PMFBYGrievance" + attributes["informationMode"] = "OnDemand" + attributes["scheme"] = scheme body, _ := json.Marshal(map[string]any{ - "context": map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"}, + "context": becknContext(action), "message": map[string]any{"contract": map[string]any{ "id": "contract-1", "commitments": []any{map[string]any{ @@ -246,13 +284,38 @@ func request(action string, attributes map[string]any) []byte { return body } +// support builds a Beckn support request, which composes no contract. fields is +// flat: orderId goes on support, code, name and longDesc on its descriptor, and +// everything else on channels[0]. A field left out is absent from the payload. +func support(fields map[string]any) []byte { + channel := map[string]any{"@context": packContext, "@type": "openagrinet:PMFBYGrievance", + "informationMode": "OnDemand", "scheme": scheme, "providerId": "pmfby"} + body := map[string]any{"channels": []any{channel}} + descriptor := map[string]any{} + for field, value := range fields { + switch field { + case "orderId": + body["orderId"] = value + case "code", "name", "longDesc": + descriptor[field] = value + default: + channel[field] = value + } + } + if len(descriptor) > 0 { + body["descriptor"] = descriptor + } + raw, _ := json.Marshal(map[string]any{"context": becknContext("support"), "message": map[string]any{"support": body}}) + return raw +} + func initAttributes() map[string]any { return map[string]any{"applicantPhone": phone} } -func confirmAttributes() map[string]any { +func supportFields() map[string]any { return map[string]any{ - "applicantPhone": phone, "otp": validOTP, "applicationNo": "040108251010160770605", - "cropYear": "2025", "season": "Kharif", "grievanceCategory": map[string]any{"code": "3.10"}, - "grievanceDescription": " Claim not received ", + "orderId": "040108251010160770605", "code": "3.10", "name": "Claim / Claim not received", + "longDesc": " Claim not received ", "applicantPhone": phone, "otp": validOTP, + "cropYear": "2025", "season": "Kharif", } } @@ -281,7 +344,6 @@ func todayIST() string { func TestInit_ValidPhone_ReturnsMaskedOTPChallenge(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - before := time.Now() got := h.mustSend(t, request("init", initAttributes())) if sent := h.pmfby.bodies[sendOTPPath]; len(sent) != 1 || sent["requestorMobileNo"] != phone { @@ -291,16 +353,12 @@ func TestInit_ValidPhone_ReturnsMaskedOTPChallenge(t *testing.T) { if challenge["sentTo"] != "98XXXXXX10" { t.Errorf("sentTo = %v, want 98XXXXXX10", challenge["sentTo"]) } - expiresAt, err := time.Parse(time.RFC3339, fmt.Sprint(challenge["expiresAt"])) - if err != nil { - t.Fatalf("expiresAt %v is not RFC 3339: %v", challenge["expiresAt"], err) - } - if wait := expiresAt.Sub(before); wait < 299*time.Second || wait > 301*time.Second { - t.Errorf("expiresAt is %v away, want the 300s PMFBY reported", wait) - } if got.Context["action"] != "on_init" || got.status() != "DRAFT" { t.Errorf("action %v, status %v; want on_init, DRAFT", got.Context["action"], got.status()) } + if got.attributes()["informationMode"] != "OnDemand" || got.attributes()["scheme"] == nil { + t.Errorf("commitmentAttributes = %v, want OnDemand and the scheme echoed", got.attributes()) + } if _, echoed := got.attributes()["applicantPhone"]; echoed { t.Error("applicantPhone must not be echoed") } @@ -341,16 +399,6 @@ func TestInit_NumericPhone_Returns400WithoutSendingOTP(t *testing.T) { } } -func TestInit_PortalRefuses_Returns502(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{ - sendOTPPath: `{"responseCode":0,"responseMessage":"Mobile 9876543210 not registered"}`}}, 0) - _, err := h.send(t, request("init", initAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) - if strings.Contains(err.Error(), phone) { - t.Errorf("error %q leaks PMFBY's message", err) - } -} - func TestInit_PortalDown_Returns502AfterOneAttempt(t *testing.T) { h := newHarness(t, &pmfbyStub{down: true}, 0) _, err := h.send(t, request("init", initAttributes())) @@ -369,11 +417,11 @@ func TestInit_LoginRejected_Returns502WithoutSendingOTP(t *testing.T) { } } -// --- confirm --------------------------------------------------------------- +// --- support --------------------------------------------------------------- -func TestConfirm_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { +func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - got := h.mustSend(t, request("confirm", confirmAttributes())) + got := h.mustSend(t, support(supportFields())) want := map[string]any{ "requestorMobileNo": phone, "otp": validOTP, "applicationNo": "040108251010160770605", @@ -390,38 +438,54 @@ func TestConfirm_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { t.Errorf("PMFBY was sent %d fields (%v), want exactly %d", len(sent), sent, len(want)) } - attributes := got.attributes() - if attributes["ticketNo"] != knownTicket || attributes["caseStatus"] != "REGISTERED" || - attributes["filedOn"] != todayIST() || attributes["source"] != "PMFBY" { - t.Errorf("commitmentAttributes = %v", attributes) + if got.Context["action"] != "on_support" { + t.Errorf("action = %v, want on_support", got.Context["action"]) + } + // orderId and descriptor are the caller's own, echoed unchanged. + if got.Message.Support.OrderID != "040108251010160770605" || got.Message.Support.Descriptor["code"] != "3.10" { + t.Errorf("support = %+v, want orderId and descriptor echoed", got.Message.Support) } - if got.Context["action"] != "on_confirm" || got.status() != "ACTIVE" { - t.Errorf("action %v, status %v; want on_confirm, ACTIVE", got.Context["action"], got.status()) + channel := got.channel() + caseStatus, _ := channel["caseStatus"].(map[string]any) + source, _ := channel["source"].(map[string]any) + if channel["ticketNo"] != knownTicket || channel["ticketId"] != "8842317" || + caseStatus["code"] != "REGISTERED" || caseStatus["name"] != "Registered" || + channel["filedOn"] != todayIST() || source["sourceId"] != "pmfby" || + channel["informationMode"] != "Direct" || channel["scheme"] == nil { + t.Errorf("channel = %v", channel) } - for _, private := range []string{"otp", "applicantPhone"} { - if _, echoed := attributes[private]; echoed { - t.Errorf("%s must never be echoed", private) + for _, private := range []string{"otp", "applicantPhone", "providerId"} { + if _, echoed := channel[private]; echoed { + t.Errorf("%s must not be echoed", private) } } } -func TestConfirm_NumericCropYear_SentAsString(t *testing.T) { +func TestSupport_NoTicketID_OmitsIt(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{ + insertPath: fmt.Sprintf(`{"responseCode":1,"responseDynamic":{"GrievenceSupportTicketNo":%q}}`, knownTicket)}}, 0) + if value, present := h.mustSend(t, support(supportFields())).channel()["ticketId"]; present { + t.Errorf("ticketId = %v, want it omitted when PMFBY sends none", value) + } +} + +func TestSupport_NumericCropYear_SentAsString(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - attributes["cropYear"] = 2025 - h.mustSend(t, request("confirm", attributes)) + fields := supportFields() + fields["cropYear"] = 2025 + h.mustSend(t, support(fields)) if got := h.pmfby.bodies[insertPath]["requestYear"]; got != "2025" { t.Errorf("requestYear = %#v, want the string \"2025\" PMFBY takes", got) } } -func TestConfirm_EachSeason_SendsItsCode(t *testing.T) { +func TestSupport_EachSeason_SendsItsCode(t *testing.T) { for season, code := range map[string]float64{"Kharif": 1, "Rabi": 2, "Zaid": 3} { t.Run(season, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - attributes["season"] = season - h.mustSend(t, request("confirm", attributes)) + fields := supportFields() + fields["season"] = season + h.mustSend(t, support(fields)) if got := h.pmfby.bodies[insertPath]["requestSeason"]; got != code { t.Errorf("requestSeason = %v, want %v", got, code) } @@ -429,13 +493,13 @@ func TestConfirm_EachSeason_SendsItsCode(t *testing.T) { } } -func TestConfirm_CategoryCode_SplitsOnTheDot(t *testing.T) { +func TestSupport_CategoryCode_SplitsOnTheDot(t *testing.T) { for category, want := range map[string][2]float64{"3.10": {3, 10}, "12.5": {12, 5}, "1.1": {1, 1}} { t.Run(category, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - attributes["grievanceCategory"] = map[string]any{"code": category} - h.mustSend(t, request("confirm", attributes)) + fields := supportFields() + fields["code"] = category + h.mustSend(t, support(fields)) sent := h.pmfby.bodies[insertPath] if sent["ticketCategoryID"] != want[0] || sent["ticketSubCategoryID"] != want[1] { t.Errorf("sent %v / %v, want %v", sent["ticketCategoryID"], sent["ticketSubCategoryID"], want) @@ -444,14 +508,13 @@ func TestConfirm_CategoryCode_SplitsOnTheDot(t *testing.T) { } } -func TestConfirm_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"applicantPhone", "otp", "applicationNo", "cropYear", "season", - "grievanceCategory", "grievanceDescription"} { +func TestSupport_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { + for _, field := range []string{"orderId", "code", "longDesc", "applicantPhone", "otp", "cropYear", "season"} { t.Run(field, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - delete(attributes, field) - _, err := h.send(t, request("confirm", attributes)) + fields := supportFields() + delete(fields, field) + _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") if h.pmfby.calls[insertPath] != 0 { t.Error("PMFBY was called for a request missing a required field") @@ -460,21 +523,22 @@ func TestConfirm_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { } } -func TestConfirm_CategoryWithoutCode_Returns400(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - attributes["grievanceCategory"] = map[string]any{"name": "Claim"} - _, err := h.send(t, request("confirm", attributes)) +func TestSupport_NoDescriptor_Returns400(t *testing.T) { + fields := supportFields() + for _, field := range []string{"code", "name", "longDesc"} { + delete(fields, field) + } + _, err := newHarness(t, &pmfbyStub{}, 0).send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") } -func TestConfirm_MalformedCategory_Returns400WithoutCallingPMFBY(t *testing.T) { +func TestSupport_MalformedCategory_Returns400WithoutCallingPMFBY(t *testing.T) { for _, category := range []string{"3", "3.", ".10", "3.10.1", "a.b", "3-10", ""} { t.Run(category, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - attributes["grievanceCategory"] = map[string]any{"code": category} - _, err := h.send(t, request("confirm", attributes)) + fields := supportFields() + fields["code"] = category + _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") if h.pmfby.calls[insertPath] != 0 { t.Error("PMFBY was called with a malformed category") @@ -483,13 +547,13 @@ func TestConfirm_MalformedCategory_Returns400WithoutCallingPMFBY(t *testing.T) { } } -func TestConfirm_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { +func TestSupport_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { for _, season := range []string{"Summer", "kharif", "KHARIF", ""} { t.Run(season, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - attributes := confirmAttributes() - attributes["season"] = season - _, err := h.send(t, request("confirm", attributes)) + fields := supportFields() + fields["season"] = season + _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") if h.pmfby.calls[insertPath] != 0 { t.Error("PMFBY was called with an unknown season") @@ -498,59 +562,94 @@ func TestConfirm_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { } } -func TestConfirm_WrongOrExpiredOTP_Returns400BizGenericError(t *testing.T) { - for _, message := range []string{"Invalid OTP", "OTP expired", "otp mismatch"} { - t.Run(message, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{ - insertPath: fmt.Sprintf(`{"responseCode":0,"responseMessage":%q}`, message)}}, 0) - _, err := h.send(t, request("confirm", confirmAttributes())) - assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") - if strings.Contains(err.Error(), message) { - t.Errorf("error %q leaks PMFBY's message", err) - } - }) - } -} - -func TestConfirm_WrongOTPFromStub_Returns400BizGenericError(t *testing.T) { - attributes := confirmAttributes() - attributes["otp"] = "000000" - _, err := newHarness(t, &pmfbyStub{}, 0).send(t, request("confirm", attributes)) +// PMFBY refusing the lodge call with a 400 -- the stub's wrong OTP -- is a wrong +// OTP to the caller, and the lodge call is not repeated. +func TestSupport_PortalAnswers400_Returns400BizGenericError(t *testing.T) { + fields := supportFields() + fields["otp"] = "000000" + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") + if strings.Contains(err.Error(), "Invalid OTP") { + t.Errorf("error %q leaks PMFBY's body", err) + } + if got := h.pmfby.calls[insertPath]; got != 1 { + t.Errorf("the lodge call was made %d times, want 1", got) + } } -func TestConfirm_PortalRefuses_Returns502(t *testing.T) { - for name, answer := range map[string]string{ - "failure code": `{"responseCode":0,"responseMessage":"Service error for 9876543210"}`, - "no message": `{"responseCode":0}`, - "no code": `{"responseMessage":"something"}`, - "null message": `{"responseCode":0,"responseMessage":null}`, - "empty message": `{"responseCode":0,"responseMessage":""}`, - "code as other value": `{"responseCode":2,"responseMessage":"duplicate"}`, - } { - t.Run(name, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: answer}}, 0) - _, err := h.send(t, request("confirm", confirmAttributes())) +// Any other 4xx is PMFBY's refusal, not the farmer's OTP. +func TestSupport_PortalAnswersOther4xx_Returns502(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} { + t.Run(http.StatusText(status), func(t *testing.T) { + h := newHarness(t, &pmfbyStub{statusFor: map[string]int{insertPath: status}}, 0) + _, err := h.send(t, support(supportFields())) assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) - if strings.Contains(err.Error(), phone) { - t.Errorf("error %q leaks PMFBY's message", err) + if got := h.pmfby.calls[insertPath]; got != 1 { + t.Errorf("the lodge call was made %d times, want 1", got) } }) } } -func TestConfirm_PortalDown_Returns502AfterOneAttempt(t *testing.T) { +// A 400 on another action is not an OTP error: only support says so. +func TestStatus_PortalAnswers400_Returns502(t *testing.T) { + h := newHarness(t, &pmfbyStub{statusFor: map[string]int{statusPath: http.StatusBadRequest}}, 0) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) +} + +func TestSupport_PortalDown_Returns502AfterOneAttempt(t *testing.T) { h := newHarness(t, &pmfbyStub{down: true}, 0) - _, err := h.send(t, request("confirm", confirmAttributes())) + _, err := h.send(t, support(supportFields())) assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) if got := h.pmfby.calls[insertPath]; got != 1 { - t.Errorf("InsertGrievenceTicket called %d times, want 1: a retry files a duplicate", got) + t.Errorf("the lodge call was made %d times, want 1: a retry files a duplicate", got) + } +} + +func TestSupport_SecondChannel_Returns400WithoutCallingPMFBY(t *testing.T) { + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + body := payload["message"].(map[string]any)["support"].(map[string]any) + body["channels"] = append(body["channels"].([]any), body["channels"].([]any)[0]) + raw, _ := json.Marshal(payload) + + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, raw) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[insertPath] != 0 { + t.Error("PMFBY was called for a request naming two channels") + } +} + +// The fallback keys are what make a support request reachable at all: without +// them it composes no contract the plugin can read a binding from. +func TestSupport_FallbackNotConfigured_PassesThroughUntouched(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0, func(c *Grievance.Config) { + c.FallbackProviderIDAt, c.FallbackCapabilityCodeAt = "", "" + }) + got, err := h.send(t, support(supportFields())) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want the support request passed on untouched", got, err) + } + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v without a way to recognise the request", h.pmfby.calls) + } +} + +func TestSupport_NoProviderID_PassesThrough(t *testing.T) { + body := strings.Replace(string(support(supportFields())), `"providerId":"pmfby",`, "", 1) + h := newHarness(t, &pmfbyStub{}, 0) + got, err := h.send(t, []byte(body)) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want a support request naming no provider passed on", got, err) } } // --- status ---------------------------------------------------------------- -func TestStatus_KnownTicket_ReturnsAllowListedDetails(t *testing.T) { +func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) got := h.mustSend(t, request("status", statusAttributes())) @@ -558,18 +657,27 @@ func TestStatus_KnownTicket_ReturnsAllowListedDetails(t *testing.T) { if len(sent) != 2 || sent["GrievenceSupportTicketNo"] != knownTicket || sent["requestorMobileNo"] != phone { t.Errorf("PMFBY was sent %v, want the ticket and the phone alone", sent) } + descriptor := got.Message.Contract.Descriptor + if descriptor["code"] != "3.10" || descriptor["name"] != "Claim / Claim not received" || + descriptor["longDesc"] != "Claim not received" { + t.Errorf("contract.descriptor = %v, want the complaint as PMFBY holds it", descriptor) + } attributes := got.attributes() - category, _ := attributes["grievanceCategory"].(map[string]any) for field, want := range map[string]any{ - "ticketNo": knownTicket, "caseStatus": "UNDER_REVIEW", "filedOn": "2026-10-04", - "grievanceDescription": "Claim not received", "officerReply": "Forwarded to insurer", "source": "PMFBY", + "ticketNo": knownTicket, "applicationNo": "040108251010160770605", "cropYear": "2025", + "season": "Kharif", "filedOn": "2026-10-04", "officerReply": "Forwarded to insurer", + "informationMode": "Direct", } { if attributes[field] != want { t.Errorf("%s = %v, want %v", field, attributes[field], want) } } - if category["code"] != "3.10" || category["name"] != "Claim / Claim not received" { - t.Errorf("grievanceCategory = %v", category) + caseStatus, _ := attributes["caseStatus"].(map[string]any) + if caseStatus["code"] != "UNDER_REVIEW" || caseStatus["name"] != "Under Review" { + t.Errorf("caseStatus = %v, want UNDER_REVIEW / Under Review", caseStatus) + } + if source, _ := attributes["source"].(map[string]any); source["sourceId"] != "pmfby" { + t.Errorf("source = %v, want pmfby", attributes["source"]) } if got.Context["action"] != "on_status" || got.status() != "ACTIVE" { t.Errorf("action %v, status %v; want on_status, ACTIVE", got.Context["action"], got.status()) @@ -578,8 +686,9 @@ func TestStatus_KnownTicket_ReturnsAllowListedDetails(t *testing.T) { func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", statusAttributes())) - allowed := map[string]bool{"@context": true, "@type": true, "ticketNo": true, "caseStatus": true, - "grievanceCategory": true, "grievanceDescription": true, "filedOn": true, "officerReply": true, "source": true} + allowed := map[string]bool{"@context": true, "@type": true, "informationMode": true, "scheme": true, + "ticketNo": true, "applicationNo": true, "cropYear": true, "season": true, "caseStatus": true, + "filedOn": true, "officerReply": true, "source": true} for field := range got.attributes() { if !allowed[field] { t.Errorf("%s is mapped; the response is an allow-list", field) @@ -597,23 +706,32 @@ func TestStatus_NoRemarkYet_OmitsOfficerReply(t *testing.T) { } func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { + inAttributes := func(field string) func(*answer) map[string]any { + return func(a *answer) map[string]any { return map[string]any{field: a.attributes()[field]} } + } + inDescriptor := func(field string) func(*answer) map[string]any { + return func(a *answer) map[string]any { return map[string]any{field: a.Message.Contract.Descriptor[field]} } + } for name, tc := range map[string]struct { from, to string - absent string + field string + where func(string) func(*answer) map[string]any }{ - "null status": {`"TicketStatus":"Under Review"`, `"TicketStatus":null`, "caseStatus"}, - "empty status": {`"TicketStatus":"Under Review"`, `"TicketStatus":""`, "caseStatus"}, - "null sub-category id": {`"TicketSubCategoryID":10`, `"TicketSubCategoryID":null`, "grievanceCategory"}, - "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "grievanceDescription"}, - "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "filedOn"}, - "null remark": {`"latestRemark":"Forwarded to insurer"`, `"latestRemark":null`, "officerReply"}, + "null status": {`"TicketStatus":"Under Review"`, `"TicketStatus":null`, "caseStatus", inAttributes}, + "empty status": {`"TicketStatus":"Under Review"`, `"TicketStatus":""`, "caseStatus", inAttributes}, + "null sub-category id": {`"TicketSubCategoryID":10`, `"TicketSubCategoryID":null`, "code", inDescriptor}, + "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "longDesc", inDescriptor}, + "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "filedOn", inAttributes}, + "null remark": {`"latestRemark":"Forwarded to insurer"`, `"latestRemark":null`, "officerReply", inAttributes}, + "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "applicationNo", inAttributes}, + "null request season": {`"RequestSeason":1`, `"RequestSeason":null`, "season", inAttributes}, } { t.Run(name, func(t *testing.T) { record := strings.Replace(knownTicketRecord, tc.from, tc.to, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). mustSend(t, request("status", statusAttributes())) - if value, present := got.attributes()[tc.absent]; present { - t.Errorf("%s = %v, want it dropped", tc.absent, value) + if value := tc.where(tc.field)(got)[tc.field]; value != nil { + t.Errorf("%s = %v, want it dropped", tc.field, value) } if got.attributes()["ticketNo"] != knownTicket { t.Errorf("ticketNo = %v, want the rest of the answer intact", got.attributes()["ticketNo"]) @@ -626,27 +744,26 @@ func TestStatus_NullCategoryName_KeepsCodeDropsName(t *testing.T) { record := strings.Replace(knownTicketRecord, `"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). mustSend(t, request("status", statusAttributes())) - category, _ := got.attributes()["grievanceCategory"].(map[string]any) - if category["code"] != "3.10" { - t.Errorf("code = %v, want 3.10", category["code"]) + descriptor := got.Message.Contract.Descriptor + if descriptor["code"] != "3.10" { + t.Errorf("code = %v, want 3.10", descriptor["code"]) } - if name, present := category["name"]; present { + if name, present := descriptor["name"]; present { t.Errorf("name = %v, want it dropped rather than \"null / ...\"", name) } } -func TestStatus_UnknownTicket_ReturnsTicketNumberAlone(t *testing.T) { +func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { attributes := statusAttributes() attributes["ticketNo"] = "999" - got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", attributes)) + _, err := newHarness(t, &pmfbyStub{}, 0).send(t, request("status", attributes)) - for field := range got.attributes() { - if field != "@context" && field != "@type" && field != "ticketNo" && field != "source" { - t.Errorf("%s = %v on an unknown ticket, want no grievance details", field, got.attributes()[field]) - } + var ack *model.AckNoCallbackErr + if !errors.As(err, &ack) { + t.Fatalf("error = %v, want a 202 ACK", err) } - if got.attributes()["ticketNo"] != "999" { - t.Errorf("ticketNo = %v, want the one asked about", got.attributes()["ticketNo"]) + if ack.Status != model.StatusACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { + t.Errorf("answer = %s %s, want ACK BIZ_NO_RESULTS_FOUND", ack.Status, ack.Err.Code) } } @@ -665,12 +782,6 @@ func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { } } -func TestStatus_PortalRefuses_Returns502(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: `{"responseCode":0,"responseMessage":"error"}`}}, 0) - _, err := h.send(t, request("status", statusAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) -} - func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { h := newHarness(t, &pmfbyStub{down: true}, 2) _, err := h.send(t, request("status", statusAttributes())) @@ -682,13 +793,13 @@ func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { // --- across the flow ------------------------------------------------------- -func TestFlow_InitConfirmStatus_LogsInOnce(t *testing.T) { +func TestFlow_InitSupportStatus_LogsInOnce(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) h.mustSend(t, request("init", initAttributes())) - ticket := h.mustSend(t, request("confirm", confirmAttributes())).attributes()["ticketNo"] + ticket := h.mustSend(t, support(supportFields())).channel()["ticketNo"] got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "ticketNo": ticket})) - if got.attributes()["caseStatus"] != "UNDER_REVIEW" { + if caseStatus, _ := got.attributes()["caseStatus"].(map[string]any); caseStatus["code"] != "UNDER_REVIEW" { t.Errorf("status of the filed ticket = %v", got.attributes()) } if logins := h.pmfby.calls[loginPath]; logins != 1 { diff --git a/pkg/plugin/implementation/internal/common/http.go b/pkg/plugin/implementation/internal/common/http.go index 33884f4d0..26fdd6cfc 100644 --- a/pkg/plugin/implementation/internal/common/http.go +++ b/pkg/plugin/implementation/internal/common/http.go @@ -150,13 +150,24 @@ func (s *Step) attempt(ctx context.Context, auth *authenticator, call model.Acti s.forgetToken(auth) } - err := fmt.Errorf("provider returned %s", resp.Status) // 5xx and 429 ask to be tried again. Every other 4xx is a statement - // about the request, which will not improve. + // about the request, which will not improve -- and which the mapping + // may know the meaning of, so it keeps its status and body. if resp.StatusCode < http.StatusInternalServerError && resp.StatusCode != http.StatusTooManyRequests { - return nil, util.DoNotRetry(err) + return nil, util.DoNotRetry(&providerRefusal{status: resp.StatusCode, text: resp.Status, body: body}) } - return nil, err + return nil, fmt.Errorf("provider returned %s", resp.Status) } return body, nil } + +// providerRefusal is a 4xx the provider answered with. It is still a failure, +// but the response half is shown its status and body, so a mapping can say what +// it means -- a wrong OTP -- rather than the generic 502. +type providerRefusal struct { + status int + text string + body []byte +} + +func (e *providerRefusal) Error() string { return "provider returned " + e.text } diff --git a/pkg/plugin/implementation/internal/common/refusal.go b/pkg/plugin/implementation/internal/common/refusal.go new file mode 100644 index 000000000..24b6fdc30 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/refusal.go @@ -0,0 +1,61 @@ +// A mapping refusing the call: either half may answer with the reserved +// _error field instead of a document. +package common + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// errorField is the reserved field a mapping half returns instead of a +// document when the call must be refused. Reserved like _local, so it cannot +// collide with a field a provider or the network defines. +// +// On the request half it refuses before the provider is called, with a code +// the mapping names -- required checks cannot name one. On the response half +// it answers what the provider's reply means, such as nothing on file. +const errorField = "_error" + +// refusal is what a mapping writes under _error. Status absent means 400. +type refusal struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` +} + +// refusalIn returns the error a mapped document carries under _error, or nil +// when it carries none. +func refusalIn(mapped []byte) error { + var document map[string]json.RawMessage + if json.Unmarshal(mapped, &document) != nil { + // Not an object, so not a refusal. Whether it is a usable document is + // the caller's question. + return nil + } + raw, refused := document[errorField] + if !refused { + return nil + } + var r refusal + if err := json.Unmarshal(raw, &r); err != nil || r.Message == "" { + return fmt.Errorf("a mapping refused the call with an unreadable %s; it needs status, code and message", errorField) + } + return r.err() +} + +// err classifies a refusal as the mapping asked. 202 is not a fault but an +// answer with nothing in it, so it is an ACK carrying the reason. +func (r refusal) err() error { + switch r.Status { + case 0, http.StatusBadRequest: + return model.NewBadReqErr(r.Code, errors.New(r.Message)) + case http.StatusAccepted: + return model.NewAckNoCallbackErr(model.StatusACK, &model.Error{Code: r.Code, Message: r.Message}) + default: + return model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) + } +} diff --git a/pkg/plugin/implementation/internal/common/refusal_test.go b/pkg/plugin/implementation/internal/common/refusal_test.go new file mode 100644 index 000000000..f7734c7e1 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/refusal_test.go @@ -0,0 +1,156 @@ +// _error: a mapping half refusing the call instead of producing a document. +package common + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// refusalStep is a step over mapper whose provider answers {"ok":true}, +// counting calls. +func refusalStep(t *testing.T, mapper *stubMapper, calls *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *calls++ + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) +} + +func TestRefusal_RequestHalf_RefusesWithoutCallingProvider(t *testing.T) { + var calls int + mapper := &stubMapper{ + requestResult: []byte(`{"_error":{"status":400,"code":"SCH_REQUIRED_FIELD_MISSING","message":"phone is required"}}`), + responseResult: []byte(`{"a":1}`), + } + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || + coded.BecknError().Code != "SCH_REQUIRED_FIELD_MISSING" { + t.Fatalf("error = %v, want 400 SCH_REQUIRED_FIELD_MISSING", err) + } + if calls != 0 { + t.Errorf("provider called %d times for a refused request, want 0", calls) + } +} + +func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { + for name, tc := range map[string]struct { + refusal string + status int + code string + }{ + "explicit status": {`{"status":502,"code":"NET_DOWNSTREAM_UNAVAILABLE","message":"refused"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE"}, + "status omitted": {`{"code":"BIZ_GENERIC_ERROR","message":"refused"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR"}, + } { + t.Run(name, func(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":` + tc.refusal + `}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != tc.status || coded.BecknError().Code != tc.code { + t.Fatalf("error = %v, want %d %s", err, tc.status, tc.code) + } + if len(stepCtx.ResponseBody) != 0 { + t.Errorf("a refusal produced a response body: %s", stepCtx.ResponseBody) + } + }) + } +} + +func TestRefusal_Status202_ReturnsAckWithCode(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), + responseResult: []byte(`{"_error":{"status":202,"code":"BIZ_NO_RESULTS_FOUND","message":"nothing on file"}}`)} + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + var ack *model.AckNoCallbackErr + if !errors.As(err, &ack) { + t.Fatalf("error = %v (%T), want a 202 ACK", err, err) + } + if ack.Status != model.StatusACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" || ack.Err.Message != "nothing on file" { + t.Errorf("ack = %s %+v, want ACK BIZ_NO_RESULTS_FOUND with the mapping's message", ack.Status, ack.Err) + } +} + +func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { + for name, refusal := range map[string]string{ + "no message": `{"_error":{"status":502,"code":"X"}}`, + "not object": `{"_error":"refused"}`, + } { + t.Run(name, func(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(refusal)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err == nil || len(stepCtx.ResponseBody) != 0 { + t.Errorf("Run() = %v with body %s, want an error and no answer", err, stepCtx.ResponseBody) + } + }) + } +} + +func TestRefusal_NoErrorField_AnswersAsBefore(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"message":{"ok":true}}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err != nil || string(stepCtx.ResponseBody) != `{"message":{"ok":true}}` || calls != 1 { + t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) + } +} + +// statusStep is a step whose provider answers every call with status and body. +func statusStep(t *testing.T, mapper *stubMapper, status int, body string, calls *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *calls++ + w.WriteHeader(status) + fmt.Fprint(w, body) + })) + t.Cleanup(provider.Close) + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) +} + +func TestExplainRefusal_Provider4xxMappedToError_ReturnsIt(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), + responseResult: []byte(`{"_error":{"status":400,"code":"BIZ_GENERIC_ERROR","message":"wrong OTP"}}`)} + _, err := runStep(t, statusStep(t, mapper, http.StatusBadRequest, `{"reason":"x"}`, &calls), selectBody) + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || coded.BecknError().Code != "BIZ_GENERIC_ERROR" { + t.Fatalf("error = %v, want the mapping's 400 BIZ_GENERIC_ERROR", err) + } + input, _ := mapper.responseInput.(map[string]any) + if input["_status"] != http.StatusBadRequest { + t.Errorf("_status = %v, want the provider's 400", input["_status"]) + } + if calls != 1 { + t.Errorf("provider called %d times, want 1", calls) + } +} + +func TestExplainRefusal_Provider4xxNotRecognised_Returns502(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + _, err := runStep(t, statusStep(t, mapper, http.StatusNotFound, `not json`, &calls), selectBody) + assertStatus(t, err, http.StatusBadGateway) +} + +func TestExplainRefusal_Provider5xx_NotShownToTheMapping(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), + responseResult: []byte(`{"_error":{"status":400,"code":"X","message":"would hide the outage"}}`)} + _, err := runStep(t, statusStep(t, mapper, http.StatusServiceUnavailable, ``, &calls), selectBody) + assertStatus(t, err, http.StatusBadGateway) + if mapper.responseInput != nil { + t.Error("the response half was run for a 5xx; only a 4xx is the request's to explain") + } +} diff --git a/pkg/plugin/implementation/internal/common/reject_test.go b/pkg/plugin/implementation/internal/common/reject_test.go deleted file mode 100644 index 39198c96c..000000000 --- a/pkg/plugin/implementation/internal/common/reject_test.go +++ /dev/null @@ -1,90 +0,0 @@ -// reject: a mapper that can refuse a provider's 200. -package common - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/beckn-one/beckn-onix/pkg/model" - "github.com/beckn-one/beckn-onix/pkg/plugin/definition" -) - -// rejectingMapper is stubMapper plus Reject, recording what Reject was given. -type rejectingMapper struct { - *stubMapper - rejectErr error - rejectInput any -} - -func (m *rejectingMapper) Reject(_ context.Context, _ string, input any) error { - m.rejectInput = input - return m.rejectErr -} - -// assertStatus fails unless err is a CodedErr carrying status. -func assertStatus(t *testing.T, err error, status int) { - t.Helper() - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != status { - t.Fatalf("error = %v, want a coded %d", err, status) - } -} - -// rejectStep is a step whose provider answers body with a 200. -func rejectStep(t *testing.T, mapper definition.Mapper, body string) *Step { - t.Helper() - provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprint(w, body) - })) - t.Cleanup(provider.Close) - return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) -} - -func TestReject_MapperRefuses_ReturnsItsErrorAndSkipsResponseMapping(t *testing.T) { - stub := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)} - mapper := &rejectingMapper{stubMapper: stub, - rejectErr: model.NewCodedErr(http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", errors.New("refused"))} - - stepCtx, err := runStep(t, rejectStep(t, mapper, `{"responseCode":0,"responseMessage":"no"}`), selectBody) - assertStatus(t, err, http.StatusBadGateway) - if stub.responseInput != nil { - t.Error("the response half ran on a refused answer") - } - if len(stepCtx.ResponseBody) != 0 { - t.Errorf("a refused answer produced a response body: %s", stepCtx.ResponseBody) - } -} - -func TestReject_MapperAccepts_MapsResponse(t *testing.T) { - stub := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)} - mapper := &rejectingMapper{stubMapper: stub} - - stepCtx, err := runStep(t, rejectStep(t, mapper, `{"responseCode":1}`), selectBody) - if err != nil { - t.Fatal(err) - } - if string(stepCtx.ResponseBody) != `{"a":1}` { - t.Errorf("response = %s, want the mapped answer", stepCtx.ResponseBody) - } - input, _ := mapper.rejectInput.(map[string]any) - if input["beckn"] == nil || input["response"].(map[string]any)["responseCode"] != 1.0 { - t.Errorf("Reject was given %v, want the beckn request and the decoded answer", mapper.rejectInput) - } -} - -func TestReject_MapperWithoutReject_MapsResponseUnchanged(t *testing.T) { - stub := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)} - - stepCtx, err := runStep(t, rejectStep(t, stub, `{"responseCode":0}`), selectBody) - if err != nil { - t.Fatalf("a mapper with no Reject refused: %v", err) - } - if !strings.Contains(string(stepCtx.ResponseBody), `"a":1`) { - t.Errorf("response = %s, want the mapped answer", stepCtx.ResponseBody) - } -} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 8019dc24d..d6e84a73e 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -130,7 +130,7 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { upstreamResponse, err := s.call(ctx, auth, plan.BaseURL, call, upstreamRequest) if err != nil { - return err + return s.explainRefusal(ctx, plan.BindingKey, call, beckn, local, err) } answer, err := decodeBody(upstreamResponse) @@ -138,10 +138,6 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return fmt.Errorf("provider answered with something that is not JSON: %w", err) } - if err := s.reject(ctx, plan.BindingKey, call.Mappings, beckn, answer); err != nil { - return err - } - // The same file's other half. It is handed what each party sent, plus // whatever prerequisites resolved, under _local. becknResponse, err := s.mapper.Transform(ctx, call.Mappings, definition.DirectionResponse, map[string]any{ @@ -152,6 +148,13 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { if err != nil { return err } + // What the reply means may be a refusal -- nothing on file -- which the + // response half says under _error. The provider's body is not logged: it can + // echo the farmer's own details, and redaction only covers credentials. + if err := refusalIn(becknResponse); err != nil { + log.Warnf(ctx, "%s refused the request: %v", plan.BindingKey, err) + return err + } if len(becknResponse) == 0 { // No response half, or its transform matched nothing. Either way there // is no Beckn response, and returning the provider's own shape would @@ -165,25 +168,32 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return nil } -// rejecter is a mapper that can also refuse a provider's answer. Optional, so -// mappers and mapping files that never refuse need nothing new. -type rejecter interface { - Reject(ctx context.Context, mappingRef string, input any) error -} - -// reject checks the provider's answer against the mapping's reject conditions. +// explainRefusal lets the response half say what a provider's 4xx means. // -// A 200 can still be a refusal. The provider's body is not logged: a refusal -// can echo the farmer's own details back, and redaction only covers -// credentials. The mapping's message says what was refused. -func (s *Step) reject(ctx context.Context, bindingKey, mappingRef string, beckn, answer any) error { - mapper, ok := s.mapper.(rejecter) - if !ok { - return nil +// The half is run with the status under _status and the body under response. +// Only an _error it returns is used. A mapping that does not recognise the +// status, or cannot read the body, leaves the original 502 standing, so +// capabilities whose mappings ignore _status behave as before. +func (s *Step) explainRefusal(ctx context.Context, bindingKey string, call model.ActionPlan, + beckn any, local map[string]any, err error) error { + var refused *providerRefusal + if !errors.As(err, &refused) { + return err } - err := mapper.Reject(ctx, mappingRef, map[string]any{"beckn": beckn, "response": answer}) - if err != nil { - log.Warnf(ctx, "%s refused the request: %v", bindingKey, err) + // Not JSON is read as nothing: the status alone may be enough. + answer, _ := decodeBody(refused.body) + mapped, mapErr := s.mapper.Transform(ctx, call.Mappings, definition.DirectionResponse, map[string]any{ + "beckn": beckn, + "_local": local, + "response": answer, + "_status": refused.status, + }) + if mapErr != nil { + return err + } + if explained := refusalIn(mapped); explained != nil { + log.Warnf(ctx, "%s refused the request: %v", bindingKey, explained) + return explained } return err } @@ -202,6 +212,10 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an if err != nil { return nil, err } + // Refused before the provider is called, so a bad request costs no call. + if err := refusalIn(mapped); err != nil { + return nil, err + } if len(mapped) == 0 { log.Debugf(ctx, "the request half of %s produced nothing; sending an empty request", call.Mappings) } diff --git a/pkg/plugin/implementation/internal/common/tokenheader_test.go b/pkg/plugin/implementation/internal/common/tokenheader_test.go index b8df5ed5d..a69c3c1bc 100644 --- a/pkg/plugin/implementation/internal/common/tokenheader_test.go +++ b/pkg/plugin/implementation/internal/common/tokenheader_test.go @@ -2,15 +2,26 @@ package common import ( + "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" + "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" ) +// assertStatus fails unless err is a CodedErr carrying status. +func assertStatus(t *testing.T, err error, status int) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != status { + t.Fatalf("error = %v, want a coded %d", err, status) + } +} + // tokenHeaderProfile is tokenQuery's profile under the header scheme. queryName // is cleared to prove it is not needed. func tokenHeaderProfile(tokenURL string) AuthProfile { diff --git a/pkg/plugin/implementation/jsonmapper/jsonmapper.go b/pkg/plugin/implementation/jsonmapper/jsonmapper.go index 0e97d38d0..c86982de1 100644 --- a/pkg/plugin/implementation/jsonmapper/jsonmapper.go +++ b/pkg/plugin/implementation/jsonmapper/jsonmapper.go @@ -59,10 +59,6 @@ type mappingFile struct { // Required are the preconditions this binding-action imposes on a payload, // verified before either half runs. Absent means none. Required []requirement `yaml:"required"` - // Reject are the conditions a provider's answer must meet, verified after - // the call and before the response half runs. For an upstream that says - // "no" with a 200 and a flag in the body. Absent means none. - Reject []requirement `yaml:"reject"` Request string `yaml:"request"` Response string `yaml:"response"` } @@ -81,10 +77,6 @@ type mappingFile struct { type requirement struct { Check string `yaml:"check"` Message string `yaml:"message"` - // Code and Status classify a failed check. Absent means a 400 with the - // generic bad-request code. - Code string `yaml:"code"` - Status int `yaml:"status"` } // Config holds configuration parameters for the mapper. @@ -198,8 +190,6 @@ type cacheEntry struct { directions map[definition.Direction]*compiledMapping // checks are the file's preconditions, in the order it declared them. checks []*compiledRequirement - // rejects are the conditions on the provider's answer, likewise in order. - rejects []*compiledRequirement // err is a failure that applies to the whole file -- it could not be // fetched, or not parsed -- as opposed to one action failing to compile. err error @@ -213,19 +203,9 @@ type cacheEntry struct { type compiledRequirement struct { expression jsonata.Expression message string - code string - status int err error } -// failure is the error a failed check returns, classified as the file says. -func (r *compiledRequirement) failure() error { - if r.status == 0 { - return model.NewBadReqErr(r.code, errors.New(r.message)) - } - return model.NewCodedErr(r.status, r.code, errors.New(r.message)) -} - // compiledMapping is one half of a mapping, or the failure that stopped it // compiling. Failures are held per half deliberately: a typo in the response // mapping is no reason for the request half to stop working, and finding out on @@ -345,22 +325,7 @@ func (m *Mapper) Verify(ctx context.Context, mappingRef string, input any) error if err != nil { return err } - return m.check(ctx, mappingRef, entry.checks, input) -} - -// Reject checks the provider's answer against the conditions the mapping -// declares under reject, in the order declared, and returns the first failure. -func (m *Mapper) Reject(ctx context.Context, mappingRef string, input any) error { - entry, err := m.compiled(ctx, mappingRef) - if err != nil { - return err - } - return m.check(ctx, mappingRef, entry.rejects, input) -} - -// check runs one list of conditions over input and returns the first failure. -func (m *Mapper) check(ctx context.Context, mappingRef string, checks []*compiledRequirement, input any) error { - if len(checks) == 0 { + if len(entry.checks) == 0 { return nil } @@ -369,7 +334,7 @@ func (m *Mapper) check(ctx context.Context, mappingRef string, checks []*compile return fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) } - for _, precondition := range checks { + for _, precondition := range entry.checks { if precondition.err != nil { return precondition.err } @@ -393,7 +358,7 @@ func (m *Mapper) check(ctx context.Context, mappingRef string, checks []*compile if !holds { // The mapping's own words: the caller is told what is wrong with // their payload, not that an expression somewhere returned false. - return precondition.failure() + return model.NewBadReqErr("", errors.New(precondition.message)) } } return nil @@ -438,8 +403,8 @@ func (m *Mapper) compiled(ctx context.Context, mappingRef string) (cacheEntry, e if entry, found := m.cached(mappingRef); found { return entry, entry.err } - compiled, err := m.fetchAndCompile(ctx, mappingRef) - return m.remember(mappingRef, compiled, err), err + directions, checks, err := m.fetchAndCompile(ctx, mappingRef) + return m.remember(mappingRef, directions, checks, err), err }) entry, _ := shared.(cacheEntry) return entry, err @@ -460,13 +425,18 @@ func (m *Mapper) cached(mappingRef string) (cacheEntry, bool) { // remember caches a compiled mapping, or the failure that stopped it compiling. // A failure gets the shorter TTL: it should stop hammering a broken reference // without outlasting the fix. -func (m *Mapper) remember(mappingRef string, entry cacheEntry, err error) cacheEntry { +func (m *Mapper) remember(mappingRef string, directions map[definition.Direction]*compiledMapping, + checks []*compiledRequirement, err error) cacheEntry { ttl := m.config.CacheTTL if err != nil { ttl = m.config.NegativeTTL } - entry.err = err - entry.expiresAt = time.Now().Add(ttl) + entry := cacheEntry{ + directions: directions, + checks: checks, + err: err, + expiresAt: time.Now().Add(ttl), + } m.mu.Lock() defer m.mu.Unlock() @@ -515,20 +485,23 @@ func (m *Mapper) cachedCount() int { } // fetchAndCompile retrieves a mapping and turns it into a runnable expression. -func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) (cacheEntry, error) { +func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) ( + map[definition.Direction]*compiledMapping, []*compiledRequirement, error) { body, err := m.fetch(ctx, mappingRef) if err != nil { - return cacheEntry{}, err + return nil, nil, err } file, err := parseMapping(body) if err != nil { - return cacheEntry{}, fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) + return nil, nil, fmt.Errorf("jsonmapper: mapping %q: %w", mappingRef, err) } // Preconditions compile with the halves, so one round trip leaves the whole // file ready and a precondition costs no extra fetch. - checks := m.compileRequirements(ctx, mappingRef, file.Required) - rejects := m.compileRequirements(ctx, mappingRef, file.Reject) + checks := make([]*compiledRequirement, 0, len(file.Required)) + for _, declared := range file.Required { + checks = append(checks, m.compileRequirement(ctx, mappingRef, declared)) + } // Both halves are compiled now rather than on first use, so one fetch leaves // the file ready in both directions. A compile failure is recorded against @@ -536,18 +509,8 @@ func (m *Mapper) fetchAndCompile(ctx context.Context, mappingRef string) (cacheE directions := make(map[definition.Direction]*compiledMapping, 2) directions[definition.DirectionRequest] = m.compileMapping(ctx, mappingRef, definition.DirectionRequest, file.Request) directions[definition.DirectionResponse] = m.compileMapping(ctx, mappingRef, definition.DirectionResponse, file.Response) - log.Debugf(ctx, "JSON mapper compiled mapping: %s (%d precondition(s), %d reject(s))", - mappingRef, len(checks), len(rejects)) - return cacheEntry{directions: directions, checks: checks, rejects: rejects}, nil -} - -// compileRequirements compiles a list of conditions, keeping their order. -func (m *Mapper) compileRequirements(ctx context.Context, mappingRef string, declared []requirement) []*compiledRequirement { - compiled := make([]*compiledRequirement, 0, len(declared)) - for _, requirement := range declared { - compiled = append(compiled, m.compileRequirement(ctx, mappingRef, requirement)) - } - return compiled + log.Debugf(ctx, "JSON mapper compiled mapping: %s (%d precondition(s))", mappingRef, len(checks)) + return directions, checks, nil } // compileRequirement compiles one precondition, keeping any failure local to it. @@ -569,8 +532,6 @@ func (m *Mapper) compileRequirement(ctx context.Context, mappingRef string, decl return &compiledRequirement{ expression: expression, message: declared.Message, - code: declared.Code, - status: declared.Status, } } diff --git a/pkg/plugin/implementation/jsonmapper/reject_test.go b/pkg/plugin/implementation/jsonmapper/reject_test.go deleted file mode 100644 index b81a24b0a..000000000 --- a/pkg/plugin/implementation/jsonmapper/reject_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package jsonmapper - -// reject_test.go covers the reject section and the code and status a failed -// check can carry. - -import ( - "context" - "errors" - "net/http" - "strings" - "testing" - - "github.com/beckn-one/beckn-onix/pkg/model" -) - -// withReject refuses a wrong OTP as the caller's fault and anything else that -// is not responseCode 1 as the provider's. -const withReject = ` -required: - - check: $exists(beckn.phone) - message: "phone is required" - code: SCH_REQUIRED_FIELD_MISSING - - check: $exists(beckn.season) = false or beckn.season = "Kharif" - message: "season must be Kharif" -reject: - - check: response.responseCode = 1 or response.responseMessage != "Invalid OTP" - message: "the OTP is wrong" - code: BIZ_GENERIC_ERROR - status: 400 - - check: response.responseCode = 1 - message: "the provider refused" - code: NET_DOWNSTREAM_UNAVAILABLE - status: 502 -request: | - { "phone": beckn.phone } -response: | - { "ok": true } -` - -// assertCoded fails unless err is a CodedErr with this status and code. -func assertCoded(t *testing.T, err error, status int, code string) { - t.Helper() - var coded *model.CodedErr - if !errors.As(err, &coded) { - t.Fatalf("error = %v (%T), want a coded %d %s", err, err, status, code) - } - if coded.HTTPStatus() != status || coded.BecknError().Code != code { - t.Errorf("error = %d %s, want %d %s", coded.HTTPStatus(), coded.BecknError().Code, status, code) - } -} - -func rejectInput(code int, message string) map[string]any { - return map[string]any{ - "beckn": map[string]any{"phone": "9876543210"}, - "response": map[string]any{"responseCode": code, "responseMessage": message}, - } -} - -func TestReject_SuccessfulAnswer_ReturnsNil(t *testing.T) { - srv := newMappingServer(t, withReject, nil) - defer srv.Close() - - if err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(1, "ok")); err != nil { - t.Errorf("Reject() = %v, want nil for a successful answer", err) - } -} - -func TestReject_FirstFailingCondition_ReturnsItsCodeAndStatus(t *testing.T) { - srv := newMappingServer(t, withReject, nil) - defer srv.Close() - - err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(0, "Invalid OTP")) - assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") - if !strings.Contains(err.Error(), "the OTP is wrong") { - t.Errorf("error %q should carry the mapping's message", err) - } -} - -func TestReject_LaterCondition_ReturnsItsCodeAndStatus(t *testing.T) { - srv := newMappingServer(t, withReject, nil) - defer srv.Close() - - err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(0, "Service down")) - assertCoded(t, err, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE") - if strings.Contains(err.Error(), "Service down") { - t.Errorf("error %q leaks the provider's own message", err) - } -} - -func TestReject_NoRejectSection_ReturnsNil(t *testing.T) { - srv := newMappingServer(t, withChecks, nil) - defer srv.Close() - - if err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(0, "anything")); err != nil { - t.Errorf("Reject() = %v, want nil when the file declares no reject", err) - } -} - -func TestReject_ConditionNotBoolean_ReturnsError(t *testing.T) { - srv := newMappingServer(t, ` -reject: - - check: response.responseMessage - message: "never shown" -response: | - { "ok": true } -`, nil) - defer srv.Close() - - if err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(1, "text")); err == nil { - t.Error("Reject() accepted a condition that answered a string") - } -} - -func TestReject_ConditionWithoutMessage_ReturnsError(t *testing.T) { - srv := newMappingServer(t, ` -reject: - - check: response.responseCode = 1 -response: | - { "ok": true } -`, nil) - defer srv.Close() - - err := newTestMapper(t).Reject(context.Background(), ref(srv.URL), rejectInput(1, "ok")) - if err == nil || !strings.Contains(err.Error(), "no message") { - t.Errorf("Reject() = %v, want a condition with no message refused", err) - } -} - -func TestReject_ConditionThatWillNotCompile_ReturnsErrorAndKeepsTransform(t *testing.T) { - srv := newMappingServer(t, ` -reject: - - check: response.responseCode = = 1 - message: "broken" -response: | - { "ok": true } -`, nil) - defer srv.Close() - - mapper := newTestMapper(t) - if err := mapper.Reject(context.Background(), ref(srv.URL), rejectInput(1, "ok")); err == nil { - t.Error("Reject() ran a condition that does not compile") - } - if out, err := mapper.Transform(context.Background(), ref(srv.URL), "response", rejectInput(1, "ok")); err != nil || string(out) != `{"ok":true}` { - t.Errorf("Transform() = %s, %v; a broken reject must not take the response half down", out, err) - } -} - -func TestReject_UnfetchableMapping_ReturnsError(t *testing.T) { - if err := newTestMapper(t).Reject(context.Background(), "file:///etc/passwd", rejectInput(1, "ok")); err == nil { - t.Error("Reject() fetched a reference that is not http or https") - } -} - -func TestVerify_RequirementWithCode_ReturnsThatCode(t *testing.T) { - srv := newMappingServer(t, withReject, nil) - defer srv.Close() - - err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), map[string]any{"beckn": map[string]any{}}) - assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") -} - -func TestVerify_RequirementWithoutCode_KeepsDefaultCode(t *testing.T) { - srv := newMappingServer(t, withReject, nil) - defer srv.Close() - - err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), - map[string]any{"beckn": map[string]any{"phone": "1", "season": "Rabi"}}) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") -} - -func TestVerify_RejectSection_NotRunBeforeTheCall(t *testing.T) { - srv := newMappingServer(t, withReject, nil) - defer srv.Close() - - // No response yet: only required runs, so a payload meeting it passes. - if err := newTestMapper(t).Verify(context.Background(), ref(srv.URL), - map[string]any{"beckn": map[string]any{"phone": "1"}}); err != nil { - t.Errorf("Verify() = %v, want reject conditions left for after the call", err) - } -} From 9932fc2d07fbb189ad1f3f68a7bc5e6bbb2422de Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 5 Oct 2026 17:15:48 +0530 Subject: [PATCH 10/39] refactor: fold the mapping refusal into serve.go [#57] --- .../internal/common/common_test.go | 144 ++++++++++++++++ .../implementation/internal/common/refusal.go | 61 ------- .../internal/common/refusal_test.go | 156 ------------------ .../implementation/internal/common/serve.go | 28 ++++ 4 files changed, 172 insertions(+), 217 deletions(-) delete mode 100644 pkg/plugin/implementation/internal/common/refusal.go delete mode 100644 pkg/plugin/implementation/internal/common/refusal_test.go diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 2a18fbfe6..1a8e77f4a 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -2558,3 +2558,147 @@ func TestOAuth2DoesNotRetryAMissingCredential(t *testing.T) { t.Errorf("token endpoint called %d times, want 0", got) } } + +// refusalStep is a step over mapper whose provider answers {"ok":true}, +// counting calls. +func refusalStep(t *testing.T, mapper *stubMapper, calls *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *calls++ + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) +} + +func TestRefusal_RequestHalf_RefusesWithoutCallingProvider(t *testing.T) { + var calls int + mapper := &stubMapper{ + requestResult: []byte(`{"_error":{"status":400,"code":"SCH_REQUIRED_FIELD_MISSING","message":"phone is required"}}`), + responseResult: []byte(`{"a":1}`), + } + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || + coded.BecknError().Code != "SCH_REQUIRED_FIELD_MISSING" { + t.Fatalf("error = %v, want 400 SCH_REQUIRED_FIELD_MISSING", err) + } + if calls != 0 { + t.Errorf("provider called %d times for a refused request, want 0", calls) + } +} + +func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { + for name, tc := range map[string]struct { + refusal string + status int + code string + }{ + "explicit status": {`{"status":502,"code":"NET_DOWNSTREAM_UNAVAILABLE","message":"refused"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE"}, + "status omitted": {`{"code":"BIZ_GENERIC_ERROR","message":"refused"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR"}, + } { + t.Run(name, func(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":` + tc.refusal + `}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != tc.status || coded.BecknError().Code != tc.code { + t.Fatalf("error = %v, want %d %s", err, tc.status, tc.code) + } + if len(stepCtx.ResponseBody) != 0 { + t.Errorf("a refusal produced a response body: %s", stepCtx.ResponseBody) + } + }) + } +} + +func TestRefusal_Status202_ReturnsAckWithCode(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), + responseResult: []byte(`{"_error":{"status":202,"code":"BIZ_NO_RESULTS_FOUND","message":"nothing on file"}}`)} + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + var ack *model.AckNoCallbackErr + if !errors.As(err, &ack) { + t.Fatalf("error = %v (%T), want a 202 ACK", err, err) + } + if ack.Status != model.StatusACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" || ack.Err.Message != "nothing on file" { + t.Errorf("ack = %s %+v, want ACK BIZ_NO_RESULTS_FOUND with the mapping's message", ack.Status, ack.Err) + } +} + +func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { + for name, refusal := range map[string]string{ + "no message": `{"_error":{"status":502,"code":"X"}}`, + "not object": `{"_error":"refused"}`, + } { + t.Run(name, func(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(refusal)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err == nil || len(stepCtx.ResponseBody) != 0 { + t.Errorf("Run() = %v with body %s, want an error and no answer", err, stepCtx.ResponseBody) + } + }) + } +} + +func TestRefusal_NoErrorField_AnswersAsBefore(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"message":{"ok":true}}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err != nil || string(stepCtx.ResponseBody) != `{"message":{"ok":true}}` || calls != 1 { + t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) + } +} + +// statusStep is a step whose provider answers every call with status and body. +func statusStep(t *testing.T, mapper *stubMapper, status int, body string, calls *int) *Step { + t.Helper() + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + *calls++ + w.WriteHeader(status) + fmt.Fprint(w, body) + })) + t.Cleanup(provider.Close) + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) +} + +func TestExplainRefusal_Provider4xxMappedToError_ReturnsIt(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), + responseResult: []byte(`{"_error":{"status":400,"code":"BIZ_GENERIC_ERROR","message":"wrong OTP"}}`)} + _, err := runStep(t, statusStep(t, mapper, http.StatusBadRequest, `{"reason":"x"}`, &calls), selectBody) + + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || coded.BecknError().Code != "BIZ_GENERIC_ERROR" { + t.Fatalf("error = %v, want the mapping's 400 BIZ_GENERIC_ERROR", err) + } + input, _ := mapper.responseInput.(map[string]any) + if input["_status"] != http.StatusBadRequest { + t.Errorf("_status = %v, want the provider's 400", input["_status"]) + } + if calls != 1 { + t.Errorf("provider called %d times, want 1", calls) + } +} + +func TestExplainRefusal_Provider4xxNotRecognised_Returns502(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + _, err := runStep(t, statusStep(t, mapper, http.StatusNotFound, `not json`, &calls), selectBody) + assertStatus(t, err, http.StatusBadGateway) +} + +func TestExplainRefusal_Provider5xx_NotShownToTheMapping(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), + responseResult: []byte(`{"_error":{"status":400,"code":"X","message":"would hide the outage"}}`)} + _, err := runStep(t, statusStep(t, mapper, http.StatusServiceUnavailable, ``, &calls), selectBody) + assertStatus(t, err, http.StatusBadGateway) + if mapper.responseInput != nil { + t.Error("the response half was run for a 5xx; only a 4xx is the request's to explain") + } +} diff --git a/pkg/plugin/implementation/internal/common/refusal.go b/pkg/plugin/implementation/internal/common/refusal.go deleted file mode 100644 index 24b6fdc30..000000000 --- a/pkg/plugin/implementation/internal/common/refusal.go +++ /dev/null @@ -1,61 +0,0 @@ -// A mapping refusing the call: either half may answer with the reserved -// _error field instead of a document. -package common - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - - "github.com/beckn-one/beckn-onix/pkg/model" -) - -// errorField is the reserved field a mapping half returns instead of a -// document when the call must be refused. Reserved like _local, so it cannot -// collide with a field a provider or the network defines. -// -// On the request half it refuses before the provider is called, with a code -// the mapping names -- required checks cannot name one. On the response half -// it answers what the provider's reply means, such as nothing on file. -const errorField = "_error" - -// refusal is what a mapping writes under _error. Status absent means 400. -type refusal struct { - Status int `json:"status"` - Code string `json:"code"` - Message string `json:"message"` -} - -// refusalIn returns the error a mapped document carries under _error, or nil -// when it carries none. -func refusalIn(mapped []byte) error { - var document map[string]json.RawMessage - if json.Unmarshal(mapped, &document) != nil { - // Not an object, so not a refusal. Whether it is a usable document is - // the caller's question. - return nil - } - raw, refused := document[errorField] - if !refused { - return nil - } - var r refusal - if err := json.Unmarshal(raw, &r); err != nil || r.Message == "" { - return fmt.Errorf("a mapping refused the call with an unreadable %s; it needs status, code and message", errorField) - } - return r.err() -} - -// err classifies a refusal as the mapping asked. 202 is not a fault but an -// answer with nothing in it, so it is an ACK carrying the reason. -func (r refusal) err() error { - switch r.Status { - case 0, http.StatusBadRequest: - return model.NewBadReqErr(r.Code, errors.New(r.Message)) - case http.StatusAccepted: - return model.NewAckNoCallbackErr(model.StatusACK, &model.Error{Code: r.Code, Message: r.Message}) - default: - return model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) - } -} diff --git a/pkg/plugin/implementation/internal/common/refusal_test.go b/pkg/plugin/implementation/internal/common/refusal_test.go deleted file mode 100644 index f7734c7e1..000000000 --- a/pkg/plugin/implementation/internal/common/refusal_test.go +++ /dev/null @@ -1,156 +0,0 @@ -// _error: a mapping half refusing the call instead of producing a document. -package common - -import ( - "errors" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/beckn-one/beckn-onix/pkg/model" -) - -// refusalStep is a step over mapper whose provider answers {"ok":true}, -// counting calls. -func refusalStep(t *testing.T, mapper *stubMapper, calls *int) *Step { - t.Helper() - provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - *calls++ - fmt.Fprint(w, `{"ok":true}`) - })) - t.Cleanup(provider.Close) - return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) -} - -func TestRefusal_RequestHalf_RefusesWithoutCallingProvider(t *testing.T) { - var calls int - mapper := &stubMapper{ - requestResult: []byte(`{"_error":{"status":400,"code":"SCH_REQUIRED_FIELD_MISSING","message":"phone is required"}}`), - responseResult: []byte(`{"a":1}`), - } - _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || - coded.BecknError().Code != "SCH_REQUIRED_FIELD_MISSING" { - t.Fatalf("error = %v, want 400 SCH_REQUIRED_FIELD_MISSING", err) - } - if calls != 0 { - t.Errorf("provider called %d times for a refused request, want 0", calls) - } -} - -func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { - for name, tc := range map[string]struct { - refusal string - status int - code string - }{ - "explicit status": {`{"status":502,"code":"NET_DOWNSTREAM_UNAVAILABLE","message":"refused"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE"}, - "status omitted": {`{"code":"BIZ_GENERIC_ERROR","message":"refused"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR"}, - } { - t.Run(name, func(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":` + tc.refusal + `}`)} - stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != tc.status || coded.BecknError().Code != tc.code { - t.Fatalf("error = %v, want %d %s", err, tc.status, tc.code) - } - if len(stepCtx.ResponseBody) != 0 { - t.Errorf("a refusal produced a response body: %s", stepCtx.ResponseBody) - } - }) - } -} - -func TestRefusal_Status202_ReturnsAckWithCode(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), - responseResult: []byte(`{"_error":{"status":202,"code":"BIZ_NO_RESULTS_FOUND","message":"nothing on file"}}`)} - _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - - var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) { - t.Fatalf("error = %v (%T), want a 202 ACK", err, err) - } - if ack.Status != model.StatusACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" || ack.Err.Message != "nothing on file" { - t.Errorf("ack = %s %+v, want ACK BIZ_NO_RESULTS_FOUND with the mapping's message", ack.Status, ack.Err) - } -} - -func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { - for name, refusal := range map[string]string{ - "no message": `{"_error":{"status":502,"code":"X"}}`, - "not object": `{"_error":"refused"}`, - } { - t.Run(name, func(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(refusal)} - stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - if err == nil || len(stepCtx.ResponseBody) != 0 { - t.Errorf("Run() = %v with body %s, want an error and no answer", err, stepCtx.ResponseBody) - } - }) - } -} - -func TestRefusal_NoErrorField_AnswersAsBefore(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"message":{"ok":true}}`)} - stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - if err != nil || string(stepCtx.ResponseBody) != `{"message":{"ok":true}}` || calls != 1 { - t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) - } -} - -// statusStep is a step whose provider answers every call with status and body. -func statusStep(t *testing.T, mapper *stubMapper, status int, body string, calls *int) *Step { - t.Helper() - provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - *calls++ - w.WriteHeader(status) - fmt.Fprint(w, body) - })) - t.Cleanup(provider.Close) - return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) -} - -func TestExplainRefusal_Provider4xxMappedToError_ReturnsIt(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), - responseResult: []byte(`{"_error":{"status":400,"code":"BIZ_GENERIC_ERROR","message":"wrong OTP"}}`)} - _, err := runStep(t, statusStep(t, mapper, http.StatusBadRequest, `{"reason":"x"}`, &calls), selectBody) - - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || coded.BecknError().Code != "BIZ_GENERIC_ERROR" { - t.Fatalf("error = %v, want the mapping's 400 BIZ_GENERIC_ERROR", err) - } - input, _ := mapper.responseInput.(map[string]any) - if input["_status"] != http.StatusBadRequest { - t.Errorf("_status = %v, want the provider's 400", input["_status"]) - } - if calls != 1 { - t.Errorf("provider called %d times, want 1", calls) - } -} - -func TestExplainRefusal_Provider4xxNotRecognised_Returns502(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} - _, err := runStep(t, statusStep(t, mapper, http.StatusNotFound, `not json`, &calls), selectBody) - assertStatus(t, err, http.StatusBadGateway) -} - -func TestExplainRefusal_Provider5xx_NotShownToTheMapping(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), - responseResult: []byte(`{"_error":{"status":400,"code":"X","message":"would hide the outage"}}`)} - _, err := runStep(t, statusStep(t, mapper, http.StatusServiceUnavailable, ``, &calls), selectBody) - assertStatus(t, err, http.StatusBadGateway) - if mapper.responseInput != nil { - t.Error("the response half was run for a 5xx; only a 4xx is the request's to explain") - } -} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index d6e84a73e..5ac310649 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "slices" "strings" @@ -222,6 +223,33 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an return mapped, nil } +// refusalIn returns the error a mapping half asks for under the reserved +// _error field -- {status, code, message}, status absent meaning 400 -- or nil +// when the document carries none. 202 is an answer with nothing in it, so it +// is an ACK carrying the reason. +func refusalIn(mapped []byte) error { + var document map[string]json.RawMessage + if json.Unmarshal(mapped, &document) != nil || document["_error"] == nil { + return nil + } + var r struct { + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal(document["_error"], &r) != nil || r.Message == "" { + return errors.New("a mapping refused the call with an unreadable _error; it needs status, code and message") + } + switch r.Status { + case 0, http.StatusBadRequest: + return model.NewBadReqErr(r.Code, errors.New(r.Message)) + case http.StatusAccepted: + return model.NewAckNoCallbackErr(model.StatusACK, &model.Error{Code: r.Code, Message: r.Message}) + default: + return model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) + } +} + // extractAction reads the Beckn action a request is for. func extractAction(body []byte) string { var payload struct { From bec3bd2ffb775ff814987e6e54143c73aa46b993 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 6 Oct 2026 15:05:47 +0530 Subject: [PATCH 11/39] fix: name the repeated array and correct comments in the PMFBY grievance path [#57] --- pkg/plugin/implementation/Grievance/mappings_test.go | 3 +++ pkg/plugin/implementation/internal/common/auth.go | 2 +- pkg/plugin/implementation/internal/common/binding.go | 8 ++++++-- pkg/plugin/implementation/internal/common/redact.go | 8 +++++--- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 3721c288b..c470bb4d8 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -618,6 +618,9 @@ func TestSupport_SecondChannel_Returns400WithoutCallingPMFBY(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) _, err := h.send(t, raw) assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if !strings.Contains(err.Error(), "2 channels") { + t.Errorf("error %q should say \"2 channels\" so the caller can act on it", err) + } if h.pmfby.calls[insertPath] != 0 { t.Error("PMFBY was called for a request naming two channels") } diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index cf698b601..09aba01ff 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -118,7 +118,7 @@ func (a *AuthProfile) validate() error { } case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: // Every one of these is required, and none can be defaulted: the JSON - // keys, the response key and the parameter name are all this + // keys, the response key and the parameter or header name are all this // provider's spelling, and guessing any of them sends a malformed // request whose rejection says nothing about the cause. if a.TokenURL == "" || a.TokenUserField == "" || a.TokenUserEnv == "" || diff --git a/pkg/plugin/implementation/internal/common/binding.go b/pkg/plugin/implementation/internal/common/binding.go index 4a3253060..cb804a8e6 100644 --- a/pkg/plugin/implementation/internal/common/binding.go +++ b/pkg/plugin/implementation/internal/common/binding.go @@ -58,10 +58,14 @@ func BindingFrom(paths Paths, body []byte) (Binding, error) { // absent leaf, so two commitments where one has no provider id would count // as one. if commitments := countAt(payload, paths.ProviderID); commitments > 1 { + // Named from the path, so a support request with two channels is told + // "2 channels" rather than "2 commitments". + array, _, _ := strings.Cut(paths.ProviderID, arrayMarker) + array = array[strings.LastIndex(array, ".")+1:] return Binding{}, fmt.Errorf( - "payload carries %d commitments; one request maps to one call, "+ + "payload carries %d %s; one request maps to one call, "+ "so send them separately rather than have all but the first dropped", - commitments) + commitments, array) } providers := distinct(ValuesAt(payload, paths.ProviderID)) diff --git a/pkg/plugin/implementation/internal/common/redact.go b/pkg/plugin/implementation/internal/common/redact.go index 000c62f5b..1f8143e79 100644 --- a/pkg/plugin/implementation/internal/common/redact.go +++ b/pkg/plugin/implementation/internal/common/redact.go @@ -140,9 +140,11 @@ func (a *authenticator) secretForms() []string { return forms case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: // oauth2's two halves, with query's escaping: the secret goes to the - // token endpoint, and the token it returns travels in a QUERY STRING -- - // which is the exposed placement, so its escaped form has to be - // covered too or a token containing "+" or "=" survives redaction. + // token endpoint, and the token it returns travels in a QUERY STRING + // for tokenQuery -- which is the exposed placement, so its escaped form + // has to be covered too or a token containing "+" or "=" survives + // redaction. tokenHeader sends it in a header; covering the escaped + // form as well costs nothing. var forms []string if secret := os.Getenv(a.cfg.TokenSecretEnv); secret != "" { forms = append(forms, secret) From a4846c4bff67b2d89e677cacb04c2c370357695b Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 6 Oct 2026 15:06:00 +0530 Subject: [PATCH 12/39] feat: align the PMFBY grievance replies with the updated design and pack [#57] --- config/mappings/pmfby/grievance.init.yaml | 17 +-- config/mappings/pmfby/grievance.status.yaml | 16 +-- config/mappings/pmfby/grievance.support.yaml | 33 +++--- config/provider-adapter.yaml | 2 +- .../Grievance/cmd/plugin_test.go | 2 +- .../implementation/Grievance/mappings_test.go | 102 ++++++++++++------ 6 files changed, 106 insertions(+), 66 deletions(-) diff --git a/config/mappings/pmfby/grievance.init.yaml b/config/mappings/pmfby/grievance.init.yaml index 5b46fc94b..503973432 100644 --- a/config/mappings/pmfby/grievance.init.yaml +++ b/config/mappings/pmfby/grievance.init.yaml @@ -26,28 +26,33 @@ request: | ) # Built from the request alone: SendOTP's reply is not documented, so nothing -# is read from it, and expiresAt is left out until it is. The phone is masked -# rather than echoed: enough for the farmer to recognise which number, not -# enough to read it back. +# is read from it. The phone is masked rather than echoed: enough for the +# farmer to recognise which number, not enough to read it back. response: | ( $commitment := beckn.message.contract.commitments[0]; $ca := $commitment.commitmentAttributes; $phone := $ca.applicantPhone; + /* The pack requires expiresAt and has the adapter derive it. PMFBY + publishes no OTP validity; ten minutes is the architecture doc's + example, to be confirmed with PMFBY. */ + $otpValidityMillis := 10 * 60 * 1000; { "context": $merge([beckn.context, { "action": "on_init", "timestamp": $now() }]), "message": { "contract": $merge([beckn.message.contract, { "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "DRAFT", "name": "draft" } }, + "status": { "descriptor": { "code": "DRAFT" } }, "commitmentAttributes": { "@context": $ca.`@context`, "@type": $ca.`@type`, /* Still OnDemand: an OTP acknowledgement carries no case. */ "informationMode": "OnDemand", "scheme": $ca.scheme, - "otpChallenge": { - "sentTo": $substring($phone, 0, 2) & "XXXXXX" & $substring($phone, -2) + "challengeIssued": { + "method": "SMS_OTP", + "sentTo": $substring($phone, 0, 2) & "XXXXXX" & $substring($phone, -2), + "expiresAt": $fromMillis($millis() + $otpValidityMillis) } } }])] diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index df9de17f8..d80545c58 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -55,7 +55,7 @@ response: | "longDesc": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription }, "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "ACTIVE", "name": "active" } }, + "status": { "descriptor": { "code": "ACTIVE" } }, "commitmentAttributes": { "@context": $ca.`@context`, "@type": $ca.`@type`, @@ -66,13 +66,17 @@ response: | "cropYear": $isNumber($ticket.RequestYear) or $isText($ticket.RequestYear) ? $string($ticket.RequestYear), "season": $isNumber($ticket.RequestSeason) ? $lookup($seasons, $string($ticket.RequestSeason)), - "caseStatus": $isText($ticket.TicketStatus) ? { - "code": $uppercase($replace($ticket.TicketStatus, " ", "_")), - "name": $ticket.TicketStatus + /* code is the network's word, name the portal's phrase. PMFBY + publishes no status vocabulary, so every phrase is one the + adapter does not recognise and maps to UnderReview, which is + never a closed state. Recognised phrases are added here once + PMFBY confirms them. */ + "caseStatus": { + "code": "UnderReview", + "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus }, "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, - "officerReply": $isText($ticket.latestRemark) ? $ticket.latestRemark, - "source": { "sourceId": "pmfby", "sourceName": "PMFBY Grievance Portal" } + "caseRemark": $isText($ticket.latestRemark) ? $ticket.latestRemark } }])] }]) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index f855c9a72..e600a1ab8 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -4,8 +4,9 @@ # Step two of three: lodge the grievance. A support request composes no # contract: message.support carries the application number in orderId, the # complaint in descriptor (code "3.10" is category and sub-category), and the -# farmer in channels[0]. The OTP init sent travels on the lodge call until -# PMFBY's verify endpoint is wired in; it is never echoed back. +# provider, the farmer and the OTP (challenge) in channels[0]. The OTP init sent +# travels on the lodge call until PMFBY's verify endpoint is wired in; it is +# never echoed back. # # "Grievence" is PMFBY's spelling, kept where PMFBY reads it. @@ -35,14 +36,14 @@ request: | /* PMFBY's fixed id for this channel as the source of the grievance. */ $receiptSourceID := 134306; $complete := $exists($s.orderId) and $exists($s.descriptor.code) and $exists($s.descriptor.longDesc) - and $exists($ch.applicantPhone) and $exists($ch.otp) + and $exists($ch.applicantPhone) and $exists($ch.challenge.value) and $exists($ch.cropYear) and $exists($ch.season); $not($complete) ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": "lodging a grievance needs orderId, descriptor.code, descriptor.longDesc and, on channels[0], applicantPhone, otp, cropYear and season" } } + "message": "lodging a grievance needs orderId, descriptor.code, descriptor.longDesc and, on channels[0], applicantPhone, challenge.value, cropYear and season" } } : { "requestorMobileNo": $ch.applicantPhone, - "otp": $ch.otp, + "otp": $ch.challenge.value, "complaintDate": $now("[Y0001]-[M01]-[D01]", "+0530"), "receiptSourceID": $receiptSourceID, "ticketCategoryID": $number($category[0]), @@ -56,10 +57,11 @@ request: | # A 4xx from PMFBY reaches this half with its status under _status. # -# orderId and descriptor are the caller's own, echoed, and the -# ticket goes on the channel. caseStatus, filedOn and source are not in PMFBY's -# reply: a freshly lodged grievance is registered, and filedOn restates the IST -# date just sent. +# orderId and descriptor are the caller's own, echoed, and the ticket goes on +# the channel. caseStatus, filedOn and provider are not in PMFBY's reply: a +# freshly lodged grievance is registered, with no portal phrase to quote as its +# name, filedOn restates the IST date just sent, and provider is the one the +# request was routed by. PMFBY's ticket id is its own row id and is not mapped. response: | $exists(_status) ? ( @@ -73,7 +75,7 @@ response: | : ( $s := beckn.message.support; $ch := $s.channels[0]; - $ticket := response.responseDynamic; + $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; { "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), "message": { @@ -84,14 +86,11 @@ response: | "@context": $ch.`@context`, "@type": $ch.`@type`, "informationMode": "Direct", + "provider": $ch.provider, "scheme": $ch.scheme, - "ticketNo": $ticket.GrievenceSupportTicketNo, - "ticketId": $type($ticket.GrievenceSupportTicketID) = "number" - or $type($ticket.GrievenceSupportTicketID) = "string" - ? $string($ticket.GrievenceSupportTicketID), - "caseStatus": { "code": "REGISTERED", "name": "Registered" }, - "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530"), - "source": { "sourceId": "pmfby", "sourceName": "PMFBY Grievance Portal" } + "ticketNo": $type($ticketNo) = "number" or $type($ticketNo) = "string" ? $string($ticketNo), + "caseStatus": { "code": "Registered" }, + "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530") }] } } diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index bd2710eaa..f9df5cc83 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -522,7 +522,7 @@ modules: # A support request composes no contract, so it names its provider # and type on its one channel instead. Tried only when the paths # above find nothing. Both or neither, like the pair above. - fallbackProviderIdAt: "message.support.channels[].providerId" + fallbackProviderIdAt: "message.support.channels[].provider.id" fallbackCapabilityCodeAt: "message.support.channels[].@type" # PMFBY issues its own token from its login endpoint diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go index f5ed45c54..f16237476 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -32,7 +32,7 @@ const ( providerIDAt = "message.contract.commitments[].offer.provider.id" capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" - fallbackProviderIDAt = "message.support.channels[].providerId" + fallbackProviderIDAt = "message.support.channels[].provider.id" fallbackCapabilityCodeAt = "message.support.channels[].@type" ) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index c470bb4d8..de1533660 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -93,7 +93,7 @@ func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { } switch r.URL.Path { case sendOTPPath: - fmt.Fprint(w, `{"responseCode":1,"responseMessage":"OTP sent","responseDynamic":{"otpValidityInSeconds":300}}`) + fmt.Fprint(w, `{"responseCode":1,"responseMessage":"OTP sent"}`) case insertPath: if body["otp"] != validOTP { w.WriteHeader(http.StatusBadRequest) @@ -165,7 +165,7 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func ProviderIDAt: "message.contract.commitments[].offer.provider.id", CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type", // A support request composes no contract; its channel names both. - FallbackProviderIDAt: "message.support.channels[].providerId", + FallbackProviderIDAt: "message.support.channels[].provider.id", FallbackCapabilityCodeAt: "message.support.channels[].@type", AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, @@ -258,6 +258,10 @@ const packContext = "https://openagrinet.github.io/network-specs/api-schemas/PMF // scheme is what every PMFBY payload names, in both directions. var scheme = map[string]any{"code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana"} +// provider is the portal a support request names on its channel, since it +// composes no contract to name it on. +var provider = map[string]any{"id": "pmfby", "descriptor": map[string]any{"name": "PMFBY Grievance Portal"}} + func becknContext(action string) map[string]any { return map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"} } @@ -289,7 +293,7 @@ func request(action string, attributes map[string]any) []byte { // everything else on channels[0]. A field left out is absent from the payload. func support(fields map[string]any) []byte { channel := map[string]any{"@context": packContext, "@type": "openagrinet:PMFBYGrievance", - "informationMode": "OnDemand", "scheme": scheme, "providerId": "pmfby"} + "informationMode": "OnDemand", "scheme": scheme, "provider": provider} body := map[string]any{"channels": []any{channel}} descriptor := map[string]any{} for field, value := range fields { @@ -311,10 +315,15 @@ func support(fields map[string]any) []byte { func initAttributes() map[string]any { return map[string]any{"applicantPhone": phone} } +// challenge is the OTP as a support request carries it. +func challenge(otp string) map[string]any { + return map[string]any{"method": "SMS_OTP", "value": otp} +} + func supportFields() map[string]any { return map[string]any{ "orderId": "040108251010160770605", "code": "3.10", "name": "Claim / Claim not received", - "longDesc": " Claim not received ", "applicantPhone": phone, "otp": validOTP, + "longDesc": " Claim not received ", "applicantPhone": phone, "challenge": challenge(validOTP), "cropYear": "2025", "season": "Kharif", } } @@ -344,14 +353,21 @@ func todayIST() string { func TestInit_ValidPhone_ReturnsMaskedOTPChallenge(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) + before := time.Now() got := h.mustSend(t, request("init", initAttributes())) if sent := h.pmfby.bodies[sendOTPPath]; len(sent) != 1 || sent["requestorMobileNo"] != phone { t.Errorf("PMFBY was sent %v, want requestorMobileNo alone", sent) } - challenge, _ := got.attributes()["otpChallenge"].(map[string]any) - if challenge["sentTo"] != "98XXXXXX10" { - t.Errorf("sentTo = %v, want 98XXXXXX10", challenge["sentTo"]) + issued, _ := got.attributes()["challengeIssued"].(map[string]any) + if issued["method"] != "SMS_OTP" || issued["sentTo"] != "98XXXXXX10" { + t.Errorf("challengeIssued = %v, want SMS_OTP sent to 98XXXXXX10", issued) + } + // Ten minutes from the call, the validity the adapter derives. + expiresAt, err := time.Parse(time.RFC3339, fmt.Sprint(issued["expiresAt"])) + if err != nil || expiresAt.Before(before.Add(10*time.Minute-time.Second)) || + expiresAt.After(time.Now().Add(10*time.Minute+time.Second)) { + t.Errorf("expiresAt = %v, want ten minutes from now", issued["expiresAt"]) } if got.Context["action"] != "on_init" || got.status() != "DRAFT" { t.Errorf("action %v, status %v; want on_init, DRAFT", got.Context["action"], got.status()) @@ -447,25 +463,25 @@ func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { } channel := got.channel() caseStatus, _ := channel["caseStatus"].(map[string]any) - source, _ := channel["source"].(map[string]any) - if channel["ticketNo"] != knownTicket || channel["ticketId"] != "8842317" || - caseStatus["code"] != "REGISTERED" || caseStatus["name"] != "Registered" || - channel["filedOn"] != todayIST() || source["sourceId"] != "pmfby" || + routedTo, _ := channel["provider"].(map[string]any) + if channel["ticketNo"] != knownTicket || len(caseStatus) != 1 || caseStatus["code"] != "Registered" || + channel["filedOn"] != todayIST() || routedTo["id"] != "pmfby" || channel["informationMode"] != "Direct" || channel["scheme"] == nil { t.Errorf("channel = %v", channel) } - for _, private := range []string{"otp", "applicantPhone", "providerId"} { + for _, private := range []string{"challenge", "applicantPhone", "ticketId"} { if _, echoed := channel[private]; echoed { t.Errorf("%s must not be echoed", private) } } } -func TestSupport_NoTicketID_OmitsIt(t *testing.T) { +// The pack holds ticketNo as a string of digits, whichever PMFBY sends. +func TestSupport_NumericTicketNo_ReturnedAsString(t *testing.T) { h := newHarness(t, &pmfbyStub{answers: map[string]string{ - insertPath: fmt.Sprintf(`{"responseCode":1,"responseDynamic":{"GrievenceSupportTicketNo":%q}}`, knownTicket)}}, 0) - if value, present := h.mustSend(t, support(supportFields())).channel()["ticketId"]; present { - t.Errorf("ticketId = %v, want it omitted when PMFBY sends none", value) + insertPath: fmt.Sprintf(`{"responseCode":1,"responseDynamic":{"GrievenceSupportTicketNo":%s}}`, knownTicket)}}, 0) + if got := h.mustSend(t, support(supportFields())).channel()["ticketNo"]; got != knownTicket { + t.Errorf("ticketNo = %#v, want %q", got, knownTicket) } } @@ -509,7 +525,7 @@ func TestSupport_CategoryCode_SplitsOnTheDot(t *testing.T) { } func TestSupport_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"orderId", "code", "longDesc", "applicantPhone", "otp", "cropYear", "season"} { + for _, field := range []string{"orderId", "code", "longDesc", "applicantPhone", "challenge", "cropYear", "season"} { t.Run(field, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() @@ -566,7 +582,7 @@ func TestSupport_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { // OTP to the caller, and the lodge call is not repeated. func TestSupport_PortalAnswers400_Returns400BizGenericError(t *testing.T) { fields := supportFields() - fields["otp"] = "000000" + fields["challenge"] = challenge("000000") h := newHarness(t, &pmfbyStub{}, 0) _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") @@ -641,10 +657,14 @@ func TestSupport_FallbackNotConfigured_PassesThroughUntouched(t *testing.T) { } } -func TestSupport_NoProviderID_PassesThrough(t *testing.T) { - body := strings.Replace(string(support(supportFields())), `"providerId":"pmfby",`, "", 1) +func TestSupport_NoProvider_PassesThrough(t *testing.T) { + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + channels := payload["message"].(map[string]any)["support"].(map[string]any)["channels"].([]any) + delete(channels[0].(map[string]any), "provider") + body, _ := json.Marshal(payload) h := newHarness(t, &pmfbyStub{}, 0) - got, err := h.send(t, []byte(body)) + got, err := h.send(t, body) if err != nil || got != nil { t.Errorf("Run() = %v, %v; want a support request naming no provider passed on", got, err) } @@ -668,19 +688,17 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { attributes := got.attributes() for field, want := range map[string]any{ "ticketNo": knownTicket, "applicationNo": "040108251010160770605", "cropYear": "2025", - "season": "Kharif", "filedOn": "2026-10-04", "officerReply": "Forwarded to insurer", + "season": "Kharif", "filedOn": "2026-10-04", "caseRemark": "Forwarded to insurer", "informationMode": "Direct", } { if attributes[field] != want { t.Errorf("%s = %v, want %v", field, attributes[field], want) } } + // An unrecognised portal phrase is UnderReview, the phrase kept as the name. caseStatus, _ := attributes["caseStatus"].(map[string]any) - if caseStatus["code"] != "UNDER_REVIEW" || caseStatus["name"] != "Under Review" { - t.Errorf("caseStatus = %v, want UNDER_REVIEW / Under Review", caseStatus) - } - if source, _ := attributes["source"].(map[string]any); source["sourceId"] != "pmfby" { - t.Errorf("source = %v, want pmfby", attributes["source"]) + if caseStatus["code"] != "UnderReview" || caseStatus["name"] != "Under Review" { + t.Errorf("caseStatus = %v, want UnderReview / Under Review", caseStatus) } if got.Context["action"] != "on_status" || got.status() != "ACTIVE" { t.Errorf("action %v, status %v; want on_status, ACTIVE", got.Context["action"], got.status()) @@ -691,7 +709,7 @@ func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", statusAttributes())) allowed := map[string]bool{"@context": true, "@type": true, "informationMode": true, "scheme": true, "ticketNo": true, "applicationNo": true, "cropYear": true, "season": true, "caseStatus": true, - "filedOn": true, "officerReply": true, "source": true} + "filedOn": true, "caseRemark": true} for field := range got.attributes() { if !allowed[field] { t.Errorf("%s is mapped; the response is an allow-list", field) @@ -699,12 +717,28 @@ func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { } } -func TestStatus_NoRemarkYet_OmitsOfficerReply(t *testing.T) { +func TestStatus_NoRemarkYet_OmitsCaseRemark(t *testing.T) { record := strings.Replace(knownTicketRecord, `"latestRemark":"Forwarded to insurer"`, `"latestRemark":""`, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). mustSend(t, request("status", statusAttributes())) - if _, present := got.attributes()["officerReply"]; present { - t.Errorf("officerReply = %v, want it omitted while PMFBY has none", got.attributes()["officerReply"]) + if _, present := got.attributes()["caseRemark"]; present { + t.Errorf("caseRemark = %v, want it omitted while PMFBY has none", got.attributes()["caseRemark"]) + } +} + +// The pack requires caseStatus on a case read, so a missing phrase still says +// UnderReview; only the name, which would quote the portal, is dropped. +func TestStatus_NoStatusPhrase_UnderReviewWithoutName(t *testing.T) { + for name, phrase := range map[string]string{"null": `null`, "empty": `""`} { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"TicketStatus":"Under Review"`, `"TicketStatus":`+phrase, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + caseStatus, _ := got.attributes()["caseStatus"].(map[string]any) + if len(caseStatus) != 1 || caseStatus["code"] != "UnderReview" { + t.Errorf("caseStatus = %v, want code UnderReview alone", caseStatus) + } + }) } } @@ -720,12 +754,10 @@ func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { field string where func(string) func(*answer) map[string]any }{ - "null status": {`"TicketStatus":"Under Review"`, `"TicketStatus":null`, "caseStatus", inAttributes}, - "empty status": {`"TicketStatus":"Under Review"`, `"TicketStatus":""`, "caseStatus", inAttributes}, "null sub-category id": {`"TicketSubCategoryID":10`, `"TicketSubCategoryID":null`, "code", inDescriptor}, "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "longDesc", inDescriptor}, "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "filedOn", inAttributes}, - "null remark": {`"latestRemark":"Forwarded to insurer"`, `"latestRemark":null`, "officerReply", inAttributes}, + "null remark": {`"latestRemark":"Forwarded to insurer"`, `"latestRemark":null`, "caseRemark", inAttributes}, "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "applicationNo", inAttributes}, "null request season": {`"RequestSeason":1`, `"RequestSeason":null`, "season", inAttributes}, } { @@ -802,7 +834,7 @@ func TestFlow_InitSupportStatus_LogsInOnce(t *testing.T) { ticket := h.mustSend(t, support(supportFields())).channel()["ticketNo"] got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "ticketNo": ticket})) - if caseStatus, _ := got.attributes()["caseStatus"].(map[string]any); caseStatus["code"] != "UNDER_REVIEW" { + if caseStatus, _ := got.attributes()["caseStatus"].(map[string]any); caseStatus["code"] != "UnderReview" { t.Errorf("status of the filed ticket = %v", got.attributes()) } if logins := h.pmfby.calls[loginPath]; logins != 1 { From 53894add6dd305384b3dae6221a28c0baf1f057a Mon Sep 17 00:00:00 2001 From: nisargabd Date: Wed, 7 Oct 2026 13:05:17 +0530 Subject: [PATCH 13/39] feat: move the PMFBY grievance into its grievance and case bands [#57] --- config/mappings/pmfby/grievance.status.yaml | 56 ++--- config/mappings/pmfby/grievance.support.yaml | 55 +++-- .../implementation/Grievance/mappings_test.go | 214 +++++++++--------- 3 files changed, 161 insertions(+), 164 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index d80545c58..cdd14834b 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -12,19 +12,20 @@ request: | ( $ca := beckn.message.contract.commitments[0].commitmentAttributes; - $not($exists($ca.applicantPhone) and $exists($ca.ticketNo)) + $not($exists($ca.applicantPhone) and $exists($ca.case.ticketNo)) ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": "checking a grievance needs applicantPhone and ticketNo" } } + "message": "checking a grievance needs applicantPhone and case.ticketNo" } } : { "requestorMobileNo": $ca.applicantPhone, - "GrievenceSupportTicketNo": $ca.ticketNo + "GrievenceSupportTicketNo": $ca.case.ticketNo } ) # No case record in the reply is not a failure -- PMFBY answered and has -# nothing on file -- so it is a 202 ACK saying so. Otherwise contract.descriptor -# is the complaint as PMFBY holds it, the attributes are the case record, and a -# field PMFBY does not return is left out. +# nothing on file -- so it is a 202 ACK saying so. Otherwise the grievance is +# the complaint as PMFBY holds it, the case is the record it keeps against it, +# and a field PMFBY does not return is left out. The category comes back by +# name alone: PMFBY returns no category id on a case read. response: | /* A 4xx from PMFBY arrives under _status; it is PMFBY's refusal, left to the adapter's 502 rather than read as nothing on file. */ @@ -40,20 +41,10 @@ response: | /* PMFBY may send null or "" for a field it has no value for; such a field is dropped rather than mapped as "null" or failing the call. */ $isText := function($v) { $type($v) = "string" and $v != "" }; - $isNumber := function($v) { $type($v) = "number" }; - $seasons := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; - $hasCategory := $isNumber($ticket.TicketCategoryID) and $isNumber($ticket.TicketSubCategoryID); { "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), "message": { "contract": $merge([beckn.message.contract, { - "descriptor": { - "code": $hasCategory - ? $string($ticket.TicketCategoryID) & "." & $string($ticket.TicketSubCategoryID), - "name": $isText($ticket.TicketCategoryName) and $isText($ticket.TicketSubCategoryName) - ? $ticket.TicketCategoryName & " / " & $ticket.TicketSubCategoryName, - "longDesc": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription - }, "commitments": [$merge([$commitment, { "status": { "descriptor": { "code": "ACTIVE" } }, "commitmentAttributes": { @@ -61,22 +52,25 @@ response: | "@type": $ca.`@type`, "informationMode": "Direct", "scheme": $ca.scheme, - "ticketNo": $ca.ticketNo, - "applicationNo": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, - "cropYear": $isNumber($ticket.RequestYear) or $isText($ticket.RequestYear) - ? $string($ticket.RequestYear), - "season": $isNumber($ticket.RequestSeason) ? $lookup($seasons, $string($ticket.RequestSeason)), - /* code is the network's word, name the portal's phrase. PMFBY - publishes no status vocabulary, so every phrase is one the - adapter does not recognise and maps to UnderReview, which is - never a closed state. Recognised phrases are added here once - PMFBY confirms them. */ - "caseStatus": { - "code": "UnderReview", - "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus + "enrolmentId": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, + "grievance": { + "category": $isText($ticket.TicketCategoryName) ? { "name": $ticket.TicketCategoryName }, + "subCategory": $isText($ticket.TicketSubCategoryName) ? { "name": $ticket.TicketSubCategoryName }, + "description": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription }, - "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, - "caseRemark": $isText($ticket.latestRemark) ? $ticket.latestRemark + "case": { + "ticketNo": $ca.case.ticketNo, + /* code is the network's word, name the portal's phrase. PMFBY + publishes no status vocabulary, so every phrase is one the + adapter does not recognise and maps to UnderReview, which is + never a closed state. Recognised phrases are added here once + PMFBY confirms them. */ + "status": { + "code": "UnderReview", + "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus + }, + "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate + } } }])] }]) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index e600a1ab8..87b1de6e1 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -2,11 +2,10 @@ # Both directions, one file. # # Step two of three: lodge the grievance. A support request composes no -# contract: message.support carries the application number in orderId, the -# complaint in descriptor (code "3.10" is category and sub-category), and the -# provider, the farmer and the OTP (challenge) in channels[0]. The OTP init sent -# travels on the lodge call until PMFBY's verify endpoint is wired in; it is -# never echoed back. +# contract: message.support carries the application number in orderId, and +# channels[0] carries the provider, the farmer, the complaint (grievance) and +# the OTP (challenge). The OTP init sent travels on the lodge call until PMFBY's +# verify endpoint is wired in; it is never echoed back. # # "Grievence" is PMFBY's spelling, kept where PMFBY reads it. @@ -14,12 +13,15 @@ # absent field pass. A missing field is refused by the request half under # _error, which can name SCH_REQUIRED_FIELD_MISSING. required: + # PMFBY takes both codes as numbers, so anything else is refused here rather + # than failing the conversion. - check: | ( - $code := beckn.message.support.descriptor.code; - $not($exists($code)) or $exists($match($code, /^[0-9]+\.[0-9]+$/)[0]) + $g := beckn.message.support.channels[0].grievance; + $digits := function($code) { $not($exists($code)) or $exists($match($code, /^[0-9]+$/)[0]) }; + $digits($g.category.code) and $digits($g.subCategory.code) ) - message: "descriptor.code must be ., e.g. 3.10" + message: "grievance.category.code and grievance.subCategory.code must be digits, e.g. 3 and 10" - check: | ( $season := beckn.message.support.channels[0].season; @@ -31,37 +33,38 @@ request: | ( $s := beckn.message.support; $ch := $s.channels[0]; - $category := $split($s.descriptor.code, "."); + $g := $ch.grievance; $seasons := { "Kharif": 1, "Rabi": 2, "Zaid": 3 }; /* PMFBY's fixed id for this channel as the source of the grievance. */ $receiptSourceID := 134306; - $complete := $exists($s.orderId) and $exists($s.descriptor.code) and $exists($s.descriptor.longDesc) - and $exists($ch.applicantPhone) and $exists($ch.challenge.value) - and $exists($ch.cropYear) and $exists($ch.season); + $complete := $exists($s.orderId) and $exists($ch.applicantPhone) and $exists($ch.challenge.value) + and $exists($ch.cropYear) and $exists($ch.season) + and $exists($g.category.code) and $exists($g.subCategory.code) and $exists($g.description); $not($complete) ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": "lodging a grievance needs orderId, descriptor.code, descriptor.longDesc and, on channels[0], applicantPhone, challenge.value, cropYear and season" } } + "message": "lodging a grievance needs orderId and, on channels[0], applicantPhone, challenge.value, cropYear, season, grievance.category.code, grievance.subCategory.code and grievance.description" } } : { "requestorMobileNo": $ch.applicantPhone, "otp": $ch.challenge.value, "complaintDate": $now("[Y0001]-[M01]-[D01]", "+0530"), "receiptSourceID": $receiptSourceID, - "ticketCategoryID": $number($category[0]), - "ticketSubCategoryID": $number($category[1]), + "ticketCategoryID": $number($g.category.code), + "ticketSubCategoryID": $number($g.subCategory.code), "requestYear": $string($ch.cropYear), "requestSeason": $lookup($seasons, $ch.season), "applicationNo": $s.orderId, - "grievenceDescription": $trim($s.descriptor.longDesc) + "grievenceDescription": $trim($g.description) } ) # A 4xx from PMFBY reaches this half with its status under _status. # -# orderId and descriptor are the caller's own, echoed, and the ticket goes on -# the channel. caseStatus, filedOn and provider are not in PMFBY's reply: a -# freshly lodged grievance is registered, with no portal phrase to quote as its -# name, filedOn restates the IST date just sent, and provider is the one the -# request was routed by. PMFBY's ticket id is its own row id and is not mapped. +# orderId and the grievance are the caller's own, echoed, and the ticket goes in +# the channel's case. Its status, filedOn and provider are not in PMFBY's +# reply: a freshly lodged grievance is registered, with no portal phrase to +# quote as its name, filedOn restates the IST date just sent, and provider is +# the one the request was routed by. PMFBY's ticket id is its own row id and is +# not mapped. response: | $exists(_status) ? ( @@ -81,16 +84,18 @@ response: | "message": { "support": { "orderId": $s.orderId, - "descriptor": $s.descriptor, "channels": [{ "@context": $ch.`@context`, "@type": $ch.`@type`, "informationMode": "Direct", "provider": $ch.provider, "scheme": $ch.scheme, - "ticketNo": $type($ticketNo) = "number" or $type($ticketNo) = "string" ? $string($ticketNo), - "caseStatus": { "code": "Registered" }, - "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530") + "grievance": $ch.grievance, + "case": { + "ticketNo": $type($ticketNo) = "number" or $type($ticketNo) = "string" ? $string($ticketNo), + "status": { "code": "Registered" }, + "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530") + } }] } } diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index de1533660..52d3d5b1b 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -38,13 +38,14 @@ const ( ) // knownTicketRecord is PMFBY's record for knownTicket, personal data included. +// The category comes back by name alone: a case read carries no category id. const knownTicketRecord = `{"responseCode":1,"responseMessage":"Fetched","responseDynamic":{ "GrievenceSupportTicketNo":"100626000099001","TicketStatus":"Under Review","TicketStatusID":109301, "ComplaintDate":"2026-10-04","ApplicationNo":"040108251010160770605", - "GrievenceDescription":"Claim not received","TicketCategoryID":3,"TicketSubCategoryID":10, + "GrievenceDescription":"Claim not received", "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received", - "RequestYear":2025,"RequestSeason":1,"latestRemark":"Forwarded to insurer", - "FarmerName":"Ramesh","RequestorMobileNo":"9876543210","InsurancePolicyNo":"P-1"}}` + "FarmerName":"Ramesh","StateName":"Karnataka","DistrictName":"Mysuru", + "RequestorMobileNo":"9876543210","InsuranceCompanyName":"Insurer"}}` // pmfbyStub stands in for PMFBY. A refusal is a non-2xx; PMFBY's real failure // shape is not documented, so nothing here depends on one. Every field left @@ -190,8 +191,7 @@ type answer struct { Context map[string]any `json:"context"` Message struct { Contract struct { - ID string `json:"id"` - Descriptor map[string]any `json:"descriptor"` + ID string `json:"id"` Commitments []struct { Status struct { Descriptor struct { @@ -203,9 +203,8 @@ type answer struct { } `json:"commitments"` } `json:"contract"` Support struct { - OrderID string `json:"orderId"` - Descriptor map[string]any `json:"descriptor"` - Channels []map[string]any `json:"channels"` + OrderID string `json:"orderId"` + Channels []map[string]any `json:"channels"` } `json:"support"` } `json:"message"` } @@ -223,6 +222,13 @@ func (a *answer) status() string { return a.Message.Contract.Commitments[0].Status.Descriptor.Code } +// band reads one of the nested objects a payload groups its fields in: the +// grievance, the case, or a status inside a case. +func band(of map[string]any, name string) map[string]any { + inner, _ := of[name].(map[string]any) + return inner +} + // send runs body through the step. A nil answer means the step passed it on. func (h *harness) send(t *testing.T, body []byte) (*answer, error) { t.Helper() @@ -289,25 +295,30 @@ func request(action string, attributes map[string]any) []byte { } // support builds a Beckn support request, which composes no contract. fields is -// flat: orderId goes on support, code, name and longDesc on its descriptor, and -// everything else on channels[0]. A field left out is absent from the payload. +// flat: orderId goes on support; categoryCode, subCategoryCode and description +// make channels[0].grievance; everything else goes on channels[0]. A field left +// out is absent from the payload. func support(fields map[string]any) []byte { channel := map[string]any{"@context": packContext, "@type": "openagrinet:PMFBYGrievance", "informationMode": "OnDemand", "scheme": scheme, "provider": provider} body := map[string]any{"channels": []any{channel}} - descriptor := map[string]any{} + grievance := map[string]any{} for field, value := range fields { switch field { case "orderId": body["orderId"] = value - case "code", "name", "longDesc": - descriptor[field] = value + case "categoryCode": + grievance["category"] = map[string]any{"code": value, "name": "Claim"} + case "subCategoryCode": + grievance["subCategory"] = map[string]any{"code": value, "name": "Claim not received"} + case "description": + grievance["description"] = value default: channel[field] = value } } - if len(descriptor) > 0 { - body["descriptor"] = descriptor + if len(grievance) > 0 { + channel["grievance"] = grievance } raw, _ := json.Marshal(map[string]any{"context": becknContext("support"), "message": map[string]any{"support": body}}) return raw @@ -322,14 +333,14 @@ func challenge(otp string) map[string]any { func supportFields() map[string]any { return map[string]any{ - "orderId": "040108251010160770605", "code": "3.10", "name": "Claim / Claim not received", - "longDesc": " Claim not received ", "applicantPhone": phone, "challenge": challenge(validOTP), + "orderId": "040108251010160770605", "categoryCode": "3", "subCategoryCode": "10", + "description": " Claim not received ", "applicantPhone": phone, "challenge": challenge(validOTP), "cropYear": "2025", "season": "Kharif", } } func statusAttributes() map[string]any { - return map[string]any{"applicantPhone": phone, "ticketNo": knownTicket} + return map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": knownTicket}} } // assertCoded fails unless err is a CodedErr with this status and code. @@ -457,15 +468,17 @@ func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { if got.Context["action"] != "on_support" { t.Errorf("action = %v, want on_support", got.Context["action"]) } - // orderId and descriptor are the caller's own, echoed unchanged. - if got.Message.Support.OrderID != "040108251010160770605" || got.Message.Support.Descriptor["code"] != "3.10" { - t.Errorf("support = %+v, want orderId and descriptor echoed", got.Message.Support) - } + // orderId and the grievance are the caller's own, echoed unchanged. channel := got.channel() - caseStatus, _ := channel["caseStatus"].(map[string]any) - routedTo, _ := channel["provider"].(map[string]any) - if channel["ticketNo"] != knownTicket || len(caseStatus) != 1 || caseStatus["code"] != "Registered" || - channel["filedOn"] != todayIST() || routedTo["id"] != "pmfby" || + grievance := band(channel, "grievance") + if got.Message.Support.OrderID != "040108251010160770605" || band(grievance, "category")["code"] != "3" || + band(grievance, "subCategory")["code"] != "10" || grievance["description"] != " Claim not received " { + t.Errorf("support = %+v, want orderId and grievance echoed", got.Message.Support) + } + filed := band(channel, "case") + status := band(filed, "status") + if filed["ticketNo"] != knownTicket || len(status) != 1 || status["code"] != "Registered" || + filed["filedOn"] != todayIST() || band(channel, "provider")["id"] != "pmfby" || channel["informationMode"] != "Direct" || channel["scheme"] == nil { t.Errorf("channel = %v", channel) } @@ -480,7 +493,7 @@ func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { func TestSupport_NumericTicketNo_ReturnedAsString(t *testing.T) { h := newHarness(t, &pmfbyStub{answers: map[string]string{ insertPath: fmt.Sprintf(`{"responseCode":1,"responseDynamic":{"GrievenceSupportTicketNo":%s}}`, knownTicket)}}, 0) - if got := h.mustSend(t, support(supportFields())).channel()["ticketNo"]; got != knownTicket { + if got := band(h.mustSend(t, support(supportFields())).channel(), "case")["ticketNo"]; got != knownTicket { t.Errorf("ticketNo = %#v, want %q", got, knownTicket) } } @@ -509,12 +522,12 @@ func TestSupport_EachSeason_SendsItsCode(t *testing.T) { } } -func TestSupport_CategoryCode_SplitsOnTheDot(t *testing.T) { - for category, want := range map[string][2]float64{"3.10": {3, 10}, "12.5": {12, 5}, "1.1": {1, 1}} { - t.Run(category, func(t *testing.T) { +func TestSupport_CategoryCodes_SentAsNumbers(t *testing.T) { + for codes, want := range map[[2]string][2]float64{{"3", "10"}: {3, 10}, {"12", "5"}: {12, 5}, {"1", "1"}: {1, 1}} { + t.Run(codes[0]+"/"+codes[1], func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() - fields["code"] = category + fields["categoryCode"], fields["subCategoryCode"] = codes[0], codes[1] h.mustSend(t, support(fields)) sent := h.pmfby.bodies[insertPath] if sent["ticketCategoryID"] != want[0] || sent["ticketSubCategoryID"] != want[1] { @@ -525,7 +538,8 @@ func TestSupport_CategoryCode_SplitsOnTheDot(t *testing.T) { } func TestSupport_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"orderId", "code", "longDesc", "applicantPhone", "challenge", "cropYear", "season"} { + for _, field := range []string{"orderId", "categoryCode", "subCategoryCode", "description", + "applicantPhone", "challenge", "cropYear", "season"} { t.Run(field, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() @@ -539,9 +553,9 @@ func TestSupport_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { } } -func TestSupport_NoDescriptor_Returns400(t *testing.T) { +func TestSupport_NoGrievance_Returns400(t *testing.T) { fields := supportFields() - for _, field := range []string{"code", "name", "longDesc"} { + for _, field := range []string{"categoryCode", "subCategoryCode", "description"} { delete(fields, field) } _, err := newHarness(t, &pmfbyStub{}, 0).send(t, support(fields)) @@ -549,11 +563,14 @@ func TestSupport_NoDescriptor_Returns400(t *testing.T) { } func TestSupport_MalformedCategory_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, category := range []string{"3", "3.", ".10", "3.10.1", "a.b", "3-10", ""} { - t.Run(category, func(t *testing.T) { + for name, codes := range map[string][2]string{ + "joined": {"3.10", "10"}, "letters": {"a", "10"}, "empty": {"", "10"}, + "sub-category joined": {"3", "3.10"}, "sub-category letters": {"3", "b"}, + } { + t.Run(name, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() - fields["code"] = category + fields["categoryCode"], fields["subCategoryCode"] = codes[0], codes[1] _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") if h.pmfby.calls[insertPath] != 0 { @@ -680,25 +697,22 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { if len(sent) != 2 || sent["GrievenceSupportTicketNo"] != knownTicket || sent["requestorMobileNo"] != phone { t.Errorf("PMFBY was sent %v, want the ticket and the phone alone", sent) } - descriptor := got.Message.Contract.Descriptor - if descriptor["code"] != "3.10" || descriptor["name"] != "Claim / Claim not received" || - descriptor["longDesc"] != "Claim not received" { - t.Errorf("contract.descriptor = %v, want the complaint as PMFBY holds it", descriptor) - } attributes := got.attributes() - for field, want := range map[string]any{ - "ticketNo": knownTicket, "applicationNo": "040108251010160770605", "cropYear": "2025", - "season": "Kharif", "filedOn": "2026-10-04", "caseRemark": "Forwarded to insurer", - "informationMode": "Direct", - } { - if attributes[field] != want { - t.Errorf("%s = %v, want %v", field, attributes[field], want) - } + // The complaint as PMFBY holds it: the category by name, with no code. + grievance := band(attributes, "grievance") + category, subCategory := band(grievance, "category"), band(grievance, "subCategory") + if len(category) != 1 || category["name"] != "Claim" || len(subCategory) != 1 || + subCategory["name"] != "Claim not received" || grievance["description"] != "Claim not received" { + t.Errorf("grievance = %v, want the complaint as PMFBY holds it", grievance) + } + filed := band(attributes, "case") + if attributes["enrolmentId"] != "040108251010160770605" || attributes["informationMode"] != "Direct" || + filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" { + t.Errorf("commitmentAttributes = %v", attributes) } // An unrecognised portal phrase is UnderReview, the phrase kept as the name. - caseStatus, _ := attributes["caseStatus"].(map[string]any) - if caseStatus["code"] != "UnderReview" || caseStatus["name"] != "Under Review" { - t.Errorf("caseStatus = %v, want UnderReview / Under Review", caseStatus) + if status := band(filed, "status"); status["code"] != "UnderReview" || status["name"] != "Under Review" { + t.Errorf("case.status = %v, want UnderReview / Under Review", status) } if got.Context["action"] != "on_status" || got.status() != "ACTIVE" { t.Errorf("action %v, status %v; want on_status, ACTIVE", got.Context["action"], got.status()) @@ -707,26 +721,27 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", statusAttributes())) - allowed := map[string]bool{"@context": true, "@type": true, "informationMode": true, "scheme": true, - "ticketNo": true, "applicationNo": true, "cropYear": true, "season": true, "caseStatus": true, - "filedOn": true, "caseRemark": true} - for field := range got.attributes() { - if !allowed[field] { - t.Errorf("%s is mapped; the response is an allow-list", field) - } + allowed := map[string]map[string]bool{ + "": {"@context": true, "@type": true, "informationMode": true, "scheme": true, + "enrolmentId": true, "grievance": true, "case": true}, + "grievance": {"category": true, "subCategory": true, "description": true}, + "case": {"ticketNo": true, "status": true, "filedOn": true}, } -} - -func TestStatus_NoRemarkYet_OmitsCaseRemark(t *testing.T) { - record := strings.Replace(knownTicketRecord, `"latestRemark":"Forwarded to insurer"`, `"latestRemark":""`, 1) - got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). - mustSend(t, request("status", statusAttributes())) - if _, present := got.attributes()["caseRemark"]; present { - t.Errorf("caseRemark = %v, want it omitted while PMFBY has none", got.attributes()["caseRemark"]) + attributes := got.attributes() + for name, fields := range allowed { + in := attributes + if name != "" { + in = band(attributes, name) + } + for field := range in { + if !fields[field] { + t.Errorf("%s %s is mapped; the response is an allow-list", name, field) + } + } } } -// The pack requires caseStatus on a case read, so a missing phrase still says +// A case read always carries a status, so a missing phrase still says // UnderReview; only the name, which would quote the portal, is dropped. func TestStatus_NoStatusPhrase_UnderReviewWithoutName(t *testing.T) { for name, phrase := range map[string]string{"null": `null`, "empty": `""`} { @@ -734,63 +749,46 @@ func TestStatus_NoStatusPhrase_UnderReviewWithoutName(t *testing.T) { record := strings.Replace(knownTicketRecord, `"TicketStatus":"Under Review"`, `"TicketStatus":`+phrase, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). mustSend(t, request("status", statusAttributes())) - caseStatus, _ := got.attributes()["caseStatus"].(map[string]any) - if len(caseStatus) != 1 || caseStatus["code"] != "UnderReview" { - t.Errorf("caseStatus = %v, want code UnderReview alone", caseStatus) + status := band(band(got.attributes(), "case"), "status") + if len(status) != 1 || status["code"] != "UnderReview" { + t.Errorf("case.status = %v, want code UnderReview alone", status) } }) } } func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { - inAttributes := func(field string) func(*answer) map[string]any { - return func(a *answer) map[string]any { return map[string]any{field: a.attributes()[field]} } - } - inDescriptor := func(field string) func(*answer) map[string]any { - return func(a *answer) map[string]any { return map[string]any{field: a.Message.Contract.Descriptor[field]} } - } for name, tc := range map[string]struct { - from, to string - field string - where func(string) func(*answer) map[string]any + from, to string + band, field string }{ - "null sub-category id": {`"TicketSubCategoryID":10`, `"TicketSubCategoryID":null`, "code", inDescriptor}, - "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "longDesc", inDescriptor}, - "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "filedOn", inAttributes}, - "null remark": {`"latestRemark":"Forwarded to insurer"`, `"latestRemark":null`, "caseRemark", inAttributes}, - "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "applicationNo", inAttributes}, - "null request season": {`"RequestSeason":1`, `"RequestSeason":null`, "season", inAttributes}, + "null category name": {`"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, "grievance", "category"}, + "empty sub-category": {`"TicketSubCategoryName":"Claim not received"`, `"TicketSubCategoryName":""`, "grievance", "subCategory"}, + "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "grievance", "description"}, + "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "case", "filedOn"}, + "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "", "enrolmentId"}, } { t.Run(name, func(t *testing.T) { record := strings.Replace(knownTicketRecord, tc.from, tc.to, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). mustSend(t, request("status", statusAttributes())) - if value := tc.where(tc.field)(got)[tc.field]; value != nil { + in := got.attributes() + if tc.band != "" { + in = band(in, tc.band) + } + if value, present := in[tc.field]; present { t.Errorf("%s = %v, want it dropped", tc.field, value) } - if got.attributes()["ticketNo"] != knownTicket { - t.Errorf("ticketNo = %v, want the rest of the answer intact", got.attributes()["ticketNo"]) + if band(got.attributes(), "case")["ticketNo"] != knownTicket { + t.Errorf("case = %v, want the rest of the answer intact", band(got.attributes(), "case")) } }) } } -func TestStatus_NullCategoryName_KeepsCodeDropsName(t *testing.T) { - record := strings.Replace(knownTicketRecord, `"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, 1) - got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). - mustSend(t, request("status", statusAttributes())) - descriptor := got.Message.Contract.Descriptor - if descriptor["code"] != "3.10" { - t.Errorf("code = %v, want 3.10", descriptor["code"]) - } - if name, present := descriptor["name"]; present { - t.Errorf("name = %v, want it dropped rather than \"null / ...\"", name) - } -} - func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { attributes := statusAttributes() - attributes["ticketNo"] = "999" + attributes["case"] = map[string]any{"ticketNo": "999"} _, err := newHarness(t, &pmfbyStub{}, 0).send(t, request("status", attributes)) var ack *model.AckNoCallbackErr @@ -803,7 +801,7 @@ func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { } func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"applicantPhone", "ticketNo"} { + for _, field := range []string{"applicantPhone", "case"} { t.Run(field, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) attributes := statusAttributes() @@ -831,10 +829,10 @@ func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { func TestFlow_InitSupportStatus_LogsInOnce(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) h.mustSend(t, request("init", initAttributes())) - ticket := h.mustSend(t, support(supportFields())).channel()["ticketNo"] - got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "ticketNo": ticket})) + ticket := band(h.mustSend(t, support(supportFields())).channel(), "case")["ticketNo"] + got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": ticket}})) - if caseStatus, _ := got.attributes()["caseStatus"].(map[string]any); caseStatus["code"] != "UnderReview" { + if status := band(band(got.attributes(), "case"), "status"); status["code"] != "UnderReview" { t.Errorf("status of the filed ticket = %v", got.attributes()) } if logins := h.pmfby.calls[loginPath]; logins != 1 { From 4a558da4d406e1d196911667e72af05e3b8919a4 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 8 Oct 2026 16:07:41 +0530 Subject: [PATCH 14/39] feat: read a login token from a nested path in the reply [#57] --- .../implementation/internal/common/auth.go | 3 +- .../implementation/internal/common/token.go | 12 ++++--- .../internal/common/tokenheader_test.go | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 09aba01ff..52c9887c3 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -60,7 +60,8 @@ type AuthProfile struct { TokenSecretName string TokenSecretEnv string - // Where the token sits in the response body, as a top-level key. + // Where the token sits in the response body, as a dotted path: + // "token" at the top level, "responseDynamic.token.Token" nested. TokenResponseField string // How long a token may be held, as written in config and as parsed. diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index e23c80c96..95597aef3 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -123,18 +123,20 @@ func (s *Step) exchangeLoginToken(ctx context.Context, auth *authenticator) (str return "", 0, err } - // Decoded into a map because the field name is configured: a struct tag - // cannot be written for a key that is not known until config is read. - var fields map[string]any + // Decoded generically because the field is configured: a struct tag cannot + // be written for a key that is not known until config is read. It is a + // dotted path, since some login replies nest the token in an envelope. + var fields any if err := json.Unmarshal(body, &fields); err != nil { return "", 0, s.permanentTokenErr(fmt.Errorf("token response from %s is not JSON: %w", cfg.TokenURL, err)) } - token, ok := fields[cfg.TokenResponseField].(string) - if !ok || token == "" { + tokens := ValuesAt(fields, cfg.TokenResponseField) + if len(tokens) == 0 || tokens[0] == "" { return "", 0, s.permanentTokenErr(fmt.Errorf( "token response from %s carries no %s", cfg.TokenURL, cfg.TokenResponseField)) } + token := tokens[0] // The skew is subtracted here for the same reason oauth2 subtracts it from // expires_in: a request that passed the expiry check must not arrive after diff --git a/pkg/plugin/implementation/internal/common/tokenheader_test.go b/pkg/plugin/implementation/internal/common/tokenheader_test.go index a69c3c1bc..5914392ab 100644 --- a/pkg/plugin/implementation/internal/common/tokenheader_test.go +++ b/pkg/plugin/implementation/internal/common/tokenheader_test.go @@ -80,6 +80,41 @@ func TestTokenHeader_ValidLogin_SendsBareTokenInHeaderNotQuery(t *testing.T) { } } +// PMFBY's login wraps the token in its reply envelope, so the field is a path. +func TestTokenHeader_NestedTokenField_ReadsTheDottedPath(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + login := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"responseCode":"1","responseDynamic":{"token":{"Token":"nested-token"}}}`) + })) + t.Cleanup(login.Close) + var authorization string + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("Authorization") + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + + step := newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)}, + func(c *Config) { + profile := tokenHeaderProfile(login.URL) + profile.TokenResponseField = "responseDynamic.token.Token" + if err := profile.validate(); err != nil { + t.Fatalf("profile does not validate: %v", err) + } + c.setProviderAuth(profile) + }) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() returned an unexpected error: %v", err) + } + if authorization != "nested-token" { + t.Errorf("provider saw Authorization %q, want the token read from the nested path", authorization) + } +} + func TestTokenHeader_RepeatedCalls_LogsInOnce(t *testing.T) { t.Setenv("TEST_TQ_USER", "user") t.Setenv("TEST_TQ_SECRET", "secret") From 5809f0ea3ab1b3fd8cc4eb5513b1e85446862e82 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 8 Oct 2026 16:07:49 +0530 Subject: [PATCH 15/39] feat: drop the PMFBY OTP leg and read FGMS's reply envelope as documented [#57] --- config/mappings/pmfby/grievance.init.yaml | 62 ---- config/mappings/pmfby/grievance.status.yaml | 41 ++- config/mappings/pmfby/grievance.support.yaml | 97 +++--- config/provider-adapter.yaml | 7 +- .../implementation/Grievance/mappings_test.go | 305 ++++++++---------- .../internal/common/common_test.go | 81 ++--- .../implementation/internal/common/http.go | 19 +- .../implementation/internal/common/serve.go | 41 +-- 8 files changed, 253 insertions(+), 400 deletions(-) delete mode 100644 config/mappings/pmfby/grievance.init.yaml diff --git a/config/mappings/pmfby/grievance.init.yaml b/config/mappings/pmfby/grievance.init.yaml deleted file mode 100644 index 503973432..000000000 --- a/config/mappings/pmfby/grievance.init.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# PMFBY, openagrinet:PMFBYGrievance, init -> POST /SendOTP. Both directions, one file. -# -# Step one of three: send an OTP to the farmer's phone. The commitment stays -# DRAFT; support carries the OTP back with the grievance itself. - -# required holds format checks, which return SCH_INVALID_FORMAT and let an -# absent field pass. A missing field is refused by the request half under -# _error, which can name SCH_REQUIRED_FIELD_MISSING. -required: - # Checked before the call: the reply masks the phone as text, so a number - # would fail only after PMFBY had already sent the OTP. - - check: | - ( - $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; - $not($exists($phone)) or $type($phone) = "string" - ) - message: "commitmentAttributes.applicantPhone must be a string" - -request: | - ( - $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; - $exists($phone) - ? { "requestorMobileNo": $phone } - : { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": "requesting an OTP needs commitmentAttributes.applicantPhone" } } - ) - -# Built from the request alone: SendOTP's reply is not documented, so nothing -# is read from it. The phone is masked rather than echoed: enough for the -# farmer to recognise which number, not enough to read it back. -response: | - ( - $commitment := beckn.message.contract.commitments[0]; - $ca := $commitment.commitmentAttributes; - $phone := $ca.applicantPhone; - /* The pack requires expiresAt and has the adapter derive it. PMFBY - publishes no OTP validity; ten minutes is the architecture doc's - example, to be confirmed with PMFBY. */ - $otpValidityMillis := 10 * 60 * 1000; - { - "context": $merge([beckn.context, { "action": "on_init", "timestamp": $now() }]), - "message": { - "contract": $merge([beckn.message.contract, { - "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "DRAFT" } }, - "commitmentAttributes": { - "@context": $ca.`@context`, - "@type": $ca.`@type`, - /* Still OnDemand: an OTP acknowledgement carries no case. */ - "informationMode": "OnDemand", - "scheme": $ca.scheme, - "challengeIssued": { - "method": "SMS_OTP", - "sentTo": $substring($phone, 0, 2) & "XXXXXX" & $substring($phone, -2), - "expiresAt": $fromMillis($millis() + $otpValidityMillis) - } - } - }])] - }]) - } - } - ) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index cdd14834b..201edf1b8 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -1,36 +1,42 @@ -# PMFBY, openagrinet:PMFBYGrievance, status -> POST /GetGrievenceTicketsStatus. +# PMFBY, openagrinet:PMFBYGrievance, status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. # Both directions, one file. # -# Step three: read a filed grievance back. PMFBY matches the ticket to the phone -# it was filed from, so both are required. +# Step two of two: read a filed grievance back. PMFBY matches the ticket to the +# phone it was filed from, so both are required. No OTP: PMFBY asks for none. # # The response is an allow-list. PMFBY's record also carries the farmer's name, -# address and policy details; none of it is mapped. +# state, district and insurer; none of it is mapped. # A missing field is refused by the request half under _error, which can name -# SCH_REQUIRED_FIELD_MISSING. +# SCH_REQUIRED_FIELD_MISSING and the field's path. request: | ( $ca := beckn.message.contract.commitments[0].commitmentAttributes; - $not($exists($ca.applicantPhone) and $exists($ca.case.ticketNo)) + $at := "$.message.contract.commitments[0].commitmentAttributes."; + $missing := [ + { "field": "applicantPhone", "path": $at & "applicantPhone", "present": $exists($ca.applicantPhone) }, + { "field": "case.ticketNo", "path": $at & "case.ticketNo", "present": $exists($ca.case.ticketNo) } + ][$not(present)][0]; + $missing ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": "checking a grievance needs applicantPhone and case.ticketNo" } } + "message": $missing.field & " is required", "path": $missing.path } } : { "requestorMobileNo": $ca.applicantPhone, "GrievenceSupportTicketNo": $ca.case.ticketNo } ) -# No case record in the reply is not a failure -- PMFBY answered and has -# nothing on file -- so it is a 202 ACK saying so. Otherwise the grievance is -# the complaint as PMFBY holds it, the case is the record it keeps against it, -# and a field PMFBY does not return is left out. The category comes back by -# name alone: PMFBY returns no category id on a case read. +# PMFBY says success with responseCode "1", seen as a string and as a number; +# anything else is the portal refusing, a 502. A success with no case record is +# not a failure -- PMFBY answered and has nothing on file -- so it is a 202 ACK +# saying so. Otherwise the grievance is the complaint as PMFBY holds it, the +# case is the record it keeps against it, and a field PMFBY does not return is +# left out. The category comes back by name alone, and the ticket number not at +# all, so the one the caller asked with is echoed. response: | - /* A 4xx from PMFBY arrives under _status; it is PMFBY's refusal, left to - the adapter's 502 rather than read as nothing on file. */ - $exists(_status) - ? {} + $string(response.responseCode) != "1" + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PMFBY could not read the grievance" } } : $type(response.responseDynamic) != "object" ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", "message": "No grievance matches this ticket and phone number." } } @@ -69,7 +75,8 @@ response: | "code": "UnderReview", "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus }, - "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate + "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, + "cropName": $isText($ticket.CropName) ? $ticket.CropName } } }])] diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 87b1de6e1..edc64daea 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -1,85 +1,94 @@ -# PMFBY, openagrinet:PMFBYGrievance, support -> POST /AddKRPHNCIPGrievenceSupportTicket. +# PMFBY, openagrinet:PMFBYGrievance, support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. # Both directions, one file. # -# Step two of three: lodge the grievance. A support request composes no -# contract: message.support carries the application number in orderId, and -# channels[0] carries the provider, the farmer, the complaint (grievance) and -# the OTP (challenge). The OTP init sent travels on the lodge call until PMFBY's -# verify endpoint is wired in; it is never echoed back. +# Step one of two: lodge the grievance. PMFBY's grievance service asks for no +# OTP, so this is the first call. A support request composes no contract: +# message.support carries the application number in orderId, and channels[0] +# carries the provider, the enrolment and the complaint (grievance). # # "Grievence" is PMFBY's spelling, kept where PMFBY reads it. # required holds format checks, which return SCH_INVALID_FORMAT and let an # absent field pass. A missing field is refused by the request half under -# _error, which can name SCH_REQUIRED_FIELD_MISSING. +# _error, which can name SCH_REQUIRED_FIELD_MISSING and the field's path. required: - # PMFBY takes both codes as numbers, so anything else is refused here rather - # than failing the conversion. + # PMFBY reads a non-numeric id as 0 and files the ticket under the wrong head + # while reporting success, so the codes are checked before they are sent. - check: | ( - $g := beckn.message.support.channels[0].grievance; - $digits := function($code) { $not($exists($code)) or $exists($match($code, /^[0-9]+$/)[0]) }; - $digits($g.category.code) and $digits($g.subCategory.code) + $ch := beckn.message.support.channels[0]; + $digits := function($v) { $not($exists($v)) or $exists($match($string($v), /^[0-9]+$/)[0]) }; + $digits($ch.grievance.category.code) and $digits($ch.grievance.subCategory.code) + and $digits($ch.receiptSourceId) ) - message: "grievance.category.code and grievance.subCategory.code must be digits, e.g. 3 and 10" + message: "grievance.category.code, grievance.subCategory.code and receiptSourceId must be digits" - check: | ( $season := beckn.message.support.channels[0].season; $not($exists($season)) or $season in ["Kharif", "Rabi", "Zaid"] ) message: "season must be Kharif, Rabi or Zaid" + - check: | + ( + $date := beckn.message.support.channels[0].complaintDate; + $not($exists($date)) or $exists($match($string($date), /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)[0]) + ) + message: "complaintDate must be YYYY-MM-DD" request: | ( $s := beckn.message.support; $ch := $s.channels[0]; $g := $ch.grievance; - $seasons := { "Kharif": 1, "Rabi": 2, "Zaid": 3 }; - /* PMFBY's fixed id for this channel as the source of the grievance. */ - $receiptSourceID := 134306; - $complete := $exists($s.orderId) and $exists($ch.applicantPhone) and $exists($ch.challenge.value) - and $exists($ch.cropYear) and $exists($ch.season) - and $exists($g.category.code) and $exists($g.subCategory.code) and $exists($g.description); - $not($complete) + $at := "$.message.support.channels[0]."; + /* The first required field the request lacks, with its path. */ + $missing := [ + { "field": "orderId", "path": "$.message.support.orderId", "present": $exists($s.orderId) }, + { "field": "applicantPhone", "path": $at & "applicantPhone", "present": $exists($ch.applicantPhone) }, + { "field": "cropYear", "path": $at & "cropYear", "present": $exists($ch.cropYear) }, + { "field": "season", "path": $at & "season", "present": $exists($ch.season) }, + { "field": "grievance.category.code", "path": $at & "grievance.category.code", "present": $exists($g.category.code) }, + { "field": "grievance.subCategory.code", "path": $at & "grievance.subCategory.code", "present": $exists($g.subCategory.code) }, + { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } + ][$not(present)][0]; + $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; + /* The adapter's channel id with PMFBY, used unless the caller has its own. */ + $receiptSourceID := "134306"; + $missing ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": "lodging a grievance needs orderId and, on channels[0], applicantPhone, challenge.value, cropYear, season, grievance.category.code, grievance.subCategory.code and grievance.description" } } + "message": $missing.field & " is required", "path": $missing.path } } : { "requestorMobileNo": $ch.applicantPhone, - "otp": $ch.challenge.value, - "complaintDate": $now("[Y0001]-[M01]-[D01]", "+0530"), - "receiptSourceID": $receiptSourceID, - "ticketCategoryID": $number($g.category.code), - "ticketSubCategoryID": $number($g.subCategory.code), + "applicationNo": $s.orderId, "requestYear": $string($ch.cropYear), "requestSeason": $lookup($seasons, $ch.season), - "applicationNo": $s.orderId, - "grievenceDescription": $trim($g.description) + "ticketCategoryID": $string($g.category.code), + "ticketSubCategoryID": $string($g.subCategory.code), + "grievenceDescription": $trim($g.description), + "complaintDate": $exists($ch.complaintDate) ? $ch.complaintDate : $now("[Y0001]-[M01]-[D01]", "+0530"), + "receiptSourceID": $exists($ch.receiptSourceId) ? $string($ch.receiptSourceId) : $receiptSourceID } ) -# A 4xx from PMFBY reaches this half with its status under _status. -# # orderId and the grievance are the caller's own, echoed, and the ticket goes in # the channel's case. Its status, filedOn and provider are not in PMFBY's # reply: a freshly lodged grievance is registered, with no portal phrase to -# quote as its name, filedOn restates the IST date just sent, and provider is +# quote as its name, filedOn is the complaintDate just sent, and provider is # the one the request was routed by. PMFBY's ticket id is its own row id and is # not mapped. response: | - $exists(_status) - ? ( - /* PMFBY refused the lodge call with a 4xx. A 400 is a wrong or expired - OTP: the farmer's to fix, so a 400 back. Any other status is left to - the adapter's 502. */ - _status = 400 - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": "the OTP is wrong or has expired" } } - : {} - ) - : ( + ( $s := beckn.message.support; $ch := $s.channels[0]; $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; - { + /* PMFBY says success with responseCode "1", seen as a string and as a + number. Anything else, or a success with no ticket, filed nothing. */ + $registered := $string(response.responseCode) = "1" + and ($type($ticketNo) = "string" or $type($ticketNo) = "number"); + $not($registered) + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PMFBY did not register the grievance" } } + : { "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), "message": { "support": { @@ -92,9 +101,9 @@ response: | "scheme": $ch.scheme, "grievance": $ch.grievance, "case": { - "ticketNo": $type($ticketNo) = "number" or $type($ticketNo) = "string" ? $string($ticketNo), + "ticketNo": $string($ticketNo), "status": { "code": "Registered" }, - "filedOn": $now("[Y0001]-[M01]-[D01]", "+0530") + "filedOn": $exists($ch.complaintDate) ? $ch.complaintDate : $now("[Y0001]-[M01]-[D01]", "+0530") } }] } diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index f9df5cc83..dc26b1325 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -507,7 +507,7 @@ modules: # ------------------------------------------------------------------ # Grievance -- filing a grievance against a scheme and checking it: - # init sends an OTP, support files the grievance, status reads it back. + # support files the grievance, status reads it back. # ------------------------------------------------------------------ - id: Grievance config: @@ -528,7 +528,8 @@ modules: # PMFBY issues its own token from its login endpoint # (.../krphapi/FGMS/NICUsersLogin) and reads it back as the bare # Authorization header, no "Bearer" -- tokenQuery's exchange, - # header's placement. tokenUrl is a per-deployment placeholder. + # header's placement. The login reply nests the token in its + # envelope. tokenUrl is a per-deployment placeholder. pmfby: authScheme: tokenHeader tokenUrl: <> @@ -536,7 +537,7 @@ modules: tokenUserEnv: PMFBY_ACCESS_UID tokenSecretField: appAccessPWD tokenSecretEnv: PMFBY_ACCESS_PWD - tokenResponseField: token + tokenResponseField: responseDynamic.token.Token tokenTtl: 10m headerName: Authorization diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 52d3d5b1b..20557efde 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -28,28 +28,27 @@ const ( mappingsDir = "../../../../config/mappings/pmfby" shippedBindingKey = "pmfby|openagrinet:PMFBYGrievance" stubToken = "stub-login-token" - validOTP = "123456" knownTicket = "100626000099001" phone = "9876543210" - sendOTPPath = "/SendOTP" - insertPath = "/AddKRPHNCIPGrievenceSupportTicket" - statusPath = "/GetGrievenceTicketsStatus" - loginPath = "/NICUsersLogin" + insertPath = "/krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket" + statusPath = "/krphapi/FGMS/GetGrievenceTicketsStatus" + loginPath = "/krphapi/FGMS/NICUsersLogin" ) // knownTicketRecord is PMFBY's record for knownTicket, personal data included. // The category comes back by name alone: a case read carries no category id. -const knownTicketRecord = `{"responseCode":1,"responseMessage":"Fetched","responseDynamic":{ - "GrievenceSupportTicketNo":"100626000099001","TicketStatus":"Under Review","TicketStatusID":109301, +// The ticket number is not returned either, only PMFBY's internal ticket id. +const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","recordCount":1,"responseDynamic":{ + "GrievenceSupportTicketID":109301,"TicketStatus":"Under Review", "ComplaintDate":"2026-10-04","ApplicationNo":"040108251010160770605", "GrievenceDescription":"Claim not received", - "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received", - "FarmerName":"Ramesh","StateName":"Karnataka","DistrictName":"Mysuru", - "RequestorMobileNo":"9876543210","InsuranceCompanyName":"Insurer"}}` + "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received","CropName":"Paddy", + "FarmerName":"Ramesh","StateMasterName":"Karnataka","DistrictMasterName":"Mysuru", + "InsuranceCompany":"Insurer"}}` -// pmfbyStub stands in for PMFBY. A refusal is a non-2xx; PMFBY's real failure -// shape is not documented, so nothing here depends on one. Every field left -// zero gives the ordinary answer. +// pmfbyStub stands in for PMFBY's FGMS: a login that nests its token in the +// reply envelope, and replies whose responseCode "1" is success. Every field +// left zero gives the ordinary answer. type pmfbyStub struct { down bool // 503 on every call but the login loginDown bool // 401 on the login @@ -68,7 +67,7 @@ func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) return } - fmt.Fprintf(w, `{"token":%q}`, stubToken) + fmt.Fprintf(w, `{"responseCode":"1","responseDynamic":{"token":{"Token":%q}}}`, stubToken) return } if p.down { @@ -93,18 +92,11 @@ func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } switch r.URL.Path { - case sendOTPPath: - fmt.Fprint(w, `{"responseCode":1,"responseMessage":"OTP sent"}`) case insertPath: - if body["otp"] != validOTP { - w.WriteHeader(http.StatusBadRequest) - fmt.Fprint(w, `{"responseMessage":"Invalid OTP"}`) - return - } - fmt.Fprintf(w, `{"responseCode":1,"responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q,"GrievenceSupportTicketID":8842317}}`, knownTicket) + fmt.Fprintf(w, `{"responseCode":"1","responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q,"GrievenceSupportTicketID":8842317}}`, knownTicket) case statusPath: if body["GrievenceSupportTicketNo"] != knownTicket { - fmt.Fprint(w, `{"responseCode":1,"responseMessage":"No record found","responseDynamic":null}`) + fmt.Fprint(w, `{"responseCode":"1","responseMessage":"No record found","responseDynamic":null}`) return } fmt.Fprint(w, knownTicketRecord) @@ -127,7 +119,7 @@ type harness struct { } // newHarness builds the step. statusRetries is the registry's retryMax for -// status; init and support get 0, as the seed ships them. tweak edits the +// status; support gets 0, as the seed ships it. tweak edits the // shipped config before the step is built. func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func(*Grievance.Config)) *harness { t.Helper() @@ -155,7 +147,6 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func BindingKey: shippedBindingKey, ParticipantID: "pmfby", CapabilityCode: "openagrinet:PMFBYGrievance", BaseURL: upstream.URL, Actions: map[string]model.ActionPlan{ - "init": action(sendOTPPath, "grievance.init.yaml", 0), "support": action(insertPath, "grievance.support.yaml", 0), "status": action(statusPath, "grievance.status.yaml", statusRetries), }, @@ -172,7 +163,7 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", TokenSecretName: "appAccessPWD", TokenSecretEnv: "PMFBY_PASSWORD", - TokenResponseField: "token", TokenTTLRaw: "10m", HeaderName: "Authorization", + TokenResponseField: "responseDynamic.token.Token", TokenTTLRaw: "10m", HeaderName: "Authorization", }}, } for _, apply := range tweak { @@ -272,7 +263,7 @@ func becknContext(action string) map[string]any { return map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"} } -// request builds a Beckn grievance payload for init or status: a contract whose +// request builds a Beckn grievance payload for status: a contract whose // one commitment carries these attributes. func request(action string, attributes map[string]any) []byte { attributes["@context"] = packContext @@ -284,9 +275,9 @@ func request(action string, attributes map[string]any) []byte { "message": map[string]any{"contract": map[string]any{ "id": "contract-1", "commitments": []any{map[string]any{ - "status": map[string]any{"descriptor": map[string]any{"code": "DRAFT"}}, + "status": map[string]any{"descriptor": map[string]any{"code": "ACTIVE"}}, "resources": []any{map[string]any{"id": "res:pmfby:grievance"}}, - "offer": map[string]any{"id": "offer:pmfby:grievance", "provider": map[string]any{"id": "pmfby"}}, + "offer": map[string]any{"id": "off:pmfby:grievance", "provider": map[string]any{"id": "pmfby"}}, "commitmentAttributes": attributes, }}, }}, @@ -324,17 +315,10 @@ func support(fields map[string]any) []byte { return raw } -func initAttributes() map[string]any { return map[string]any{"applicantPhone": phone} } - -// challenge is the OTP as a support request carries it. -func challenge(otp string) map[string]any { - return map[string]any{"method": "SMS_OTP", "value": otp} -} - func supportFields() map[string]any { return map[string]any{ "orderId": "040108251010160770605", "categoryCode": "3", "subCategoryCode": "10", - "description": " Claim not received ", "applicantPhone": phone, "challenge": challenge(validOTP), + "description": " Claim not received ", "applicantPhone": phone, "cropYear": "2025", "season": "Kharif", } } @@ -343,15 +327,27 @@ func statusAttributes() map[string]any { return map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": knownTicket}} } -// assertCoded fails unless err is a CodedErr with this status and code. -func assertCoded(t *testing.T, err error, status int, code string) { +// refusal is the HTTP status and Beckn error a failed request is answered +// with, whichever of the two step error types carries it. +func refusal(t *testing.T, err error) (int, *model.Error) { t.Helper() + var invalid *model.SchemaValidationErr var coded *model.CodedErr - if !errors.As(err, &coded) { - t.Fatalf("error = %v, want a coded %d %s", err, status, code) + switch { + case errors.As(err, &invalid): + return http.StatusBadRequest, invalid.BecknError() + case errors.As(err, &coded): + return coded.HTTPStatus(), coded.BecknError() } - if coded.HTTPStatus() != status || coded.BecknError().Code != code { - t.Errorf("error = %d %s (%v), want %d %s", coded.HTTPStatus(), coded.BecknError().Code, err, status, code) + t.Fatalf("error = %v (%T), want a refusal", err, err) + return 0, nil +} + +// assertCoded fails unless err is answered with this status and code. +func assertCoded(t *testing.T, err error, status int, code string) { + t.Helper() + if got, refused := refusal(t, err); got != status || refused.Code != code { + t.Errorf("error = %d %s (%v), want %d %s", got, refused.Code, err, status, code) } } @@ -360,100 +356,18 @@ func todayIST() string { return time.Now().In(time.FixedZone("IST", 5*3600+1800)).Format("2006-01-02") } -// --- init ------------------------------------------------------------------ - -func TestInit_ValidPhone_ReturnsMaskedOTPChallenge(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - before := time.Now() - got := h.mustSend(t, request("init", initAttributes())) - - if sent := h.pmfby.bodies[sendOTPPath]; len(sent) != 1 || sent["requestorMobileNo"] != phone { - t.Errorf("PMFBY was sent %v, want requestorMobileNo alone", sent) - } - issued, _ := got.attributes()["challengeIssued"].(map[string]any) - if issued["method"] != "SMS_OTP" || issued["sentTo"] != "98XXXXXX10" { - t.Errorf("challengeIssued = %v, want SMS_OTP sent to 98XXXXXX10", issued) - } - // Ten minutes from the call, the validity the adapter derives. - expiresAt, err := time.Parse(time.RFC3339, fmt.Sprint(issued["expiresAt"])) - if err != nil || expiresAt.Before(before.Add(10*time.Minute-time.Second)) || - expiresAt.After(time.Now().Add(10*time.Minute+time.Second)) { - t.Errorf("expiresAt = %v, want ten minutes from now", issued["expiresAt"]) - } - if got.Context["action"] != "on_init" || got.status() != "DRAFT" { - t.Errorf("action %v, status %v; want on_init, DRAFT", got.Context["action"], got.status()) - } - if got.attributes()["informationMode"] != "OnDemand" || got.attributes()["scheme"] == nil { - t.Errorf("commitmentAttributes = %v, want OnDemand and the scheme echoed", got.attributes()) - } - if _, echoed := got.attributes()["applicantPhone"]; echoed { - t.Error("applicantPhone must not be echoed") - } -} - -func TestInit_Answer_EchoesContextContractAndType(t *testing.T) { - got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("init", initAttributes())) - - if got.Context["transactionId"] != "txn-1" || got.Context["messageId"] != "msg-1" || got.Context["timestamp"] == nil { - t.Errorf("context = %v, want the request's ids and a fresh timestamp", got.Context) - } - if got.Message.Contract.ID != "contract-1" { - t.Errorf("contract id = %q, want contract-1 carried across the flow", got.Message.Contract.ID) - } - if got.attributes()["@type"] != "openagrinet:PMFBYGrievance" || got.attributes()["@context"] == nil { - t.Errorf("commitmentAttributes = %v, want @type and @context echoed", got.attributes()) - } - if got.Message.Contract.Commitments[0].Offer["id"] != "offer:pmfby:grievance" { - t.Errorf("offer = %v, want the request's offer", got.Message.Contract.Commitments[0].Offer) - } -} - -func TestInit_MissingPhone_Returns400WithoutCallingPMFBY(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - _, err := h.send(t, request("init", map[string]any{})) - assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") - if h.pmfby.calls[sendOTPPath] != 0 { - t.Error("PMFBY was called for a request missing a required field") - } -} - -func TestInit_NumericPhone_Returns400WithoutSendingOTP(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - _, err := h.send(t, request("init", map[string]any{"applicantPhone": 9876543210})) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if h.pmfby.calls[sendOTPPath] != 0 { - t.Error("SendOTP was called for a phone the reply cannot mask") - } -} - -func TestInit_PortalDown_Returns502AfterOneAttempt(t *testing.T) { - h := newHarness(t, &pmfbyStub{down: true}, 0) - _, err := h.send(t, request("init", initAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) - if got := h.pmfby.calls[sendOTPPath]; got != 1 { - t.Errorf("SendOTP called %d times, want 1: a retry sends a second OTP", got) - } -} - -func TestInit_LoginRejected_Returns502WithoutSendingOTP(t *testing.T) { - h := newHarness(t, &pmfbyStub{loginDown: true}, 0) - _, err := h.send(t, request("init", initAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) - if h.pmfby.calls[sendOTPPath] != 0 { - t.Error("SendOTP was called without a token") - } -} - // --- support --------------------------------------------------------------- -func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { +func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) got := h.mustSend(t, support(supportFields())) + // No OTP: PMFBY's grievance service asks for none. Every value goes as a + // string, the season as its code. want := map[string]any{ - "requestorMobileNo": phone, "otp": validOTP, "applicationNo": "040108251010160770605", - "requestYear": "2025", "requestSeason": 1.0, "ticketCategoryID": 3.0, "ticketSubCategoryID": 10.0, - "grievenceDescription": "Claim not received", "complaintDate": todayIST(), "receiptSourceID": 134306.0, + "requestorMobileNo": phone, "applicationNo": "040108251010160770605", + "requestYear": "2025", "requestSeason": "1", "ticketCategoryID": "3", "ticketSubCategoryID": "10", + "grievenceDescription": "Claim not received", "complaintDate": todayIST(), "receiptSourceID": "134306", } sent := h.pmfby.bodies[insertPath] for field, value := range want { @@ -465,8 +379,8 @@ func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { t.Errorf("PMFBY was sent %d fields (%v), want exactly %d", len(sent), sent, len(want)) } - if got.Context["action"] != "on_support" { - t.Errorf("action = %v, want on_support", got.Context["action"]) + if got.Context["action"] != "on_support" || got.Context["transactionId"] != "txn-1" { + t.Errorf("context = %v, want on_support on the request's transaction", got.Context) } // orderId and the grievance are the caller's own, echoed unchanged. channel := got.channel() @@ -482,7 +396,7 @@ func TestSupport_ValidOTP_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { channel["informationMode"] != "Direct" || channel["scheme"] == nil { t.Errorf("channel = %v", channel) } - for _, private := range []string{"challenge", "applicantPhone", "ticketId"} { + for _, private := range []string{"applicantPhone", "ticketId"} { if _, echoed := channel[private]; echoed { t.Errorf("%s must not be echoed", private) } @@ -509,7 +423,7 @@ func TestSupport_NumericCropYear_SentAsString(t *testing.T) { } func TestSupport_EachSeason_SendsItsCode(t *testing.T) { - for season, code := range map[string]float64{"Kharif": 1, "Rabi": 2, "Zaid": 3} { + for season, code := range map[string]string{"Kharif": "1", "Rabi": "2", "Zaid": "3"} { t.Run(season, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() @@ -522,8 +436,8 @@ func TestSupport_EachSeason_SendsItsCode(t *testing.T) { } } -func TestSupport_CategoryCodes_SentAsNumbers(t *testing.T) { - for codes, want := range map[[2]string][2]float64{{"3", "10"}: {3, 10}, {"12", "5"}: {12, 5}, {"1", "1"}: {1, 1}} { +func TestSupport_CategoryCodes_SentAsGiven(t *testing.T) { + for codes, want := range map[[2]string][2]string{{"3", "10"}: {"3", "10"}, {"12", "5"}: {"12", "5"}, {"1", "1"}: {"1", "1"}} { t.Run(codes[0]+"/"+codes[1], func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() @@ -537,15 +451,23 @@ func TestSupport_CategoryCodes_SentAsNumbers(t *testing.T) { } } -func TestSupport_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"orderId", "categoryCode", "subCategoryCode", "description", - "applicantPhone", "challenge", "cropYear", "season"} { +func TestSupport_MissingField_Returns400NamingItsPath(t *testing.T) { + channel := "$.message.support.channels[0]." + for field, path := range map[string]string{ + "orderId": "$.message.support.orderId", "applicantPhone": channel + "applicantPhone", + "cropYear": channel + "cropYear", "season": channel + "season", + "categoryCode": channel + "grievance.category.code", "subCategoryCode": channel + "grievance.subCategory.code", + "description": channel + "grievance.description", + } { t.Run(field, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() delete(fields, field) _, err := h.send(t, support(fields)) assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") + if _, refused := refusal(t, err); refused.Details == nil || refused.Details.Path != path { + t.Errorf("details = %+v, want path %s", refused.Details, path) + } if h.pmfby.calls[insertPath] != 0 { t.Error("PMFBY was called for a request missing a required field") } @@ -595,25 +517,61 @@ func TestSupport_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { } } -// PMFBY refusing the lodge call with a 400 -- the stub's wrong OTP -- is a wrong -// OTP to the caller, and the lodge call is not repeated. -func TestSupport_PortalAnswers400_Returns400BizGenericError(t *testing.T) { - fields := supportFields() - fields["challenge"] = challenge("000000") +// complaintDate and receiptSourceId are optional: sent when the caller has +// them, and filedOn restates the date that went. +func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - _, err := h.send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "BIZ_GENERIC_ERROR") - if strings.Contains(err.Error(), "Invalid OTP") { - t.Errorf("error %q leaks PMFBY's body", err) + fields := supportFields() + fields["complaintDate"], fields["receiptSourceId"] = "2026-09-20", "200001" + got := h.mustSend(t, support(fields)) + sent := h.pmfby.bodies[insertPath] + if sent["complaintDate"] != "2026-09-20" || sent["receiptSourceID"] != "200001" { + t.Errorf("PMFBY was sent %v / %v, want the caller's date and source", sent["complaintDate"], sent["receiptSourceID"]) } - if got := h.pmfby.calls[insertPath]; got != 1 { - t.Errorf("the lodge call was made %d times, want 1", got) + if filed := band(got.channel(), "case")["filedOn"]; filed != "2026-09-20" { + t.Errorf("filedOn = %v, want the date that was sent", filed) + } +} + +func TestSupport_MalformedDateOrSource_Returns400WithoutCallingPMFBY(t *testing.T) { + for name, field := range map[string][2]string{ + "date": {"complaintDate", "20-09-2026"}, "source": {"receiptSourceId", "web"}, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + fields[field[0]] = field[1] + _, err := h.send(t, support(fields)) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[insertPath] != 0 { + t.Errorf("PMFBY was called with %s %q", field[0], field[1]) + } + }) } } -// Any other 4xx is PMFBY's refusal, not the farmer's OTP. -func TestSupport_PortalAnswersOther4xx_Returns502(t *testing.T) { - for _, status := range []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} { +// PMFBY answers its own refusal with HTTP 200 and a responseCode other than +// "1". That filed nothing, so it is never reported as Registered. +func TestSupport_PortalDidNotRegister_Returns502(t *testing.T) { + for name, reply := range map[string]string{ + "refused": `{"responseCode":"0","responseMessage":"Invalid application number","responseDynamic":null}`, + "success, no ticket": `{"responseCode":"1","responseDynamic":{}}`, + "refused, ticket": `{"responseCode":"0","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: reply}}, 0) + _, err := h.send(t, support(supportFields())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + if strings.Contains(err.Error(), "Invalid application") { + t.Errorf("error %q leaks PMFBY's message", err) + } + }) + } +} + +// A 4xx is PMFBY's refusal: no OTP is involved, so nothing is the farmer's to fix. +func TestSupport_PortalAnswers4xx_Returns502(t *testing.T) { + for _, status := range []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} { t.Run(http.StatusText(status), func(t *testing.T) { h := newHarness(t, &pmfbyStub{statusFor: map[string]int{insertPath: status}}, 0) _, err := h.send(t, support(supportFields())) @@ -625,7 +583,6 @@ func TestSupport_PortalAnswersOther4xx_Returns502(t *testing.T) { } } -// A 400 on another action is not an OTP error: only support says so. func TestStatus_PortalAnswers400_Returns502(t *testing.T) { h := newHarness(t, &pmfbyStub{statusFor: map[string]int{statusPath: http.StatusBadRequest}}, 0) _, err := h.send(t, request("status", statusAttributes())) @@ -707,7 +664,7 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { } filed := band(attributes, "case") if attributes["enrolmentId"] != "040108251010160770605" || attributes["informationMode"] != "Direct" || - filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" { + filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" || filed["cropName"] != "Paddy" { t.Errorf("commitmentAttributes = %v", attributes) } // An unrecognised portal phrase is UnderReview, the phrase kept as the name. @@ -725,7 +682,7 @@ func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { "": {"@context": true, "@type": true, "informationMode": true, "scheme": true, "enrolmentId": true, "grievance": true, "case": true}, "grievance": {"category": true, "subCategory": true, "description": true}, - "case": {"ticketNo": true, "status": true, "filedOn": true}, + "case": {"ticketNo": true, "status": true, "filedOn": true, "cropName": true}, } attributes := got.attributes() for name, fields := range allowed { @@ -767,6 +724,7 @@ func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "grievance", "description"}, "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "case", "filedOn"}, "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "", "enrolmentId"}, + "empty crop": {`"CropName":"Paddy"`, `"CropName":""`, "case", "cropName"}, } { t.Run(name, func(t *testing.T) { record := strings.Replace(knownTicketRecord, tc.from, tc.to, 1) @@ -800,6 +758,13 @@ func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { } } +func TestStatus_PortalRefused_Returns502(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{ + statusPath: `{"responseCode":"0","responseMessage":"Session expired"}`}}, 0) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) +} + func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { for _, field := range []string{"applicantPhone", "case"} { t.Run(field, func(t *testing.T) { @@ -826,9 +791,8 @@ func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { // --- across the flow ------------------------------------------------------- -func TestFlow_InitSupportStatus_LogsInOnce(t *testing.T) { +func TestFlow_SupportThenStatus_LogsInOnce(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - h.mustSend(t, request("init", initAttributes())) ticket := band(h.mustSend(t, support(supportFields())).channel(), "case")["ticketNo"] got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": ticket}})) @@ -836,30 +800,35 @@ func TestFlow_InitSupportStatus_LogsInOnce(t *testing.T) { t.Errorf("status of the filed ticket = %v", got.attributes()) } if logins := h.pmfby.calls[loginPath]; logins != 1 { - t.Errorf("logged in %d times over three calls, want 1", logins) + t.Errorf("logged in %d times over two calls, want 1", logins) } } func TestFlow_AnswerNotJSON_ReturnsErrorWithoutMapping(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{sendOTPPath: `maintenance`}}, 0) - got, err := h.send(t, request("init", initAttributes())) + h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: `maintenance`}}, 0) + got, err := h.send(t, support(supportFields())) if err == nil || got != nil { t.Errorf("Run() = %v, %v; want an error and no answer", got, err) } } +// init included: PMFBY publishes no challenge, so there is no init to serve. func TestFlow_UnservedAction_Returns400WithoutCallingPMFBY(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - _, err := h.send(t, request("select", initAttributes())) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if len(h.pmfby.calls) != 0 { - t.Errorf("PMFBY was called %v for an action it does not serve", h.pmfby.calls) + for _, action := range []string{"init", "select"} { + t.Run(action, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + _, err := h.send(t, request(action, statusAttributes())) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if len(h.pmfby.calls) != 0 { + t.Errorf("PMFBY was called %v for an action it does not serve", h.pmfby.calls) + } + }) } } func TestFlow_OtherCapability_PassesThroughUntouched(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) - body := strings.Replace(string(request("init", initAttributes())), + body := strings.Replace(string(request("status", statusAttributes())), "openagrinet:PMFBYGrievance", "openagrinet:PMKISANGrievance", 1) got, err := h.send(t, []byte(body)) if err != nil || got != nil { diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 1a8e77f4a..82f499cf9 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -2571,18 +2571,35 @@ func refusalStep(t *testing.T, mapper *stubMapper, calls *int) *Step { return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) } +// refusedAs reads the HTTP status and Beckn error a refusal is answered with, +// whichever of the two step error types carries it. +func refusedAs(t *testing.T, err error) (int, *model.Error) { + t.Helper() + var invalid *model.SchemaValidationErr + var coded *model.CodedErr + switch { + case errors.As(err, &invalid): + return http.StatusBadRequest, invalid.BecknError() + case errors.As(err, &coded): + return coded.HTTPStatus(), coded.BecknError() + } + t.Fatalf("error = %v (%T), want a refusal", err, err) + return 0, nil +} + func TestRefusal_RequestHalf_RefusesWithoutCallingProvider(t *testing.T) { var calls int mapper := &stubMapper{ - requestResult: []byte(`{"_error":{"status":400,"code":"SCH_REQUIRED_FIELD_MISSING","message":"phone is required"}}`), + requestResult: []byte(`{"_error":{"status":400,"code":"SCH_REQUIRED_FIELD_MISSING", + "message":"phone is required","path":"$.message.phone"}}`), responseResult: []byte(`{"a":1}`), } _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || - coded.BecknError().Code != "SCH_REQUIRED_FIELD_MISSING" { - t.Fatalf("error = %v, want 400 SCH_REQUIRED_FIELD_MISSING", err) + status, refused := refusedAs(t, err) + if status != http.StatusBadRequest || refused.Code != "SCH_REQUIRED_FIELD_MISSING" || + refused.Message != "phone is required" || refused.Details == nil || refused.Details.Path != "$.message.phone" { + t.Fatalf("answer = %d %+v, want 400 SCH_REQUIRED_FIELD_MISSING at $.message.phone", status, refused) } if calls != 0 { t.Errorf("provider called %d times for a refused request, want 0", calls) @@ -2603,9 +2620,8 @@ func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":` + tc.refusal + `}`)} stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != tc.status || coded.BecknError().Code != tc.code { - t.Fatalf("error = %v, want %d %s", err, tc.status, tc.code) + if status, refused := refusedAs(t, err); status != tc.status || refused.Code != tc.code { + t.Fatalf("answer = %d %+v, want %d %s", status, refused, tc.status, tc.code) } if len(stepCtx.ResponseBody) != 0 { t.Errorf("a refusal produced a response body: %s", stepCtx.ResponseBody) @@ -2653,52 +2669,3 @@ func TestRefusal_NoErrorField_AnswersAsBefore(t *testing.T) { t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) } } - -// statusStep is a step whose provider answers every call with status and body. -func statusStep(t *testing.T, mapper *stubMapper, status int, body string, calls *int) *Step { - t.Helper() - provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - *calls++ - w.WriteHeader(status) - fmt.Fprint(w, body) - })) - t.Cleanup(provider.Close) - return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, mapper) -} - -func TestExplainRefusal_Provider4xxMappedToError_ReturnsIt(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), - responseResult: []byte(`{"_error":{"status":400,"code":"BIZ_GENERIC_ERROR","message":"wrong OTP"}}`)} - _, err := runStep(t, statusStep(t, mapper, http.StatusBadRequest, `{"reason":"x"}`, &calls), selectBody) - - var coded *model.CodedErr - if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadRequest || coded.BecknError().Code != "BIZ_GENERIC_ERROR" { - t.Fatalf("error = %v, want the mapping's 400 BIZ_GENERIC_ERROR", err) - } - input, _ := mapper.responseInput.(map[string]any) - if input["_status"] != http.StatusBadRequest { - t.Errorf("_status = %v, want the provider's 400", input["_status"]) - } - if calls != 1 { - t.Errorf("provider called %d times, want 1", calls) - } -} - -func TestExplainRefusal_Provider4xxNotRecognised_Returns502(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} - _, err := runStep(t, statusStep(t, mapper, http.StatusNotFound, `not json`, &calls), selectBody) - assertStatus(t, err, http.StatusBadGateway) -} - -func TestExplainRefusal_Provider5xx_NotShownToTheMapping(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), - responseResult: []byte(`{"_error":{"status":400,"code":"X","message":"would hide the outage"}}`)} - _, err := runStep(t, statusStep(t, mapper, http.StatusServiceUnavailable, ``, &calls), selectBody) - assertStatus(t, err, http.StatusBadGateway) - if mapper.responseInput != nil { - t.Error("the response half was run for a 5xx; only a 4xx is the request's to explain") - } -} diff --git a/pkg/plugin/implementation/internal/common/http.go b/pkg/plugin/implementation/internal/common/http.go index 26fdd6cfc..33884f4d0 100644 --- a/pkg/plugin/implementation/internal/common/http.go +++ b/pkg/plugin/implementation/internal/common/http.go @@ -150,24 +150,13 @@ func (s *Step) attempt(ctx context.Context, auth *authenticator, call model.Acti s.forgetToken(auth) } + err := fmt.Errorf("provider returned %s", resp.Status) // 5xx and 429 ask to be tried again. Every other 4xx is a statement - // about the request, which will not improve -- and which the mapping - // may know the meaning of, so it keeps its status and body. + // about the request, which will not improve. if resp.StatusCode < http.StatusInternalServerError && resp.StatusCode != http.StatusTooManyRequests { - return nil, util.DoNotRetry(&providerRefusal{status: resp.StatusCode, text: resp.Status, body: body}) + return nil, util.DoNotRetry(err) } - return nil, fmt.Errorf("provider returned %s", resp.Status) + return nil, err } return body, nil } - -// providerRefusal is a 4xx the provider answered with. It is still a failure, -// but the response half is shown its status and body, so a mapping can say what -// it means -- a wrong OTP -- rather than the generic 502. -type providerRefusal struct { - status int - text string - body []byte -} - -func (e *providerRefusal) Error() string { return "provider returned " + e.text } diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 5ac310649..f09c1cf00 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -131,7 +131,7 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { upstreamResponse, err := s.call(ctx, auth, plan.BaseURL, call, upstreamRequest) if err != nil { - return s.explainRefusal(ctx, plan.BindingKey, call, beckn, local, err) + return err } answer, err := decodeBody(upstreamResponse) @@ -169,36 +169,6 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return nil } -// explainRefusal lets the response half say what a provider's 4xx means. -// -// The half is run with the status under _status and the body under response. -// Only an _error it returns is used. A mapping that does not recognise the -// status, or cannot read the body, leaves the original 502 standing, so -// capabilities whose mappings ignore _status behave as before. -func (s *Step) explainRefusal(ctx context.Context, bindingKey string, call model.ActionPlan, - beckn any, local map[string]any, err error) error { - var refused *providerRefusal - if !errors.As(err, &refused) { - return err - } - // Not JSON is read as nothing: the status alone may be enough. - answer, _ := decodeBody(refused.body) - mapped, mapErr := s.mapper.Transform(ctx, call.Mappings, definition.DirectionResponse, map[string]any{ - "beckn": beckn, - "_local": local, - "response": answer, - "_status": refused.status, - }) - if mapErr != nil { - return err - } - if explained := refusalIn(mapped); explained != nil { - log.Warnf(ctx, "%s refused the request: %v", bindingKey, explained) - return explained - } - return err -} - // buildRequest produces what the provider is sent. // // Whatever the mapping produces IS the request: a body for a method that takes @@ -224,8 +194,9 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an } // refusalIn returns the error a mapping half asks for under the reserved -// _error field -- {status, code, message}, status absent meaning 400 -- or nil -// when the document carries none. 202 is an answer with nothing in it, so it +// _error field -- {status, code, message, path}, status absent meaning 400 -- +// or nil when the document carries none. path is the JSONPath of the field at +// fault, returned as details.path. 202 is an answer with nothing in it, so it // is an ACK carrying the reason. func refusalIn(mapped []byte) error { var document map[string]json.RawMessage @@ -236,13 +207,15 @@ func refusalIn(mapped []byte) error { Status int `json:"status"` Code string `json:"code"` Message string `json:"message"` + Path string `json:"path"` } if json.Unmarshal(document["_error"], &r) != nil || r.Message == "" { return errors.New("a mapping refused the call with an unreadable _error; it needs status, code and message") } switch r.Status { case 0, http.StatusBadRequest: - return model.NewBadReqErr(r.Code, errors.New(r.Message)) + // The one 400 that carries a path on the wire: CodedErr drops details. + return &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} case http.StatusAccepted: return model.NewAckNoCallbackErr(model.StatusACK, &model.Error{Code: r.Code, Message: r.Message}) default: From 3141dfce3d927d777a73dff2deeeb38a501981e7 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 8 Oct 2026 16:30:24 +0530 Subject: [PATCH 16/39] fix: let other capabilities' support requests pass the fallback and decode each payload once [#57] --- .../implementation/internal/common/binding.go | 6 +++ .../implementation/internal/common/serve.go | 40 ++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/pkg/plugin/implementation/internal/common/binding.go b/pkg/plugin/implementation/internal/common/binding.go index cb804a8e6..65513bb62 100644 --- a/pkg/plugin/implementation/internal/common/binding.go +++ b/pkg/plugin/implementation/internal/common/binding.go @@ -48,6 +48,12 @@ func BindingFrom(paths Paths, body []byte) (Binding, error) { if err := json.Unmarshal(body, &payload); err != nil { return Binding{}, fmt.Errorf("payload could not be read: %w", err) } + return bindingIn(paths, payload) +} + +// bindingIn is BindingFrom over a payload already decoded, so a step trying two +// sets of paths reads the body once. +func bindingIn(paths Paths, payload any) (Binding, error) { // Checked before distinctness: N commitments naming the SAME provider and // type collapse to one key, so they would pass unnoticed and the mapping diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index f09c1cf00..036a20a6e 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -3,6 +3,7 @@ package common import ( + "bytes" "context" "encoding/json" "errors" @@ -23,17 +24,28 @@ import ( // recognises its own work, so adding a provider is one config entry rather than // a routing-table change. func (s *Step) Run(ctx *model.StepContext) error { - binding, err := BindingFrom(s.paths, ctx.Body) + var payload any + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + // Unclassified it becomes a 500, which blames this adapter and hides + // the reason from the caller. + return model.NewBadReqErr("", fmt.Errorf("payload could not be read: %w", err)) + } + binding, err := bindingIn(s.paths, payload) if errors.Is(err, errNoBinding) && s.fallback != nil { - binding, err = BindingFrom(*s.fallback, ctx.Body) + binding, err = bindingIn(*s.fallback, payload) + // The fallback reads a container other capabilities use too, so a + // payload it cannot resolve is refused only when it names this step's + // own work; anyone else's passes through. + if err != nil && !errors.Is(err, errNoBinding) && !s.namesServed(*s.fallback, payload) { + return nil + } } if errors.Is(err, errNoBinding) { return nil } if err != nil { - // Everything BindingFrom refuses is about the payload -- unreadable - // JSON, or more than one call named. Unclassified it becomes a 500, - // which blames this adapter and hides the reason from the caller. + // Everything bindingIn refuses is about the payload -- more than one + // call named. Unclassified it becomes a 500, as above. return model.NewBadReqErr("", err) } if !s.serves(binding.Key()) { @@ -86,6 +98,19 @@ func (s *Step) serves(key string) bool { return slices.Contains(s.config.BindingKeys, key) } +// namesServed reports whether any provider and type the paths reach pair into +// a binding this step serves. +func (s *Step) namesServed(paths Paths, payload any) bool { + for _, provider := range ValuesAt(payload, paths.ProviderID) { + for _, capability := range ValuesAt(payload, paths.CapabilityCode) { + if s.serves(Binding{ParticipantID: provider, CapabilityCode: capability}.Key()) { + return true + } + } + } + return false +} + // serve runs the exchange this step exists for: resolve, map out, call, map back. func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { action := extractAction(ctx.Body) @@ -199,6 +224,11 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an // fault, returned as details.path. 202 is an answer with nothing in it, so it // is an ACK carrying the reason. func refusalIn(mapped []byte) error { + // Checked as text first: every answer passes through here, and almost + // none carries the field, so most are spared a second decode. + if !bytes.Contains(mapped, []byte(`"_error"`)) { + return nil + } var document map[string]json.RawMessage if json.Unmarshal(mapped, &document) != nil || document["_error"] == nil { return nil From 6e390a2ce81c9a7af74667fd01130983f52e178c Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 8 Oct 2026 16:30:30 +0530 Subject: [PATCH 17/39] fix: check the PMFBY phone and description and date a lodge once [#57] --- config/mappings/pmfby/grievance.status.yaml | 114 ++++++++++-------- config/mappings/pmfby/grievance.support.yaml | 29 ++++- .../implementation/Grievance/mappings_test.go | 97 ++++++++++++++- 3 files changed, 186 insertions(+), 54 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 201edf1b8..7f4f592c9 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -7,8 +7,19 @@ # The response is an allow-list. PMFBY's record also carries the farmer's name, # state, district and insurer; none of it is mapped. -# A missing field is refused by the request half under _error, which can name -# SCH_REQUIRED_FIELD_MISSING and the field's path. +# required holds format checks, which return SCH_INVALID_FORMAT and let an +# absent field pass. A missing field is refused by the request half under +# _error, which can name SCH_REQUIRED_FIELD_MISSING and the field's path. +required: + # A ten-digit Indian mobile, as text: PMFBY files the ticket against it and + # matches the read on it, so a number or a mangled one is refused here. + - check: | + ( + $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; + $not($exists($phone)) or ($type($phone) = "string" ? $exists($match($phone, /^[6-9][0-9]{9}$/)[0]) : false) + ) + message: "applicantPhone must be a ten-digit mobile number, as text" + request: | ( $ca := beckn.message.contract.commitments[0].commitmentAttributes; @@ -34,53 +45,60 @@ request: | # left out. The category comes back by name alone, and the ticket number not at # all, so the one the caller asked with is echoed. response: | - $string(response.responseCode) != "1" - ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY could not read the grievance" } } - : $type(response.responseDynamic) != "object" - ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", - "message": "No grievance matches this ticket and phone number." } } - : ( - $commitment := beckn.message.contract.commitments[0]; - $ca := $commitment.commitmentAttributes; - $ticket := response.responseDynamic; - /* PMFBY may send null or "" for a field it has no value for; such a - field is dropped rather than mapped as "null" or failing the call. */ - $isText := function($v) { $type($v) = "string" and $v != "" }; - { - "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), - "message": { - "contract": $merge([beckn.message.contract, { - "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "ACTIVE" } }, - "commitmentAttributes": { - "@context": $ca.`@context`, - "@type": $ca.`@type`, - "informationMode": "Direct", - "scheme": $ca.scheme, - "enrolmentId": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, - "grievance": { - "category": $isText($ticket.TicketCategoryName) ? { "name": $ticket.TicketCategoryName }, - "subCategory": $isText($ticket.TicketSubCategoryName) ? { "name": $ticket.TicketSubCategoryName }, - "description": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription - }, - "case": { - "ticketNo": $ca.case.ticketNo, - /* code is the network's word, name the portal's phrase. PMFBY - publishes no status vocabulary, so every phrase is one the - adapter does not recognise and maps to UnderReview, which is - never a closed state. Recognised phrases are added here once - PMFBY confirms them. */ - "status": { - "code": "UnderReview", - "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus + ( + /* Tested as equal to "1", not unequal: a reply with no responseCode at + all is a failure, and an unequal test against a missing value is false. + The record is read as the first of the reply's, so one ticket sent as a + list of one reads the same as one sent bare. */ + $ok := $string(response.responseCode) = "1"; + $ticket := response.responseDynamic[0]; + $not($ok) + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PMFBY could not read the grievance" } } + : $type($ticket) != "object" + ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", + "message": "No grievance matches this ticket and phone number." } } + : ( + $commitment := beckn.message.contract.commitments[0]; + $ca := $commitment.commitmentAttributes; + /* PMFBY may send null or "" for a field it has no value for; such a + field is dropped rather than mapped as "null" or failing the call. */ + $isText := function($v) { $type($v) = "string" and $v != "" }; + { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": "ACTIVE" } }, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "informationMode": "Direct", + "scheme": $ca.scheme, + "enrolmentId": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, + "grievance": { + "category": $isText($ticket.TicketCategoryName) ? { "name": $ticket.TicketCategoryName }, + "subCategory": $isText($ticket.TicketSubCategoryName) ? { "name": $ticket.TicketSubCategoryName }, + "description": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription }, - "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, - "cropName": $isText($ticket.CropName) ? $ticket.CropName + "case": { + "ticketNo": $ca.case.ticketNo, + /* code is the network's word, name the portal's phrase. PMFBY + publishes no status vocabulary, so every phrase is one the + adapter does not recognise and maps to UnderReview, which is + never a closed state. Recognised phrases are added here once + PMFBY confirms them. */ + "status": { + "code": "UnderReview", + "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus + }, + "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, + "cropName": $isText($ticket.CropName) ? $ticket.CropName + } } - } - }])] - }]) + }])] + }]) + } } - } + ) ) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index edc64daea..82f554615 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -12,6 +12,22 @@ # absent field pass. A missing field is refused by the request half under # _error, which can name SCH_REQUIRED_FIELD_MISSING and the field's path. required: + # A ten-digit Indian mobile, as text: PMFBY files the ticket against it and + # matches the read on it, so a number or a mangled one is refused here. + - check: | + ( + $phone := beckn.message.support.channels[0].applicantPhone; + $not($exists($phone)) or ($type($phone) = "string" ? $exists($match($phone, /^[6-9][0-9]{9}$/)[0]) : false) + ) + message: "applicantPhone must be a ten-digit mobile number, as text" + # Text with something in it: a blank complaint would be filed and reported + # as Registered. + - check: | + ( + $d := beckn.message.support.channels[0].grievance.description; + $not($exists($d)) or ($type($d) = "string" ? $exists($match($d, /\S/)[0]) : false) + ) + message: "grievance.description must be text that is not blank" # PMFBY reads a non-numeric id as 0 and files the ticket under the wrong head # while reporting success, so the codes are checked before they are sent. - check: | @@ -52,6 +68,11 @@ request: | { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } ][$not(present)][0]; $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; + /* Today in IST, from the request's own timestamp so the response half + arrives at the same date. */ + $today := $exists(beckn.context.timestamp) + ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") + : $now("[Y0001]-[M01]-[D01]", "+0530"); /* The adapter's channel id with PMFBY, used unless the caller has its own. */ $receiptSourceID := "134306"; $missing @@ -65,7 +86,7 @@ request: | "ticketCategoryID": $string($g.category.code), "ticketSubCategoryID": $string($g.subCategory.code), "grievenceDescription": $trim($g.description), - "complaintDate": $exists($ch.complaintDate) ? $ch.complaintDate : $now("[Y0001]-[M01]-[D01]", "+0530"), + "complaintDate": $exists($ch.complaintDate) ? $ch.complaintDate : $today, "receiptSourceID": $exists($ch.receiptSourceId) ? $string($ch.receiptSourceId) : $receiptSourceID } ) @@ -81,6 +102,10 @@ response: | $s := beckn.message.support; $ch := $s.channels[0]; $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; + /* The same date the request half sent as complaintDate. */ + $today := $exists(beckn.context.timestamp) + ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") + : $now("[Y0001]-[M01]-[D01]", "+0530"); /* PMFBY says success with responseCode "1", seen as a string and as a number. Anything else, or a success with no ticket, filed nothing. */ $registered := $string(response.responseCode) = "1" @@ -103,7 +128,7 @@ response: | "case": { "ticketNo": $string($ticketNo), "status": { "code": "Registered" }, - "filedOn": $exists($ch.complaintDate) ? $ch.complaintDate : $now("[Y0001]-[M01]-[D01]", "+0530") + "filedOn": $exists($ch.complaintDate) ? $ch.complaintDate : $today } }] } diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 20557efde..532376b62 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -550,6 +550,48 @@ func TestSupport_MalformedDateOrSource_Returns400WithoutCallingPMFBY(t *testing. } } +func TestSupport_MalformedPhoneOrDescription_Returns400WithoutCallingPMFBY(t *testing.T) { + for name, field := range map[string]struct { + key string + value any + }{ + "phone as a number": {"applicantPhone", 9876543210}, + "phone too short": {"applicantPhone", "98765"}, + "phone not a mobile": {"applicantPhone", "1234567890"}, + "description a number": {"description", 123}, + "description blank": {"description", " "}, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + fields[field.key] = field.value + _, err := h.send(t, support(fields)) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[insertPath] != 0 { + t.Errorf("PMFBY was called with %s %v", field.key, field.value) + } + }) + } +} + +// With no complaintDate, the date is the request's own, in IST, on both the +// lodge call and filedOn: a call crossing midnight cannot report a different day. +func TestSupport_NoComplaintDate_DatesFromTheRequestInIST(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + payload["context"].(map[string]any)["timestamp"] = "2026-10-07T18:30:00Z" // 00:00 IST on the 8th + raw, _ := json.Marshal(payload) + + got := h.mustSend(t, raw) + if sent := h.pmfby.bodies[insertPath]["complaintDate"]; sent != "2026-10-08" { + t.Errorf("complaintDate = %v, want the IST date of the request, 2026-10-08", sent) + } + if filed := band(got.channel(), "case")["filedOn"]; filed != "2026-10-08" { + t.Errorf("filedOn = %v, want the date that was sent", filed) + } +} + // PMFBY answers its own refusal with HTTP 200 and a responseCode other than // "1". That filed nothing, so it is never reported as Registered. func TestSupport_PortalDidNotRegister_Returns502(t *testing.T) { @@ -631,6 +673,24 @@ func TestSupport_FallbackNotConfigured_PassesThroughUntouched(t *testing.T) { } } +// Two channels is refused only when they name this step's work: another +// capability's support request reaches this step too and must pass through. +func TestSupport_OtherProviderTwoChannels_PassesThrough(t *testing.T) { + var payload map[string]any + _ = json.Unmarshal(support(supportFields()), &payload) + body := payload["message"].(map[string]any)["support"].(map[string]any) + channel := body["channels"].([]any)[0].(map[string]any) + channel["provider"] = map[string]any{"id": "someone-else"} + body["channels"] = append(body["channels"].([]any), channel) + raw, _ := json.Marshal(payload) + + h := newHarness(t, &pmfbyStub{}, 0) + got, err := h.send(t, raw) + if err != nil || got != nil { + t.Errorf("Run() = %v, %v; want another provider's request passed on", got, err) + } +} + func TestSupport_NoProvider_PassesThrough(t *testing.T) { var payload map[string]any _ = json.Unmarshal(support(supportFields()), &payload) @@ -758,11 +818,40 @@ func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { } } +// A reply without FGMS's success code is a failure, never "nothing on file" -- +// including one with no envelope at all. func TestStatus_PortalRefused_Returns502(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{ - statusPath: `{"responseCode":"0","responseMessage":"Session expired"}`}}, 0) - _, err := h.send(t, request("status", statusAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + for name, reply := range map[string]string{ + "refused": `{"responseCode":"0","responseMessage":"Session expired"}`, + "no envelope": `{"message":"An error has occurred."}`, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0) + _, err := h.send(t, request("status", statusAttributes())) + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + }) + } +} + +// One ticket sent as a list of one reads the same as one sent bare. +func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { + record := strings.Replace(strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":[{`, 1), `}}`, `}]}`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if crop := band(got.attributes(), "case")["cropName"]; crop != "Paddy" { + t.Errorf("case = %v, want the record read from the list", band(got.attributes(), "case")) + } +} + +func TestStatus_MalformedPhone_Returns400WithoutCallingPMFBY(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + attributes := statusAttributes() + attributes["applicantPhone"] = 9876543210 + _, err := h.send(t, request("status", attributes)) + assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") + if h.pmfby.calls[statusPath] != 0 { + t.Error("PMFBY was called with a phone number that is not text") + } } func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { From 7ef95fd61db066441b994cfa4530d7727924c620 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 9 Oct 2026 14:48:51 +0530 Subject: [PATCH 18/39] refactor: read a binding key per action instead of through a fallback [#57] A support request names its provider and type on its channel rather than on a contract. Saying where they sit per action, in config, replaces the fallback pair: a later action, or a schema that moves the fields, is a config edit. --- config/provider-adapter.yaml | 12 +-- .../implementation/Grievance/cmd/plugin.go | 12 ++- .../Grievance/cmd/plugin_test.go | 19 ++--- .../implementation/Grievance/mappings_test.go | 15 ++-- .../implementation/internal/common/auth.go | 4 + .../implementation/internal/common/binding.go | 14 +--- .../implementation/internal/common/common.go | 73 +++++++++++++------ .../implementation/internal/common/serve.go | 36 ++++----- 8 files changed, 106 insertions(+), 79 deletions(-) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index dc26b1325..8053d8839 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -519,11 +519,13 @@ modules: providerIdAt: "message.contract.commitments[].offer.provider.id" capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type" - # A support request composes no contract, so it names its provider - # and type on its one channel instead. Tried only when the paths - # above find nothing. Both or neither, like the pair above. - fallbackProviderIdAt: "message.support.channels[].provider.id" - fallbackCapabilityCodeAt: "message.support.channels[].@type" + # Per action, where its payload puts them elsewhere. A support + # request composes no contract, so it names its provider and type + # on its one channel. Every action not listed here reads the pair + # above. Both or neither, like the pair above. + support: + providerIdAt: "message.support.channels[].provider.id" + capabilityCodeAt: "message.support.channels[].@type" # PMFBY issues its own token from its login endpoint # (.../krphapi/FGMS/NICUsersLogin) and reads it back as the bare diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index 7d7a5289b..82d1a65b2 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -33,10 +33,6 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con // this is a default rather than something to set. ProviderIDAt: config["providerIdAt"], CapabilityCodeAt: config["capabilityCodeAt"], - // Where a support request, which composes no contract, names its - // provider and type. - FallbackProviderIDAt: config["fallbackProviderIdAt"], - FallbackCapabilityCodeAt: config["fallbackCapabilityCodeAt"], } if raw, exists := config["maxResponseBytes"]; exists && raw != "" { @@ -60,6 +56,14 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con } cfg.AuthByProvider = auth + // Where an action whose payload composes no contract -- support -- names + // its provider and type, read from a block named for the action. + actionPaths, err := common.ParseActionPaths(config) + if err != nil { + return nil, err + } + cfg.PathsByAction = actionPaths + return cfg, nil } diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go index f16237476..e9dbcd58b 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -32,8 +32,8 @@ const ( providerIDAt = "message.contract.commitments[].offer.provider.id" capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" - fallbackProviderIDAt = "message.support.channels[].provider.id" - fallbackCapabilityCodeAt = "message.support.channels[].@type" + supportProviderIDAt = "message.support.channels[].provider.id" + supportCapabilityCodeAt = "message.support.channels[].@type" ) // pmfbyConfig is the block config/provider-adapter.yaml ships, as the plugin @@ -43,8 +43,8 @@ func pmfbyConfig() map[string]string { "bindingKeys": pmfbyBindingKey, "providerIdAt": providerIDAt, "capabilityCodeAt": capabilityCodeAt, - "fallbackProviderIdAt": fallbackProviderIDAt, - "fallbackCapabilityCodeAt": fallbackCapabilityCodeAt, + "providerIdAt-support": supportProviderIDAt, + "capabilityCodeAt-support": supportCapabilityCodeAt, "authScheme-pmfby": "tokenHeader", "tokenUrl-pmfby": "https://pmfby.example/krphapi/FGMS/NICUsersLogin", "tokenUserField-pmfby": "appAccessUID", @@ -66,9 +66,9 @@ func TestParseConfig_PMFBYBlock_ReadsEverySetting(t *testing.T) { BindingKeys: []string{pmfbyBindingKey}, ProviderIDAt: providerIDAt, CapabilityCodeAt: capabilityCodeAt, - - FallbackProviderIDAt: fallbackProviderIDAt, - FallbackCapabilityCodeAt: fallbackCapabilityCodeAt, + PathsByAction: map[string]common.Paths{ + "support": {ProviderID: supportProviderIDAt, CapabilityCode: supportCapabilityCodeAt}, + }, AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Provider: "pmfby", Scheme: "tokenHeader", TokenURL: "https://pmfby.example/krphapi/FGMS/NICUsersLogin", TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", @@ -139,8 +139,9 @@ func TestNew_InvalidConfig_Refused(t *testing.T) { "no binding keys": func(c map[string]string) { delete(c, "bindingKeys") }, "providerIdAt alone": func(c map[string]string) { delete(c, "capabilityCodeAt") }, "capabilityCodeAt alone": func(c map[string]string) { delete(c, "providerIdAt") }, - "fallback provider alone": func(c map[string]string) { delete(c, "fallbackCapabilityCodeAt") }, - "fallback type alone": func(c map[string]string) { delete(c, "fallbackProviderIdAt") }, + "support provider alone": func(c map[string]string) { delete(c, "capabilityCodeAt-support") }, + "support type alone": func(c map[string]string) { delete(c, "providerIdAt-support") }, + "support path blank segment": func(c map[string]string) { c["providerIdAt-support"] = "message..id" }, "no auth block for pmfby": func(c map[string]string) { delete(c, "authScheme-pmfby") }, "unknown auth scheme": func(c map[string]string) { c["authScheme-pmfby"] = "loginHeader" }, "tokenHeader without ttl": func(c map[string]string) { delete(c, "tokenTtl-pmfby") }, diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 532376b62..61be56adc 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -157,8 +157,10 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func ProviderIDAt: "message.contract.commitments[].offer.provider.id", CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type", // A support request composes no contract; its channel names both. - FallbackProviderIDAt: "message.support.channels[].provider.id", - FallbackCapabilityCodeAt: "message.support.channels[].@type", + PathsByAction: map[string]common.Paths{"support": { + ProviderID: "message.support.channels[].provider.id", + CapabilityCode: "message.support.channels[].@type", + }}, AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", @@ -650,19 +652,16 @@ func TestSupport_SecondChannel_Returns400WithoutCallingPMFBY(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) _, err := h.send(t, raw) assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if !strings.Contains(err.Error(), "2 channels") { - t.Errorf("error %q should say \"2 channels\" so the caller can act on it", err) - } if h.pmfby.calls[insertPath] != 0 { t.Error("PMFBY was called for a request naming two channels") } } -// The fallback keys are what make a support request reachable at all: without +// The support paths are what make a support request reachable at all: without // them it composes no contract the plugin can read a binding from. -func TestSupport_FallbackNotConfigured_PassesThroughUntouched(t *testing.T) { +func TestSupport_SupportPathsNotConfigured_PassesThroughUntouched(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0, func(c *Grievance.Config) { - c.FallbackProviderIDAt, c.FallbackCapabilityCodeAt = "", "" + c.PathsByAction = nil }) got, err := h.send(t, support(supportFields())) if err != nil || got != nil { diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 52c9887c3..7aeceb6e3 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -202,6 +202,10 @@ func ParseProviderAuth(config map[string]string) (map[string]*AuthProfile, error } continue } + if field == providerIDAtSetting || field == capabilityCodeAtSetting { + // A block named for an action, read by ParseActionPaths. + continue + } set, isSetting := authFields[field] if !isSetting { // Nothing else on a provider step carries a dash, so this is a diff --git a/pkg/plugin/implementation/internal/common/binding.go b/pkg/plugin/implementation/internal/common/binding.go index 65513bb62..4a3253060 100644 --- a/pkg/plugin/implementation/internal/common/binding.go +++ b/pkg/plugin/implementation/internal/common/binding.go @@ -48,12 +48,6 @@ func BindingFrom(paths Paths, body []byte) (Binding, error) { if err := json.Unmarshal(body, &payload); err != nil { return Binding{}, fmt.Errorf("payload could not be read: %w", err) } - return bindingIn(paths, payload) -} - -// bindingIn is BindingFrom over a payload already decoded, so a step trying two -// sets of paths reads the body once. -func bindingIn(paths Paths, payload any) (Binding, error) { // Checked before distinctness: N commitments naming the SAME provider and // type collapse to one key, so they would pass unnoticed and the mapping @@ -64,14 +58,10 @@ func bindingIn(paths Paths, payload any) (Binding, error) { // absent leaf, so two commitments where one has no provider id would count // as one. if commitments := countAt(payload, paths.ProviderID); commitments > 1 { - // Named from the path, so a support request with two channels is told - // "2 channels" rather than "2 commitments". - array, _, _ := strings.Cut(paths.ProviderID, arrayMarker) - array = array[strings.LastIndex(array, ".")+1:] return Binding{}, fmt.Errorf( - "payload carries %d %s; one request maps to one call, "+ + "payload carries %d commitments; one request maps to one call, "+ "so send them separately rather than have all but the first dropped", - commitments, array) + commitments) } providers := distinct(ValuesAt(payload, paths.ProviderID)) diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 8000a4cd6..049e9c6a6 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -18,7 +18,9 @@ import ( "context" "errors" "fmt" + "maps" "net/http" + "slices" "strings" "github.com/beckn-one/beckn-onix/pkg/log" @@ -55,12 +57,14 @@ type Config struct { ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` - // A second place to read a binding key from, tried only when the first - // finds none. For an action whose payload composes no contract: a support - // request carries its provider and type on its channel instead. Both or - // neither; absent means there is no second place. - FallbackProviderIDAt string `yaml:"fallbackProviderIdAt" json:"fallbackProviderIdAt"` - FallbackCapabilityCodeAt string `yaml:"fallbackCapabilityCodeAt" json:"fallbackCapabilityCodeAt"` + // Where the two halves sit for one action, keyed by the Beckn action, when + // its payload puts them somewhere other than the paths above. A support + // request composes no contract, so it names its provider and type on its + // channel. An action not listed reads the paths above. + // + // Built by ParseActionPaths, not decoded from YAML -- an operator writes a + // nested block named for the action, which pkg/plugin flattens on the way in. + PathsByAction map[string]Paths `yaml:"-" json:"-"` // One credential profile per provider, keyed by participant id -- the left // half of a binding key. @@ -87,7 +91,6 @@ type Config struct { type Step struct { config *Config paths Paths - fallback *Paths prerequisites Prerequisites registry definition.ProviderRecordLookup mapper definition.Mapper @@ -118,15 +121,15 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d if err != nil { return nil, nil, err } - fallback, err := fallbackPaths(cfg) - if err != nil { - return nil, nil, err + for action, actionPaths := range cfg.PathsByAction { + if err := actionPaths.Validate(); err != nil { + return nil, nil, fmt.Errorf("%s: %w", action, err) + } } step := &Step{ config: cfg, paths: paths, - fallback: fallback, prerequisites: prerequisites, registry: registry, mapper: mapper, @@ -174,21 +177,45 @@ func BindingPaths(cfg *Config) (Paths, error) { return paths, nil } -// fallbackPaths resolves the second place a binding key may be read from, or -// nil when none is configured. Both halves or neither, for the reason +// The two settings that say where a binding key sits, at the top of a step's +// config or in a block named for an action. +const ( + providerIDAtSetting = "providerIdAt" + capabilityCodeAtSetting = "capabilityCodeAt" +) + +// ParseActionPaths reads the per-action binding paths from a step's flattened +// config: a block named for an action arrives as providerIdAt- and +// capabilityCodeAt-. Both halves or neither, for the reason // BindingPaths gives. -func fallbackPaths(cfg *Config) (*Paths, error) { - if cfg.FallbackProviderIDAt == "" && cfg.FallbackCapabilityCodeAt == "" { - return nil, nil - } - if cfg.FallbackProviderIDAt == "" || cfg.FallbackCapabilityCodeAt == "" { - return nil, errors.New("fallbackProviderIdAt and fallbackCapabilityCodeAt are set together or not at all") +// +// EXPORTED for a domain plugin's cmd package, which hands the flat map over. +func ParseActionPaths(config map[string]string) (map[string]Paths, error) { + byAction := map[string]Paths{} + for key, value := range config { + setting, action, dashed := strings.Cut(key, "-") + if !dashed { + continue + } + paths := byAction[action] + switch setting { + case providerIDAtSetting: + paths.ProviderID = value + case capabilityCodeAtSetting: + paths.CapabilityCode = value + default: + continue + } + byAction[action] = paths } - paths := Paths{ProviderID: cfg.FallbackProviderIDAt, CapabilityCode: cfg.FallbackCapabilityCodeAt} - if err := paths.Validate(); err != nil { - return nil, fmt.Errorf("fallback %w", err) + // Sorted so a config with two mistakes reports the same one every run. + for _, action := range slices.Sorted(maps.Keys(byAction)) { + if paths := byAction[action]; paths.ProviderID == "" || paths.CapabilityCode == "" { + return nil, fmt.Errorf("%s: %s and %s are set together or not at all", + action, providerIDAtSetting, capabilityCodeAtSetting) + } } - return &paths, nil + return byAction, nil } // applyDefaults fills in what was left out and rejects what cannot be defaulted. diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 036a20a6e..9867062f6 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -24,28 +24,24 @@ import ( // recognises its own work, so adding a provider is one config entry rather than // a routing-table change. func (s *Step) Run(ctx *model.StepContext) error { - var payload any - if err := json.Unmarshal(ctx.Body, &payload); err != nil { - // Unclassified it becomes a 500, which blames this adapter and hides - // the reason from the caller. - return model.NewBadReqErr("", fmt.Errorf("payload could not be read: %w", err)) - } - binding, err := bindingIn(s.paths, payload) - if errors.Is(err, errNoBinding) && s.fallback != nil { - binding, err = bindingIn(*s.fallback, payload) - // The fallback reads a container other capabilities use too, so a - // payload it cannot resolve is refused only when it names this step's - // own work; anyone else's passes through. - if err != nil && !errors.Is(err, errNoBinding) && !s.namesServed(*s.fallback, payload) { - return nil - } + paths, ownPaths := s.config.PathsByAction[extractAction(ctx.Body)] + if !ownPaths { + paths = s.paths } + binding, err := BindingFrom(paths, ctx.Body) if errors.Is(err, errNoBinding) { return nil } if err != nil { - // Everything bindingIn refuses is about the payload -- more than one - // call named. Unclassified it becomes a 500, as above. + // An action's own paths may read a container other capabilities share + // -- a support request's channels -- so a payload they cannot resolve + // is refused only when it names this step's own work. + if ownPaths && !s.namesServed(paths, ctx.Body) { + return nil + } + // Everything BindingFrom refuses is about the payload -- unreadable + // JSON, or more than one call named. Unclassified it becomes a 500, + // which blames this adapter and hides the reason from the caller. return model.NewBadReqErr("", err) } if !s.serves(binding.Key()) { @@ -100,7 +96,11 @@ func (s *Step) serves(key string) bool { // namesServed reports whether any provider and type the paths reach pair into // a binding this step serves. -func (s *Step) namesServed(paths Paths, payload any) bool { +func (s *Step) namesServed(paths Paths, body []byte) bool { + var payload any + if json.Unmarshal(body, &payload) != nil { + return false + } for _, provider := range ValuesAt(payload, paths.ProviderID) { for _, capability := range ValuesAt(payload, paths.CapabilityCode) { if s.serves(Binding{ParticipantID: provider, CapabilityCode: capability}.Key()) { From ecfeb0c6e62f85b030ba152c7d6f7ca71126b955 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 9 Oct 2026 15:02:27 +0530 Subject: [PATCH 19/39] refactor: leave a half-set action block to New's path check [#57] New already validates every action's paths and refuses an empty one, so the parser checking both halves again was a second copy of the same rule. --- .../implementation/Grievance/cmd/plugin.go | 6 +----- .../implementation/internal/common/common.go | 17 ++++------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index 82d1a65b2..aa039a593 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -58,11 +58,7 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con // Where an action whose payload composes no contract -- support -- names // its provider and type, read from a block named for the action. - actionPaths, err := common.ParseActionPaths(config) - if err != nil { - return nil, err - } - cfg.PathsByAction = actionPaths + cfg.PathsByAction = common.ParseActionPaths(config) return cfg, nil } diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 049e9c6a6..5e898142d 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -18,9 +18,7 @@ import ( "context" "errors" "fmt" - "maps" "net/http" - "slices" "strings" "github.com/beckn-one/beckn-onix/pkg/log" @@ -186,11 +184,11 @@ const ( // ParseActionPaths reads the per-action binding paths from a step's flattened // config: a block named for an action arrives as providerIdAt- and -// capabilityCodeAt-. Both halves or neither, for the reason -// BindingPaths gives. +// capabilityCodeAt-. A block missing either half is refused by New, +// whose Validate finds the empty path. // // EXPORTED for a domain plugin's cmd package, which hands the flat map over. -func ParseActionPaths(config map[string]string) (map[string]Paths, error) { +func ParseActionPaths(config map[string]string) map[string]Paths { byAction := map[string]Paths{} for key, value := range config { setting, action, dashed := strings.Cut(key, "-") @@ -208,14 +206,7 @@ func ParseActionPaths(config map[string]string) (map[string]Paths, error) { } byAction[action] = paths } - // Sorted so a config with two mistakes reports the same one every run. - for _, action := range slices.Sorted(maps.Keys(byAction)) { - if paths := byAction[action]; paths.ProviderID == "" || paths.CapabilityCode == "" { - return nil, fmt.Errorf("%s: %s and %s are set together or not at all", - action, providerIDAtSetting, capabilityCodeAtSetting) - } - } - return byAction, nil + return byAction } // applyDefaults fills in what was left out and rejects what cannot be defaulted. From bf032b75ff6ef0a5634388fda28afe69456767b2 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 9 Oct 2026 15:52:13 +0530 Subject: [PATCH 20/39] refactor: list binding paths in order instead of per action [#57] providerIdAt and capabilityCodeAt may each list several paths, paired by position and tried in order. A support request is found on its channel by the second pair, so no action-keyed block, no parser for it and no change to the plugin's config parsing are needed. A single path behaves as before. --- config/provider-adapter.yaml | 15 ++-- .../implementation/Grievance/cmd/plugin.go | 4 -- .../Grievance/cmd/plugin_test.go | 17 ++--- .../implementation/Grievance/mappings_test.go | 17 ++--- .../implementation/internal/common/auth.go | 4 -- .../implementation/internal/common/common.go | 69 +++++++------------ .../internal/common/common_test.go | 2 +- .../implementation/internal/common/serve.go | 24 ++++--- 8 files changed, 55 insertions(+), 97 deletions(-) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 8053d8839..1103a33c9 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -516,16 +516,11 @@ modules: # BOTH, OR THE ADAPTER DOES NOT START. A grievance names its # capability on the commitment, not on a resource, so without # capabilityCodeAt every grievance request passes through unserved. - providerIdAt: "message.contract.commitments[].offer.provider.id" - capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type" - - # Per action, where its payload puts them elsewhere. A support - # request composes no contract, so it names its provider and type - # on its one channel. Every action not listed here reads the pair - # above. Both or neither, like the pair above. - support: - providerIdAt: "message.support.channels[].provider.id" - capabilityCodeAt: "message.support.channels[].@type" + # Tried in order, paired by position: status names them on its + # contract; support composes no contract and names them on its + # one channel instead. + providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" + capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" # PMFBY issues its own token from its login endpoint # (.../krphapi/FGMS/NICUsersLogin) and reads it back as the bare diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index aa039a593..0f2fcc056 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -56,10 +56,6 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con } cfg.AuthByProvider = auth - // Where an action whose payload composes no contract -- support -- names - // its provider and type, read from a block named for the action. - cfg.PathsByAction = common.ParseActionPaths(config) - return cfg, nil } diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go index e9dbcd58b..0521d37ee 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -29,11 +29,8 @@ func (stubMapper) Transform(context.Context, string, definition.Direction, any) const ( pmfbyBindingKey = "pmfby|openagrinet:PMFBYGrievance" - providerIDAt = "message.contract.commitments[].offer.provider.id" - capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" - - supportProviderIDAt = "message.support.channels[].provider.id" - supportCapabilityCodeAt = "message.support.channels[].@type" + providerIDAt = "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" + capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" ) // pmfbyConfig is the block config/provider-adapter.yaml ships, as the plugin @@ -43,8 +40,6 @@ func pmfbyConfig() map[string]string { "bindingKeys": pmfbyBindingKey, "providerIdAt": providerIDAt, "capabilityCodeAt": capabilityCodeAt, - "providerIdAt-support": supportProviderIDAt, - "capabilityCodeAt-support": supportCapabilityCodeAt, "authScheme-pmfby": "tokenHeader", "tokenUrl-pmfby": "https://pmfby.example/krphapi/FGMS/NICUsersLogin", "tokenUserField-pmfby": "appAccessUID", @@ -66,9 +61,6 @@ func TestParseConfig_PMFBYBlock_ReadsEverySetting(t *testing.T) { BindingKeys: []string{pmfbyBindingKey}, ProviderIDAt: providerIDAt, CapabilityCodeAt: capabilityCodeAt, - PathsByAction: map[string]common.Paths{ - "support": {ProviderID: supportProviderIDAt, CapabilityCode: supportCapabilityCodeAt}, - }, AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Provider: "pmfby", Scheme: "tokenHeader", TokenURL: "https://pmfby.example/krphapi/FGMS/NICUsersLogin", TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", @@ -139,9 +131,8 @@ func TestNew_InvalidConfig_Refused(t *testing.T) { "no binding keys": func(c map[string]string) { delete(c, "bindingKeys") }, "providerIdAt alone": func(c map[string]string) { delete(c, "capabilityCodeAt") }, "capabilityCodeAt alone": func(c map[string]string) { delete(c, "providerIdAt") }, - "support provider alone": func(c map[string]string) { delete(c, "capabilityCodeAt-support") }, - "support type alone": func(c map[string]string) { delete(c, "providerIdAt-support") }, - "support path blank segment": func(c map[string]string) { c["providerIdAt-support"] = "message..id" }, + "unpaired path lists": func(c map[string]string) { c["capabilityCodeAt"] = "a.@type" }, + "blank listed path": func(c map[string]string) { c["providerIdAt"] = "a.id, " }, "no auth block for pmfby": func(c map[string]string) { delete(c, "authScheme-pmfby") }, "unknown auth scheme": func(c map[string]string) { c["authScheme-pmfby"] = "loginHeader" }, "tokenHeader without ttl": func(c map[string]string) { delete(c, "tokenTtl-pmfby") }, diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 61be56adc..afef010ae 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -153,14 +153,10 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func }} cfg := &Grievance.Config{ - BindingKeys: []string{shippedBindingKey}, - ProviderIDAt: "message.contract.commitments[].offer.provider.id", - CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type", + BindingKeys: []string{shippedBindingKey}, // A support request composes no contract; its channel names both. - PathsByAction: map[string]common.Paths{"support": { - ProviderID: "message.support.channels[].provider.id", - CapabilityCode: "message.support.channels[].@type", - }}, + ProviderIDAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id", + CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type", AuthByProvider: map[string]*common.AuthProfile{"pmfby": { Scheme: util.AuthSchemeTokenHeader, TokenURL: upstream.URL + loginPath, TokenUserField: "appAccessUID", TokenUserEnv: "PMFBY_USER", @@ -657,11 +653,12 @@ func TestSupport_SecondChannel_Returns400WithoutCallingPMFBY(t *testing.T) { } } -// The support paths are what make a support request reachable at all: without +// The channel paths are what make a support request reachable at all: without // them it composes no contract the plugin can read a binding from. -func TestSupport_SupportPathsNotConfigured_PassesThroughUntouched(t *testing.T) { +func TestSupport_ChannelPathsNotConfigured_PassesThroughUntouched(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0, func(c *Grievance.Config) { - c.PathsByAction = nil + c.ProviderIDAt = "message.contract.commitments[].offer.provider.id" + c.CapabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type" }) got, err := h.send(t, support(supportFields())) if err != nil || got != nil { diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 7aeceb6e3..52c9887c3 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -202,10 +202,6 @@ func ParseProviderAuth(config map[string]string) (map[string]*AuthProfile, error } continue } - if field == providerIDAtSetting || field == capabilityCodeAtSetting { - // A block named for an action, read by ParseActionPaths. - continue - } set, isSetting := authFields[field] if !isSetting { // Nothing else on a provider step carries a dash, so this is a diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 5e898142d..b6d2d5635 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -52,18 +52,13 @@ type Config struct { // A network convention, not a preference: every participant must agree or // requests silently fail to match. Configurable only so a spec change can // be tracked without waiting for a release. Both or neither. + // + // Either may list several paths, comma-separated, paired by position and + // tried in order until one pair finds a binding: a support request composes + // no contract and names its provider and type on its channel instead. ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` - // Where the two halves sit for one action, keyed by the Beckn action, when - // its payload puts them somewhere other than the paths above. A support - // request composes no contract, so it names its provider and type on its - // channel. An action not listed reads the paths above. - // - // Built by ParseActionPaths, not decoded from YAML -- an operator writes a - // nested block named for the action, which pkg/plugin flattens on the way in. - PathsByAction map[string]Paths `yaml:"-" json:"-"` - // One credential profile per provider, keyed by participant id -- the left // half of a binding key. // @@ -88,7 +83,7 @@ type Config struct { // safe for concurrent use. type Step struct { config *Config - paths Paths + paths []Paths prerequisites Prerequisites registry definition.ProviderRecordLookup mapper definition.Mapper @@ -119,15 +114,14 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d if err != nil { return nil, nil, err } - for action, actionPaths := range cfg.PathsByAction { - if err := actionPaths.Validate(); err != nil { - return nil, nil, fmt.Errorf("%s: %w", action, err) - } + pathList, err := splitPaths(paths) + if err != nil { + return nil, nil, err } step := &Step{ config: cfg, - paths: paths, + paths: pathList, prerequisites: prerequisites, registry: registry, mapper: mapper, @@ -175,38 +169,23 @@ func BindingPaths(cfg *Config) (Paths, error) { return paths, nil } -// The two settings that say where a binding key sits, at the top of a step's -// config or in a block named for an action. -const ( - providerIDAtSetting = "providerIdAt" - capabilityCodeAtSetting = "capabilityCodeAt" -) - -// ParseActionPaths reads the per-action binding paths from a step's flattened -// config: a block named for an action arrives as providerIdAt- and -// capabilityCodeAt-. A block missing either half is refused by New, -// whose Validate finds the empty path. -// -// EXPORTED for a domain plugin's cmd package, which hands the flat map over. -func ParseActionPaths(config map[string]string) map[string]Paths { - byAction := map[string]Paths{} - for key, value := range config { - setting, action, dashed := strings.Cut(key, "-") - if !dashed { - continue - } - paths := byAction[action] - switch setting { - case providerIDAtSetting: - paths.ProviderID = value - case capabilityCodeAtSetting: - paths.CapabilityCode = value - default: - continue +// splitPaths pairs the comma-separated halves of paths by position, so each +// pair is one place a binding key may sit. +func splitPaths(paths Paths) ([]Paths, error) { + providers := strings.Split(paths.ProviderID, ",") + capabilities := strings.Split(paths.CapabilityCode, ",") + if len(providers) != len(capabilities) { + return nil, fmt.Errorf("providerIdAt lists %d paths and capabilityCodeAt %d; they pair by position", + len(providers), len(capabilities)) + } + pairs := make([]Paths, len(providers)) + for i := range providers { + pairs[i] = Paths{ProviderID: strings.TrimSpace(providers[i]), CapabilityCode: strings.TrimSpace(capabilities[i])} + if err := pairs[i].Validate(); err != nil { + return nil, err } - byAction[action] = paths } - return byAction + return pairs, nil } // applyDefaults fills in what was left out and rejects what cannot be defaulted. diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 82f499cf9..1ac9f3ba7 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -1288,7 +1288,7 @@ func TestNewUsesTheBecknConventionByDefault(t *testing.T) { t.Parallel() step := newStep(t, &stubRegistry{}, &stubMapper{}) - if step.paths != BecknV2 { + if !slices.Equal(step.paths, []Paths{BecknV2}) { t.Errorf("paths = %+v, want the Beckn v2 convention", step.paths) } } diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 9867062f6..0dd3ef280 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -24,21 +24,25 @@ import ( // recognises its own work, so adding a provider is one config entry rather than // a routing-table change. func (s *Step) Run(ctx *model.StepContext) error { - paths, ownPaths := s.config.PathsByAction[extractAction(ctx.Body)] - if !ownPaths { - paths = s.paths + var binding Binding + err := errNoBinding + for i, paths := range s.paths { + binding, err = BindingFrom(paths, ctx.Body) + if errors.Is(err, errNoBinding) { + continue + } + // A later pair may read a container other capabilities share -- a + // support request's channels -- so a payload it cannot resolve is + // refused only when it names this step's own work. + if err != nil && i > 0 && !s.namesServed(paths, ctx.Body) { + return nil + } + break } - binding, err := BindingFrom(paths, ctx.Body) if errors.Is(err, errNoBinding) { return nil } if err != nil { - // An action's own paths may read a container other capabilities share - // -- a support request's channels -- so a payload they cannot resolve - // is refused only when it names this step's own work. - if ownPaths && !s.namesServed(paths, ctx.Body) { - return nil - } // Everything BindingFrom refuses is about the payload -- unreadable // JSON, or more than one call named. Unclassified it becomes a 500, // which blames this adapter and hides the reason from the caller. From dc29b9482f487cad9eaf542cbd1d09e79ac446b4 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 9 Oct 2026 16:14:20 +0530 Subject: [PATCH 21/39] refactor: keep the login exchange's name and comments as they were [#57] tokenHeader reuses the tokenQuery exchange unchanged, so its name and the redaction comment need no rewrite; only the scheme check and the token path differ. --- pkg/plugin/implementation/internal/common/redact.go | 8 +++----- pkg/plugin/implementation/internal/common/token.go | 11 +++++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pkg/plugin/implementation/internal/common/redact.go b/pkg/plugin/implementation/internal/common/redact.go index 1f8143e79..000c62f5b 100644 --- a/pkg/plugin/implementation/internal/common/redact.go +++ b/pkg/plugin/implementation/internal/common/redact.go @@ -140,11 +140,9 @@ func (a *authenticator) secretForms() []string { return forms case util.AuthSchemeTokenQuery, util.AuthSchemeTokenHeader: // oauth2's two halves, with query's escaping: the secret goes to the - // token endpoint, and the token it returns travels in a QUERY STRING - // for tokenQuery -- which is the exposed placement, so its escaped form - // has to be covered too or a token containing "+" or "=" survives - // redaction. tokenHeader sends it in a header; covering the escaped - // form as well costs nothing. + // token endpoint, and the token it returns travels in a QUERY STRING -- + // which is the exposed placement, so its escaped form has to be + // covered too or a token containing "+" or "=" survives redaction. var forms []string if secret := os.Getenv(a.cfg.TokenSecretEnv); secret != "" { forms = append(forms, secret) diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index 95597aef3..d6cb05183 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -88,18 +88,18 @@ type tokenResponse struct { // this adapter broke when it did not. func (s *Step) exchangeToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { if auth.cfg.Scheme == util.AuthSchemeTokenQuery || auth.cfg.Scheme == util.AuthSchemeTokenHeader { - return s.exchangeLoginToken(ctx, auth) + return s.exchangeQueryToken(ctx, auth) } return s.exchangeOAuth2Token(ctx, auth) } -// exchangeLoginToken POSTs a JSON body of configured field names and reads the +// exchangeQueryToken POSTs a JSON body of configured field names and reads the // token out of a configured response field. // // The lifetime comes from config, not from the response: this endpoint says // nothing about how long its token lives. That is the whole reason tokenTtl is // required rather than defaulted -- see AuthProfile. -func (s *Step) exchangeLoginToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { +func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { cfg := auth.cfg user, secret := os.Getenv(cfg.TokenUserEnv), os.Getenv(cfg.TokenSecretEnv) if user == "" || secret == "" { @@ -123,9 +123,8 @@ func (s *Step) exchangeLoginToken(ctx context.Context, auth *authenticator) (str return "", 0, err } - // Decoded generically because the field is configured: a struct tag cannot - // be written for a key that is not known until config is read. It is a - // dotted path, since some login replies nest the token in an envelope. + // Decoded into a map because the field name is configured: a struct tag + // cannot be written for a key that is not known until config is read. var fields any if err := json.Unmarshal(body, &fields); err != nil { return "", 0, s.permanentTokenErr(fmt.Errorf("token response from %s is not JSON: %w", From 1f8c1538b76ca97de75dcd9035e115d5633e238d Mon Sep 17 00:00:00 2001 From: nisargabd Date: Sat, 10 Oct 2026 10:55:30 +0530 Subject: [PATCH 22/39] fix: answer the review on PR #70 [#57] - An empty or all-null PMFBY record is no record (202), not a grievance reported as under review. - cropYear must be a four-digit year: PMFBY reads requestYear like the ids. - A mapping writing "_error": null means no error, not a 500. - BindingPaths refuses a path list, so a caller reading one pair fails at startup instead of matching nothing. --- config/mappings/pmfby/grievance.status.yaml | 10 +++--- config/mappings/pmfby/grievance.support.yaml | 7 ++++ .../implementation/Grievance/mappings_test.go | 21 ++++++++++++ .../implementation/internal/common/common.go | 34 ++++++++++++------- .../internal/common/common_test.go | 23 +++++++++++++ .../implementation/internal/common/serve.go | 3 +- 6 files changed, 80 insertions(+), 18 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 7f4f592c9..bd3c60baa 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -52,18 +52,20 @@ response: | list of one reads the same as one sent bare. */ $ok := $string(response.responseCode) = "1"; $ticket := response.responseDynamic[0]; + /* PMFBY may send null or "" for a field it has no value for; such a + field is dropped rather than mapped as "null" or failing the call. */ + $isText := function($v) { $type($v) = "string" and $v != "" }; $not($ok) ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", "message": "PMFBY could not read the grievance" } } - : $type($ticket) != "object" + /* A record is one PMFBY filled: an empty or all-null one is no record, + or a grievance nobody filed would be reported as under review. */ + : $not($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", "message": "No grievance matches this ticket and phone number." } } : ( $commitment := beckn.message.contract.commitments[0]; $ca := $commitment.commitmentAttributes; - /* PMFBY may send null or "" for a field it has no value for; such a - field is dropped rather than mapped as "null" or failing the call. */ - $isText := function($v) { $type($v) = "string" and $v != "" }; { "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), "message": { diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 82f554615..e34f0c8f7 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -38,6 +38,13 @@ required: and $digits($ch.receiptSourceId) ) message: "grievance.category.code, grievance.subCategory.code and receiptSourceId must be digits" + # Sent as requestYear, which PMFBY reads the same way as the ids above. + - check: | + ( + $year := beckn.message.support.channels[0].cropYear; + $not($exists($year)) or $exists($match($string($year), /^[0-9]{4}$/)[0]) + ) + message: "cropYear must be a four-digit year" - check: | ( $season := beckn.message.support.channels[0].season; diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index afef010ae..f82eac9ad 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -534,6 +534,7 @@ func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { func TestSupport_MalformedDateOrSource_Returns400WithoutCallingPMFBY(t *testing.T) { for name, field := range map[string][2]string{ "date": {"complaintDate", "20-09-2026"}, "source": {"receiptSourceId", "web"}, + "crop year": {"cropYear", "last year"}, "short crop year": {"cropYear", "25"}, } { t.Run(name, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) @@ -830,6 +831,26 @@ func TestStatus_PortalRefused_Returns502(t *testing.T) { } // One ticket sent as a list of one reads the same as one sent bare. +// A record PMFBY left empty is no record: reporting it as under review would +// describe a grievance nobody filed. +func TestStatus_EmptyRecord_Returns202NoResultsFound(t *testing.T) { + for name, dynamic := range map[string]string{ + "empty object": `{}`, + "empty in list": `[{}]`, + "all fields null": `{"ApplicationNo":null,"TicketStatus":null,"ComplaintDate":null,"CropName":null}`, + } { + t.Run(name, func(t *testing.T) { + record := `{"responseCode":"1","responseMessage":"Fetched","responseDynamic":` + dynamic + `}` + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + send(t, request("status", statusAttributes())) + var ack *model.AckNoCallbackErr + if !errors.As(err, &ack) || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { + t.Errorf("error = %v, want a 202 ACK BIZ_NO_RESULTS_FOUND", err) + } + }) + } +} + func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { record := strings.Replace(strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":[{`, 1), `}}`, `}]}`, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index b6d2d5635..7ec57283e 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -110,11 +110,7 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d return nil, nil, err } - paths, err := BindingPaths(cfg) - if err != nil { - return nil, nil, err - } - pathList, err := splitPaths(paths) + pathList, err := bindingPathList(cfg) if err != nil { return nil, nil, err } @@ -152,21 +148,33 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d // same way this step does -- AgricultureFacility, whose search.go fans one // request out over several capability types. The alternative is reading the // same two config fields a second time, which is how the two drift apart. +// +// A list of paths is refused here: such a caller reads one pair, and handed the +// list as one path it would quietly match nothing. func BindingPaths(cfg *Config) (Paths, error) { + pathList, err := bindingPathList(cfg) + if err != nil { + return Paths{}, err + } + if len(pathList) > 1 { + return Paths{}, errors.New("providerIdAt and capabilityCodeAt list several paths, which only the step itself reads") + } + return pathList[0], nil +} + +// bindingPathList resolves the pairs this step reads a binding key from, in +// the order they are tried. +func bindingPathList(cfg *Config) ([]Paths, error) { if cfg.ProviderIDAt == "" && cfg.CapabilityCodeAt == "" { - return BecknV2, nil + return []Paths{BecknV2}, nil } if cfg.ProviderIDAt == "" { - return Paths{}, errors.New("capabilityCodeAt is set without providerIdAt") + return nil, errors.New("capabilityCodeAt is set without providerIdAt") } if cfg.CapabilityCodeAt == "" { - return Paths{}, errors.New("providerIdAt is set without capabilityCodeAt") - } - paths := Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt} - if err := paths.Validate(); err != nil { - return Paths{}, err + return nil, errors.New("providerIdAt is set without capabilityCodeAt") } - return paths, nil + return splitPaths(Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt}) } // splitPaths pairs the comma-separated halves of paths by position, so each diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 1ac9f3ba7..93f242a9b 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -2661,6 +2661,29 @@ func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { } } +// A mapping that writes "_error": null means no error, not an unreadable one. +func TestRefusal_NullErrorField_AnswersAsBefore(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":null,"message":{"ok":true}}`)} + stepCtx, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + if err != nil || calls != 1 || !strings.Contains(string(stepCtx.ResponseBody), `"ok":true`) { + t.Errorf("Run() = %v, body %s, %d calls; want the mapped answer after one call", err, stepCtx.ResponseBody, calls) + } +} + +// A caller of BindingPaths reads one pair, so a list is refused rather than +// handed over as one path that matches nothing. +func TestBindingPaths_PathList_Refused(t *testing.T) { + cfg := &Config{ProviderIDAt: "a.id, b.id", CapabilityCodeAt: "a.@type, b.@type"} + if _, err := BindingPaths(cfg); err == nil { + t.Error("BindingPaths() accepted a path list") + } + cfg = &Config{ProviderIDAt: "a.id", CapabilityCodeAt: "a.@type"} + if got, err := BindingPaths(cfg); err != nil || got != (Paths{ProviderID: "a.id", CapabilityCode: "a.@type"}) { + t.Errorf("BindingPaths() = %+v, %v; want the single pair", got, err) + } +} + func TestRefusal_NoErrorField_AnswersAsBefore(t *testing.T) { var calls int mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"message":{"ok":true}}`)} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 0dd3ef280..014fcd9cf 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -234,7 +234,8 @@ func refusalIn(mapped []byte) error { return nil } var document map[string]json.RawMessage - if json.Unmarshal(mapped, &document) != nil || document["_error"] == nil { + // A JSON null decodes to the bytes "null", not to nil, so both mean none. + if json.Unmarshal(mapped, &document) != nil || document["_error"] == nil || string(document["_error"]) == "null" { return nil } var r struct { From df3fa3a98a05d34be7984473e4405467a452d576 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Sat, 10 Oct 2026 10:55:52 +0530 Subject: [PATCH 23/39] feat: follow the grievance pack's code-only case status and orderId bounds [#57] The pack now refuses case.status.name: the portal's TicketStatus picks the code and is not forwarded. And the implementation doc restates enrolmentId's 10-30 character bound on support, where it rides in orderId outside the channel the pack validates. --- config/mappings/pmfby/grievance.status.yaml | 16 +++++++--------- config/mappings/pmfby/grievance.support.yaml | 9 +++++++++ .../implementation/Grievance/mappings_test.go | 7 ++++--- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index bd3c60baa..7a5b9b66d 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -85,15 +85,13 @@ response: | }, "case": { "ticketNo": $ca.case.ticketNo, - /* code is the network's word, name the portal's phrase. PMFBY - publishes no status vocabulary, so every phrase is one the - adapter does not recognise and maps to UnderReview, which is - never a closed state. Recognised phrases are added here once - PMFBY confirms them. */ - "status": { - "code": "UnderReview", - "name": $isText($ticket.TicketStatus) ? $ticket.TicketStatus - }, + /* A code and nothing else: the portal's TicketStatus phrase + picks the code and is not forwarded. PMFBY publishes no + status vocabulary, so every phrase is one the adapter does + not recognise and maps to UnderReview, which is never a + closed state. Recognised phrases are added here once PMFBY + confirms them. */ + "status": { "code": "UnderReview" }, "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, "cropName": $isText($ticket.CropName) ? $ticket.CropName } diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index e34f0c8f7..efa54ba59 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -12,6 +12,15 @@ # absent field pass. A missing field is refused by the request half under # _error, which can name SCH_REQUIRED_FIELD_MISSING and the field's path. required: + # The application number's bounds, restated: the pack holds enrolmentId to + # 10-30 characters, but on support it rides in orderId, outside the channel + # the pack validates. + - check: | + ( + $id := beckn.message.support.orderId; + $not($exists($id)) or ($type($id) = "string" ? ($length($id) >= 10 and $length($id) <= 30) : false) + ) + message: "orderId, the application number, must be 10-30 characters" # A ten-digit Indian mobile, as text: PMFBY files the ticket against it and # matches the read on it, so a number or a mangled one is refused here. - check: | diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index f82eac9ad..530e10b99 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -535,6 +535,7 @@ func TestSupport_MalformedDateOrSource_Returns400WithoutCallingPMFBY(t *testing. for name, field := range map[string][2]string{ "date": {"complaintDate", "20-09-2026"}, "source": {"receiptSourceId", "web"}, "crop year": {"cropYear", "last year"}, "short crop year": {"cropYear", "25"}, + "short order id": {"orderId", "123456789"}, "long order id": {"orderId", strings.Repeat("1", 31)}, } { t.Run(name, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) @@ -724,9 +725,9 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" || filed["cropName"] != "Paddy" { t.Errorf("commitmentAttributes = %v", attributes) } - // An unrecognised portal phrase is UnderReview, the phrase kept as the name. - if status := band(filed, "status"); status["code"] != "UnderReview" || status["name"] != "Under Review" { - t.Errorf("case.status = %v, want UnderReview / Under Review", status) + // An unrecognised portal phrase is UnderReview, and the phrase is not forwarded. + if status := band(filed, "status"); len(status) != 1 || status["code"] != "UnderReview" { + t.Errorf("case.status = %v, want code UnderReview alone", status) } if got.Context["action"] != "on_status" || got.status() != "ACTIVE" { t.Errorf("action %v, status %v; want on_status, ACTIVE", got.Context["action"], got.status()) From fa84699439457434a1871ab3f2b09b4ca471c7a5 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 12 Oct 2026 11:34:37 +0530 Subject: [PATCH 24/39] refactor: shorten the grievance plugin's comments and merge splitPaths [#57] --- config/mappings/pmfby/grievance.status.yaml | 50 ++++-------- config/mappings/pmfby/grievance.support.yaml | 52 ++++-------- config/provider-adapter.yaml | 18 ++--- .../implementation/Grievance/Grievance.go | 15 +--- .../implementation/Grievance/cmd/plugin.go | 34 +++----- .../implementation/Grievance/mappings_test.go | 80 +++++++------------ .../implementation/internal/common/auth.go | 5 +- .../implementation/internal/common/common.go | 22 ++--- .../implementation/internal/common/serve.go | 27 +++---- .../implementation/internal/common/token.go | 3 +- .../internal/common/util/constant.go | 6 +- 11 files changed, 101 insertions(+), 211 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 7a5b9b66d..6ad5dd02b 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -1,18 +1,12 @@ -# PMFBY, openagrinet:PMFBYGrievance, status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. -# Both directions, one file. -# -# Step two of two: read a filed grievance back. PMFBY matches the ticket to the -# phone it was filed from, so both are required. No OTP: PMFBY asks for none. -# -# The response is an allow-list. PMFBY's record also carries the farmer's name, -# state, district and insurer; none of it is mapped. +# PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. +# Reads a grievance back by ticket number and the phone it was filed from. +# The response maps an allow-list; the farmer's name, location and insurer +# are dropped. -# required holds format checks, which return SCH_INVALID_FORMAT and let an -# absent field pass. A missing field is refused by the request half under -# _error, which can name SCH_REQUIRED_FIELD_MISSING and the field's path. +# Format checks (SCH_INVALID_FORMAT); an absent field passes here and is +# refused by the request half with SCH_REQUIRED_FIELD_MISSING. required: - # A ten-digit Indian mobile, as text: PMFBY files the ticket against it and - # matches the read on it, so a number or a mangled one is refused here. + # PMFBY files and reads the ticket by this number. - check: | ( $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; @@ -37,29 +31,21 @@ request: | } ) -# PMFBY says success with responseCode "1", seen as a string and as a number; -# anything else is the portal refusing, a 502. A success with no case record is -# not a failure -- PMFBY answered and has nothing on file -- so it is a 202 ACK -# saying so. Otherwise the grievance is the complaint as PMFBY holds it, the -# case is the record it keeps against it, and a field PMFBY does not return is -# left out. The category comes back by name alone, and the ticket number not at -# all, so the one the caller asked with is echoed. +# responseCode other than "1": 502. Success with no record: 202 +# BIZ_NO_RESULTS_FOUND. Otherwise the record is mapped; fields PMFBY leaves +# empty are dropped, and the ticket number is echoed from the request. response: | ( - /* Tested as equal to "1", not unequal: a reply with no responseCode at - all is a failure, and an unequal test against a missing value is false. - The record is read as the first of the reply's, so one ticket sent as a - list of one reads the same as one sent bare. */ + /* A missing responseCode is a failure. [0] reads a bare record or a + list of one alike. */ $ok := $string(response.responseCode) = "1"; $ticket := response.responseDynamic[0]; - /* PMFBY may send null or "" for a field it has no value for; such a - field is dropped rather than mapped as "null" or failing the call. */ + /* PMFBY sends null or "" for missing values. */ $isText := function($v) { $type($v) = "string" and $v != "" }; $not($ok) ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", "message": "PMFBY could not read the grievance" } } - /* A record is one PMFBY filled: an empty or all-null one is no record, - or a grievance nobody filed would be reported as under review. */ + /* An empty or all-null record means nothing on file. */ : $not($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", "message": "No grievance matches this ticket and phone number." } } @@ -85,12 +71,8 @@ response: | }, "case": { "ticketNo": $ca.case.ticketNo, - /* A code and nothing else: the portal's TicketStatus phrase - picks the code and is not forwarded. PMFBY publishes no - status vocabulary, so every phrase is one the adapter does - not recognise and maps to UnderReview, which is never a - closed state. Recognised phrases are added here once PMFBY - confirms them. */ + /* PMFBY publishes no status vocabulary, so every + TicketStatus maps to UnderReview until it does. */ "status": { "code": "UnderReview" }, "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, "cropName": $isText($ticket.CropName) ? $ticket.CropName diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index efa54ba59..44f929d30 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -1,44 +1,32 @@ -# PMFBY, openagrinet:PMFBYGrievance, support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. -# Both directions, one file. -# -# Step one of two: lodge the grievance. PMFBY's grievance service asks for no -# OTP, so this is the first call. A support request composes no contract: -# message.support carries the application number in orderId, and channels[0] -# carries the provider, the enrolment and the complaint (grievance). -# -# "Grievence" is PMFBY's spelling, kept where PMFBY reads it. +# PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. +# Files a grievance; no OTP. orderId is the application number, channels[0] +# holds the provider and the complaint. "Grievence" is PMFBY's spelling. -# required holds format checks, which return SCH_INVALID_FORMAT and let an -# absent field pass. A missing field is refused by the request half under -# _error, which can name SCH_REQUIRED_FIELD_MISSING and the field's path. +# Format checks (SCH_INVALID_FORMAT); an absent field passes here and is +# refused by the request half with SCH_REQUIRED_FIELD_MISSING. required: - # The application number's bounds, restated: the pack holds enrolmentId to - # 10-30 characters, but on support it rides in orderId, outside the channel - # the pack validates. + # The pack bounds enrolmentId to 10-30 characters; orderId is outside it. - check: | ( $id := beckn.message.support.orderId; $not($exists($id)) or ($type($id) = "string" ? ($length($id) >= 10 and $length($id) <= 30) : false) ) message: "orderId, the application number, must be 10-30 characters" - # A ten-digit Indian mobile, as text: PMFBY files the ticket against it and - # matches the read on it, so a number or a mangled one is refused here. + # PMFBY files and reads the ticket by this number. - check: | ( $phone := beckn.message.support.channels[0].applicantPhone; $not($exists($phone)) or ($type($phone) = "string" ? $exists($match($phone, /^[6-9][0-9]{9}$/)[0]) : false) ) message: "applicantPhone must be a ten-digit mobile number, as text" - # Text with something in it: a blank complaint would be filed and reported - # as Registered. + # PMFBY would file a blank complaint. - check: | ( $d := beckn.message.support.channels[0].grievance.description; $not($exists($d)) or ($type($d) = "string" ? $exists($match($d, /\S/)[0]) : false) ) message: "grievance.description must be text that is not blank" - # PMFBY reads a non-numeric id as 0 and files the ticket under the wrong head - # while reporting success, so the codes are checked before they are sent. + # PMFBY reads a non-numeric id as 0 and still reports success. - check: | ( $ch := beckn.message.support.channels[0]; @@ -47,7 +35,7 @@ required: and $digits($ch.receiptSourceId) ) message: "grievance.category.code, grievance.subCategory.code and receiptSourceId must be digits" - # Sent as requestYear, which PMFBY reads the same way as the ids above. + # Sent as requestYear. - check: | ( $year := beckn.message.support.channels[0].cropYear; @@ -73,7 +61,7 @@ request: | $ch := $s.channels[0]; $g := $ch.grievance; $at := "$.message.support.channels[0]."; - /* The first required field the request lacks, with its path. */ + /* First missing required field, with its path. */ $missing := [ { "field": "orderId", "path": "$.message.support.orderId", "present": $exists($s.orderId) }, { "field": "applicantPhone", "path": $at & "applicantPhone", "present": $exists($ch.applicantPhone) }, @@ -84,12 +72,11 @@ request: | { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } ][$not(present)][0]; $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; - /* Today in IST, from the request's own timestamp so the response half - arrives at the same date. */ + /* Today in IST, from the request timestamp. */ $today := $exists(beckn.context.timestamp) ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") : $now("[Y0001]-[M01]-[D01]", "+0530"); - /* The adapter's channel id with PMFBY, used unless the caller has its own. */ + /* Default receipt source id for this adapter. */ $receiptSourceID := "134306"; $missing ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", @@ -107,23 +94,18 @@ request: | } ) -# orderId and the grievance are the caller's own, echoed, and the ticket goes in -# the channel's case. Its status, filedOn and provider are not in PMFBY's -# reply: a freshly lodged grievance is registered, with no portal phrase to -# quote as its name, filedOn is the complaintDate just sent, and provider is -# the one the request was routed by. PMFBY's ticket id is its own row id and is -# not mapped. +# Echoes the request and adds the ticket. PMFBY returns only the ticket number, +# so status is Registered and filedOn is the complaintDate sent. response: | ( $s := beckn.message.support; $ch := $s.channels[0]; $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; - /* The same date the request half sent as complaintDate. */ + /* Same date the request half sent. */ $today := $exists(beckn.context.timestamp) ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") : $now("[Y0001]-[M01]-[D01]", "+0530"); - /* PMFBY says success with responseCode "1", seen as a string and as a - number. Anything else, or a success with no ticket, filed nothing. */ + /* Success is responseCode "1" (string or number) with a ticket number. */ $registered := $string(response.responseCode) = "1" and ($type($ticketNo) = "string" or $type($ticketNo) = "number"); $not($registered) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 1103a33c9..692b9dd27 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -506,27 +506,19 @@ modules: searchConcurrency: 1 # ------------------------------------------------------------------ - # Grievance -- filing a grievance against a scheme and checking it: - # support files the grievance, status reads it back. + # Grievance -- support files a grievance, status reads it back. # ------------------------------------------------------------------ - id: Grievance config: bindingKeys: "pmfby|openagrinet:PMFBYGrievance" - # BOTH, OR THE ADAPTER DOES NOT START. A grievance names its - # capability on the commitment, not on a resource, so without - # capabilityCodeAt every grievance request passes through unserved. - # Tried in order, paired by position: status names them on its - # contract; support composes no contract and names them on its - # one channel instead. + # Paired by position, tried in order: status names the binding on + # its contract, support on its channel. providerIdAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" - # PMFBY issues its own token from its login endpoint - # (.../krphapi/FGMS/NICUsersLogin) and reads it back as the bare - # Authorization header, no "Bearer" -- tokenQuery's exchange, - # header's placement. The login reply nests the token in its - # envelope. tokenUrl is a per-deployment placeholder. + # Logs in at PMFBY's NICUsersLogin and sends the token bare in + # Authorization. tokenUrl is set per deployment. pmfby: authScheme: tokenHeader tokenUrl: <> diff --git a/pkg/plugin/implementation/Grievance/Grievance.go b/pkg/plugin/implementation/Grievance/Grievance.go index 727abd016..7073e617d 100644 --- a/pkg/plugin/implementation/Grievance/Grievance.go +++ b/pkg/plugin/implementation/Grievance/Grievance.go @@ -1,10 +1,6 @@ // Package Grievance serves the network's grievance capabilities: filing a -// grievance against a government scheme and checking on it. -// -// One package for every scheme. What differs between PMFBY and the next portal -// -- endpoints, field names, how it says "no", how it authenticates -- is the -// registry's, the mapping's and the adapter config's, so nothing here names a -// provider. Like MandiPrice, this package is a name over internal/common. +// grievance against a government scheme and checking on it. Provider details +// live in the registry, the mappings and the adapter config, not here. package Grievance import ( @@ -14,13 +10,10 @@ import ( "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" ) -// Config is upstream's, unchanged. Aliased here so a domain plugin's cmd package -// need not know where the machinery lives. +// Config is the shared step config. type Config = common.Config -// New creates the grievance step. -// -// No prerequisites: everything a grievance call sends is in the payload. +// New creates the grievance step. It has no prerequisites. func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper definition.Mapper, cfg *Config) (definition.Step, func() error, error) { return common.New(ctx, registry, mapper, nil, cfg) diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index 0f2fcc056..dae3fc93a 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -1,7 +1,5 @@ -// Command plugin builds the grievance provider step as a loadable plugin. -// -// The filename of the built .so is the id a deployment names in providerSteps, -// so this package is grievance's whole public surface: a config map in, a step out. +// Command plugin builds the grievance provider step as a loadable plugin. The +// .so's filename is the id a deployment names in providerSteps. package main import ( @@ -23,14 +21,11 @@ type grievanceProvider struct{} // newStepFunc creates a new step. Indirected for tests. var newStepFunc = Grievance.New -// parseConfig turns the plugin config map into a typed Config. Anything absent -// is left zero: Grievance.New applies the defaults and validates the auth scheme, -// so those rules live in one place. +// parseConfig turns the plugin config map into a typed Config. Defaults and +// validation are left to Grievance.New. func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Config, error) { cfg := &Grievance.Config{ - BindingKeys: splitList(config["bindingKeys"]), - // Absent means the Beckn v2 convention. See common.Config for why - // this is a default rather than something to set. + BindingKeys: splitList(config["bindingKeys"]), ProviderIDAt: config["providerIdAt"], CapabilityCodeAt: config["capabilityCodeAt"], } @@ -46,10 +41,7 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con cfg.MaxResponseBytes = value } - // One credential profile per provider, read from the flattened - // authScheme- settings. Shared with the other capability - // plugins: each used to copy the same field list, so a scheme added in one - // had to be remembered in three. + // One credential profile per provider. auth, err := common.ParseProviderAuth(config) if err != nil { return nil, err @@ -81,16 +73,9 @@ func (p grievanceProvider) New(ctx context.Context, registry definition.Provider return step, closer, nil } -// splitList reads a comma-separated config value, which is how a list reaches a -// plugin -- the config is map[string]string. Blanks are dropped and spaces -// trimmed, so a trailing comma or a wrapped line is not a config error. -// -// A comma is unambiguous here: a binding key separates its own halves with a -// pipe. +// splitList reads a comma-separated config value, trimming spaces and dropping +// blanks. func splitList(raw string) []string { - if strings.TrimSpace(raw) == "" { - return nil - } var out []string for _, part := range strings.Split(raw, ",") { if trimmed := strings.TrimSpace(part); trimmed != "" { @@ -103,6 +88,5 @@ func splitList(raw string) []string { // Provider is the exported plugin instance. var Provider = grievanceProvider{} -// Compile-time proof the provider satisfies the interface the manager asserts -// against. A mismatch is otherwise a runtime cast failure at startup. +// Compile-time check that Provider satisfies the interface. var _ definition.ProviderStepProvider = Provider diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 530e10b99..f6a94475a 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -1,8 +1,7 @@ package Grievance_test -// mappings_test.go runs the shipped PMFBY mapping files through the real mapper -// and the real grievance step, against a stub PMFBY that logs in, sends an OTP, -// files a ticket and reads it back. +// mappings_test.go runs the shipped PMFBY mappings through the real step, +// against a stub PMFBY that logs in, files a ticket and reads it back. import ( "context" @@ -36,8 +35,7 @@ const ( ) // knownTicketRecord is PMFBY's record for knownTicket, personal data included. -// The category comes back by name alone: a case read carries no category id. -// The ticket number is not returned either, only PMFBY's internal ticket id. +// It carries the category by name only, and no ticket number. const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","recordCount":1,"responseDynamic":{ "GrievenceSupportTicketID":109301,"TicketStatus":"Under Review", "ComplaintDate":"2026-10-04","ApplicationNo":"040108251010160770605", @@ -46,9 +44,7 @@ const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","reco "FarmerName":"Ramesh","StateMasterName":"Karnataka","DistrictMasterName":"Mysuru", "InsuranceCompany":"Insurer"}}` -// pmfbyStub stands in for PMFBY's FGMS: a login that nests its token in the -// reply envelope, and replies whose responseCode "1" is success. Every field -// left zero gives the ordinary answer. +// pmfbyStub stands in for PMFBY's FGMS. Zero fields give the ordinary answer. type pmfbyStub struct { down bool // 503 on every call but the login loginDown bool // 401 on the login @@ -112,15 +108,13 @@ func (s *stubRegistry) ProviderRecord(context.Context, string) (*model.ProviderR } // harness is the real step over the shipped mappings, wired to a stub PMFBY. -// One harness serves several requests, so a held token carries across them. type harness struct { pmfby *pmfbyStub step definition.Step } -// newHarness builds the step. statusRetries is the registry's retryMax for -// status; support gets 0, as the seed ships it. tweak edits the -// shipped config before the step is built. +// newHarness builds the step. statusRetries is status's retryMax (support +// gets 0); tweak edits the config first. func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func(*Grievance.Config)) *harness { t.Helper() t.Setenv("PMFBY_USER", "user") @@ -153,8 +147,7 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func }} cfg := &Grievance.Config{ - BindingKeys: []string{shippedBindingKey}, - // A support request composes no contract; its channel names both. + BindingKeys: []string{shippedBindingKey}, ProviderIDAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id", CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type", AuthByProvider: map[string]*common.AuthProfile{"pmfby": { @@ -211,8 +204,7 @@ func (a *answer) status() string { return a.Message.Contract.Commitments[0].Status.Descriptor.Code } -// band reads one of the nested objects a payload groups its fields in: the -// grievance, the case, or a status inside a case. +// band reads a nested object: the grievance, the case, or its status. func band(of map[string]any, name string) map[string]any { inner, _ := of[name].(map[string]any) return inner @@ -250,19 +242,17 @@ func (h *harness) mustSend(t *testing.T, body []byte) *answer { const packContext = "https://openagrinet.github.io/network-specs/api-schemas/PMFBYGrievance/v0.1/context.jsonld" -// scheme is what every PMFBY payload names, in both directions. +// scheme is what every PMFBY payload names. var scheme = map[string]any{"code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana"} -// provider is the portal a support request names on its channel, since it -// composes no contract to name it on. +// provider is the portal a support channel names. var provider = map[string]any{"id": "pmfby", "descriptor": map[string]any{"name": "PMFBY Grievance Portal"}} func becknContext(action string) map[string]any { return map[string]any{"version": "2.0.0", "action": action, "transactionId": "txn-1", "messageId": "msg-1"} } -// request builds a Beckn grievance payload for status: a contract whose -// one commitment carries these attributes. +// request builds a status payload whose one commitment carries attributes. func request(action string, attributes map[string]any) []byte { attributes["@context"] = packContext attributes["@type"] = "openagrinet:PMFBYGrievance" @@ -283,10 +273,9 @@ func request(action string, attributes map[string]any) []byte { return body } -// support builds a Beckn support request, which composes no contract. fields is -// flat: orderId goes on support; categoryCode, subCategoryCode and description -// make channels[0].grievance; everything else goes on channels[0]. A field left -// out is absent from the payload. +// support builds a support request from flat fields: orderId on support, +// categoryCode, subCategoryCode and description under grievance, the rest on +// channels[0]. func support(fields map[string]any) []byte { channel := map[string]any{"@context": packContext, "@type": "openagrinet:PMFBYGrievance", "informationMode": "OnDemand", "scheme": scheme, "provider": provider} @@ -325,8 +314,7 @@ func statusAttributes() map[string]any { return map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": knownTicket}} } -// refusal is the HTTP status and Beckn error a failed request is answered -// with, whichever of the two step error types carries it. +// refusal is the HTTP status and Beckn error a failed request is answered with. func refusal(t *testing.T, err error) (int, *model.Error) { t.Helper() var invalid *model.SchemaValidationErr @@ -360,8 +348,7 @@ func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) got := h.mustSend(t, support(supportFields())) - // No OTP: PMFBY's grievance service asks for none. Every value goes as a - // string, the season as its code. + // Every value goes as a string, the season as its code. want := map[string]any{ "requestorMobileNo": phone, "applicationNo": "040108251010160770605", "requestYear": "2025", "requestSeason": "1", "ticketCategoryID": "3", "ticketSubCategoryID": "10", @@ -380,7 +367,7 @@ func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { if got.Context["action"] != "on_support" || got.Context["transactionId"] != "txn-1" { t.Errorf("context = %v, want on_support on the request's transaction", got.Context) } - // orderId and the grievance are the caller's own, echoed unchanged. + // orderId and the grievance are echoed unchanged. channel := got.channel() grievance := band(channel, "grievance") if got.Message.Support.OrderID != "040108251010160770605" || band(grievance, "category")["code"] != "3" || @@ -515,8 +502,7 @@ func TestSupport_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { } } -// complaintDate and receiptSourceId are optional: sent when the caller has -// them, and filedOn restates the date that went. +// complaintDate and receiptSourceId are sent when given; filedOn echoes the date. func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() @@ -574,8 +560,7 @@ func TestSupport_MalformedPhoneOrDescription_Returns400WithoutCallingPMFBY(t *te } } -// With no complaintDate, the date is the request's own, in IST, on both the -// lodge call and filedOn: a call crossing midnight cannot report a different day. +// With no complaintDate, both the call and filedOn use the request's date in IST. func TestSupport_NoComplaintDate_DatesFromTheRequestInIST(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) var payload map[string]any @@ -592,8 +577,7 @@ func TestSupport_NoComplaintDate_DatesFromTheRequestInIST(t *testing.T) { } } -// PMFBY answers its own refusal with HTTP 200 and a responseCode other than -// "1". That filed nothing, so it is never reported as Registered. +// PMFBY refuses with HTTP 200 and a responseCode other than "1". func TestSupport_PortalDidNotRegister_Returns502(t *testing.T) { for name, reply := range map[string]string{ "refused": `{"responseCode":"0","responseMessage":"Invalid application number","responseDynamic":null}`, @@ -611,7 +595,7 @@ func TestSupport_PortalDidNotRegister_Returns502(t *testing.T) { } } -// A 4xx is PMFBY's refusal: no OTP is involved, so nothing is the farmer's to fix. +// A 4xx from PMFBY is its refusal. func TestSupport_PortalAnswers4xx_Returns502(t *testing.T) { for _, status := range []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity} { t.Run(http.StatusText(status), func(t *testing.T) { @@ -655,8 +639,7 @@ func TestSupport_SecondChannel_Returns400WithoutCallingPMFBY(t *testing.T) { } } -// The channel paths are what make a support request reachable at all: without -// them it composes no contract the plugin can read a binding from. +// Without the channel paths a support request names no binding the step reads. func TestSupport_ChannelPathsNotConfigured_PassesThroughUntouched(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0, func(c *Grievance.Config) { c.ProviderIDAt = "message.contract.commitments[].offer.provider.id" @@ -671,8 +654,7 @@ func TestSupport_ChannelPathsNotConfigured_PassesThroughUntouched(t *testing.T) } } -// Two channels is refused only when they name this step's work: another -// capability's support request reaches this step too and must pass through. +// Two channels are refused only when they name this step's binding. func TestSupport_OtherProviderTwoChannels_PassesThrough(t *testing.T) { var payload map[string]any _ = json.Unmarshal(support(supportFields()), &payload) @@ -713,7 +695,7 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { t.Errorf("PMFBY was sent %v, want the ticket and the phone alone", sent) } attributes := got.attributes() - // The complaint as PMFBY holds it: the category by name, with no code. + // The category comes back by name, with no code. grievance := band(attributes, "grievance") category, subCategory := band(grievance, "category"), band(grievance, "subCategory") if len(category) != 1 || category["name"] != "Claim" || len(subCategory) != 1 || @@ -725,7 +707,7 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" || filed["cropName"] != "Paddy" { t.Errorf("commitmentAttributes = %v", attributes) } - // An unrecognised portal phrase is UnderReview, and the phrase is not forwarded. + // Any portal phrase maps to UnderReview and is not forwarded. if status := band(filed, "status"); len(status) != 1 || status["code"] != "UnderReview" { t.Errorf("case.status = %v, want code UnderReview alone", status) } @@ -756,8 +738,7 @@ func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { } } -// A case read always carries a status, so a missing phrase still says -// UnderReview; only the name, which would quote the portal, is dropped. +// A missing phrase still maps to UnderReview. func TestStatus_NoStatusPhrase_UnderReviewWithoutName(t *testing.T) { for name, phrase := range map[string]string{"null": `null`, "empty": `""`} { t.Run(name, func(t *testing.T) { @@ -816,8 +797,7 @@ func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { } } -// A reply without FGMS's success code is a failure, never "nothing on file" -- -// including one with no envelope at all. +// A reply without the success code, or with no envelope, is a failure. func TestStatus_PortalRefused_Returns502(t *testing.T) { for name, reply := range map[string]string{ "refused": `{"responseCode":"0","responseMessage":"Session expired"}`, @@ -831,9 +811,7 @@ func TestStatus_PortalRefused_Returns502(t *testing.T) { } } -// One ticket sent as a list of one reads the same as one sent bare. -// A record PMFBY left empty is no record: reporting it as under review would -// describe a grievance nobody filed. +// A list of one reads like a bare record; an empty record is no record. func TestStatus_EmptyRecord_Returns202NoResultsFound(t *testing.T) { for name, dynamic := range map[string]string{ "empty object": `{}`, @@ -919,7 +897,7 @@ func TestFlow_AnswerNotJSON_ReturnsErrorWithoutMapping(t *testing.T) { } } -// init included: PMFBY publishes no challenge, so there is no init to serve. +// init included: PMFBY has no OTP, so there is no init. func TestFlow_UnservedAction_Returns400WithoutCallingPMFBY(t *testing.T) { for _, action := range []string{"init", "select"} { t.Run(action, func(t *testing.T) { diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 52c9887c3..e69384838 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -60,8 +60,7 @@ type AuthProfile struct { TokenSecretName string TokenSecretEnv string - // Where the token sits in the response body, as a dotted path: - // "token" at the top level, "responseDynamic.token.Token" nested. + // Where the token sits in the response body, as a dotted path. TokenResponseField string // How long a token may be held, as written in config and as parsed. @@ -315,7 +314,7 @@ func (s *Step) authenticate(auth *authenticator, req *http.Request) error { req.Header.Set("Authorization", "Bearer "+token) case util.AuthSchemeTokenHeader: - // Exchanged like tokenQuery, placed like header: the bare token. + // Exchanged like tokenQuery, sent bare in the header. token, err := s.providerToken(req.Context(), auth) if err != nil { return err diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 7ec57283e..3012eb414 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -53,9 +53,8 @@ type Config struct { // requests silently fail to match. Configurable only so a spec change can // be tracked without waiting for a release. Both or neither. // - // Either may list several paths, comma-separated, paired by position and - // tried in order until one pair finds a binding: a support request composes - // no contract and names its provider and type on its channel instead. + // Either may list several comma-separated paths, paired by position and + // tried in order until one finds a binding. ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` @@ -149,8 +148,7 @@ func New(ctx context.Context, registry definition.ProviderRecordLookup, mapper d // request out over several capability types. The alternative is reading the // same two config fields a second time, which is how the two drift apart. // -// A list of paths is refused here: such a caller reads one pair, and handed the -// list as one path it would quietly match nothing. +// A path list is refused: such a caller reads only one pair. func BindingPaths(cfg *Config) (Paths, error) { pathList, err := bindingPathList(cfg) if err != nil { @@ -162,8 +160,8 @@ func BindingPaths(cfg *Config) (Paths, error) { return pathList[0], nil } -// bindingPathList resolves the pairs this step reads a binding key from, in -// the order they are tried. +// bindingPathList returns the path pairs to try, in order: the comma-separated +// halves paired by position. func bindingPathList(cfg *Config) ([]Paths, error) { if cfg.ProviderIDAt == "" && cfg.CapabilityCodeAt == "" { return []Paths{BecknV2}, nil @@ -174,14 +172,8 @@ func bindingPathList(cfg *Config) ([]Paths, error) { if cfg.CapabilityCodeAt == "" { return nil, errors.New("providerIdAt is set without capabilityCodeAt") } - return splitPaths(Paths{ProviderID: cfg.ProviderIDAt, CapabilityCode: cfg.CapabilityCodeAt}) -} - -// splitPaths pairs the comma-separated halves of paths by position, so each -// pair is one place a binding key may sit. -func splitPaths(paths Paths) ([]Paths, error) { - providers := strings.Split(paths.ProviderID, ",") - capabilities := strings.Split(paths.CapabilityCode, ",") + providers := strings.Split(cfg.ProviderIDAt, ",") + capabilities := strings.Split(cfg.CapabilityCodeAt, ",") if len(providers) != len(capabilities) { return nil, fmt.Errorf("providerIdAt lists %d paths and capabilityCodeAt %d; they pair by position", len(providers), len(capabilities)) diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 014fcd9cf..3724090b4 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -31,9 +31,8 @@ func (s *Step) Run(ctx *model.StepContext) error { if errors.Is(err, errNoBinding) { continue } - // A later pair may read a container other capabilities share -- a - // support request's channels -- so a payload it cannot resolve is - // refused only when it names this step's own work. + // A later pair may read a shared container (support channels), so an + // unresolvable payload is refused only if it names this step's binding. if err != nil && i > 0 && !s.namesServed(paths, ctx.Body) { return nil } @@ -98,8 +97,7 @@ func (s *Step) serves(key string) bool { return slices.Contains(s.config.BindingKeys, key) } -// namesServed reports whether any provider and type the paths reach pair into -// a binding this step serves. +// namesServed reports whether the payload names a binding this step serves. func (s *Step) namesServed(paths Paths, body []byte) bool { var payload any if json.Unmarshal(body, &payload) != nil { @@ -178,9 +176,7 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { if err != nil { return err } - // What the reply means may be a refusal -- nothing on file -- which the - // response half says under _error. The provider's body is not logged: it can - // echo the farmer's own details, and redaction only covers credentials. + // The provider body is not logged: it can carry the farmer's details. if err := refusalIn(becknResponse); err != nil { log.Warnf(ctx, "%s refused the request: %v", plan.BindingKey, err) return err @@ -212,7 +208,6 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an if err != nil { return nil, err } - // Refused before the provider is called, so a bad request costs no call. if err := refusalIn(mapped); err != nil { return nil, err } @@ -222,19 +217,15 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an return mapped, nil } -// refusalIn returns the error a mapping half asks for under the reserved -// _error field -- {status, code, message, path}, status absent meaning 400 -- -// or nil when the document carries none. path is the JSONPath of the field at -// fault, returned as details.path. 202 is an answer with nothing in it, so it -// is an ACK carrying the reason. +// refusalIn returns the error a mapping asks for under the reserved _error +// field {status, code, message, path}, or nil if there is none. Status 0 or +// 400 is a 400 with details.path; 202 is an ACK carrying the reason. func refusalIn(mapped []byte) error { - // Checked as text first: every answer passes through here, and almost - // none carries the field, so most are spared a second decode. + // Cheap text check first: almost no answer carries the field. if !bytes.Contains(mapped, []byte(`"_error"`)) { return nil } var document map[string]json.RawMessage - // A JSON null decodes to the bytes "null", not to nil, so both mean none. if json.Unmarshal(mapped, &document) != nil || document["_error"] == nil || string(document["_error"]) == "null" { return nil } @@ -249,7 +240,7 @@ func refusalIn(mapped []byte) error { } switch r.Status { case 0, http.StatusBadRequest: - // The one 400 that carries a path on the wire: CodedErr drops details. + // CodedErr drops details, so the path needs SchemaValidationErr. return &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} case http.StatusAccepted: return model.NewAckNoCallbackErr(model.StatusACK, &model.Error{Code: r.Code, Message: r.Message}) diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index d6cb05183..9b6d071d8 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -123,8 +123,7 @@ func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (str return "", 0, err } - // Decoded into a map because the field name is configured: a struct tag - // cannot be written for a key that is not known until config is read. + // Decoded generically because the token's path is configured. var fields any if err := json.Unmarshal(body, &fields); err != nil { return "", 0, s.permanentTokenErr(fmt.Errorf("token response from %s is not JSON: %w", diff --git a/pkg/plugin/implementation/internal/common/util/constant.go b/pkg/plugin/implementation/internal/common/util/constant.go index 9e40e65fd..5d305d6e8 100644 --- a/pkg/plugin/implementation/internal/common/util/constant.go +++ b/pkg/plugin/implementation/internal/common/util/constant.go @@ -81,10 +81,8 @@ const ( // errors -- so it is redacted the same way. See redact. AuthSchemeTokenQuery = "tokenQuery" - // tokenQuery's exchange, header's placement: the token that comes back is - // sent as-is in the configured header, with no "Bearer" prefix. For an - // upstream with its own login endpoint that reads the bare token from a - // header -- PMFBY. + // tokenQuery's login exchange, but the token is sent bare (no "Bearer") + // in the configured header. AuthSchemeTokenHeader = "tokenHeader" ) From a133fa5aeca113076015f83dc77527428269e688 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 12 Oct 2026 12:18:57 +0530 Subject: [PATCH 25/39] fix: read PMFBY's real no-record reply and refuse another ticket's record [#57] --- config/mappings/pmfby/grievance.status.yaml | 19 ++++++++---- .../implementation/Grievance/mappings_test.go | 30 ++++++++++++++++--- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 6ad5dd02b..08a8da514 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -31,22 +31,31 @@ request: | } ) -# responseCode other than "1": 502. Success with no record: 202 -# BIZ_NO_RESULTS_FOUND. Otherwise the record is mapped; fields PMFBY leaves -# empty are dropped, and the ticket number is echoed from the request. +# No record: 202 BIZ_NO_RESULTS_FOUND. Any other responseCode than "1": 502. +# Otherwise the record is mapped; fields PMFBY leaves empty are dropped. response: | ( /* A missing responseCode is a failure. [0] reads a bare record or a list of one alike. */ $ok := $string(response.responseCode) = "1"; $ticket := response.responseDynamic[0]; + $asked := $string(beckn.message.contract.commitments[0].commitmentAttributes.case.ticketNo); /* PMFBY sends null or "" for missing values. */ $isText := function($v) { $type($v) = "string" and $v != "" }; - $not($ok) + /* PMFBY answers "No record found" as responseCode "0" with an empty list, + which JSONata reads as no value; a refusal sends null or no key. */ + $notFound := $string(response.responseCode) = "0" and "responseDynamic" in $keys(response) + and $not($exists(response.responseDynamic)); + $notFound + ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", + "message": "No grievance matches this ticket and phone number." } } + : $not($ok) ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", "message": "PMFBY could not read the grievance" } } - /* An empty or all-null record means nothing on file. */ + /* An empty or all-null record, or one for another ticket, is no record: + PMFBY may answer an unknown ticket with the phone's other ticket. */ : $not($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) + or ($exists($ticket.GrievenceSupportTicketNo) and $string($ticket.GrievenceSupportTicketNo) != $asked) ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", "message": "No grievance matches this ticket and phone number." } } : ( diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index f6a94475a..3f2e86ede 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -35,7 +35,7 @@ const ( ) // knownTicketRecord is PMFBY's record for knownTicket, personal data included. -// It carries the category by name only, and no ticket number. +// It carries the category by name only. const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","recordCount":1,"responseDynamic":{ "GrievenceSupportTicketID":109301,"TicketStatus":"Under Review", "ComplaintDate":"2026-10-04","ApplicationNo":"040108251010160770605", @@ -92,7 +92,7 @@ func (p *pmfbyStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"responseCode":"1","responseMessage":"Ticket created","responseDynamic":{"GrievenceSupportTicketNo":%q,"GrievenceSupportTicketID":8842317}}`, knownTicket) case statusPath: if body["GrievenceSupportTicketNo"] != knownTicket { - fmt.Fprint(w, `{"responseCode":"1","responseMessage":"No record found","responseDynamic":null}`) + fmt.Fprint(w, `{"responseCode":"0","responseMessage":"No record found","responseDynamic":[]}`) return } fmt.Fprint(w, knownTicketRecord) @@ -800,8 +800,9 @@ func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { // A reply without the success code, or with no envelope, is a failure. func TestStatus_PortalRefused_Returns502(t *testing.T) { for name, reply := range map[string]string{ - "refused": `{"responseCode":"0","responseMessage":"Session expired"}`, - "no envelope": `{"message":"An error has occurred."}`, + "refused": `{"responseCode":"0","responseMessage":"Session expired"}`, + "token required": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, + "no envelope": `{"message":"An error has occurred."}`, } { t.Run(name, func(t *testing.T) { h := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0) @@ -814,6 +815,7 @@ func TestStatus_PortalRefused_Returns502(t *testing.T) { // A list of one reads like a bare record; an empty record is no record. func TestStatus_EmptyRecord_Returns202NoResultsFound(t *testing.T) { for name, dynamic := range map[string]string{ + "null": `null`, "empty object": `{}`, "empty in list": `[{}]`, "all fields null": `{"ApplicationNo":null,"TicketStatus":null,"ComplaintDate":null,"CropName":null}`, @@ -830,6 +832,26 @@ func TestStatus_EmptyRecord_Returns202NoResultsFound(t *testing.T) { } } +// PMFBY may answer an unknown ticket with the phone's other ticket. +func TestStatus_RecordForAnotherTicket_Returns202NoResultsFound(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{"GrievenceSupportTicketNo":"100626000099002",`, 1) + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + send(t, request("status", statusAttributes())) + var ack *model.AckNoCallbackErr + if !errors.As(err, &ack) || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { + t.Errorf("error = %v, want a 202 ACK BIZ_NO_RESULTS_FOUND", err) + } +} + +func TestStatus_RecordNamingTheTicket_ReturnsIt(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{"GrievenceSupportTicketNo":"`+knownTicket+`",`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if no := band(got.attributes(), "case")["ticketNo"]; no != knownTicket { + t.Errorf("ticketNo = %v, want %s", no, knownTicket) + } +} + func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { record := strings.Replace(strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":[{`, 1), `}}`, `}]}`, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). From f75a2934a6e47fededbd9a379e93180ea24faf90 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 13 Oct 2026 07:22:06 +0530 Subject: [PATCH 26/39] fix: default PMFBY's category pair and read enrolmentId from the channel [#57] The use case doc makes the category codes optional, defaulting to PMFBY's published 3 and 10, and the pack keeps enrolmentId in the channel instead of message.support.orderId. --- config/mappings/pmfby/grievance.support.yaml | 40 +++++++++---------- .../implementation/Grievance/mappings_test.go | 40 ++++++++++++------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 44f929d30..d680e3468 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -1,17 +1,10 @@ # PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. -# Files a grievance; no OTP. orderId is the application number, channels[0] -# holds the provider and the complaint. "Grievence" is PMFBY's spelling. +# Files a grievance; no OTP. channels[0] holds the provider, the enrolment and +# the complaint. "Grievence" is PMFBY's spelling. # Format checks (SCH_INVALID_FORMAT); an absent field passes here and is # refused by the request half with SCH_REQUIRED_FIELD_MISSING. required: - # The pack bounds enrolmentId to 10-30 characters; orderId is outside it. - - check: | - ( - $id := beckn.message.support.orderId; - $not($exists($id)) or ($type($id) = "string" ? ($length($id) >= 10 and $length($id) <= 30) : false) - ) - message: "orderId, the application number, must be 10-30 characters" # PMFBY files and reads the ticket by this number. - check: | ( @@ -57,21 +50,21 @@ required: request: | ( - $s := beckn.message.support; - $ch := $s.channels[0]; + $ch := beckn.message.support.channels[0]; $g := $ch.grievance; $at := "$.message.support.channels[0]."; /* First missing required field, with its path. */ $missing := [ - { "field": "orderId", "path": "$.message.support.orderId", "present": $exists($s.orderId) }, + { "field": "enrolmentId", "path": $at & "enrolmentId", "present": $exists($ch.enrolmentId) }, { "field": "applicantPhone", "path": $at & "applicantPhone", "present": $exists($ch.applicantPhone) }, { "field": "cropYear", "path": $at & "cropYear", "present": $exists($ch.cropYear) }, { "field": "season", "path": $at & "season", "present": $exists($ch.season) }, - { "field": "grievance.category.code", "path": $at & "grievance.category.code", "present": $exists($g.category.code) }, - { "field": "grievance.subCategory.code", "path": $at & "grievance.subCategory.code", "present": $exists($g.subCategory.code) }, { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } ][$not(present)][0]; $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; + /* PMFBY's published pair, sent when the caller names none. */ + $category := $exists($g.category.code) ? $string($g.category.code) : "3"; + $subCategory := $exists($g.subCategory.code) ? $string($g.subCategory.code) : "10"; /* Today in IST, from the request timestamp. */ $today := $exists(beckn.context.timestamp) ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") @@ -83,11 +76,11 @@ request: | "message": $missing.field & " is required", "path": $missing.path } } : { "requestorMobileNo": $ch.applicantPhone, - "applicationNo": $s.orderId, + "applicationNo": $ch.enrolmentId, "requestYear": $string($ch.cropYear), "requestSeason": $lookup($seasons, $ch.season), - "ticketCategoryID": $string($g.category.code), - "ticketSubCategoryID": $string($g.subCategory.code), + "ticketCategoryID": $category, + "ticketSubCategoryID": $subCategory, "grievenceDescription": $trim($g.description), "complaintDate": $exists($ch.complaintDate) ? $ch.complaintDate : $today, "receiptSourceID": $exists($ch.receiptSourceId) ? $string($ch.receiptSourceId) : $receiptSourceID @@ -98,8 +91,8 @@ request: | # so status is Registered and filedOn is the complaintDate sent. response: | ( - $s := beckn.message.support; - $ch := $s.channels[0]; + $ch := beckn.message.support.channels[0]; + $g := $ch.grievance; $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; /* Same date the request half sent. */ $today := $exists(beckn.context.timestamp) @@ -115,14 +108,19 @@ response: | "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), "message": { "support": { - "orderId": $s.orderId, "channels": [{ "@context": $ch.`@context`, "@type": $ch.`@type`, "informationMode": "Direct", "provider": $ch.provider, "scheme": $ch.scheme, - "grievance": $ch.grievance, + "enrolmentId": $ch.enrolmentId, + /* The codes sent, defaults included; a name beside a code is dropped. */ + "grievance": { + "category": { "code": $exists($g.category.code) ? $string($g.category.code) : "3" }, + "subCategory": { "code": $exists($g.subCategory.code) ? $string($g.subCategory.code) : "10" }, + "description": $g.description + }, "case": { "ticketNo": $string($ticketNo), "status": { "code": "Registered" }, diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 3f2e86ede..db426fcda 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -185,7 +185,6 @@ type answer struct { } `json:"commitments"` } `json:"contract"` Support struct { - OrderID string `json:"orderId"` Channels []map[string]any `json:"channels"` } `json:"support"` } `json:"message"` @@ -273,9 +272,8 @@ func request(action string, attributes map[string]any) []byte { return body } -// support builds a support request from flat fields: orderId on support, -// categoryCode, subCategoryCode and description under grievance, the rest on -// channels[0]. +// support builds a support request from flat fields: categoryCode, +// subCategoryCode and description under grievance, the rest on channels[0]. func support(fields map[string]any) []byte { channel := map[string]any{"@context": packContext, "@type": "openagrinet:PMFBYGrievance", "informationMode": "OnDemand", "scheme": scheme, "provider": provider} @@ -283,8 +281,6 @@ func support(fields map[string]any) []byte { grievance := map[string]any{} for field, value := range fields { switch field { - case "orderId": - body["orderId"] = value case "categoryCode": grievance["category"] = map[string]any{"code": value, "name": "Claim"} case "subCategoryCode": @@ -304,7 +300,7 @@ func support(fields map[string]any) []byte { func supportFields() map[string]any { return map[string]any{ - "orderId": "040108251010160770605", "categoryCode": "3", "subCategoryCode": "10", + "enrolmentId": "040108251010160770605", "categoryCode": "3", "subCategoryCode": "10", "description": " Claim not received ", "applicantPhone": phone, "cropYear": "2025", "season": "Kharif", } @@ -367,12 +363,13 @@ func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { if got.Context["action"] != "on_support" || got.Context["transactionId"] != "txn-1" { t.Errorf("context = %v, want on_support on the request's transaction", got.Context) } - // orderId and the grievance are echoed unchanged. + // enrolmentId and the grievance are echoed; a name beside a code is dropped. channel := got.channel() grievance := band(channel, "grievance") - if got.Message.Support.OrderID != "040108251010160770605" || band(grievance, "category")["code"] != "3" || - band(grievance, "subCategory")["code"] != "10" || grievance["description"] != " Claim not received " { - t.Errorf("support = %+v, want orderId and grievance echoed", got.Message.Support) + if channel["enrolmentId"] != "040108251010160770605" || len(band(grievance, "category")) != 1 || + band(grievance, "category")["code"] != "3" || band(grievance, "subCategory")["code"] != "10" || + grievance["description"] != " Claim not received " { + t.Errorf("channel = %v, want enrolmentId and grievance echoed", channel) } filed := band(channel, "case") status := band(filed, "status") @@ -436,12 +433,28 @@ func TestSupport_CategoryCodes_SentAsGiven(t *testing.T) { } } +// Without category codes, PMFBY's published pair is sent and echoed. +func TestSupport_NoCategoryCodes_SendsAndEchoesDefaults(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + fields := supportFields() + delete(fields, "categoryCode") + delete(fields, "subCategoryCode") + got := h.mustSend(t, support(fields)) + sent := h.pmfby.bodies[insertPath] + if sent["ticketCategoryID"] != "3" || sent["ticketSubCategoryID"] != "10" { + t.Errorf("sent %v / %v, want 3 / 10", sent["ticketCategoryID"], sent["ticketSubCategoryID"]) + } + grievance := band(got.channel(), "grievance") + if band(grievance, "category")["code"] != "3" || band(grievance, "subCategory")["code"] != "10" { + t.Errorf("grievance = %v, want the defaults echoed", grievance) + } +} + func TestSupport_MissingField_Returns400NamingItsPath(t *testing.T) { channel := "$.message.support.channels[0]." for field, path := range map[string]string{ - "orderId": "$.message.support.orderId", "applicantPhone": channel + "applicantPhone", + "enrolmentId": channel + "enrolmentId", "applicantPhone": channel + "applicantPhone", "cropYear": channel + "cropYear", "season": channel + "season", - "categoryCode": channel + "grievance.category.code", "subCategoryCode": channel + "grievance.subCategory.code", "description": channel + "grievance.description", } { t.Run(field, func(t *testing.T) { @@ -521,7 +534,6 @@ func TestSupport_MalformedDateOrSource_Returns400WithoutCallingPMFBY(t *testing. for name, field := range map[string][2]string{ "date": {"complaintDate", "20-09-2026"}, "source": {"receiptSourceId", "web"}, "crop year": {"cropYear", "last year"}, "short crop year": {"cropYear", "25"}, - "short order id": {"orderId", "123456789"}, "long order id": {"orderId", strings.Repeat("1", 31)}, } { t.Run(name, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) From 9dec9bb1eaaf5cd575de2d6b4aa9ade12aa5f411 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Tue, 13 Oct 2026 09:08:53 +0530 Subject: [PATCH 27/39] fix: answer no grievance found as a 202 NACK [#57] --- .../implementation/Grievance/mappings_test.go | 14 +++++++------- .../implementation/internal/common/common_test.go | 8 ++++---- pkg/plugin/implementation/internal/common/serve.go | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index db426fcda..e52136a0e 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -802,10 +802,10 @@ func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { var ack *model.AckNoCallbackErr if !errors.As(err, &ack) { - t.Fatalf("error = %v, want a 202 ACK", err) + t.Fatalf("error = %v, want a 202 NACK", err) } - if ack.Status != model.StatusACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { - t.Errorf("answer = %s %s, want ACK BIZ_NO_RESULTS_FOUND", ack.Status, ack.Err.Code) + if ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { + t.Errorf("answer = %s %s, want NACK BIZ_NO_RESULTS_FOUND", ack.Status, ack.Err.Code) } } @@ -837,8 +837,8 @@ func TestStatus_EmptyRecord_Returns202NoResultsFound(t *testing.T) { _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). send(t, request("status", statusAttributes())) var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { - t.Errorf("error = %v, want a 202 ACK BIZ_NO_RESULTS_FOUND", err) + if !errors.As(err, &ack) || ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { + t.Errorf("error = %v, want a 202 NACK BIZ_NO_RESULTS_FOUND", err) } }) } @@ -850,8 +850,8 @@ func TestStatus_RecordForAnotherTicket_Returns202NoResultsFound(t *testing.T) { _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). send(t, request("status", statusAttributes())) var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { - t.Errorf("error = %v, want a 202 ACK BIZ_NO_RESULTS_FOUND", err) + if !errors.As(err, &ack) || ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { + t.Errorf("error = %v, want a 202 NACK BIZ_NO_RESULTS_FOUND", err) } } diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 93f242a9b..e64a84503 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -2630,7 +2630,7 @@ func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { } } -func TestRefusal_Status202_ReturnsAckWithCode(t *testing.T) { +func TestRefusal_Status202_ReturnsNackWithCode(t *testing.T) { var calls int mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"_error":{"status":202,"code":"BIZ_NO_RESULTS_FOUND","message":"nothing on file"}}`)} @@ -2638,10 +2638,10 @@ func TestRefusal_Status202_ReturnsAckWithCode(t *testing.T) { var ack *model.AckNoCallbackErr if !errors.As(err, &ack) { - t.Fatalf("error = %v (%T), want a 202 ACK", err, err) + t.Fatalf("error = %v (%T), want a 202 NACK", err, err) } - if ack.Status != model.StatusACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" || ack.Err.Message != "nothing on file" { - t.Errorf("ack = %s %+v, want ACK BIZ_NO_RESULTS_FOUND with the mapping's message", ack.Status, ack.Err) + if ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" || ack.Err.Message != "nothing on file" { + t.Errorf("ack = %s %+v, want NACK BIZ_NO_RESULTS_FOUND with the mapping's message", ack.Status, ack.Err) } } diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 3724090b4..0349aba13 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -219,7 +219,7 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an // refusalIn returns the error a mapping asks for under the reserved _error // field {status, code, message, path}, or nil if there is none. Status 0 or -// 400 is a 400 with details.path; 202 is an ACK carrying the reason. +// 400 is a 400 with details.path; 202 is a NACK carrying the reason. func refusalIn(mapped []byte) error { // Cheap text check first: almost no answer carries the field. if !bytes.Contains(mapped, []byte(`"_error"`)) { @@ -243,7 +243,7 @@ func refusalIn(mapped []byte) error { // CodedErr drops details, so the path needs SchemaValidationErr. return &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} case http.StatusAccepted: - return model.NewAckNoCallbackErr(model.StatusACK, &model.Error{Code: r.Code, Message: r.Message}) + return model.NewAckNoCallbackErr(model.StatusNACK, &model.Error{Code: r.Code, Message: r.Message}) default: return model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) } From 0ec04ccc187f125ed31f7142bcda9313e2555d4d Mon Sep 17 00:00:00 2001 From: nisargabd Date: Wed, 14 Oct 2026 08:50:46 +0530 Subject: [PATCH 28/39] fix: answer PMFBY refusals with its own message and case times in UTC [#57] PMFBY answers every call with HTTP 200, so a refusal was reported as a 502 with our own text, hiding the reason the caller can act on. A refusal with a reason is now a 400 carrying PMFBY's message; a reply with no reason is a 502, since that is PMFBY failing rather than the caller. Format checks move out of the mappings to the pack, and filedOn becomes a UTC instant. --- config/mappings/pmfby/grievance.status.yaml | 69 ++--- config/mappings/pmfby/grievance.support.yaml | 100 ++----- .../implementation/Grievance/mappings_test.go | 282 ++++++++---------- .../internal/common/common_test.go | 15 - .../implementation/internal/common/serve.go | 4 +- 5 files changed, 178 insertions(+), 292 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 08a8da514..481a2df54 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -1,18 +1,7 @@ # PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. # Reads a grievance back by ticket number and the phone it was filed from. # The response maps an allow-list; the farmer's name, location and insurer -# are dropped. - -# Format checks (SCH_INVALID_FORMAT); an absent field passes here and is -# refused by the request half with SCH_REQUIRED_FIELD_MISSING. -required: - # PMFBY files and reads the ticket by this number. - - check: | - ( - $phone := beckn.message.contract.commitments[0].commitmentAttributes.applicantPhone; - $not($exists($phone)) or ($type($phone) = "string" ? $exists($match($phone, /^[6-9][0-9]{9}$/)[0]) : false) - ) - message: "applicantPhone must be a ten-digit mobile number, as text" +# are dropped. Field formats are the pack's. request: | ( @@ -31,34 +20,31 @@ request: | } ) -# No record: 202 BIZ_NO_RESULTS_FOUND. Any other responseCode than "1": 502. -# Otherwise the record is mapped; fields PMFBY leaves empty are dropped. +# The record is mapped; fields PMFBY leaves empty are dropped. No record, or +# one for another ticket, is a 400 "No record found"; a refusal is a 400 with +# PMFBY's own message; a reply with no reason is a 502. response: | ( - /* A missing responseCode is a failure. [0] reads a bare record or a - list of one alike. */ - $ok := $string(response.responseCode) = "1"; + /* [0] reads a bare record or a list of one alike. */ $ticket := response.responseDynamic[0]; $asked := $string(beckn.message.contract.commitments[0].commitmentAttributes.case.ticketNo); + $said := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); /* PMFBY sends null or "" for missing values. */ $isText := function($v) { $type($v) = "string" and $v != "" }; - /* PMFBY answers "No record found" as responseCode "0" with an empty list, - which JSONata reads as no value; a refusal sends null or no key. */ - $notFound := $string(response.responseCode) = "0" and "responseDynamic" in $keys(response) - and $not($exists(response.responseDynamic)); - $notFound - ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", - "message": "No grievance matches this ticket and phone number." } } - : $not($ok) - ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY could not read the grievance" } } - /* An empty or all-null record, or one for another ticket, is no record: - PMFBY may answer an unknown ticket with the phone's other ticket. */ - : $not($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) - or ($exists($ticket.GrievenceSupportTicketNo) and $string($ticket.GrievenceSupportTicketNo) != $asked) - ? { "_error": { "status": 202, "code": "BIZ_NO_RESULTS_FOUND", - "message": "No grievance matches this ticket and phone number." } } - : ( + $ok := $string(response.responseCode) = "1"; + /* PMFBY reads by phone alone, so a record for another ticket is no record. */ + $found := $ok + and ($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) + and $not($exists($ticket.GrievenceSupportTicketNo) and $string($ticket.GrievenceSupportTicketNo) != $asked); + /* PMFBY's TicketStatus as a CaseStatusCode; a phrase not listed is UnderReview. */ + $statusOf := { "resolved": "Resolved" }; + $status := $isText($ticket.TicketStatus) ? $lookup($statusOf, $lowercase($trim($ticket.TicketStatus))); + /* InsertDateTime is "YYYY-MM-DD HH:MM:SS" in IST. */ + $inserted := $ticket.InsertDateTime; + $filedOn := $isText($inserted) and $contains($inserted, /^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/) + ? $fromMillis($toMillis($replace($inserted, " ", "T") & "+05:30"), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"); + $found + ? ( $commitment := beckn.message.contract.commitments[0]; $ca := $commitment.commitmentAttributes; { @@ -71,7 +57,8 @@ response: | "@context": $ca.`@context`, "@type": $ca.`@type`, "informationMode": "Direct", - "scheme": $ca.scheme, + /* Stated, not echoed: the caller may send only the code. */ + "scheme": { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }, "enrolmentId": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, "grievance": { "category": $isText($ticket.TicketCategoryName) ? { "name": $ticket.TicketCategoryName }, @@ -80,10 +67,8 @@ response: | }, "case": { "ticketNo": $ca.case.ticketNo, - /* PMFBY publishes no status vocabulary, so every - TicketStatus maps to UnderReview until it does. */ - "status": { "code": "UnderReview" }, - "filedOn": $isText($ticket.ComplaintDate) ? $ticket.ComplaintDate, + "status": { "code": $exists($status) ? $status : "UnderReview" }, + "filedOn": $filedOn, "cropName": $isText($ticket.CropName) ? $ticket.CropName } } @@ -92,4 +77,10 @@ response: | } } ) + : $ok + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": "No record found" } } + : $isText($said) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $said } } + : { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PMFBY replied with neither a record nor a reason" } } ) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index d680e3468..b95931cff 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -1,52 +1,6 @@ # PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. # Files a grievance; no OTP. channels[0] holds the provider, the enrolment and -# the complaint. "Grievence" is PMFBY's spelling. - -# Format checks (SCH_INVALID_FORMAT); an absent field passes here and is -# refused by the request half with SCH_REQUIRED_FIELD_MISSING. -required: - # PMFBY files and reads the ticket by this number. - - check: | - ( - $phone := beckn.message.support.channels[0].applicantPhone; - $not($exists($phone)) or ($type($phone) = "string" ? $exists($match($phone, /^[6-9][0-9]{9}$/)[0]) : false) - ) - message: "applicantPhone must be a ten-digit mobile number, as text" - # PMFBY would file a blank complaint. - - check: | - ( - $d := beckn.message.support.channels[0].grievance.description; - $not($exists($d)) or ($type($d) = "string" ? $exists($match($d, /\S/)[0]) : false) - ) - message: "grievance.description must be text that is not blank" - # PMFBY reads a non-numeric id as 0 and still reports success. - - check: | - ( - $ch := beckn.message.support.channels[0]; - $digits := function($v) { $not($exists($v)) or $exists($match($string($v), /^[0-9]+$/)[0]) }; - $digits($ch.grievance.category.code) and $digits($ch.grievance.subCategory.code) - and $digits($ch.receiptSourceId) - ) - message: "grievance.category.code, grievance.subCategory.code and receiptSourceId must be digits" - # Sent as requestYear. - - check: | - ( - $year := beckn.message.support.channels[0].cropYear; - $not($exists($year)) or $exists($match($string($year), /^[0-9]{4}$/)[0]) - ) - message: "cropYear must be a four-digit year" - - check: | - ( - $season := beckn.message.support.channels[0].season; - $not($exists($season)) or $season in ["Kharif", "Rabi", "Zaid"] - ) - message: "season must be Kharif, Rabi or Zaid" - - check: | - ( - $date := beckn.message.support.channels[0].complaintDate; - $not($exists($date)) or $exists($match($string($date), /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)[0]) - ) - message: "complaintDate must be YYYY-MM-DD" +# the complaint. "Grievence" is PMFBY's spelling. Field formats are the pack's. request: | ( @@ -62,15 +16,8 @@ request: | { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } ][$not(present)][0]; $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; - /* PMFBY's published pair, sent when the caller names none. */ - $category := $exists($g.category.code) ? $string($g.category.code) : "3"; - $subCategory := $exists($g.subCategory.code) ? $string($g.subCategory.code) : "10"; - /* Today in IST, from the request timestamp. */ - $today := $exists(beckn.context.timestamp) - ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") - : $now("[Y0001]-[M01]-[D01]", "+0530"); - /* Default receipt source id for this adapter. */ - $receiptSourceID := "134306"; + /* PMFBY takes the filing day as a date in IST. */ + $filed := $exists($ch.complaintDate) ? $toMillis($ch.complaintDate) : $millis(); $missing ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", "message": $missing.field & " is required", "path": $missing.path } } @@ -79,32 +26,28 @@ request: | "applicationNo": $ch.enrolmentId, "requestYear": $string($ch.cropYear), "requestSeason": $lookup($seasons, $ch.season), - "ticketCategoryID": $category, - "ticketSubCategoryID": $subCategory, + /* PMFBY's published pair, sent when the caller names none. */ + "ticketCategoryID": $exists($g.category.code) ? $string($g.category.code) : "3", + "ticketSubCategoryID": $exists($g.subCategory.code) ? $string($g.subCategory.code) : "10", "grievenceDescription": $trim($g.description), - "complaintDate": $exists($ch.complaintDate) ? $ch.complaintDate : $today, - "receiptSourceID": $exists($ch.receiptSourceId) ? $string($ch.receiptSourceId) : $receiptSourceID + "complaintDate": $fromMillis($filed, "[Y0001]-[M01]-[D01]", "+0530"), + /* This adapter's receipt source id, unless the caller names its own. */ + "receiptSourceID": $exists($ch.receiptSourceId) ? $string($ch.receiptSourceId) : "134306" } ) -# Echoes the request and adds the ticket. PMFBY returns only the ticket number, -# so status is Registered and filedOn is the complaintDate sent. +# Echoes the request and adds the ticket; filedOn is the filing time in UTC. +# A refusal is a 400 with PMFBY's own message; a reply with no reason is a 502. response: | ( $ch := beckn.message.support.channels[0]; $g := $ch.grievance; $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; - /* Same date the request half sent. */ - $today := $exists(beckn.context.timestamp) - ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") - : $now("[Y0001]-[M01]-[D01]", "+0530"); - /* Success is responseCode "1" (string or number) with a ticket number. */ - $registered := $string(response.responseCode) = "1" - and ($type($ticketNo) = "string" or $type($ticketNo) = "number"); - $not($registered) - ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY did not register the grievance" } } - : { + $said := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $filed := $exists($ch.complaintDate) ? $toMillis($ch.complaintDate) : $millis(); + /* responseCode "1" (string or number) with a ticket, "Ticket Already Raised" included. */ + $string(response.responseCode) = "1" and ($type($ticketNo) = "string" or $type($ticketNo) = "number") + ? { "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), "message": { "support": { @@ -112,8 +55,9 @@ response: | "@context": $ch.`@context`, "@type": $ch.`@type`, "informationMode": "Direct", - "provider": $ch.provider, - "scheme": $ch.scheme, + /* Stated, not echoed: the caller may send only the ids. */ + "provider": { "id": $ch.provider.id, "descriptor": { "name": "PMFBY Grievance Portal" } }, + "scheme": { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }, "enrolmentId": $ch.enrolmentId, /* The codes sent, defaults included; a name beside a code is dropped. */ "grievance": { @@ -124,10 +68,14 @@ response: | "case": { "ticketNo": $string($ticketNo), "status": { "code": "Registered" }, - "filedOn": $exists($ch.complaintDate) ? $ch.complaintDate : $today + "filedOn": $fromMillis($filed, "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z") } }] } } } + : $type($said) = "string" and $said != "" + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $said } } + : { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PMFBY replied with neither a ticket nor a reason" } } ) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index e52136a0e..3911889e4 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" @@ -38,7 +39,7 @@ const ( // It carries the category by name only. const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","recordCount":1,"responseDynamic":{ "GrievenceSupportTicketID":109301,"TicketStatus":"Under Review", - "ComplaintDate":"2026-10-04","ApplicationNo":"040108251010160770605", + "ComplaintDate":"2026-10-04","InsertDateTime":"2026-10-04 15:30:00","ApplicationNo":"040108251010160770605", "GrievenceDescription":"Claim not received", "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received","CropName":"Paddy", "FarmerName":"Ramesh","StateMasterName":"Karnataka","DistrictMasterName":"Mysuru", @@ -256,7 +257,9 @@ func request(action string, attributes map[string]any) []byte { attributes["@context"] = packContext attributes["@type"] = "openagrinet:PMFBYGrievance" attributes["informationMode"] = "OnDemand" - attributes["scheme"] = scheme + if _, named := attributes["scheme"]; !named { + attributes["scheme"] = scheme + } body, _ := json.Marshal(map[string]any{ "context": becknContext(action), "message": map[string]any{"contract": map[string]any{ @@ -333,6 +336,25 @@ func assertCoded(t *testing.T, err error, status int, code string) { } } +// assertRefused fails unless err is a 400 BIZ_GENERIC_ERROR carrying message. +func assertRefused(t *testing.T, err error, message string) { + t.Helper() + if status, refused := refusal(t, err); status != http.StatusBadRequest || + refused.Code != "BIZ_GENERIC_ERROR" || refused.Message != message { + t.Errorf("error = %d %+v, want 400 BIZ_GENERIC_ERROR %q", status, refused, message) + } +} + +// assertJustNowUTC fails unless value is an RFC 3339 UTC instant within a minute of now. +func assertJustNowUTC(t *testing.T, value any) { + t.Helper() + text, _ := value.(string) + at, err := time.Parse("2006-01-02T15:04:05Z", text) + if err != nil || time.Since(at).Abs() > time.Minute { + t.Errorf("filedOn = %v, want this moment as YYYY-MM-DDTHH:MM:SSZ", value) + } +} + // todayIST is the date PMFBY expects complaintDate in. func todayIST() string { return time.Now().In(time.FixedZone("IST", 5*3600+1800)).Format("2006-01-02") @@ -373,8 +395,9 @@ func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { } filed := band(channel, "case") status := band(filed, "status") + assertJustNowUTC(t, filed["filedOn"]) if filed["ticketNo"] != knownTicket || len(status) != 1 || status["code"] != "Registered" || - filed["filedOn"] != todayIST() || band(channel, "provider")["id"] != "pmfby" || + band(channel, "provider")["id"] != "pmfby" || channel["informationMode"] != "Direct" || channel["scheme"] == nil { t.Errorf("channel = %v", channel) } @@ -450,6 +473,21 @@ func TestSupport_NoCategoryCodes_SendsAndEchoesDefaults(t *testing.T) { } } +// The pack lists provider and scheme.name as coming back; the caller may send +// only the ids. +func TestSupport_BareProviderAndScheme_ReturnsPortalNameAndSchemeName(t *testing.T) { + fields := supportFields() + fields["provider"] = map[string]any{"id": "pmfby", "availableAt": []any{}} + fields["scheme"] = map[string]any{"code": "PMFBY"} + channel := newHarness(t, &pmfbyStub{}, 0).mustSend(t, support(fields)).channel() + if got := band(channel, "provider"); !reflect.DeepEqual(got, provider) { + t.Errorf("provider = %v, want %v", got, provider) + } + if got := band(channel, "scheme"); !reflect.DeepEqual(got, scheme) { + t.Errorf("scheme = %v, want %v", got, scheme) + } +} + func TestSupport_MissingField_Returns400NamingItsPath(t *testing.T) { channel := "$.message.support.channels[0]." for field, path := range map[string]string{ @@ -482,127 +520,64 @@ func TestSupport_NoGrievance_Returns400(t *testing.T) { assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") } -func TestSupport_MalformedCategory_Returns400WithoutCallingPMFBY(t *testing.T) { - for name, codes := range map[string][2]string{ - "joined": {"3.10", "10"}, "letters": {"a", "10"}, "empty": {"", "10"}, - "sub-category joined": {"3", "3.10"}, "sub-category letters": {"3", "b"}, +// complaintDate goes to PMFBY as its IST date and comes back as filedOn in UTC. +func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { + for given, want := range map[string][2]string{ + "2026-09-20T04:30:00Z": {"2026-09-20", "2026-09-20T04:30:00Z"}, + "2026-09-21T01:00:00+05:30": {"2026-09-21", "2026-09-20T19:30:00Z"}, // 1am IST is the previous day in UTC } { - t.Run(name, func(t *testing.T) { + t.Run(given, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) fields := supportFields() - fields["categoryCode"], fields["subCategoryCode"] = codes[0], codes[1] - _, err := h.send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if h.pmfby.calls[insertPath] != 0 { - t.Error("PMFBY was called with a malformed category") + fields["complaintDate"], fields["receiptSourceId"] = given, "200001" + got := h.mustSend(t, support(fields)) + sent := h.pmfby.bodies[insertPath] + if sent["complaintDate"] != want[0] || sent["receiptSourceID"] != "200001" { + t.Errorf("PMFBY was sent %v / %v, want %s and the caller's source", sent["complaintDate"], sent["receiptSourceID"], want[0]) } - }) - } -} - -func TestSupport_UnknownSeason_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, season := range []string{"Summer", "kharif", "KHARIF", ""} { - t.Run(season, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - fields := supportFields() - fields["season"] = season - _, err := h.send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if h.pmfby.calls[insertPath] != 0 { - t.Error("PMFBY was called with an unknown season") + if filed := band(got.channel(), "case")["filedOn"]; filed != want[1] { + t.Errorf("filedOn = %v, want %s", filed, want[1]) } }) } } -// complaintDate and receiptSourceId are sent when given; filedOn echoes the date. -func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - fields := supportFields() - fields["complaintDate"], fields["receiptSourceId"] = "2026-09-20", "200001" - got := h.mustSend(t, support(fields)) - sent := h.pmfby.bodies[insertPath] - if sent["complaintDate"] != "2026-09-20" || sent["receiptSourceID"] != "200001" { - t.Errorf("PMFBY was sent %v / %v, want the caller's date and source", sent["complaintDate"], sent["receiptSourceID"]) - } - if filed := band(got.channel(), "case")["filedOn"]; filed != "2026-09-20" { - t.Errorf("filedOn = %v, want the date that was sent", filed) - } -} - -func TestSupport_MalformedDateOrSource_Returns400WithoutCallingPMFBY(t *testing.T) { - for name, field := range map[string][2]string{ - "date": {"complaintDate", "20-09-2026"}, "source": {"receiptSourceId", "web"}, - "crop year": {"cropYear", "last year"}, "short crop year": {"cropYear", "25"}, - } { - t.Run(name, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - fields := supportFields() - fields[field[0]] = field[1] - _, err := h.send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if h.pmfby.calls[insertPath] != 0 { - t.Errorf("PMFBY was called with %s %q", field[0], field[1]) - } - }) +// "Ticket Already Raised" is a success: the existing ticket comes back. +func TestSupport_TicketAlreadyRaised_ReturnsTheExistingTicket(t *testing.T) { + h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: `{"responseCode":"1","responseMessage":"Ticket Already Raised for !", + "responseDynamic":{"GrievenceSupportTicketNo":"121026000099003","GrievenceSupportTicketID":13}}`}}, 0) + filed := band(h.mustSend(t, support(supportFields())).channel(), "case") + if filed["ticketNo"] != "121026000099003" || band(filed, "status")["code"] != "Registered" { + t.Errorf("case = %v, want the existing ticket, Registered", filed) } + assertJustNowUTC(t, filed["filedOn"]) } -func TestSupport_MalformedPhoneOrDescription_Returns400WithoutCallingPMFBY(t *testing.T) { - for name, field := range map[string]struct { - key string - value any - }{ - "phone as a number": {"applicantPhone", 9876543210}, - "phone too short": {"applicantPhone", "98765"}, - "phone not a mobile": {"applicantPhone", "1234567890"}, - "description a number": {"description", 123}, - "description blank": {"description", " "}, +// A refusal with a reason is a 400 carrying PMFBY's own message. +func TestSupport_PortalRefuses_Returns400WithItsMessage(t *testing.T) { + for name, tc := range map[string]struct{ reply, message string }{ + "phone not matched": {`{"responseObject":null,"responseDynamic":null,"responseCode":"0","responseMessage":"Mobile Number Not Matched","jsonString":null,"recordCount":0}`, + "Mobile Number Not Matched"}, + "no application": {`{"responseCode":"0","responseMessage":"Application Details not Exists","responseDynamic":null}`, "Application Details not Exists"}, + "field required": {`{"responseCode":"0","responseMessage":"requestYear is required","responseDynamic":null}`, "requestYear is required"}, + "refused, ticket": {`{"responseCode":"0","responseMessage":"Invalid","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, "Invalid"}, } { t.Run(name, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - fields := supportFields() - fields[field.key] = field.value - _, err := h.send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if h.pmfby.calls[insertPath] != 0 { - t.Errorf("PMFBY was called with %s %v", field.key, field.value) - } + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: tc.reply}}, 0).send(t, support(supportFields())) + assertRefused(t, err, tc.message) }) } } -// With no complaintDate, both the call and filedOn use the request's date in IST. -func TestSupport_NoComplaintDate_DatesFromTheRequestInIST(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - var payload map[string]any - _ = json.Unmarshal(support(supportFields()), &payload) - payload["context"].(map[string]any)["timestamp"] = "2026-10-07T18:30:00Z" // 00:00 IST on the 8th - raw, _ := json.Marshal(payload) - - got := h.mustSend(t, raw) - if sent := h.pmfby.bodies[insertPath]["complaintDate"]; sent != "2026-10-08" { - t.Errorf("complaintDate = %v, want the IST date of the request, 2026-10-08", sent) - } - if filed := band(got.channel(), "case")["filedOn"]; filed != "2026-10-08" { - t.Errorf("filedOn = %v, want the date that was sent", filed) - } -} - -// PMFBY refuses with HTTP 200 and a responseCode other than "1". -func TestSupport_PortalDidNotRegister_Returns502(t *testing.T) { +// A reply with neither a ticket nor a reason is PMFBY's failure, not the caller's. +func TestSupport_PortalGivesNoReason_Returns502(t *testing.T) { for name, reply := range map[string]string{ - "refused": `{"responseCode":"0","responseMessage":"Invalid application number","responseDynamic":null}`, "success, no ticket": `{"responseCode":"1","responseDynamic":{}}`, - "refused, ticket": `{"responseCode":"0","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, + "no message": `{"responseCode":"0","responseMessage":null}`, } { t.Run(name, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: reply}}, 0) - _, err := h.send(t, support(supportFields())) + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: reply}}, 0).send(t, support(supportFields())) assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) - if strings.Contains(err.Error(), "Invalid application") { - t.Errorf("error %q leaks PMFBY's message", err) - } }) } } @@ -716,10 +691,10 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { } filed := band(attributes, "case") if attributes["enrolmentId"] != "040108251010160770605" || attributes["informationMode"] != "Direct" || - filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04" || filed["cropName"] != "Paddy" { + filed["ticketNo"] != knownTicket || filed["filedOn"] != "2026-10-04T10:00:00Z" || filed["cropName"] != "Paddy" { t.Errorf("commitmentAttributes = %v", attributes) } - // Any portal phrase maps to UnderReview and is not forwarded. + // "Under Review" is not a phrase the lookup lists, so it is UnderReview. if status := band(filed, "status"); len(status) != 1 || status["code"] != "UnderReview" { t.Errorf("case.status = %v, want code UnderReview alone", status) } @@ -750,6 +725,15 @@ func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { } } +func TestStatus_BareScheme_ReturnsSchemeName(t *testing.T) { + attributes := statusAttributes() + attributes["scheme"] = map[string]any{"code": "PMFBY"} + got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", attributes)) + if got := band(got.attributes(), "scheme"); !reflect.DeepEqual(got, scheme) { + t.Errorf("scheme = %v, want %v", got, scheme) + } +} + // A missing phrase still maps to UnderReview. func TestStatus_NoStatusPhrase_UnderReviewWithoutName(t *testing.T) { for name, phrase := range map[string]string{"null": `null`, "empty": `""`} { @@ -773,7 +757,8 @@ func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { "null category name": {`"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, "grievance", "category"}, "empty sub-category": {`"TicketSubCategoryName":"Claim not received"`, `"TicketSubCategoryName":""`, "grievance", "subCategory"}, "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "grievance", "description"}, - "null complaint date": {`"ComplaintDate":"2026-10-04"`, `"ComplaintDate":null`, "case", "filedOn"}, + "null insert time": {`"InsertDateTime":"2026-10-04 15:30:00"`, `"InsertDateTime":null`, "case", "filedOn"}, + "date-only insert": {`"InsertDateTime":"2026-10-04 15:30:00"`, `"InsertDateTime":"2026-10-04"`, "case", "filedOn"}, "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "", "enrolmentId"}, "empty crop": {`"CropName":"Paddy"`, `"CropName":""`, "case", "cropName"}, } { @@ -795,66 +780,56 @@ func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { } } -func TestStatus_NoRecordInReply_Returns202NoResultsFound(t *testing.T) { - attributes := statusAttributes() - attributes["case"] = map[string]any{"ticketNo": "999"} - _, err := newHarness(t, &pmfbyStub{}, 0).send(t, request("status", attributes)) - - var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) { - t.Fatalf("error = %v, want a 202 NACK", err) - } - if ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { - t.Errorf("answer = %s %s, want NACK BIZ_NO_RESULTS_FOUND", ack.Status, ack.Err.Code) +// PMFBY's TicketStatus maps to a CaseStatusCode; an unlisted phrase is UnderReview. +func TestStatus_TicketStatus_MapsToCaseStatusCode(t *testing.T) { + for phrase, want := range map[string]string{"Resolved": "Resolved", " resolved ": "Resolved", + "Open": "UnderReview", "Under Review": "UnderReview", "Escalated": "UnderReview"} { + t.Run(phrase, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"TicketStatus":"Under Review"`, `"TicketStatus":"`+phrase+`"`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if status := band(band(got.attributes(), "case"), "status"); len(status) != 1 || status["code"] != want { + t.Errorf("case.status = %v, want code %s alone", status, want) + } + }) } } -// A reply without the success code, or with no envelope, is a failure. -func TestStatus_PortalRefused_Returns502(t *testing.T) { - for name, reply := range map[string]string{ - "refused": `{"responseCode":"0","responseMessage":"Session expired"}`, - "token required": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, - "no envelope": `{"message":"An error has occurred."}`, +// A refusal is a 400 carrying PMFBY's own message; an empty record, or one +// for another ticket (PMFBY reads by phone alone), is "No record found". +func TestStatus_NoMatchingRecord_Returns400(t *testing.T) { + another := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{"GrievenceSupportTicketNo":"100626000099002",`, 1) + for name, tc := range map[string]struct{ reply, message string }{ + "no record": {`{"responseCode":"0","responseMessage":"No record found","responseDynamic":[]}`, "No record found"}, + "token required": {`{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, "Token required!"}, + "null": {`{"responseCode":"1","responseDynamic":null}`, "No record found"}, + "empty object": {`{"responseCode":"1","responseDynamic":{}}`, "No record found"}, + "empty in list": {`{"responseCode":"1","responseDynamic":[{}]}`, "No record found"}, + "all fields null": {`{"responseCode":"1","responseDynamic":{"ApplicationNo":null,"TicketStatus":null}}`, "No record found"}, + "another ticket": {another, "No record found"}, } { t.Run(name, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0) - _, err := h.send(t, request("status", statusAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: tc.reply}}, 0). + send(t, request("status", statusAttributes())) + assertRefused(t, err, tc.message) }) } } -// A list of one reads like a bare record; an empty record is no record. -func TestStatus_EmptyRecord_Returns202NoResultsFound(t *testing.T) { - for name, dynamic := range map[string]string{ - "null": `null`, - "empty object": `{}`, - "empty in list": `[{}]`, - "all fields null": `{"ApplicationNo":null,"TicketStatus":null,"ComplaintDate":null,"CropName":null}`, +// A reply with neither a record nor a reason is PMFBY's failure, not the caller's. +func TestStatus_PortalGivesNoReason_Returns502(t *testing.T) { + for name, reply := range map[string]string{ + "no envelope": `{"message":"An error has occurred."}`, + "no message": `{"responseCode":"0","responseMessage":"","responseDynamic":null}`, } { t.Run(name, func(t *testing.T) { - record := `{"responseCode":"1","responseMessage":"Fetched","responseDynamic":` + dynamic + `}` - _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0). send(t, request("status", statusAttributes())) - var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) || ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { - t.Errorf("error = %v, want a 202 NACK BIZ_NO_RESULTS_FOUND", err) - } + assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) }) } } -// PMFBY may answer an unknown ticket with the phone's other ticket. -func TestStatus_RecordForAnotherTicket_Returns202NoResultsFound(t *testing.T) { - record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{"GrievenceSupportTicketNo":"100626000099002",`, 1) - _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). - send(t, request("status", statusAttributes())) - var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) || ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" { - t.Errorf("error = %v, want a 202 NACK BIZ_NO_RESULTS_FOUND", err) - } -} - func TestStatus_RecordNamingTheTicket_ReturnsIt(t *testing.T) { record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{"GrievenceSupportTicketNo":"`+knownTicket+`",`, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). @@ -873,17 +848,6 @@ func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { } } -func TestStatus_MalformedPhone_Returns400WithoutCallingPMFBY(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - attributes := statusAttributes() - attributes["applicantPhone"] = 9876543210 - _, err := h.send(t, request("status", attributes)) - assertCoded(t, err, http.StatusBadRequest, "SCH_INVALID_FORMAT") - if h.pmfby.calls[statusPath] != 0 { - t.Error("PMFBY was called with a phone number that is not text") - } -} - func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { for _, field := range []string{"applicantPhone", "case"} { t.Run(field, func(t *testing.T) { diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index e64a84503..0e85b3588 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -2630,21 +2630,6 @@ func TestRefusal_ResponseHalf_ReturnsItsStatusAndCode(t *testing.T) { } } -func TestRefusal_Status202_ReturnsNackWithCode(t *testing.T) { - var calls int - mapper := &stubMapper{requestResult: []byte(`{}`), - responseResult: []byte(`{"_error":{"status":202,"code":"BIZ_NO_RESULTS_FOUND","message":"nothing on file"}}`)} - _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) - - var ack *model.AckNoCallbackErr - if !errors.As(err, &ack) { - t.Fatalf("error = %v (%T), want a 202 NACK", err, err) - } - if ack.Status != model.StatusNACK || ack.Err.Code != "BIZ_NO_RESULTS_FOUND" || ack.Err.Message != "nothing on file" { - t.Errorf("ack = %s %+v, want NACK BIZ_NO_RESULTS_FOUND with the mapping's message", ack.Status, ack.Err) - } -} - func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { for name, refusal := range map[string]string{ "no message": `{"_error":{"status":502,"code":"X"}}`, diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 0349aba13..e2970bbca 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -219,7 +219,7 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an // refusalIn returns the error a mapping asks for under the reserved _error // field {status, code, message, path}, or nil if there is none. Status 0 or -// 400 is a 400 with details.path; 202 is a NACK carrying the reason. +// 400 is a 400 with details.path. func refusalIn(mapped []byte) error { // Cheap text check first: almost no answer carries the field. if !bytes.Contains(mapped, []byte(`"_error"`)) { @@ -242,8 +242,6 @@ func refusalIn(mapped []byte) error { case 0, http.StatusBadRequest: // CodedErr drops details, so the path needs SchemaValidationErr. return &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} - case http.StatusAccepted: - return model.NewAckNoCallbackErr(model.StatusNACK, &model.Error{Code: r.Code, Message: r.Message}) default: return model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) } From 9a1bd84dbd047527207c3f06c2b162662d5eaf80 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Wed, 14 Oct 2026 08:50:53 +0530 Subject: [PATCH 29/39] feat: hold a login token until the expiry its reply states [#57] PMFBY's login states when its token expires, so a configured tokenTtl was a guess at a number the issuer already gives. tokenExpiryField reads it from the reply instead; tokenTtl stays for issuers that state none. --- config/provider-adapter.yaml | 5 +- .../implementation/internal/common/auth.go | 27 ++-- .../implementation/internal/common/token.go | 31 ++++- .../internal/common/tokenheader_test.go | 115 ++++++++++++++++++ 4 files changed, 163 insertions(+), 15 deletions(-) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 692b9dd27..b747d04a9 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -518,7 +518,8 @@ modules: capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" # Logs in at PMFBY's NICUsersLogin and sends the token bare in - # Authorization. tokenUrl is set per deployment. + # Authorization, held until the login reply's expirationTime. + # tokenUrl is set per deployment. pmfby: authScheme: tokenHeader tokenUrl: <> @@ -527,7 +528,7 @@ modules: tokenSecretField: appAccessPWD tokenSecretEnv: PMFBY_ACCESS_PWD tokenResponseField: responseDynamic.token.Token - tokenTtl: 10m + tokenExpiryField: responseDynamic.token.expirationTime headerName: Authorization # Declaring a step above is not enough: THIS list is what runs. A step diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index e69384838..9ef0a98f4 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -63,15 +63,14 @@ type AuthProfile struct { // Where the token sits in the response body, as a dotted path. TokenResponseField string - // How long a token may be held, as written in config and as parsed. - // - // Configured because the response carries no expiry at all -- nothing in it - // says how long the token lives, so an operator states what they believe - // and this refuses to guess. Set it too long and calls fail with the - // provider's own rejection until it lapses; the cache is dropped on such a - // rejection so the next call re-exchanges rather than waiting that out. + // How long a token may be held, for a response that states no expiry. Too + // long costs one rejected call: the rejection drops the token. TokenTTLRaw string TokenTTL time.Duration + + // Where the response states the token's expiry, as a dotted path to Unix + // seconds. Set this or tokenTtl: the issuer's own expiry beats an estimate. + TokenExpiryField string } // authenticator is one provider's profile plus the token it holds. @@ -134,9 +133,16 @@ func (a *AuthProfile) validate() error { if a.Scheme == util.AuthSchemeTokenHeader && a.HeaderName == "" { return fmt.Errorf("%s: authScheme tokenHeader requires headerName", a.Provider) } + if a.TokenExpiryField != "" { + // Two lifetimes would leave an operator guessing which one holds. + if a.TokenTTLRaw != "" { + return fmt.Errorf("%s: set tokenTtl or tokenExpiryField, not both", a.Provider) + } + break + } if a.TokenTTLRaw == "" { - return fmt.Errorf("%s: authScheme %s requires tokenTtl, "+ - "because the token response carries no expiry to read", a.Provider, a.Scheme) + return fmt.Errorf("%s: authScheme %s requires tokenTtl or tokenExpiryField, "+ + "or the token's lifetime is unknown", a.Provider, a.Scheme) } ttl, err := time.ParseDuration(a.TokenTTLRaw) if err != nil { @@ -251,7 +257,8 @@ var authFields = map[string]func(*AuthProfile, string){ // Kept as written and turned into a duration by validate, so a bad value is // refused at startup naming the provider rather than read as zero -- which // would mean re-exchanging a token on every single call. - "tokenTtl": func(p *AuthProfile, v string) { p.TokenTTLRaw = v }, + "tokenTtl": func(p *AuthProfile, v string) { p.TokenTTLRaw = v }, + "tokenExpiryField": func(p *AuthProfile, v string) { p.TokenExpiryField = v }, } // providerIDFrom returns the provider half of "|". diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index 9b6d071d8..013aa513f 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -96,9 +96,8 @@ func (s *Step) exchangeToken(ctx context.Context, auth *authenticator) (string, // exchangeQueryToken POSTs a JSON body of configured field names and reads the // token out of a configured response field. // -// The lifetime comes from config, not from the response: this endpoint says -// nothing about how long its token lives. That is the whole reason tokenTtl is -// required rather than defaulted -- see AuthProfile. +// The lifetime is read from tokenExpiryField when the endpoint states one, and +// taken from tokenTtl when it does not -- see AuthProfile. func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (string, time.Duration, error) { cfg := auth.cfg user, secret := os.Getenv(cfg.TokenUserEnv), os.Getenv(cfg.TokenSecretEnv) @@ -136,6 +135,17 @@ func (s *Step) exchangeQueryToken(ctx context.Context, auth *authenticator) (str } token := tokens[0] + if cfg.TokenExpiryField != "" { + lifetime, ok := expiryLifetime(fields, cfg.TokenExpiryField) + if !ok { + // Not cached for a guessed period, the same rule as oauth2. + return "", 0, s.permanentTokenErr(fmt.Errorf( + "token response from %s carries no future %s, so its lifetime is unknown", + cfg.TokenURL, cfg.TokenExpiryField)) + } + return token, lifetime, nil + } + // The skew is subtracted here for the same reason oauth2 subtracts it from // expires_in: a request that passed the expiry check must not arrive after // the token died. validate has already refused a ttl at or below it. @@ -246,6 +256,21 @@ func (s *Step) permanentTokenErr(err error) error { return util.DoNotRetry(s.tokenErr(err)) } +// expiryLifetime reads an expiry stated as Unix seconds and returns how long the +// token may be held, false when it is absent, not a number, or already past. +func expiryLifetime(fields any, path string) (time.Duration, bool) { + leaves := LeavesAt(fields, path) + if len(leaves) == 0 { + return 0, false + } + seconds, ok := leaves[0].(float64) + if !ok { + return 0, false + } + remaining := time.Until(time.Unix(int64(seconds), 0)) + return tokenLifetime(int(remaining/time.Second), util.TokenRefreshSkew) +} + // tokenLifetime turns expires_in into how long the token may be held, or // reports that it may not be held at all. // diff --git a/pkg/plugin/implementation/internal/common/tokenheader_test.go b/pkg/plugin/implementation/internal/common/tokenheader_test.go index 5914392ab..0fecca0c6 100644 --- a/pkg/plugin/implementation/internal/common/tokenheader_test.go +++ b/pkg/plugin/implementation/internal/common/tokenheader_test.go @@ -7,7 +7,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" + "time" "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" @@ -280,3 +282,116 @@ func TestParseProviderAuth_TokenHeaderBlock_BuildsProfile(t *testing.T) { t.Errorf("validate() = %v, want the parsed profile accepted", err) } } + +// expiryLoginStep logs in at a server whose reply states the token's expiry, as +// PMFBY's does, and counts the logins. +func expiryLoginStep(t *testing.T, reply string, logins *atomic.Int32) *Step { + t.Helper() + login := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + logins.Add(1) + fmt.Fprint(w, reply) + })) + t.Cleanup(login.Close) + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(provider.Close) + + return newStep(t, &stubRegistry{plan: testPlan(provider.URL, http.MethodPost)}, + &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"a":1}`)}, + func(c *Config) { + profile := tokenHeaderProfile(login.URL) + profile.TokenResponseField = "responseDynamic.token.Token" + profile.TokenTTLRaw = "" + profile.TokenExpiryField = "responseDynamic.token.expirationTime" + if err := profile.validate(); err != nil { + t.Fatalf("profile does not validate: %v", err) + } + c.setProviderAuth(profile) + }) +} + +func pmfbyLoginReply(expirationTime string) string { + return `{"responseCode":"1","responseDynamic":{"token":{"Token":"tok",` + + `"expirationTime":` + expirationTime + `}}}` +} + +func TestTokenHeader_ExpiryInReply_HeldWithoutTokenTtl(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + var logins atomic.Int32 + inHalfAnHour := fmt.Sprintf("%d.367", time.Now().Add(30*time.Minute).Unix()) + step := expiryLoginStep(t, pmfbyLoginReply(inHalfAnHour), &logins) + + for i := 0; i < 3; i++ { + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("request %d: %v", i, err) + } + } + if got := logins.Load(); got != 1 { + t.Errorf("login called %d times for 3 requests, want 1", got) + } +} + +func TestTokenHeader_ExpiryMissingOrPast_Returns502AndCachesNothing(t *testing.T) { + for name, expirationTime := range map[string]string{ + "missing": "null", + "not number": `"soon"`, + "past": fmt.Sprintf("%d", time.Now().Add(-time.Minute).Unix()), + } { + t.Run(name, func(t *testing.T) { + t.Setenv("TEST_TQ_USER", "user") + t.Setenv("TEST_TQ_SECRET", "secret") + + var logins atomic.Int32 + step := expiryLoginStep(t, pmfbyLoginReply(expirationTime), &logins) + + _, err := runStep(t, step, selectBody) + assertStatus(t, err, http.StatusBadGateway) + _, _ = runStep(t, step, selectBody) + if got := logins.Load(); got != 2 { + t.Errorf("login called %d times for 2 requests, want 2: nothing is cached", got) + } + }) + } +} + +func TestExpiryLifetime_FutureExpiry_ReturnsTimeLeftLessSkew(t *testing.T) { + expiry := float64(time.Now().Add(30 * time.Minute).Unix()) + lifetime, ok := expiryLifetime(map[string]any{"token": map[string]any{"expirationTime": expiry}}, + "token.expirationTime") + want := 30*time.Minute - util.TokenRefreshSkew + if !ok || lifetime > want || lifetime < want-2*time.Second { + t.Errorf("expiryLifetime() = %s, %v; want about %s", lifetime, ok, want) + } +} + +func TestTokenHeader_TtlAndExpiryField_RefusedAtStartup(t *testing.T) { + profile := tokenHeaderProfile("https://login.example") + profile.Provider = "pmfby" + profile.TokenExpiryField = "responseDynamic.token.expirationTime" + if err := profile.validate(); err == nil || !strings.Contains(err.Error(), "not both") { + t.Errorf("validate() = %v, want tokenTtl and tokenExpiryField refused together", err) + } +} + +func TestParseProviderAuth_TokenExpiryField_BuildsProfileWithoutTtl(t *testing.T) { + profiles, err := ParseProviderAuth(map[string]string{ + "authScheme-pmfby": "tokenHeader", + "tokenUrl-pmfby": "https://login.example", + "tokenUserField-pmfby": "userName", + "tokenUserEnv-pmfby": "PMFBY_USER", + "tokenSecretField-pmfby": "password", + "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", + "tokenResponseField-pmfby": "responseDynamic.token.Token", + "tokenExpiryField-pmfby": "responseDynamic.token.expirationTime", + "headerName-pmfby": "Authorization", + }) + if err != nil { + t.Fatal(err) + } + if err := profiles["pmfby"].validate(); err != nil { + t.Errorf("validate() = %v, want the profile accepted without tokenTtl", err) + } +} From f6995b2faf4ea6e81571955041ac9a50003de139 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Wed, 14 Oct 2026 13:08:44 +0530 Subject: [PATCH 30/39] fix: hold PMFBY's token until validTo, its real expiry [#57] PMFBY's expirationTime is the moment a token was issued, not when it expires, so every token read as already expired and every call failed. validTo is the expiry, as an RFC 3339 time, so tokenExpiryField now reads that form as well as Unix seconds. --- config/provider-adapter.yaml | 4 +- .../implementation/internal/common/auth.go | 3 +- .../implementation/internal/common/token.go | 21 +++++++--- .../internal/common/tokenheader_test.go | 40 +++++++++++-------- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index b747d04a9..0af7dee1d 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -518,7 +518,7 @@ modules: capabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" # Logs in at PMFBY's NICUsersLogin and sends the token bare in - # Authorization, held until the login reply's expirationTime. + # Authorization, held until the login reply's validTo. # tokenUrl is set per deployment. pmfby: authScheme: tokenHeader @@ -528,7 +528,7 @@ modules: tokenSecretField: appAccessPWD tokenSecretEnv: PMFBY_ACCESS_PWD tokenResponseField: responseDynamic.token.Token - tokenExpiryField: responseDynamic.token.expirationTime + tokenExpiryField: responseDynamic.token.validTo headerName: Authorization # Declaring a step above is not enough: THIS list is what runs. A step diff --git a/pkg/plugin/implementation/internal/common/auth.go b/pkg/plugin/implementation/internal/common/auth.go index 9ef0a98f4..a2afd67f2 100644 --- a/pkg/plugin/implementation/internal/common/auth.go +++ b/pkg/plugin/implementation/internal/common/auth.go @@ -69,7 +69,8 @@ type AuthProfile struct { TokenTTL time.Duration // Where the response states the token's expiry, as a dotted path to Unix - // seconds. Set this or tokenTtl: the issuer's own expiry beats an estimate. + // seconds or an RFC 3339 time. Set this or tokenTtl: the issuer's own + // expiry beats an estimate. TokenExpiryField string } diff --git a/pkg/plugin/implementation/internal/common/token.go b/pkg/plugin/implementation/internal/common/token.go index 013aa513f..1a4766c90 100644 --- a/pkg/plugin/implementation/internal/common/token.go +++ b/pkg/plugin/implementation/internal/common/token.go @@ -256,19 +256,28 @@ func (s *Step) permanentTokenErr(err error) error { return util.DoNotRetry(s.tokenErr(err)) } -// expiryLifetime reads an expiry stated as Unix seconds and returns how long the -// token may be held, false when it is absent, not a number, or already past. +// expiryLifetime reads an expiry stated as Unix seconds or an RFC 3339 time and +// returns how long the token may be held, false when it is absent, unreadable, +// or already past. func expiryLifetime(fields any, path string) (time.Duration, bool) { leaves := LeavesAt(fields, path) if len(leaves) == 0 { return 0, false } - seconds, ok := leaves[0].(float64) - if !ok { + var expiry time.Time + switch value := leaves[0].(type) { + case float64: + expiry = time.Unix(int64(value), 0) + case string: + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0, false + } + expiry = parsed + default: return 0, false } - remaining := time.Until(time.Unix(int64(seconds), 0)) - return tokenLifetime(int(remaining/time.Second), util.TokenRefreshSkew) + return tokenLifetime(int(time.Until(expiry)/time.Second), util.TokenRefreshSkew) } // tokenLifetime turns expires_in into how long the token may be held, or diff --git a/pkg/plugin/implementation/internal/common/tokenheader_test.go b/pkg/plugin/implementation/internal/common/tokenheader_test.go index 0fecca0c6..01236b4d5 100644 --- a/pkg/plugin/implementation/internal/common/tokenheader_test.go +++ b/pkg/plugin/implementation/internal/common/tokenheader_test.go @@ -303,7 +303,7 @@ func expiryLoginStep(t *testing.T, reply string, logins *atomic.Int32) *Step { profile := tokenHeaderProfile(login.URL) profile.TokenResponseField = "responseDynamic.token.Token" profile.TokenTTLRaw = "" - profile.TokenExpiryField = "responseDynamic.token.expirationTime" + profile.TokenExpiryField = "responseDynamic.token.validTo" if err := profile.validate(); err != nil { t.Fatalf("profile does not validate: %v", err) } @@ -311,9 +311,11 @@ func expiryLoginStep(t *testing.T, reply string, logins *atomic.Int32) *Step { }) } -func pmfbyLoginReply(expirationTime string) string { +// pmfbyLoginReply is PMFBY's login reply. Its expirationTime is the moment of +// issue, not the expiry; validTo is the expiry. +func pmfbyLoginReply(validTo string) string { return `{"responseCode":"1","responseDynamic":{"token":{"Token":"tok",` + - `"expirationTime":` + expirationTime + `}}}` + fmt.Sprintf(`"expirationTime":%d,"validTo":%s}}}`, time.Now().Unix(), validTo) } func TestTokenHeader_ExpiryInReply_HeldWithoutTokenTtl(t *testing.T) { @@ -321,7 +323,7 @@ func TestTokenHeader_ExpiryInReply_HeldWithoutTokenTtl(t *testing.T) { t.Setenv("TEST_TQ_SECRET", "secret") var logins atomic.Int32 - inHalfAnHour := fmt.Sprintf("%d.367", time.Now().Add(30*time.Minute).Unix()) + inHalfAnHour := `"` + time.Now().Add(30*time.Minute).UTC().Format("2006-01-02T15:04:05.000Z") + `"` step := expiryLoginStep(t, pmfbyLoginReply(inHalfAnHour), &logins) for i := 0; i < 3; i++ { @@ -335,17 +337,17 @@ func TestTokenHeader_ExpiryInReply_HeldWithoutTokenTtl(t *testing.T) { } func TestTokenHeader_ExpiryMissingOrPast_Returns502AndCachesNothing(t *testing.T) { - for name, expirationTime := range map[string]string{ + for name, validTo := range map[string]string{ "missing": "null", - "not number": `"soon"`, - "past": fmt.Sprintf("%d", time.Now().Add(-time.Minute).Unix()), + "not a time": `"soon"`, + "past": `"` + time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + `"`, } { t.Run(name, func(t *testing.T) { t.Setenv("TEST_TQ_USER", "user") t.Setenv("TEST_TQ_SECRET", "secret") var logins atomic.Int32 - step := expiryLoginStep(t, pmfbyLoginReply(expirationTime), &logins) + step := expiryLoginStep(t, pmfbyLoginReply(validTo), &logins) _, err := runStep(t, step, selectBody) assertStatus(t, err, http.StatusBadGateway) @@ -358,19 +360,25 @@ func TestTokenHeader_ExpiryMissingOrPast_Returns502AndCachesNothing(t *testing.T } func TestExpiryLifetime_FutureExpiry_ReturnsTimeLeftLessSkew(t *testing.T) { - expiry := float64(time.Now().Add(30 * time.Minute).Unix()) - lifetime, ok := expiryLifetime(map[string]any{"token": map[string]any{"expirationTime": expiry}}, - "token.expirationTime") - want := 30*time.Minute - util.TokenRefreshSkew - if !ok || lifetime > want || lifetime < want-2*time.Second { - t.Errorf("expiryLifetime() = %s, %v; want about %s", lifetime, ok, want) + expiry := time.Now().Add(30 * time.Minute) + for name, stated := range map[string]any{ + "unix seconds": float64(expiry.Unix()), + "rfc 3339": expiry.UTC().Format(time.RFC3339Nano), + } { + t.Run(name, func(t *testing.T) { + lifetime, ok := expiryLifetime(map[string]any{"token": map[string]any{"expiry": stated}}, "token.expiry") + want := 30*time.Minute - util.TokenRefreshSkew + if !ok || lifetime > want || lifetime < want-2*time.Second { + t.Errorf("expiryLifetime() = %s, %v; want about %s", lifetime, ok, want) + } + }) } } func TestTokenHeader_TtlAndExpiryField_RefusedAtStartup(t *testing.T) { profile := tokenHeaderProfile("https://login.example") profile.Provider = "pmfby" - profile.TokenExpiryField = "responseDynamic.token.expirationTime" + profile.TokenExpiryField = "responseDynamic.token.validTo" if err := profile.validate(); err == nil || !strings.Contains(err.Error(), "not both") { t.Errorf("validate() = %v, want tokenTtl and tokenExpiryField refused together", err) } @@ -385,7 +393,7 @@ func TestParseProviderAuth_TokenExpiryField_BuildsProfileWithoutTtl(t *testing.T "tokenSecretField-pmfby": "password", "tokenSecretEnv-pmfby": "PMFBY_PASSWORD", "tokenResponseField-pmfby": "responseDynamic.token.Token", - "tokenExpiryField-pmfby": "responseDynamic.token.expirationTime", + "tokenExpiryField-pmfby": "responseDynamic.token.validTo", "headerName-pmfby": "Authorization", }) if err != nil { From 4c0a7ce4f63ead0fc1485c71e6607b641ea90cd5 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Wed, 14 Oct 2026 13:08:54 +0530 Subject: [PATCH 31/39] fix: read PMFBY's InsertDateTime as the RFC 3339 time it sends [#57] The demo portal sends InsertDateTime as "2026-10-12T11:02:49.000Z", not the bare IST time the mapping expected, so filedOn was always dropped on a read. --- config/mappings/pmfby/grievance.status.yaml | 6 +++--- .../implementation/Grievance/mappings_test.go | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 481a2df54..eac40b757 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -39,10 +39,10 @@ response: | /* PMFBY's TicketStatus as a CaseStatusCode; a phrase not listed is UnderReview. */ $statusOf := { "resolved": "Resolved" }; $status := $isText($ticket.TicketStatus) ? $lookup($statusOf, $lowercase($trim($ticket.TicketStatus))); - /* InsertDateTime is "YYYY-MM-DD HH:MM:SS" in IST. */ + /* InsertDateTime is an RFC 3339 time, e.g. "2026-10-12T11:02:49.000Z". */ $inserted := $ticket.InsertDateTime; - $filedOn := $isText($inserted) and $contains($inserted, /^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$/) - ? $fromMillis($toMillis($replace($inserted, " ", "T") & "+05:30"), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"); + $filedOn := $isText($inserted) and $contains($inserted, /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/) + ? $fromMillis($toMillis($inserted), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"); $found ? ( $commitment := beckn.message.contract.commitments[0]; diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 3911889e4..7d6e4aa6e 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -39,7 +39,7 @@ const ( // It carries the category by name only. const knownTicketRecord = `{"responseCode":"1","responseMessage":"Fetched","recordCount":1,"responseDynamic":{ "GrievenceSupportTicketID":109301,"TicketStatus":"Under Review", - "ComplaintDate":"2026-10-04","InsertDateTime":"2026-10-04 15:30:00","ApplicationNo":"040108251010160770605", + "ComplaintDate":"2026-10-04","InsertDateTime":"2026-10-04T10:00:00.000Z","ApplicationNo":"040108251010160770605", "GrievenceDescription":"Claim not received", "TicketCategoryName":"Claim","TicketSubCategoryName":"Claim not received","CropName":"Paddy", "FarmerName":"Ramesh","StateMasterName":"Karnataka","DistrictMasterName":"Mysuru", @@ -725,6 +725,16 @@ func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { } } +// InsertDateTime comes back as filedOn in UTC, whatever offset PMFBY states. +func TestStatus_InsertDateTimeWithOffset_FiledOnInUTC(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"2026-10-04T10:00:00.000Z"`, `"2026-10-04T15:30:00+05:30"`, 1) + got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())) + if filed := band(got.attributes(), "case")["filedOn"]; filed != "2026-10-04T10:00:00Z" { + t.Errorf("filedOn = %v, want 2026-10-04T10:00:00Z", filed) + } +} + func TestStatus_BareScheme_ReturnsSchemeName(t *testing.T) { attributes := statusAttributes() attributes["scheme"] = map[string]any{"code": "PMFBY"} @@ -757,8 +767,8 @@ func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { "null category name": {`"TicketCategoryName":"Claim"`, `"TicketCategoryName":null`, "grievance", "category"}, "empty sub-category": {`"TicketSubCategoryName":"Claim not received"`, `"TicketSubCategoryName":""`, "grievance", "subCategory"}, "null description": {`"GrievenceDescription":"Claim not received"`, `"GrievenceDescription":null`, "grievance", "description"}, - "null insert time": {`"InsertDateTime":"2026-10-04 15:30:00"`, `"InsertDateTime":null`, "case", "filedOn"}, - "date-only insert": {`"InsertDateTime":"2026-10-04 15:30:00"`, `"InsertDateTime":"2026-10-04"`, "case", "filedOn"}, + "null insert time": {`"InsertDateTime":"2026-10-04T10:00:00.000Z"`, `"InsertDateTime":null`, "case", "filedOn"}, + "date-only insert": {`"InsertDateTime":"2026-10-04T10:00:00.000Z"`, `"InsertDateTime":"2026-10-04"`, "case", "filedOn"}, "null application no": {`"ApplicationNo":"040108251010160770605"`, `"ApplicationNo":null`, "", "enrolmentId"}, "empty crop": {`"CropName":"Paddy"`, `"CropName":""`, "case", "cropName"}, } { From 7bf896f235435f841e1ad61d26a7b6cc27a29717 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 16 Oct 2026 13:14:17 +0530 Subject: [PATCH 32/39] fix: answer PMFBY failures in our own words, its own faults as 502 [#57] PMFBY answers every failure with HTTP 200 and responseCode "0", so the mapping passed its message straight to the caller: an internal "AxiosError: Request failed with status code 403" reached the farmer as a 400, as if the request were wrong. The contract says the portal's own message is never forwarded. A refusal PMFBY is known to give is now a 400 in our words; any other failure is a 502 asking the caller to try again later. PMFBY's text rides on _error.cause into the warning log and is never returned. --- config/mappings/pmfby/grievance.status.yaml | 19 ++++-- config/mappings/pmfby/grievance.support.yaml | 18 +++-- .../implementation/Grievance/mappings_test.go | 67 +++++++++++-------- .../internal/common/common_test.go | 13 ++++ .../implementation/internal/common/serve.go | 24 ++++--- 5 files changed, 93 insertions(+), 48 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index eac40b757..9c2ecdb11 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -21,8 +21,8 @@ request: | ) # The record is mapped; fields PMFBY leaves empty are dropped. No record, or -# one for another ticket, is a 400 "No record found"; a refusal is a 400 with -# PMFBY's own message; a reply with no reason is a 502. +# one for another ticket, is a 400 in our words; any other failure is a 502. +# PMFBY's own message is logged as the cause, never returned. response: | ( /* [0] reads a bare record or a list of one alike. */ @@ -32,6 +32,12 @@ response: | /* PMFBY sends null or "" for missing values. */ $isText := function($v) { $type($v) = "string" and $v != "" }; $ok := $string(response.responseCode) = "1"; + $notFound := "No grievance was found for this ticket number and phone number."; + /* PMFBY answers every failure with responseCode "0", so its wording is the + only way to tell the caller's mistake from PMFBY's own fault. Keyed in + lower case. Drop this once PMFBY sends a code per refusal. */ + $refusals := { "no record found": $notFound }; + $refusal := $isText($said) ? $lookup($refusals, $lowercase($said)); /* PMFBY reads by phone alone, so a record for another ticket is no record. */ $found := $ok and ($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) @@ -78,9 +84,10 @@ response: | } ) : $ok - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": "No record found" } } - : $isText($said) - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $said } } + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $notFound } } + : $exists($refusal) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $refusal, "cause": $said } } : { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY replied with neither a record nor a reason" } } + "message": "PMFBY could not return the grievance status right now. Please try again later.", + "cause": $said } } ) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index b95931cff..a7fefa2bb 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -37,13 +37,22 @@ request: | ) # Echoes the request and adds the ticket; filedOn is the filing time in UTC. -# A refusal is a 400 with PMFBY's own message; a reply with no reason is a 502. +# A refusal PMFBY is known to give is a 400 in our words; any other failure is +# a 502. PMFBY's own message is logged as the cause, never returned. response: | ( $ch := beckn.message.support.channels[0]; $g := $ch.grievance; $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; $said := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + /* PMFBY answers every failure with responseCode "0", so its wording is the + only way to tell the caller's mistake from PMFBY's own fault. Keyed in + lower case. Drop this once PMFBY sends a code per refusal. */ + $refusals := { + "mobile number not matched": "The phone number does not match the one registered for this application number.", + "application details not exists": "No PMFBY application was found for this application number." + }; + $refusal := $said ? $lookup($refusals, $lowercase($said)); $filed := $exists($ch.complaintDate) ? $toMillis($ch.complaintDate) : $millis(); /* responseCode "1" (string or number) with a ticket, "Ticket Already Raised" included. */ $string(response.responseCode) = "1" and ($type($ticketNo) = "string" or $type($ticketNo) = "number") @@ -74,8 +83,9 @@ response: | } } } - : $type($said) = "string" and $said != "" - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $said } } + : $exists($refusal) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $refusal, "cause": $said } } : { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY replied with neither a ticket nor a reason" } } + "message": "PMFBY could not register the grievance right now. Please try again later.", + "cause": $said } } ) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 7d6e4aa6e..6aea61b6d 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -345,6 +345,15 @@ func assertRefused(t *testing.T, err error, message string) { } } +// assertFailedUpstream fails unless err is a 502 NET_DOWNSTREAM_UNAVAILABLE carrying message. +func assertFailedUpstream(t *testing.T, err error, message string) { + t.Helper() + if status, refused := refusal(t, err); status != http.StatusBadGateway || + refused.Code != util.CodeUpstreamUnavailable || refused.Message != message { + t.Errorf("error = %d %+v, want 502 %s %q", status, refused, util.CodeUpstreamUnavailable, message) + } +} + // assertJustNowUTC fails unless value is an RFC 3339 UTC instant within a minute of now. func assertJustNowUTC(t *testing.T, value any) { t.Helper() @@ -520,7 +529,6 @@ func TestSupport_NoGrievance_Returns400(t *testing.T) { assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") } -// complaintDate goes to PMFBY as its IST date and comes back as filedOn in UTC. func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { for given, want := range map[string][2]string{ "2026-09-20T04:30:00Z": {"2026-09-20", "2026-09-20T04:30:00Z"}, @@ -553,14 +561,15 @@ func TestSupport_TicketAlreadyRaised_ReturnsTheExistingTicket(t *testing.T) { assertJustNowUTC(t, filed["filedOn"]) } -// A refusal with a reason is a 400 carrying PMFBY's own message. -func TestSupport_PortalRefuses_Returns400WithItsMessage(t *testing.T) { +// A refusal PMFBY is known to give is a 400 in our words, never PMFBY's. +func TestSupport_KnownRefusal_Returns400InOurWords(t *testing.T) { for name, tc := range map[string]struct{ reply, message string }{ "phone not matched": {`{"responseObject":null,"responseDynamic":null,"responseCode":"0","responseMessage":"Mobile Number Not Matched","jsonString":null,"recordCount":0}`, - "Mobile Number Not Matched"}, - "no application": {`{"responseCode":"0","responseMessage":"Application Details not Exists","responseDynamic":null}`, "Application Details not Exists"}, - "field required": {`{"responseCode":"0","responseMessage":"requestYear is required","responseDynamic":null}`, "requestYear is required"}, - "refused, ticket": {`{"responseCode":"0","responseMessage":"Invalid","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, "Invalid"}, + "The phone number does not match the one registered for this application number."}, + "no application": {`{"responseCode":"0","responseMessage":"Application Details not Exists","responseDynamic":null}`, + "No PMFBY application was found for this application number."}, + "case and spaces": {`{"responseCode":"0","responseMessage":" mobile number NOT matched ","responseDynamic":null}`, + "The phone number does not match the one registered for this application number."}, } { t.Run(name, func(t *testing.T) { _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: tc.reply}}, 0).send(t, support(supportFields())) @@ -569,15 +578,18 @@ func TestSupport_PortalRefuses_Returns400WithItsMessage(t *testing.T) { } } -// A reply with neither a ticket nor a reason is PMFBY's failure, not the caller's. -func TestSupport_PortalGivesNoReason_Returns502(t *testing.T) { +// Any other failure is PMFBY's own: a 502 in our words, PMFBY's text withheld. +func TestSupport_PortalFails_Returns502WithoutItsText(t *testing.T) { for name, reply := range map[string]string{ "success, no ticket": `{"responseCode":"1","responseDynamic":{}}`, "no message": `{"responseCode":"0","responseMessage":null}`, + "internal error": `{"responseCode":"0","responseMessage":"AxiosError: Request failed with status code 403","responseDynamic":null}`, + "token rejected": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, + "unknown wording": `{"responseCode":"0","responseMessage":"Invalid","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, } { t.Run(name, func(t *testing.T) { _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: reply}}, 0).send(t, support(supportFields())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + assertFailedUpstream(t, err, "PMFBY could not register the grievance right now. Please try again later.") }) } } @@ -805,37 +817,38 @@ func TestStatus_TicketStatus_MapsToCaseStatusCode(t *testing.T) { } } -// A refusal is a 400 carrying PMFBY's own message; an empty record, or one -// for another ticket (PMFBY reads by phone alone), is "No record found". +// No record, or one for another ticket (PMFBY reads by phone alone), is a 400 +// in our words. func TestStatus_NoMatchingRecord_Returns400(t *testing.T) { another := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{"GrievenceSupportTicketNo":"100626000099002",`, 1) - for name, tc := range map[string]struct{ reply, message string }{ - "no record": {`{"responseCode":"0","responseMessage":"No record found","responseDynamic":[]}`, "No record found"}, - "token required": {`{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, "Token required!"}, - "null": {`{"responseCode":"1","responseDynamic":null}`, "No record found"}, - "empty object": {`{"responseCode":"1","responseDynamic":{}}`, "No record found"}, - "empty in list": {`{"responseCode":"1","responseDynamic":[{}]}`, "No record found"}, - "all fields null": {`{"responseCode":"1","responseDynamic":{"ApplicationNo":null,"TicketStatus":null}}`, "No record found"}, - "another ticket": {another, "No record found"}, + for name, reply := range map[string]string{ + "no record": `{"responseCode":"0","responseMessage":"No record found","responseDynamic":[]}`, + "null": `{"responseCode":"1","responseDynamic":null}`, + "empty object": `{"responseCode":"1","responseDynamic":{}}`, + "empty in list": `{"responseCode":"1","responseDynamic":[{}]}`, + "all fields null": `{"responseCode":"1","responseDynamic":{"ApplicationNo":null,"TicketStatus":null}}`, + "another ticket": another, } { t.Run(name, func(t *testing.T) { - _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: tc.reply}}, 0). + _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0). send(t, request("status", statusAttributes())) - assertRefused(t, err, tc.message) + assertRefused(t, err, "No grievance was found for this ticket number and phone number.") }) } } -// A reply with neither a record nor a reason is PMFBY's failure, not the caller's. -func TestStatus_PortalGivesNoReason_Returns502(t *testing.T) { +// Any other failure is PMFBY's own: a 502 in our words, PMFBY's text withheld. +func TestStatus_PortalFails_Returns502WithoutItsText(t *testing.T) { for name, reply := range map[string]string{ - "no envelope": `{"message":"An error has occurred."}`, - "no message": `{"responseCode":"0","responseMessage":"","responseDynamic":null}`, + "no envelope": `{"message":"An error has occurred."}`, + "no message": `{"responseCode":"0","responseMessage":"","responseDynamic":null}`, + "internal error": `{"responseCode":"0","responseMessage":"AxiosError: Request failed with status code 403","responseDynamic":null}`, + "token rejected": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, } { t.Run(name, func(t *testing.T) { _, err := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: reply}}, 0). send(t, request("status", statusAttributes())) - assertCoded(t, err, http.StatusBadGateway, util.CodeUpstreamUnavailable) + assertFailedUpstream(t, err, "PMFBY could not return the grievance status right now. Please try again later.") }) } } diff --git a/pkg/plugin/implementation/internal/common/common_test.go b/pkg/plugin/implementation/internal/common/common_test.go index 0e85b3588..e7c9609ef 100644 --- a/pkg/plugin/implementation/internal/common/common_test.go +++ b/pkg/plugin/implementation/internal/common/common_test.go @@ -2646,6 +2646,19 @@ func TestRefusal_UnreadableError_FailsRatherThanAnswering(t *testing.T) { } } +// cause is the provider's own wording, kept for the log: the caller never sees it. +func TestRefusal_Cause_NotSentToCaller(t *testing.T) { + var calls int + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte( + `{"_error":{"status":502,"code":"NET_DOWNSTREAM_UNAVAILABLE","message":"try again later","cause":"AxiosError: 403"}}`)} + _, err := runStep(t, refusalStep(t, mapper, &calls), selectBody) + + _, refused := refusedAs(t, err) + if refused.Message != "try again later" || strings.Contains(err.Error(), "AxiosError") { + t.Errorf("answer = %+v (%v), want the mapping's message without the cause", refused, err) + } +} + // A mapping that writes "_error": null means no error, not an unreadable one. func TestRefusal_NullErrorField_AnswersAsBefore(t *testing.T) { var calls int diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index e2970bbca..1671df2c0 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -177,8 +177,8 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { return err } // The provider body is not logged: it can carry the farmer's details. - if err := refusalIn(becknResponse); err != nil { - log.Warnf(ctx, "%s refused the request: %v", plan.BindingKey, err) + if cause, err := refusalIn(becknResponse); err != nil { + log.Warnf(ctx, "%s refused the request: %v (provider said: %q)", plan.BindingKey, err, s.redactString(cause)) return err } if len(becknResponse) == 0 { @@ -208,7 +208,7 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an if err != nil { return nil, err } - if err := refusalIn(mapped); err != nil { + if _, err := refusalIn(mapped); err != nil { return nil, err } if len(mapped) == 0 { @@ -218,32 +218,34 @@ func (s *Step) buildRequest(ctx context.Context, call model.ActionPlan, beckn an } // refusalIn returns the error a mapping asks for under the reserved _error -// field {status, code, message, path}, or nil if there is none. Status 0 or -// 400 is a 400 with details.path. -func refusalIn(mapped []byte) error { +// field {status, code, message, path, cause}, or nil if there is none. Status +// 0 or 400 is a 400 with details.path. cause is the provider's own wording, +// returned for the log and never sent to the caller. +func refusalIn(mapped []byte) (string, error) { // Cheap text check first: almost no answer carries the field. if !bytes.Contains(mapped, []byte(`"_error"`)) { - return nil + return "", nil } var document map[string]json.RawMessage if json.Unmarshal(mapped, &document) != nil || document["_error"] == nil || string(document["_error"]) == "null" { - return nil + return "", nil } var r struct { Status int `json:"status"` Code string `json:"code"` Message string `json:"message"` Path string `json:"path"` + Cause string `json:"cause"` } if json.Unmarshal(document["_error"], &r) != nil || r.Message == "" { - return errors.New("a mapping refused the call with an unreadable _error; it needs status, code and message") + return "", errors.New("a mapping refused the call with an unreadable _error; it needs status, code and message") } switch r.Status { case 0, http.StatusBadRequest: // CodedErr drops details, so the path needs SchemaValidationErr. - return &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} + return r.Cause, &model.SchemaValidationErr{Errors: []model.Error{*model.NewCodedErrorWithCause(r.Code, r.Message, r.Path, nil)}} default: - return model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) + return r.Cause, model.NewCodedErr(r.Status, r.Code, errors.New(r.Message)) } } From bc350a1cbe739d74d4adbaee75412bba70a05692 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 16 Oct 2026 13:14:39 +0530 Subject: [PATCH 33/39] feat: move the PMFBY grievance onto the Grievance pack and return its status fields [#57] The separate PMFBYGrievance pack is gone: PMFBY is now the PMFBY branch of Grievance/v0.1, picked by scheme.code, under @type openagrinet:Grievance. The binding key, the request's applicantPhone (now applicantMobile) and the fixtures follow it. The pack's status read now lists what PMFBY's record really carries, so the mapping returns cropYear, season (1/2/3 as Kharif/Rabi/Zaid), the category and sub-category codes beside their names, and case.reply from latestRemark. PMFBY's 0 for "none", out-of-range values and blank remarks are dropped rather than sent as values the pack refuses. --- config/mappings/pmfby/grievance.status.yaml | 28 ++++++- config/mappings/pmfby/grievance.support.yaml | 4 +- config/provider-adapter.yaml | 2 +- .../Grievance/cmd/plugin_test.go | 2 +- .../implementation/Grievance/mappings_test.go | 80 ++++++++++++++++--- 5 files changed, 96 insertions(+), 20 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 9c2ecdb11..56bbc4b99 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -8,14 +8,14 @@ request: | $ca := beckn.message.contract.commitments[0].commitmentAttributes; $at := "$.message.contract.commitments[0].commitmentAttributes."; $missing := [ - { "field": "applicantPhone", "path": $at & "applicantPhone", "present": $exists($ca.applicantPhone) }, + { "field": "applicantMobile", "path": $at & "applicantMobile", "present": $exists($ca.applicantMobile) }, { "field": "case.ticketNo", "path": $at & "case.ticketNo", "present": $exists($ca.case.ticketNo) } ][$not(present)][0]; $missing ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", "message": $missing.field & " is required", "path": $missing.path } } : { - "requestorMobileNo": $ca.applicantPhone, + "requestorMobileNo": $ca.applicantMobile, "GrievenceSupportTicketNo": $ca.case.ticketNo } ) @@ -49,6 +49,16 @@ response: | $inserted := $ticket.InsertDateTime; $filedOn := $isText($inserted) and $contains($inserted, /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/) ? $fromMillis($toMillis($inserted), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"); + /* PMFBY sends ids as numbers or digit strings, and 0 for none. */ + $id := function($v) { + ($type($v) = "number" or $type($v) = "string") and $contains($string($v), /^[0-9]+$/) and $number($string($v)) != 0 + ? $string($v) + }; + /* A band with nothing in it is dropped, not sent as {}. */ + $band := function($o) { $exists($keys($o)[0]) ? $o }; + $seasons := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; + $year := $id($ticket.RequestYear); + $reply := $isText($ticket.latestRemark) ? $trim($ticket.latestRemark); $found ? ( $commitment := beckn.message.contract.commitments[0]; @@ -66,15 +76,25 @@ response: | /* Stated, not echoed: the caller may send only the code. */ "scheme": { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }, "enrolmentId": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, + "cropYear": $year and $contains($year, /^[0-9]{4}$/) ? $year, + "season": $lookup($seasons, $id($ticket.RequestSeason)), "grievance": { - "category": $isText($ticket.TicketCategoryName) ? { "name": $ticket.TicketCategoryName }, - "subCategory": $isText($ticket.TicketSubCategoryName) ? { "name": $ticket.TicketSubCategoryName }, + "category": $band({ + "code": $id($ticket.TicketCategoryID), + "name": $isText($ticket.TicketCategoryName) ? $ticket.TicketCategoryName + }), + "subCategory": $band({ + "code": $id($ticket.TicketSubCategoryID), + "name": $isText($ticket.TicketSubCategoryName) ? $ticket.TicketSubCategoryName + }), "description": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription }, "case": { "ticketNo": $ca.case.ticketNo, "status": { "code": $exists($status) ? $status : "UnderReview" }, "filedOn": $filedOn, + /* The officer's answer; PMFBY sends "" until there is one. */ + "reply": $isText($reply) ? $reply, "cropName": $isText($ticket.CropName) ? $ticket.CropName } } diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index a7fefa2bb..59cd61389 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -10,7 +10,7 @@ request: | /* First missing required field, with its path. */ $missing := [ { "field": "enrolmentId", "path": $at & "enrolmentId", "present": $exists($ch.enrolmentId) }, - { "field": "applicantPhone", "path": $at & "applicantPhone", "present": $exists($ch.applicantPhone) }, + { "field": "applicantMobile", "path": $at & "applicantMobile", "present": $exists($ch.applicantMobile) }, { "field": "cropYear", "path": $at & "cropYear", "present": $exists($ch.cropYear) }, { "field": "season", "path": $at & "season", "present": $exists($ch.season) }, { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } @@ -22,7 +22,7 @@ request: | ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", "message": $missing.field & " is required", "path": $missing.path } } : { - "requestorMobileNo": $ch.applicantPhone, + "requestorMobileNo": $ch.applicantMobile, "applicationNo": $ch.enrolmentId, "requestYear": $string($ch.cropYear), "requestSeason": $lookup($seasons, $ch.season), diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 0af7dee1d..4c94f991d 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -510,7 +510,7 @@ modules: # ------------------------------------------------------------------ - id: Grievance config: - bindingKeys: "pmfby|openagrinet:PMFBYGrievance" + bindingKeys: "pmfby|openagrinet:Grievance" # Paired by position, tried in order: status names the binding on # its contract, support on its channel. diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go index 0521d37ee..dfbbc4b9c 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin_test.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin_test.go @@ -28,7 +28,7 @@ func (stubMapper) Transform(context.Context, string, definition.Direction, any) } const ( - pmfbyBindingKey = "pmfby|openagrinet:PMFBYGrievance" + pmfbyBindingKey = "pmfby|openagrinet:Grievance" providerIDAt = "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id" capabilityCodeAt = "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type" ) diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 6aea61b6d..5a1e06070 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -26,7 +26,7 @@ import ( const ( mappingsDir = "../../../../config/mappings/pmfby" - shippedBindingKey = "pmfby|openagrinet:PMFBYGrievance" + shippedBindingKey = "pmfby|openagrinet:Grievance" stubToken = "stub-login-token" knownTicket = "100626000099001" phone = "9876543210" @@ -139,7 +139,7 @@ func newHarness(t *testing.T, pmfby *pmfbyStub, statusRetries int, tweak ...func Mappings: mappings.URL + "/" + file, RetryMax: retries} } registry := &stubRegistry{plan: &model.ProviderRecord{ - BindingKey: shippedBindingKey, ParticipantID: "pmfby", CapabilityCode: "openagrinet:PMFBYGrievance", + BindingKey: shippedBindingKey, ParticipantID: "pmfby", CapabilityCode: "openagrinet:Grievance", BaseURL: upstream.URL, Actions: map[string]model.ActionPlan{ "support": action(insertPath, "grievance.support.yaml", 0), @@ -240,7 +240,7 @@ func (h *harness) mustSend(t *testing.T, body []byte) *answer { return got } -const packContext = "https://openagrinet.github.io/network-specs/api-schemas/PMFBYGrievance/v0.1/context.jsonld" +const packContext = "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld" // scheme is what every PMFBY payload names. var scheme = map[string]any{"code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana"} @@ -255,7 +255,7 @@ func becknContext(action string) map[string]any { // request builds a status payload whose one commitment carries attributes. func request(action string, attributes map[string]any) []byte { attributes["@context"] = packContext - attributes["@type"] = "openagrinet:PMFBYGrievance" + attributes["@type"] = "openagrinet:Grievance" attributes["informationMode"] = "OnDemand" if _, named := attributes["scheme"]; !named { attributes["scheme"] = scheme @@ -278,7 +278,7 @@ func request(action string, attributes map[string]any) []byte { // support builds a support request from flat fields: categoryCode, // subCategoryCode and description under grievance, the rest on channels[0]. func support(fields map[string]any) []byte { - channel := map[string]any{"@context": packContext, "@type": "openagrinet:PMFBYGrievance", + channel := map[string]any{"@context": packContext, "@type": "openagrinet:Grievance", "informationMode": "OnDemand", "scheme": scheme, "provider": provider} body := map[string]any{"channels": []any{channel}} grievance := map[string]any{} @@ -304,13 +304,13 @@ func support(fields map[string]any) []byte { func supportFields() map[string]any { return map[string]any{ "enrolmentId": "040108251010160770605", "categoryCode": "3", "subCategoryCode": "10", - "description": " Claim not received ", "applicantPhone": phone, + "description": " Claim not received ", "applicantMobile": phone, "cropYear": "2025", "season": "Kharif", } } func statusAttributes() map[string]any { - return map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": knownTicket}} + return map[string]any{"applicantMobile": phone, "case": map[string]any{"ticketNo": knownTicket}} } // refusal is the HTTP status and Beckn error a failed request is answered with. @@ -410,7 +410,7 @@ func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { channel["informationMode"] != "Direct" || channel["scheme"] == nil { t.Errorf("channel = %v", channel) } - for _, private := range []string{"applicantPhone", "ticketId"} { + for _, private := range []string{"applicantMobile", "ticketId"} { if _, echoed := channel[private]; echoed { t.Errorf("%s must not be echoed", private) } @@ -500,7 +500,7 @@ func TestSupport_BareProviderAndScheme_ReturnsPortalNameAndSchemeName(t *testing func TestSupport_MissingField_Returns400NamingItsPath(t *testing.T) { channel := "$.message.support.channels[0]." for field, path := range map[string]string{ - "enrolmentId": channel + "enrolmentId", "applicantPhone": channel + "applicantPhone", + "enrolmentId": channel + "enrolmentId", "applicantMobile": channel + "applicantMobile", "cropYear": channel + "cropYear", "season": channel + "season", "description": channel + "grievance.description", } { @@ -715,6 +715,49 @@ func TestStatus_KnownTicket_ReturnsCaseRecordAndComplaint(t *testing.T) { } } +// The live record carries ids, crop year and season as numbers, and the +// officer's reply in latestRemark. +func TestStatus_FullRecord_ReturnsCodesCropYearSeasonAndReply(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{ + "TicketCategoryID":3,"TicketSubCategoryID":10,"RequestYear":2025,"RequestSeason":2, + "latestRemark":" Claim credited on 12 Oct ",`, 1) + attributes := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())).attributes() + + grievance := band(attributes, "grievance") + if category, subCategory := band(grievance, "category"), band(grievance, "subCategory"); category["code"] != "3" || + category["name"] != "Claim" || subCategory["code"] != "10" || subCategory["name"] != "Claim not received" { + t.Errorf("grievance = %v, want code and name on both levels", grievance) + } + if attributes["cropYear"] != "2025" || attributes["season"] != "Rabi" { + t.Errorf("cropYear, season = %v, %v; want 2025, Rabi", attributes["cropYear"], attributes["season"]) + } + if reply := band(attributes, "case")["reply"]; reply != "Claim credited on 12 Oct" { + t.Errorf("case.reply = %q, want the trimmed remark", reply) + } +} + +// Values PMFBY uses for "none", or that fit no pack field, are dropped. +func TestStatus_UnusableCodes_Dropped(t *testing.T) { + for name, fields := range map[string]string{ + "zeros": `"TicketCategoryID":0,"TicketSubCategoryID":"0","RequestYear":0,"RequestSeason":0,"latestRemark":"",`, + "out of range": `"RequestYear":25,"RequestSeason":4,"latestRemark":" ",`, + "not a number": `"TicketCategoryID":"x","RequestYear":"20x5","RequestSeason":"Kharif",`, + } { + t.Run(name, func(t *testing.T) { + record := strings.Replace(knownTicketRecord, `"responseDynamic":{`, `"responseDynamic":{`+fields, 1) + attributes := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). + mustSend(t, request("status", statusAttributes())).attributes() + _, year := attributes["cropYear"] + _, season := attributes["season"] + _, reply := band(attributes, "case")["reply"] + if category := band(band(attributes, "grievance"), "category"); year || season || reply || category["code"] != nil { + t.Errorf("attributes = %v, want no cropYear, season, reply or category code", attributes) + } + }) + } +} + func TestStatus_KnownTicket_DropsPersonalAndInternalFields(t *testing.T) { got := newHarness(t, &pmfbyStub{}, 0).mustSend(t, request("status", statusAttributes())) allowed := map[string]map[string]bool{ @@ -872,7 +915,7 @@ func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { } func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"applicantPhone", "case"} { + for _, field := range []string{"applicantMobile", "case"} { t.Run(field, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) attributes := statusAttributes() @@ -900,7 +943,7 @@ func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { func TestFlow_SupportThenStatus_LogsInOnce(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) ticket := band(h.mustSend(t, support(supportFields())).channel(), "case")["ticketNo"] - got := h.mustSend(t, request("status", map[string]any{"applicantPhone": phone, "case": map[string]any{"ticketNo": ticket}})) + got := h.mustSend(t, request("status", map[string]any{"applicantMobile": phone, "case": map[string]any{"ticketNo": ticket}})) if status := band(band(got.attributes(), "case"), "status"); status["code"] != "UnderReview" { t.Errorf("status of the filed ticket = %v", got.attributes()) @@ -932,10 +975,23 @@ func TestFlow_UnservedAction_Returns400WithoutCallingPMFBY(t *testing.T) { } } +// PM-KISAN shares the Grievance type; only the provider tells the desks apart. +func TestFlow_OtherSchemeSameType_PassesThroughUntouched(t *testing.T) { + h := newHarness(t, &pmfbyStub{}, 0) + body := strings.Replace(string(request("status", statusAttributes())), `"id":"pmfby"`, `"id":"pmkisan"`, 1) + if !strings.Contains(body, `"id":"pmkisan"`) { + t.Fatal("the request names no pmfby provider to replace") + } + got, err := h.send(t, []byte(body)) + if err != nil || got != nil || len(h.pmfby.calls) != 0 { + t.Errorf("Run() = %v, %v, PMFBY calls %v; want PM-KISAN's request passed on untouched", got, err, h.pmfby.calls) + } +} + func TestFlow_OtherCapability_PassesThroughUntouched(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) body := strings.Replace(string(request("status", statusAttributes())), - "openagrinet:PMFBYGrievance", "openagrinet:PMKISANGrievance", 1) + "openagrinet:Grievance", "openagrinet:SchemeApplication", 1) got, err := h.send(t, []byte(body)) if err != nil || got != nil { t.Errorf("Run() = %v, %v; want another capability passed on untouched", got, err) From c604cb0fb3c4dce5bdc4f1b72643b68e4f31318f Mon Sep 17 00:00:00 2001 From: nisargabd Date: Fri, 16 Oct 2026 13:14:52 +0530 Subject: [PATCH 34/39] fix: map Open to Registered and date a lodge by when it was lodged [#57] PMFBY's "Open" is a ticket nobody has acted on yet, which is Registered, not UnderReview; it is still not final, so an unknown phrase keeps falling to UnderReview. The lodge reply took filedOn from the caller's complaintDate while the status read takes it from PMFBY's InsertDateTime, so a back-dated complaint showed two filing times for one ticket. filedOn is now the lodge time on both; complaintDate still goes to PMFBY as its IST date. --- config/mappings/pmfby/grievance.status.yaml | 2 +- config/mappings/pmfby/grievance.support.yaml | 7 ++++--- .../implementation/Grievance/mappings_test.go | 20 ++++++++++--------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 56bbc4b99..d67bbabdb 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -43,7 +43,7 @@ response: | and ($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) and $not($exists($ticket.GrievenceSupportTicketNo) and $string($ticket.GrievenceSupportTicketNo) != $asked); /* PMFBY's TicketStatus as a CaseStatusCode; a phrase not listed is UnderReview. */ - $statusOf := { "resolved": "Resolved" }; + $statusOf := { "open": "Registered", "resolved": "Resolved" }; $status := $isText($ticket.TicketStatus) ? $lookup($statusOf, $lowercase($trim($ticket.TicketStatus))); /* InsertDateTime is an RFC 3339 time, e.g. "2026-10-12T11:02:49.000Z". */ $inserted := $ticket.InsertDateTime; diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 59cd61389..426f29dfd 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -36,7 +36,7 @@ request: | } ) -# Echoes the request and adds the ticket; filedOn is the filing time in UTC. +# Echoes the request and adds the ticket; filedOn is when it was lodged, in UTC. # A refusal PMFBY is known to give is a 400 in our words; any other failure is # a 502. PMFBY's own message is logged as the cause, never returned. response: | @@ -53,7 +53,6 @@ response: | "application details not exists": "No PMFBY application was found for this application number." }; $refusal := $said ? $lookup($refusals, $lowercase($said)); - $filed := $exists($ch.complaintDate) ? $toMillis($ch.complaintDate) : $millis(); /* responseCode "1" (string or number) with a ticket, "Ticket Already Raised" included. */ $string(response.responseCode) = "1" and ($type($ticketNo) = "string" or $type($ticketNo) = "number") ? { @@ -77,7 +76,9 @@ response: | "case": { "ticketNo": $string($ticketNo), "status": { "code": "Registered" }, - "filedOn": $fromMillis($filed, "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z") + /* Lodge time, not complaintDate: a status read reports when PMFBY + recorded the ticket, and the two must agree. */ + "filedOn": $fromMillis($millis(), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z") } }] } diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 5a1e06070..48752f241 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -529,10 +529,14 @@ func TestSupport_NoGrievance_Returns400(t *testing.T) { assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") } +// complaintDate goes to PMFBY as its IST date and comes back as filedOn in UTC. +// complaintDate goes to PMFBY as its IST date; filedOn stays the lodge time, +// so a later status read reports the same moment. func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { - for given, want := range map[string][2]string{ - "2026-09-20T04:30:00Z": {"2026-09-20", "2026-09-20T04:30:00Z"}, - "2026-09-21T01:00:00+05:30": {"2026-09-21", "2026-09-20T19:30:00Z"}, // 1am IST is the previous day in UTC + for given, want := range map[string]string{ + "2026-09-20": "2026-09-20", + "2026-09-20T04:30:00Z": "2026-09-20", + "2026-09-21T01:00:00+05:30": "2026-09-21", } { t.Run(given, func(t *testing.T) { h := newHarness(t, &pmfbyStub{}, 0) @@ -540,12 +544,10 @@ func TestSupport_CallerDateAndSource_SentInsteadOfDefaults(t *testing.T) { fields["complaintDate"], fields["receiptSourceId"] = given, "200001" got := h.mustSend(t, support(fields)) sent := h.pmfby.bodies[insertPath] - if sent["complaintDate"] != want[0] || sent["receiptSourceID"] != "200001" { - t.Errorf("PMFBY was sent %v / %v, want %s and the caller's source", sent["complaintDate"], sent["receiptSourceID"], want[0]) - } - if filed := band(got.channel(), "case")["filedOn"]; filed != want[1] { - t.Errorf("filedOn = %v, want %s", filed, want[1]) + if sent["complaintDate"] != want || sent["receiptSourceID"] != "200001" { + t.Errorf("PMFBY was sent %v / %v, want %s and the caller's source", sent["complaintDate"], sent["receiptSourceID"], want) } + assertJustNowUTC(t, band(got.channel(), "case")["filedOn"]) }) } } @@ -848,7 +850,7 @@ func TestStatus_NullFields_DroppedNotMappedOrFailed(t *testing.T) { // PMFBY's TicketStatus maps to a CaseStatusCode; an unlisted phrase is UnderReview. func TestStatus_TicketStatus_MapsToCaseStatusCode(t *testing.T) { for phrase, want := range map[string]string{"Resolved": "Resolved", " resolved ": "Resolved", - "Open": "UnderReview", "Under Review": "UnderReview", "Escalated": "UnderReview"} { + "Open": "Registered", "Under Review": "UnderReview", "Escalated": "UnderReview"} { t.Run(phrase, func(t *testing.T) { record := strings.Replace(knownTicketRecord, `"TicketStatus":"Under Review"`, `"TicketStatus":"`+phrase+`"`, 1) got := newHarness(t, &pmfbyStub{answers: map[string]string{statusPath: record}}, 0). From 3dc301819c101454f4a4e40b1d613b68f0a522d1 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Mon, 19 Oct 2026 15:03:58 +0530 Subject: [PATCH 35/39] refactor: leave required fields to the Grievance pack [#57] The Grievance pack now enforces its mustSend lists, so the schema validator refuses a support or status call missing a required field before the mapping runs. The mappings' own $missing checks could no longer fire, so they and their tests are removed. --- config/mappings/pmfby/grievance.status.yaml | 12 +---- config/mappings/pmfby/grievance.support.yaml | 16 +------ .../implementation/Grievance/mappings_test.go | 47 ------------------- 3 files changed, 4 insertions(+), 71 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index d67bbabdb..5821cf0ad 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -1,20 +1,12 @@ # PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. # Reads a grievance back by ticket number and the phone it was filed from. # The response maps an allow-list; the farmer's name, location and insurer -# are dropped. Field formats are the pack's. +# are dropped. Required fields and formats are the pack's. request: | ( $ca := beckn.message.contract.commitments[0].commitmentAttributes; - $at := "$.message.contract.commitments[0].commitmentAttributes."; - $missing := [ - { "field": "applicantMobile", "path": $at & "applicantMobile", "present": $exists($ca.applicantMobile) }, - { "field": "case.ticketNo", "path": $at & "case.ticketNo", "present": $exists($ca.case.ticketNo) } - ][$not(present)][0]; - $missing - ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": $missing.field & " is required", "path": $missing.path } } - : { + { "requestorMobileNo": $ca.applicantMobile, "GrievenceSupportTicketNo": $ca.case.ticketNo } diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 426f29dfd..4ca8c6676 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -1,27 +1,15 @@ # PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. # Files a grievance; no OTP. channels[0] holds the provider, the enrolment and -# the complaint. "Grievence" is PMFBY's spelling. Field formats are the pack's. +# the complaint. "Grievence" is PMFBY's spelling. Required fields and formats are the pack's. request: | ( $ch := beckn.message.support.channels[0]; $g := $ch.grievance; - $at := "$.message.support.channels[0]."; - /* First missing required field, with its path. */ - $missing := [ - { "field": "enrolmentId", "path": $at & "enrolmentId", "present": $exists($ch.enrolmentId) }, - { "field": "applicantMobile", "path": $at & "applicantMobile", "present": $exists($ch.applicantMobile) }, - { "field": "cropYear", "path": $at & "cropYear", "present": $exists($ch.cropYear) }, - { "field": "season", "path": $at & "season", "present": $exists($ch.season) }, - { "field": "grievance.description", "path": $at & "grievance.description", "present": $exists($g.description) } - ][$not(present)][0]; $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; /* PMFBY takes the filing day as a date in IST. */ $filed := $exists($ch.complaintDate) ? $toMillis($ch.complaintDate) : $millis(); - $missing - ? { "_error": { "status": 400, "code": "SCH_REQUIRED_FIELD_MISSING", - "message": $missing.field & " is required", "path": $missing.path } } - : { + { "requestorMobileNo": $ch.applicantMobile, "applicationNo": $ch.enrolmentId, "requestYear": $string($ch.cropYear), diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 48752f241..928b5f3c7 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -497,38 +497,6 @@ func TestSupport_BareProviderAndScheme_ReturnsPortalNameAndSchemeName(t *testing } } -func TestSupport_MissingField_Returns400NamingItsPath(t *testing.T) { - channel := "$.message.support.channels[0]." - for field, path := range map[string]string{ - "enrolmentId": channel + "enrolmentId", "applicantMobile": channel + "applicantMobile", - "cropYear": channel + "cropYear", "season": channel + "season", - "description": channel + "grievance.description", - } { - t.Run(field, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - fields := supportFields() - delete(fields, field) - _, err := h.send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") - if _, refused := refusal(t, err); refused.Details == nil || refused.Details.Path != path { - t.Errorf("details = %+v, want path %s", refused.Details, path) - } - if h.pmfby.calls[insertPath] != 0 { - t.Error("PMFBY was called for a request missing a required field") - } - }) - } -} - -func TestSupport_NoGrievance_Returns400(t *testing.T) { - fields := supportFields() - for _, field := range []string{"categoryCode", "subCategoryCode", "description"} { - delete(fields, field) - } - _, err := newHarness(t, &pmfbyStub{}, 0).send(t, support(fields)) - assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") -} - // complaintDate goes to PMFBY as its IST date and comes back as filedOn in UTC. // complaintDate goes to PMFBY as its IST date; filedOn stays the lodge time, // so a later status read reports the same moment. @@ -916,21 +884,6 @@ func TestStatus_RecordInAList_ReadAsTheRecord(t *testing.T) { } } -func TestStatus_MissingField_Returns400WithoutCallingPMFBY(t *testing.T) { - for _, field := range []string{"applicantMobile", "case"} { - t.Run(field, func(t *testing.T) { - h := newHarness(t, &pmfbyStub{}, 0) - attributes := statusAttributes() - delete(attributes, field) - _, err := h.send(t, request("status", attributes)) - assertCoded(t, err, http.StatusBadRequest, "SCH_REQUIRED_FIELD_MISSING") - if h.pmfby.calls[statusPath] != 0 { - t.Error("PMFBY was called for a request missing a required field") - } - }) - } -} - func TestStatus_PortalDown_RetriedAsTheRegistrySays(t *testing.T) { h := newHarness(t, &pmfbyStub{down: true}, 2) _, err := h.send(t, request("status", statusAttributes())) From c658a54e2eed809f78bc87ab2c3cdd18625f6832 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 22 Oct 2026 13:23:24 +0530 Subject: [PATCH 36/39] refactor: name the PMFBY grievance mappings' constants and variables [#57] --- config/mappings/pmfby/grievance.status.yaml | 192 ++++++++++--------- config/mappings/pmfby/grievance.support.yaml | 140 ++++++++------ 2 files changed, 188 insertions(+), 144 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 5821cf0ad..4212c050a 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -1,105 +1,117 @@ # PMFBY status -> POST /krphapi/FGMS/GetGrievenceTicketsStatus. -# Reads a grievance back by ticket number and the phone it was filed from. -# The response maps an allow-list; the farmer's name, location and insurer -# are dropped. Required fields and formats are the pack's. +# Reads a grievance back by ticket number and phone. Only listed fields are +# returned; the farmer's personal details are dropped. +# Required fields and formats are checked by the Grievance pack. request: | ( - $ca := beckn.message.contract.commitments[0].commitmentAttributes; + $attributes := beckn.message.contract.commitments[0].commitmentAttributes; { - "requestorMobileNo": $ca.applicantMobile, - "GrievenceSupportTicketNo": $ca.case.ticketNo + "requestorMobileNo": $attributes.applicantMobile, + "GrievenceSupportTicketNo": $attributes.case.ticketNo } ) -# The record is mapped; fields PMFBY leaves empty are dropped. No record, or -# one for another ticket, is a 400 in our words; any other failure is a 502. -# PMFBY's own message is logged as the cause, never returned. +# Empty PMFBY fields are dropped. No record, or one for another ticket, is a +# 400 in our words; any other failure is a 502. PMFBY's message is logged, +# never returned. response: | ( - /* [0] reads a bare record or a list of one alike. */ - $ticket := response.responseDynamic[0]; - $asked := $string(beckn.message.contract.commitments[0].commitmentAttributes.case.ticketNo); - $said := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + /* Constants */ + $PMFBY_SUCCESS_CODE := "1"; + $SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }; + $INFORMATION_MODE := "Direct"; + $COMMITMENT_STATUS_ACTIVE := "ACTIVE"; + $DEFAULT_CASE_STATUS := "UnderReview"; + $CASE_STATUS_CODES := { "open": "Registered", "resolved": "Resolved" }; + $SEASON_NAMES := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; + $TIMESTAMP_FORMAT := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"; + + /* e.g. "2026-10-12T11:02:49.000Z" */ + $RFC3339_PATTERN := /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/; + $DIGITS_PATTERN := /^[0-9]+$/; + $YEAR_PATTERN := /^[0-9]{4}$/; + + $MSG_NOT_FOUND := "No grievance was found for this ticket number and phone number."; + $MSG_UNAVAILABLE := "PMFBY could not return the grievance status right now. Please try again later."; + + $ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" }; + $ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" }; + /* PMFBY fails everything with responseCode "0", so its wording (lower case) + is the only way to tell a caller mistake from a PMFBY fault. */ + $KNOWN_REFUSALS := { "no record found": $MSG_NOT_FOUND }; + + /* Helpers */ /* PMFBY sends null or "" for missing values. */ - $isText := function($v) { $type($v) = "string" and $v != "" }; - $ok := $string(response.responseCode) = "1"; - $notFound := "No grievance was found for this ticket number and phone number."; - /* PMFBY answers every failure with responseCode "0", so its wording is the - only way to tell the caller's mistake from PMFBY's own fault. Keyed in - lower case. Drop this once PMFBY sends a code per refusal. */ - $refusals := { "no record found": $notFound }; - $refusal := $isText($said) ? $lookup($refusals, $lowercase($said)); - /* PMFBY reads by phone alone, so a record for another ticket is no record. */ - $found := $ok - and ($isText($ticket.ApplicationNo) or $isText($ticket.TicketStatus)) - and $not($exists($ticket.GrievenceSupportTicketNo) and $string($ticket.GrievenceSupportTicketNo) != $asked); - /* PMFBY's TicketStatus as a CaseStatusCode; a phrase not listed is UnderReview. */ - $statusOf := { "open": "Registered", "resolved": "Resolved" }; - $status := $isText($ticket.TicketStatus) ? $lookup($statusOf, $lowercase($trim($ticket.TicketStatus))); - /* InsertDateTime is an RFC 3339 time, e.g. "2026-10-12T11:02:49.000Z". */ - $inserted := $ticket.InsertDateTime; - $filedOn := $isText($inserted) and $contains($inserted, /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$/) - ? $fromMillis($toMillis($inserted), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"); - /* PMFBY sends ids as numbers or digit strings, and 0 for none. */ - $id := function($v) { - ($type($v) = "number" or $type($v) = "string") and $contains($string($v), /^[0-9]+$/) and $number($string($v)) != 0 - ? $string($v) + $isText := function($value) { $type($value) = "string" and $value != "" }; + $asText := function($value) { $isText($value) ? $value }; + /* Ids come as numbers or digit strings, and 0 means none. */ + $asId := function($value) { + ($type($value) = "number" or $type($value) = "string") + and $contains($string($value), $DIGITS_PATTERN) and $number($string($value)) != 0 + ? $string($value) }; - /* A band with nothing in it is dropped, not sent as {}. */ - $band := function($o) { $exists($keys($o)[0]) ? $o }; - $seasons := { "1": "Kharif", "2": "Rabi", "3": "Zaid" }; - $year := $id($ticket.RequestYear); - $reply := $isText($ticket.latestRemark) ? $trim($ticket.latestRemark); - $found - ? ( - $commitment := beckn.message.contract.commitments[0]; - $ca := $commitment.commitmentAttributes; - { - "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), - "message": { - "contract": $merge([beckn.message.contract, { - "commitments": [$merge([$commitment, { - "status": { "descriptor": { "code": "ACTIVE" } }, - "commitmentAttributes": { - "@context": $ca.`@context`, - "@type": $ca.`@type`, - "informationMode": "Direct", - /* Stated, not echoed: the caller may send only the code. */ - "scheme": { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }, - "enrolmentId": $isText($ticket.ApplicationNo) ? $ticket.ApplicationNo, - "cropYear": $year and $contains($year, /^[0-9]{4}$/) ? $year, - "season": $lookup($seasons, $id($ticket.RequestSeason)), - "grievance": { - "category": $band({ - "code": $id($ticket.TicketCategoryID), - "name": $isText($ticket.TicketCategoryName) ? $ticket.TicketCategoryName - }), - "subCategory": $band({ - "code": $id($ticket.TicketSubCategoryID), - "name": $isText($ticket.TicketSubCategoryName) ? $ticket.TicketSubCategoryName - }), - "description": $isText($ticket.GrievenceDescription) ? $ticket.GrievenceDescription - }, - "case": { - "ticketNo": $ca.case.ticketNo, - "status": { "code": $exists($status) ? $status : "UnderReview" }, - "filedOn": $filedOn, - /* The officer's answer; PMFBY sends "" until there is one. */ - "reply": $isText($reply) ? $reply, - "cropName": $isText($ticket.CropName) ? $ticket.CropName - } + /* Drops an object with no fields instead of sending {}. */ + $nonEmpty := function($object) { $exists($keys($object)[0]) ? $object }; + $error := function($kind, $message, $cause) { + { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } + }; + + /* Inputs */ + $commitment := beckn.message.contract.commitments[0]; + $attributes := $commitment.commitmentAttributes; + /* [0] reads a bare record or a list of one alike. */ + $record := response.responseDynamic[0]; + $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $refusalMessage := $isText($pmfbyMessage) ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $isAccepted := $string(response.responseCode) = $PMFBY_SUCCESS_CODE; + /* PMFBY reads by phone alone, so a record for another ticket is no record. */ + $isOtherTicket := $exists($record.GrievenceSupportTicketNo) + and $string($record.GrievenceSupportTicketNo) != $string($attributes.case.ticketNo); + $isFound := $isAccepted and ($isText($record.ApplicationNo) or $isText($record.TicketStatus)) and $not($isOtherTicket); + $mappedStatus := $isText($record.TicketStatus) ? $lookup($CASE_STATUS_CODES, $lowercase($trim($record.TicketStatus))); + $caseStatus := $exists($mappedStatus) ? $mappedStatus : $DEFAULT_CASE_STATUS; + $requestYear := $asId($record.RequestYear); + $cropYear := $requestYear and $contains($requestYear, $YEAR_PATTERN) ? $requestYear; + $filedOn := $isText($record.InsertDateTime) and $contains($record.InsertDateTime, $RFC3339_PATTERN) + ? $fromMillis($toMillis($record.InsertDateTime), $TIMESTAMP_FORMAT); + /* The officer's answer; "" until there is one. */ + $reply := $asText($isText($record.latestRemark) ? $trim($record.latestRemark)); + + /* Output */ + $isFound + ? { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": $COMMITMENT_STATUS_ACTIVE } }, + "commitmentAttributes": { + "@context": $attributes.`@context`, + "@type": $attributes.`@type`, + "informationMode": $INFORMATION_MODE, + "scheme": $SCHEME, + "enrolmentId": $asText($record.ApplicationNo), + "cropYear": $cropYear, + "season": $lookup($SEASON_NAMES, $asId($record.RequestSeason)), + "grievance": { + "category": $nonEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }), + "subCategory": $nonEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }), + "description": $asText($record.GrievenceDescription) + }, + "case": { + "ticketNo": $attributes.case.ticketNo, + "status": { "code": $caseStatus }, + "filedOn": $filedOn, + "reply": $reply, + "cropName": $asText($record.CropName) } - }])] - }]) - } + } + }])] + }]) } - ) - : $ok - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $notFound } } - : $exists($refusal) - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $refusal, "cause": $said } } - : { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY could not return the grievance status right now. Please try again later.", - "cause": $said } } + } + : $isAccepted ? $error($ERROR_REFUSED, $MSG_NOT_FOUND) + : $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage) + : $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage) ) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 4ca8c6676..59109629d 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -1,80 +1,112 @@ # PMFBY support -> POST /krphapi/FGMS/AddKRPHNCIPGrievenceSupportTicket. -# Files a grievance; no OTP. channels[0] holds the provider, the enrolment and -# the complaint. "Grievence" is PMFBY's spelling. Required fields and formats are the pack's. +# Files a grievance; no OTP. "Grievence" is PMFBY's spelling. +# Required fields and formats are checked by the Grievance pack. +# The two halves cannot share variables, so the default codes are in both. request: | ( - $ch := beckn.message.support.channels[0]; - $g := $ch.grievance; - $seasons := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; - /* PMFBY takes the filing day as a date in IST. */ - $filed := $exists($ch.complaintDate) ? $toMillis($ch.complaintDate) : $millis(); + /* Constants */ + $DEFAULT_CATEGORY_CODE := "3"; + $DEFAULT_SUB_CATEGORY_CODE := "10"; + $DEFAULT_RECEIPT_SOURCE_ID := "134306"; + $SEASON_CODES := { "Kharif": "1", "Rabi": "2", "Zaid": "3" }; + + $DATE_FORMAT := "[Y0001]-[M01]-[D01]"; + $IST_OFFSET := "+0530"; + + /* Helpers */ + $orDefault := function($value, $default) { $exists($value) ? $string($value) : $default }; + + /* Inputs */ + $channel := beckn.message.support.channels[0]; + $grievance := $channel.grievance; + $filedAt := $exists($channel.complaintDate) ? $toMillis($channel.complaintDate) : $millis(); + + /* Output */ { - "requestorMobileNo": $ch.applicantMobile, - "applicationNo": $ch.enrolmentId, - "requestYear": $string($ch.cropYear), - "requestSeason": $lookup($seasons, $ch.season), - /* PMFBY's published pair, sent when the caller names none. */ - "ticketCategoryID": $exists($g.category.code) ? $string($g.category.code) : "3", - "ticketSubCategoryID": $exists($g.subCategory.code) ? $string($g.subCategory.code) : "10", - "grievenceDescription": $trim($g.description), - "complaintDate": $fromMillis($filed, "[Y0001]-[M01]-[D01]", "+0530"), - /* This adapter's receipt source id, unless the caller names its own. */ - "receiptSourceID": $exists($ch.receiptSourceId) ? $string($ch.receiptSourceId) : "134306" + "requestorMobileNo": $channel.applicantMobile, + "applicationNo": $channel.enrolmentId, + "requestYear": $string($channel.cropYear), + "requestSeason": $lookup($SEASON_CODES, $channel.season), + "ticketCategoryID": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE), + "ticketSubCategoryID": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE), + "grievenceDescription": $trim($grievance.description), + "complaintDate": $fromMillis($filedAt, $DATE_FORMAT, $IST_OFFSET), + "receiptSourceID": $orDefault($channel.receiptSourceId, $DEFAULT_RECEIPT_SOURCE_ID) } ) -# Echoes the request and adds the ticket; filedOn is when it was lodged, in UTC. -# A refusal PMFBY is known to give is a 400 in our words; any other failure is -# a 502. PMFBY's own message is logged as the cause, never returned. +# Echoes the request and adds the ticket. A known PMFBY refusal is a 400 in our +# words; any other failure is a 502. PMFBY's message is logged, never returned. response: | ( - $ch := beckn.message.support.channels[0]; - $g := $ch.grievance; - $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; - $said := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); - /* PMFBY answers every failure with responseCode "0", so its wording is the - only way to tell the caller's mistake from PMFBY's own fault. Keyed in - lower case. Drop this once PMFBY sends a code per refusal. */ - $refusals := { - "mobile number not matched": "The phone number does not match the one registered for this application number.", - "application details not exists": "No PMFBY application was found for this application number." + /* Constants */ + $DEFAULT_CATEGORY_CODE := "3"; + $DEFAULT_SUB_CATEGORY_CODE := "10"; + $PMFBY_SUCCESS_CODE := "1"; + $PROVIDER_NAME := "PMFBY Grievance Portal"; + $SCHEME := { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }; + $INFORMATION_MODE := "Direct"; + $CASE_STATUS_REGISTERED := "Registered"; + $TIMESTAMP_FORMAT := "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z"; + + $MSG_PHONE_MISMATCH := "The phone number does not match the one registered for this application number."; + $MSG_APPLICATION_NOT_FOUND := "No PMFBY application was found for this application number."; + $MSG_UNAVAILABLE := "PMFBY could not register the grievance right now. Please try again later."; + + $ERROR_REFUSED := { "status": 400, "code": "BIZ_GENERIC_ERROR" }; + $ERROR_UNAVAILABLE := { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE" }; + /* PMFBY fails everything with responseCode "0", so its wording (lower case) + is the only way to tell a caller mistake from a PMFBY fault. */ + $KNOWN_REFUSALS := { + "mobile number not matched": $MSG_PHONE_MISMATCH, + "application details not exists": $MSG_APPLICATION_NOT_FOUND + }; + + /* Helpers */ + $orDefault := function($value, $default) { $exists($value) ? $string($value) : $default }; + $error := function($kind, $message, $cause) { + { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } }; - $refusal := $said ? $lookup($refusals, $lowercase($said)); - /* responseCode "1" (string or number) with a ticket, "Ticket Already Raised" included. */ - $string(response.responseCode) = "1" and ($type($ticketNo) = "string" or $type($ticketNo) = "number") + + /* Inputs */ + $channel := beckn.message.support.channels[0]; + $grievance := $channel.grievance; + $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; + $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); + $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + /* "Ticket Already Raised" also comes back as success with a ticket. */ + $isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE + and ($type($ticketNo) = "string" or $type($ticketNo) = "number"); + + /* Output */ + $isLodged ? { "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), "message": { "support": { "channels": [{ - "@context": $ch.`@context`, - "@type": $ch.`@type`, - "informationMode": "Direct", - /* Stated, not echoed: the caller may send only the ids. */ - "provider": { "id": $ch.provider.id, "descriptor": { "name": "PMFBY Grievance Portal" } }, - "scheme": { "code": "PMFBY", "name": "Pradhan Mantri Fasal Bima Yojana" }, - "enrolmentId": $ch.enrolmentId, - /* The codes sent, defaults included; a name beside a code is dropped. */ + "@context": $channel.`@context`, + "@type": $channel.`@type`, + "informationMode": $INFORMATION_MODE, + "provider": { "id": $channel.provider.id, "descriptor": { "name": $PROVIDER_NAME } }, + "scheme": $SCHEME, + "enrolmentId": $channel.enrolmentId, "grievance": { - "category": { "code": $exists($g.category.code) ? $string($g.category.code) : "3" }, - "subCategory": { "code": $exists($g.subCategory.code) ? $string($g.subCategory.code) : "10" }, - "description": $g.description + "category": { "code": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE) }, + "subCategory": { "code": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE) }, + "description": $grievance.description }, "case": { "ticketNo": $string($ticketNo), - "status": { "code": "Registered" }, - /* Lodge time, not complaintDate: a status read reports when PMFBY - recorded the ticket, and the two must agree. */ - "filedOn": $fromMillis($millis(), "[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z") + "status": { "code": $CASE_STATUS_REGISTERED }, + /* Lodge time, so it agrees with what a status read reports. */ + "filedOn": $fromMillis($millis(), $TIMESTAMP_FORMAT) } }] } } } - : $exists($refusal) - ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", "message": $refusal, "cause": $said } } - : { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", - "message": "PMFBY could not register the grievance right now. Please try again later.", - "cause": $said } } + : $exists($refusalMessage) ? $error($ERROR_REFUSED, $refusalMessage, $pmfbyMessage) + : $error($ERROR_UNAVAILABLE, $MSG_UNAVAILABLE, $pmfbyMessage) ) From d7b730464a8853f3e61392ee95c37fe83593a5e5 Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 22 Oct 2026 14:43:56 +0530 Subject: [PATCH 37/39] fix: reject a blank PMFBY ticket and echo the trimmed description [#57] --- config/mappings/pmfby/grievance.support.yaml | 6 +++--- .../implementation/Grievance/mappings_test.go | 15 ++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/config/mappings/pmfby/grievance.support.yaml b/config/mappings/pmfby/grievance.support.yaml index 59109629d..f38c15544 100644 --- a/config/mappings/pmfby/grievance.support.yaml +++ b/config/mappings/pmfby/grievance.support.yaml @@ -75,9 +75,9 @@ response: | $ticketNo := response.responseDynamic.GrievenceSupportTicketNo; $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $hasTicket := $type($ticketNo) = "number" or ($type($ticketNo) = "string" and $ticketNo != ""); /* "Ticket Already Raised" also comes back as success with a ticket. */ - $isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE - and ($type($ticketNo) = "string" or $type($ticketNo) = "number"); + $isLodged := $string(response.responseCode) = $PMFBY_SUCCESS_CODE and $hasTicket; /* Output */ $isLodged @@ -95,7 +95,7 @@ response: | "grievance": { "category": { "code": $orDefault($grievance.category.code, $DEFAULT_CATEGORY_CODE) }, "subCategory": { "code": $orDefault($grievance.subCategory.code, $DEFAULT_SUB_CATEGORY_CODE) }, - "description": $grievance.description + "description": $trim($grievance.description) }, "case": { "ticketNo": $string($ticketNo), diff --git a/pkg/plugin/implementation/Grievance/mappings_test.go b/pkg/plugin/implementation/Grievance/mappings_test.go index 928b5f3c7..725d20da7 100644 --- a/pkg/plugin/implementation/Grievance/mappings_test.go +++ b/pkg/plugin/implementation/Grievance/mappings_test.go @@ -399,8 +399,8 @@ func TestSupport_Lodged_SendsPMFBYFieldsAndReturnsTicket(t *testing.T) { grievance := band(channel, "grievance") if channel["enrolmentId"] != "040108251010160770605" || len(band(grievance, "category")) != 1 || band(grievance, "category")["code"] != "3" || band(grievance, "subCategory")["code"] != "10" || - grievance["description"] != " Claim not received " { - t.Errorf("channel = %v, want enrolmentId and grievance echoed", channel) + grievance["description"] != "Claim not received" { + t.Errorf("channel = %v, want enrolmentId and grievance echoed, the description trimmed", channel) } filed := band(channel, "case") status := band(filed, "status") @@ -551,11 +551,12 @@ func TestSupport_KnownRefusal_Returns400InOurWords(t *testing.T) { // Any other failure is PMFBY's own: a 502 in our words, PMFBY's text withheld. func TestSupport_PortalFails_Returns502WithoutItsText(t *testing.T) { for name, reply := range map[string]string{ - "success, no ticket": `{"responseCode":"1","responseDynamic":{}}`, - "no message": `{"responseCode":"0","responseMessage":null}`, - "internal error": `{"responseCode":"0","responseMessage":"AxiosError: Request failed with status code 403","responseDynamic":null}`, - "token rejected": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, - "unknown wording": `{"responseCode":"0","responseMessage":"Invalid","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, + "success, no ticket": `{"responseCode":"1","responseDynamic":{}}`, + "success, blank ticket": `{"responseCode":"1","responseDynamic":{"GrievenceSupportTicketNo":""}}`, + "no message": `{"responseCode":"0","responseMessage":null}`, + "internal error": `{"responseCode":"0","responseMessage":"AxiosError: Request failed with status code 403","responseDynamic":null}`, + "token rejected": `{"responseCode":"0","responseMessage":"Token required!","responseDynamic":null}`, + "unknown wording": `{"responseCode":"0","responseMessage":"Invalid","responseDynamic":{"GrievenceSupportTicketNo":"100626000099001"}}`, } { t.Run(name, func(t *testing.T) { _, err := newHarness(t, &pmfbyStub{answers: map[string]string{insertPath: reply}}, 0).send(t, support(supportFields())) From 4d44c56c988180f1ca65eb1a5625a453660bc79c Mon Sep 17 00:00:00 2001 From: nisargabd Date: Thu, 22 Oct 2026 14:44:01 +0530 Subject: [PATCH 38/39] refactor: name the status mapping's empty-object helper omitIfEmpty [#57] --- config/mappings/pmfby/grievance.status.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/mappings/pmfby/grievance.status.yaml b/config/mappings/pmfby/grievance.status.yaml index 4212c050a..9f6edffb1 100644 --- a/config/mappings/pmfby/grievance.status.yaml +++ b/config/mappings/pmfby/grievance.status.yaml @@ -52,7 +52,7 @@ response: | ? $string($value) }; /* Drops an object with no fields instead of sending {}. */ - $nonEmpty := function($object) { $exists($keys($object)[0]) ? $object }; + $omitIfEmpty := function($object) { $exists($keys($object)[0]) ? $object }; $error := function($kind, $message, $cause) { { "_error": $merge([$kind, { "message": $message, "cause": $cause }]) } }; @@ -63,7 +63,7 @@ response: | /* [0] reads a bare record or a list of one alike. */ $record := response.responseDynamic[0]; $pmfbyMessage := $type(response.responseMessage) = "string" ? $trim(response.responseMessage); - $refusalMessage := $isText($pmfbyMessage) ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); + $refusalMessage := $pmfbyMessage ? $lookup($KNOWN_REFUSALS, $lowercase($pmfbyMessage)); $isAccepted := $string(response.responseCode) = $PMFBY_SUCCESS_CODE; /* PMFBY reads by phone alone, so a record for another ticket is no record. */ $isOtherTicket := $exists($record.GrievenceSupportTicketNo) @@ -95,8 +95,8 @@ response: | "cropYear": $cropYear, "season": $lookup($SEASON_NAMES, $asId($record.RequestSeason)), "grievance": { - "category": $nonEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }), - "subCategory": $nonEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }), + "category": $omitIfEmpty({ "code": $asId($record.TicketCategoryID), "name": $asText($record.TicketCategoryName) }), + "subCategory": $omitIfEmpty({ "code": $asId($record.TicketSubCategoryID), "name": $asText($record.TicketSubCategoryName) }), "description": $asText($record.GrievenceDescription) }, "case": { From 877a6a608a7959e845d61f75b34b6d903e4a5504 Mon Sep 17 00:00:00 2001 From: KrutikaPhirangi <138781661+KrutikaPhirangi@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:14 +0530 Subject: [PATCH 39/39] docs: record the PM-KISAN design decisions as ADRs [#60] docs/adr holds one record per decision, on discovery-service's template: 0001 provider envelopes in common, 0002 an action served by another participant, 0003 input validation in the capability packs, 0004 upstream refusals as 400 or 502, 0005 the follow-up read. /docs/ stays ignored except docs/adr. --- .gitignore | 3 +- docs/adr/0000-template.md | 23 +++++++++ docs/adr/0001-provider-envelopes-in-common.md | 47 +++++++++++++++++++ ...an-action-served-by-another-participant.md | 32 +++++++++++++ ...03-input-validation-in-capability-packs.md | 42 +++++++++++++++++ docs/adr/0004-upstream-refusals-400-or-502.md | 31 ++++++++++++ .../0005-a-follow-up-read-after-the-call.md | 28 +++++++++++ docs/adr/README.md | 15 ++++++ 8 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0000-template.md create mode 100644 docs/adr/0001-provider-envelopes-in-common.md create mode 100644 docs/adr/0002-an-action-served-by-another-participant.md create mode 100644 docs/adr/0003-input-validation-in-capability-packs.md create mode 100644 docs/adr/0004-upstream-refusals-400-or-502.md create mode 100644 docs/adr/0005-a-follow-up-read-after-the-call.md create mode 100644 docs/adr/README.md diff --git a/.gitignore b/.gitignore index 17bdec8ed..ec91d6806 100644 --- a/.gitignore +++ b/.gitignore @@ -178,7 +178,8 @@ test_request.json # catalogPublish handler's local output root (config/local-beckn-one-bap.yaml) /catalog/ -/docs/ +/docs/* +!/docs/adr/ /dev_docs/ # Deployment configs carry real key material and must never be committed. diff --git a/docs/adr/0000-template.md b/docs/adr/0000-template.md new file mode 100644 index 000000000..05ee5bef7 --- /dev/null +++ b/docs/adr/0000-template.md @@ -0,0 +1,23 @@ +# ADR-0000 — Title + +**Status:** Proposed | Accepted | Superseded by ADR-NNNN +**Date:** YYYY-MM-DD + +## Context + +What forced a decision. The constraint, not the preamble. + +## Decision + +What was chosen, stated so that a reader can tell whether a given change +complies with it. + +## Alternatives considered + +What was rejected, and the specific property that disqualified it. "It was +worse" is not a reason; "it resolves columns by reflection at runtime" is. + +## Consequences + +What this costs, including the cases it makes harder. An ADR that lists only +benefits is advocacy, not a record. diff --git a/docs/adr/0001-provider-envelopes-in-common.md b/docs/adr/0001-provider-envelopes-in-common.md new file mode 100644 index 000000000..936c6028a --- /dev/null +++ b/docs/adr/0001-provider-envelopes-in-common.md @@ -0,0 +1,47 @@ +# ADR-0001 — Provider envelopes live in common, with capability hooks + +**Status:** Accepted +**Date:** 2026-10-23 + +## Context + +PM-KISAN does not take plain JSON. Its grievance service takes AES-GCM under a +fixed key and nonce with a service token inside the body; its chatbot (OTP) +service takes AES-128-CBC with a fresh key sent beside the ciphertext. Some +calls must first verify an OTP the farmer received. Mappings are published +files and must stay plain JSON, and must never carry the token. + +## Decision + +`internal/common/envelope` seals a mapped request and opens the answer: +`aesGcm` (fixed key and nonce, from env vars named in config) and +`aesCbcKeyInBand` (fresh key per request, `cipher@key`). It adds the service +token after the mapping runs. It can verify a challenge on the provider before +the guarded call (`otpVerifyPath`, `otpVerifyActions`), on its own time budget +(`otpVerifyTimeoutMs`). Its HTTP client refuses an off-host redirect, as +common's does. + +What only a capability knows comes in as `envelope.Hooks`: where its payload +carries the challenge, and what its provider's "yes" looks like. Settings are a +provider block in the step's config, read by `envelope.Parse`; `envelope:` is +required whenever any `envelope*` setting is present, so a typo cannot send +plaintext. + +## Alternatives considered + +- **Inside the Grievance plugin.** The next provider with an encrypted + upstream would have to import a package named for another capability or copy + it. +- **The `encrypter`/`decrypter` plugins.** They do X25519 key agreement for + Beckn payloads; PM-KISAN's fixed-key GCM and key-in-band CBC are neither. +- **Encryption in the mapping.** JSONata has no crypto, and the token would + have to be in a public file. + +## Consequences + +The GCM nonce is fixed because PM-KISAN requires it. With one key and one +nonce, two ciphertexts give the XOR of their plaintexts and a ciphertext can +be forged; this envelope adds no confidentiality or integrity of its own and +TLS is the only protection. A nonce shorter than 12 bytes is refused. The CBC +key travels with the ciphertext, so that envelope is obfuscation; its token is +the only secret. diff --git a/docs/adr/0002-an-action-served-by-another-participant.md b/docs/adr/0002-an-action-served-by-another-participant.md new file mode 100644 index 000000000..f619a9c11 --- /dev/null +++ b/docs/adr/0002-an-action-served-by-another-participant.md @@ -0,0 +1,32 @@ +# ADR-0002 — An action served by another participant is named by its binding key + +**Status:** Accepted +**Date:** 2026-10-23 + +## Context + +PM-KISAN grievance's `init` (send the OTP) and the OTP verify before `support` +and `status` are served by the chatbot service, not the grievance service. A +binding has one participant, so one `baseUrl`. The chatbot service is already +a participant in the registry for application status. + +## Decision + +The provider block names the OTP service by binding key: +`otpBindingKey: "pmkisan-chatbot|openagrinet:SchemeApplication"`. +`common.Config.HostedElsewhere` (provider → action → binding key) sends those +actions to that participant's `baseUrl`, read from the registry per call with +the same active checks as any participant (`common.HostOf`). The verify reads +it the same way. An unpublished binding is a 502. + +## Alternatives considered + +- **An env var with the host** (`otpBaseUrlEnv`). The address lived in two + places, and the env one skipped the registry's status checks. +- **A `Router` interface on the envelope.** It existed only for this redirect + and hid routing inside an encryption concern. + +## Consequences + +Grievance depends on the chatbot's binding being active in the registry. Moving +the OTP service is one registry write, with no restart. diff --git a/docs/adr/0003-input-validation-in-capability-packs.md b/docs/adr/0003-input-validation-in-capability-packs.md new file mode 100644 index 000000000..d32d50fda --- /dev/null +++ b/docs/adr/0003-input-validation-in-capability-packs.md @@ -0,0 +1,42 @@ +# ADR-0003 — Input validation lives in the capability packs, not the mappings + +**Status:** Accepted +**Date:** 2026-10-23 + +## Context + +The PM-KISAN mappings checked their own inputs: identifier format, Aadhaar +refusal, category codes, OTP shape, description length, the one-identifier +rule. The Grievance and SchemeApplication packs (network-specs, +`api-schema-packs-v0.1`) state the same rules, and the extended schema +validator enforces them on every object that carries `@context` and `@type`. + +## Decision + +The mappings carry no input checks; the request half only builds the upstream +body. One check stays in `application-status.status.yaml`: the OTP must be +present. The pack cannot require it on `status` alone, because a status ask and +an init ask have the same shape. + +The packs pin `@context` to `openagrinet.github.io`, which does not yet serve +`api-schemas/`. `extendedSchema_contextRewrite` (`from|to` prefix pairs) +fetches them from the branch instead. The payload keeps its `@context`; the +allowlist is checked on the rewritten host. + +A schema error carries the reason, code and path, never the value: kin-openapi +appends the value and the schema by default, and a pack marks `enrolmentId` and +`applicant.mobile` no-echo. + +## Alternatives considered + +- **Keep both.** Two statements of one rule drift, and the mapping's codes + disagreed with the validator's. +- **A local copy of the packs** (`extendedSchema_localSchemaPath`). It goes + stale against the branch. + +## Consequences + +Error codes are the validator's (`SCH_SCHEMA_VALIDATION_FAILED`, +`SCH_INVALID_ENUM`, …) with its paths. The schema's `enrolmentId` minimum is 6 +characters, stricter than the mapping's 1. A description padded with spaces is +measured untrimmed. Drop the rewrite once the published URL serves the packs. diff --git a/docs/adr/0004-upstream-refusals-400-or-502.md b/docs/adr/0004-upstream-refusals-400-or-502.md new file mode 100644 index 000000000..93697cf41 --- /dev/null +++ b/docs/adr/0004-upstream-refusals-400-or-502.md @@ -0,0 +1,31 @@ +# ADR-0004 — An upstream refusal is a 400 when its reason is known, else a 502 + +**Status:** Accepted +**Date:** 2026-10-23 + +## Context + +PM-KISAN answers HTTP 200 for everything and says no with a `"False"` flag. +Some refusals are the caller's to fix (an unknown registration number); others +are PM-KISAN failing. The shared refusal no longer turns a mapping's 202 into a +NACK. + +## Decision + +As PMFBY does: a refusal whose reason the mapping knows is a 400 +`BIZ_GENERIC_ERROR` in our words, with `details.path` on the field to fix. Any +other refusal is a 502 `NET_DOWNSTREAM_UNAVAILABLE`. No record found is a 400 +`BIZ_GENERIC_ERROR`. The provider's own text is logged as `cause` and never +returned. + +## Alternatives considered + +- **Every refusal a 502.** Tells the caller PM-KISAN is down when the + enrolmentId is wrong. +- **Return the provider's message.** Its wording is not ours to publish and + can carry personal data. + +## Consequences + +Each new reason is a line in the mapping's `KNOWN_REFUSALS`. A reason not yet +listed is a 502 until it is. diff --git a/docs/adr/0005-a-follow-up-read-after-the-call.md b/docs/adr/0005-a-follow-up-read-after-the-call.md new file mode 100644 index 000000000..badfec006 --- /dev/null +++ b/docs/adr/0005-a-follow-up-read-after-the-call.md @@ -0,0 +1,28 @@ +# ADR-0005 — A follow-up read adds a second answer to the first + +**Status:** Accepted +**Date:** 2026-10-23 + +## Context + +PM-KISAN application status needs two reads: `/ChatbotUserDetails` for the +record and `/ChatbotBeneficiaryStatus` for what is holding payment up. The +registry holds one call per action. + +## Decision + +The envelope makes the second read after a successful first one +(`followUpPath`, `followUpField`, `followUpActions`) and adds its answer to the +first under `followUpField`, on its own time budget (`followUpTimeoutMs`). The +mapping reads both. A failed follow-up leaves the field absent, never empty: +absent means "not checked", `[]` means "nothing found". + +## Alternatives considered + +- **Two registry actions.** Beckn `status` is one call; the network has no + action for the second read. + +## Consequences + +A slow or failed follow-up does not fail the status; the answer simply has no +blockers. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 000000000..484d5bfc9 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,15 @@ +# Architecture Decision Records + +One file per decision, numbered, never edited after acceptance: a decision +that turns out wrong is superseded by a new record, not rewritten. Code +comments stay short and point here for the reasoning. + +Start from [`0000-template.md`](0000-template.md). + +| # | Decision | +|---|---| +| [0001](0001-provider-envelopes-in-common.md) | Provider envelopes live in common, with capability hooks | +| [0002](0002-an-action-served-by-another-participant.md) | An action served by another participant is named by its binding key | +| [0003](0003-input-validation-in-capability-packs.md) | Input validation lives in the capability packs, not the mappings | +| [0004](0004-upstream-refusals-400-or-502.md) | An upstream refusal is a 400 when its reason is known, else a 502 | +| [0005](0005-a-follow-up-read-after-the-call.md) | A follow-up read adds a second answer to the first |