@stackonward/cli 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +14 -2
- package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +3 -1
- package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useWebPush.ts +4 -0
- package/dist/resources/components/nuxt/site-source/app/plugins/web-push.ts +52 -0
- package/dist/resources/components/nuxt/site-source/app/theme/button.ts +18 -22
- package/dist/resources/components/nuxt/site-source/app/theme/index.ts +24 -0
- package/dist/resources/components/nuxt/site-source/app/theme/input.ts +32 -17
- package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +1 -1
- package/dist/resources/components/nuxt/site-source/app/theme/select.ts +62 -18
- package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +73 -1
- package/dist/resources/components/nuxt/site-source/app/web-push/worker.ts +8 -0
- package/dist/resources/components/registry.json +11 -1
- package/dist/resources/registry.json +15 -3
- package/dist/resources/templates/base/{.claude → .codex}/docs/architecture-template.md +1 -1
- package/dist/resources/templates/base/{.claude → .codex}/docs/architecture.md +1 -1
- package/dist/resources/templates/base/{.claude → .codex}/docs/index.md +3 -3
- package/dist/resources/templates/base/{.claude → .codex}/docs/verification.md +1 -1
- package/dist/resources/templates/base/AGENTS.md +24 -24
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/api-design.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/architecture-template.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/check-list.md +3 -3
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/database.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/error-handling.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/index.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/logging.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-onboarding-sop.md +2 -2
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-quick-reference.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/module-standards.md +1 -1
- package/dist/resources/templates/go-backend/.dockerignore +1 -2
- package/dist/resources/templates/go-backend/AGENTS.md +19 -19
- package/dist/resources/templates/go-backend/README.md +5 -1
- package/dist/resources/templates/go-backend/deployments/onex/product-onboarding.example.json +201 -0
- package/dist/resources/templates/go-backend/docs/onex-notifications.md +85 -0
- package/dist/resources/templates/go-backend/docs/openapi-provenance.json +1 -1
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +23 -2
- package/dist/resources/templates/go-backend/docs/source-provenance.json +124 -6
- package/dist/resources/templates/go-backend/docs/standards-provenance.json +30 -30
- package/dist/resources/templates/go-backend/internal/common/middleware/cors.go +1 -0
- package/dist/resources/templates/go-backend/internal/common/middleware/middleware_test.go +1 -0
- package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post.go +28 -0
- package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post_test.go +56 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client.go +453 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client_test.go +772 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/codec.go +33 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error.go +146 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error_notification_test.go +29 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client.go +132 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client_test.go +222 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/contracts.go +219 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/resilience.go +23 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/transport_policy.go +96 -0
- package/dist/resources/templates/go-backend/internal/products/README.md +1 -1
- package/dist/resources/templates/go-backend/internal/sharedevents/README.md +1 -1
- package/dist/resources/templates/go-backend/scripts/check-openapi-clean-deletion.sh +3 -3
- package/dist/resources/templates/mcp-server/.prettierignore +1 -0
- package/dist/resources/templates/nuxt/{.claude → .codex}/docs/stack.md +17 -1
- package/dist/resources/templates/nuxt/.dockerignore +1 -1
- package/dist/resources/templates/nuxt/.env.example +6 -0
- package/dist/resources/templates/nuxt/.prettierignore +2 -1
- package/dist/resources/templates/nuxt/DESIGN.md +55 -10
- package/dist/resources/templates/nuxt/README.md +8 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +13 -1
- package/dist/resources/templates/nuxt/package.json +4 -2
- package/package.json +1 -1
- package/dist/resources/templates/base/CLAUDE.md +0 -437
- package/dist/resources/templates/go-backend/CLAUDE.md +0 -1139
- /package/dist/resources/templates/base/{.claude → .codex}/docs/security-lifecycle.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/e2e-standards.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/event-runtime-boundary.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/identifier-contract.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/openapi-contract.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/payment-gateway-onboarding.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/platform-boundary.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/quality-gates.md +0 -0
- /package/dist/resources/templates/mcp-server/{.claude → .codex}/docs/stack.md +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package v1
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
|
|
9
|
+
commonvalidator "{{module}}/internal/common/validator"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// DecodeStrict 拒绝重复字段、未知字段、空响应和多个 JSON 值。
|
|
13
|
+
func DecodeStrict(body []byte, out any) error {
|
|
14
|
+
if len(bytes.TrimSpace(body)) == 0 || out == nil {
|
|
15
|
+
return fmt.Errorf("JSON body and decode target are required")
|
|
16
|
+
}
|
|
17
|
+
if err := commonvalidator.RejectDuplicateJSONFields(body); err != nil {
|
|
18
|
+
return err
|
|
19
|
+
}
|
|
20
|
+
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
21
|
+
decoder.DisallowUnknownFields()
|
|
22
|
+
if err := decoder.Decode(out); err != nil {
|
|
23
|
+
return err
|
|
24
|
+
}
|
|
25
|
+
var trailing any
|
|
26
|
+
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
27
|
+
if err == nil {
|
|
28
|
+
return fmt.Errorf("JSON body contains multiple values")
|
|
29
|
+
}
|
|
30
|
+
return err
|
|
31
|
+
}
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package v1
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/http"
|
|
6
|
+
"strconv"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
"{{module}}/internal/core/httpclient"
|
|
10
|
+
"{{module}}/pkg/apierror"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// ErrorKind 是产品可安全分支处理的 OneX 边界错误闭集。
|
|
14
|
+
type ErrorKind string
|
|
15
|
+
|
|
16
|
+
const (
|
|
17
|
+
ErrorKindValidation ErrorKind = "validation"
|
|
18
|
+
ErrorKindUnauthenticated ErrorKind = "unauthenticated"
|
|
19
|
+
ErrorKindForbidden ErrorKind = "forbidden"
|
|
20
|
+
ErrorKindNotFound ErrorKind = "not_found"
|
|
21
|
+
ErrorKindConflict ErrorKind = "conflict"
|
|
22
|
+
ErrorKindRateLimited ErrorKind = "rate_limited"
|
|
23
|
+
ErrorKindUnavailable ErrorKind = "unavailable"
|
|
24
|
+
ErrorKindResultUnknown ErrorKind = "result_unknown"
|
|
25
|
+
ErrorKindContract ErrorKind = "contract"
|
|
26
|
+
ErrorKindCanceled ErrorKind = "canceled"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Error 保留稳定错误代码和重试语义,不向产品传播上游消息。
|
|
30
|
+
type Error struct {
|
|
31
|
+
Kind ErrorKind
|
|
32
|
+
ErrorType string
|
|
33
|
+
Code string
|
|
34
|
+
HTTPStatus int
|
|
35
|
+
Retryable bool
|
|
36
|
+
RetryAfter string
|
|
37
|
+
// CurrentRegistrationRevision 仅保留安装能力已验证后的原生设备冲突恢复提示。
|
|
38
|
+
CurrentRegistrationRevision *int64
|
|
39
|
+
cause error
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Error 返回不含上游消息或秘密的稳定描述。
|
|
43
|
+
func (e *Error) Error() string {
|
|
44
|
+
if e == nil {
|
|
45
|
+
return "onex request failed"
|
|
46
|
+
}
|
|
47
|
+
return fmt.Sprintf("onex request failed: kind=%s code=%s", e.Kind, e.Code)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Unwrap 返回仅供错误链判断使用的底层原因。
|
|
51
|
+
func (e *Error) Unwrap() error {
|
|
52
|
+
if e == nil {
|
|
53
|
+
return nil
|
|
54
|
+
}
|
|
55
|
+
return e.cause
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// RetryAfterDelay 复用传输层对受控 Retry-After 的解析,供持久恢复安排下一次读取。
|
|
59
|
+
func (e *Error) RetryAfterDelay(now time.Time) time.Duration {
|
|
60
|
+
if e == nil {
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
delay, _ := parseRetryAfter(e.RetryAfter, now)
|
|
64
|
+
return delay
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// AffectsDependencyAvailability 区分业务拒绝与会降低依赖可用性的边界失败。
|
|
68
|
+
func (e *Error) AffectsDependencyAvailability() bool {
|
|
69
|
+
if e == nil {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
switch e.Kind {
|
|
73
|
+
case ErrorKindRateLimited, ErrorKindUnavailable, ErrorKindResultUnknown, ErrorKindContract, ErrorKindUnauthenticated, ErrorKindForbidden:
|
|
74
|
+
return true
|
|
75
|
+
default:
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// NewValidationError 构造不会发起网络请求的边界校验错误。
|
|
81
|
+
func NewValidationError(code string) *Error {
|
|
82
|
+
return &Error{Kind: ErrorKindValidation, Code: code}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// NewContractError 构造拒绝上游 schema 漂移的合同错误。
|
|
86
|
+
func NewContractError(code string, cause error) *Error {
|
|
87
|
+
return &Error{Kind: ErrorKindContract, Code: code, cause: cause}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func decodeHTTPError(response *httpclient.Response) error {
|
|
91
|
+
kind, retryable := errorKindFromStatus(response.StatusCode)
|
|
92
|
+
result := &Error{
|
|
93
|
+
Kind: kind,
|
|
94
|
+
ErrorType: "",
|
|
95
|
+
Code: "onex_request_failed",
|
|
96
|
+
HTTPStatus: response.StatusCode,
|
|
97
|
+
Retryable: retryable,
|
|
98
|
+
}
|
|
99
|
+
fallbackCode := "onex_error_contract_invalid"
|
|
100
|
+
decoded := apierror.DecodeHTTPResponse(response.StatusCode, response.Headers, response.Body, apierror.New(http.StatusBadGateway, apierror.TypeAPI, fallbackCode, "Invalid OneX error contract"))
|
|
101
|
+
if decoded.Detail.Code == fallbackCode {
|
|
102
|
+
result.Kind = ErrorKindContract
|
|
103
|
+
result.Code = fallbackCode
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
result.Code = decoded.Detail.Code
|
|
107
|
+
result.ErrorType = decoded.Detail.Type
|
|
108
|
+
result.RetryAfter = decoded.HTTPResponseMetadata().RetryAfter()
|
|
109
|
+
if result.Code == "native_device_revision_conflict" && response.StatusCode == http.StatusConflict {
|
|
110
|
+
for _, field := range decoded.Detail.Errors {
|
|
111
|
+
if field.Field != "current_registration_revision" {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
revision, err := strconv.ParseInt(field.Message, 10, 64)
|
|
115
|
+
if err != nil || revision < 1 || strconv.FormatInt(revision, 10) != field.Message || result.CurrentRegistrationRevision != nil {
|
|
116
|
+
result.Kind = ErrorKindContract
|
|
117
|
+
result.Code = "onex_error_contract_invalid"
|
|
118
|
+
result.CurrentRegistrationRevision = nil
|
|
119
|
+
return result
|
|
120
|
+
}
|
|
121
|
+
result.CurrentRegistrationRevision = &revision
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func errorKindFromStatus(status int) (ErrorKind, bool) {
|
|
128
|
+
switch status {
|
|
129
|
+
case http.StatusBadRequest, http.StatusUnprocessableEntity:
|
|
130
|
+
return ErrorKindValidation, false
|
|
131
|
+
case http.StatusUnauthorized:
|
|
132
|
+
return ErrorKindUnauthenticated, false
|
|
133
|
+
case http.StatusForbidden:
|
|
134
|
+
return ErrorKindForbidden, false
|
|
135
|
+
case http.StatusNotFound:
|
|
136
|
+
return ErrorKindNotFound, false
|
|
137
|
+
case http.StatusConflict:
|
|
138
|
+
return ErrorKindConflict, false
|
|
139
|
+
case http.StatusTooManyRequests:
|
|
140
|
+
return ErrorKindRateLimited, true
|
|
141
|
+
case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
|
142
|
+
return ErrorKindUnavailable, true
|
|
143
|
+
default:
|
|
144
|
+
return ErrorKindContract, false
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error_notification_test.go
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package v1
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"net/http"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/stretchr/testify/require"
|
|
9
|
+
|
|
10
|
+
"{{module}}/internal/core/httpclient"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestNativeDeviceConflictOnlyAcceptsCanonicalPositiveRevision(t *testing.T) {
|
|
14
|
+
for _, value := range []string{"7", "0", "-1", "07", "token-secret"} {
|
|
15
|
+
t.Run(value, func(t *testing.T) {
|
|
16
|
+
err := decodeHTTPError(&httpclient.Response{StatusCode: http.StatusConflict, Headers: http.Header{"Content-Type": []string{"application/json"}}, Body: []byte(fmt.Sprintf(`{"error":{"type":"api_error","code":"native_device_revision_conflict","message":"private upstream text","errors":[{"field":"current_registration_revision","message":%q}]}}`, value))})
|
|
17
|
+
var result *Error
|
|
18
|
+
require.ErrorAs(t, err, &result)
|
|
19
|
+
if value == "7" {
|
|
20
|
+
require.NotNil(t, result.CurrentRegistrationRevision)
|
|
21
|
+
require.EqualValues(t, 7, *result.CurrentRegistrationRevision)
|
|
22
|
+
} else {
|
|
23
|
+
require.Nil(t, result.CurrentRegistrationRevision)
|
|
24
|
+
require.Equal(t, ErrorKindContract, result.Kind)
|
|
25
|
+
}
|
|
26
|
+
require.NotContains(t, result.Error(), "private upstream text")
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client.go
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
package notification
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/url"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
onexv1 "{{module}}/internal/integrations/onex/v1"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// Transport 借用已有 OneX 传输,不创建签名或连接生命周期。
|
|
16
|
+
type Transport interface {
|
|
17
|
+
Do(context.Context, onexv1.Call, any) error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Client 借用统一 transport 执行平台既有通知协议。
|
|
21
|
+
type Client struct {
|
|
22
|
+
transport Transport
|
|
23
|
+
timeout time.Duration
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// NewClient 仅保存依赖,构造过程不会发送通知或访问网络。
|
|
27
|
+
func NewClient(transport Transport, timeout time.Duration) (*Client, error) {
|
|
28
|
+
if transport == nil || timeout <= 0 {
|
|
29
|
+
return nil, fmt.Errorf("notification transport and timeout are required")
|
|
30
|
+
}
|
|
31
|
+
return &Client{transport: transport, timeout: timeout}, nil
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// QueryInbox 查询原生收件箱,不裁剪过滤条件或展示字段。
|
|
35
|
+
func (c *Client) QueryInbox(ctx context.Context, request InboxQuery) (InboxPage, error) {
|
|
36
|
+
var result InboxPage
|
|
37
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/inbox-queries", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
38
|
+
return result, err
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// GetMessage 读取接收人的精确消息,不允许路径段穿越。
|
|
42
|
+
func (c *Client) GetMessage(ctx context.Context, userID int64, messageNo string) (InboxMessage, error) {
|
|
43
|
+
var result InboxMessage
|
|
44
|
+
if len(messageNo) < 5 || len(messageNo) > 40 || messageNo != strings.TrimSpace(messageNo) || strings.ContainsAny(messageNo, "/\\%?#\x00\r\n") {
|
|
45
|
+
return result, onexv1.NewValidationError("onex_notification_message_invalid")
|
|
46
|
+
}
|
|
47
|
+
err := c.call(ctx, http.MethodGet, "/native-notifications/inbox-messages/"+url.PathEscape(messageNo), userID, nil, userQuery(userID), http.StatusOK, "native_read", &result)
|
|
48
|
+
return result, err
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// CountUnread 返回原生未读汇总。
|
|
52
|
+
func (c *Client) CountUnread(ctx context.Context, request InboxQuery) (Unread, error) {
|
|
53
|
+
var result Unread
|
|
54
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/unread-queries", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
55
|
+
return result, err
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// AcknowledgeMessages 只确认请求中的消息版本。
|
|
59
|
+
func (c *Client) AcknowledgeMessages(ctx context.Context, request AcknowledgeMessagesRequest) (Acknowledgement, error) {
|
|
60
|
+
var result Acknowledgement
|
|
61
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/message-acknowledgements", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
62
|
+
return result, err
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// AcknowledgeScope 确认调用方显式指定的原生查询范围。
|
|
66
|
+
func (c *Client) AcknowledgeScope(ctx context.Context, request InboxQuery) (Acknowledgement, error) {
|
|
67
|
+
var result Acknowledgement
|
|
68
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/acknowledgements", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
69
|
+
return result, err
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// RegisterDevice 透传可信账号与会话,保留安装版本冲突。
|
|
73
|
+
func (c *Client) RegisterDevice(ctx context.Context, request DeviceRegistration) (Device, error) {
|
|
74
|
+
var result Device
|
|
75
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/devices", request.UserID, request, nil, http.StatusCreated, "native_write", &result)
|
|
76
|
+
return result, err
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// RevokeDevice 以安装能力注销,不携带账号会话。
|
|
80
|
+
func (c *Client) RevokeDevice(ctx context.Context, request DeviceRevocation) error {
|
|
81
|
+
return c.call(ctx, http.MethodPost, "/native-notifications/device-revocations", 0, request, nil, http.StatusNoContent, "native_write", nil)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ListDevices 查询接收人的有效设备列表。
|
|
85
|
+
func (c *Client) ListDevices(ctx context.Context, userID int64) (DeviceList, error) {
|
|
86
|
+
var result DeviceList
|
|
87
|
+
err := c.call(ctx, http.MethodGet, "/native-notifications/devices", userID, nil, userQuery(userID), http.StatusOK, "native_read", &result)
|
|
88
|
+
return result, err
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// GetPreferences 按调用方提供的分类和默认值读取偏好。
|
|
92
|
+
func (c *Client) GetPreferences(ctx context.Context, request PreferenceQuery) (PushPreferences, error) {
|
|
93
|
+
var result PushPreferences
|
|
94
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/push-preference-queries", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
95
|
+
return result, err
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// PutPreferences 返回平台接受的完整偏好。
|
|
99
|
+
func (c *Client) PutPreferences(ctx context.Context, request PreferenceUpdate) (PushPreferences, error) {
|
|
100
|
+
var result PushPreferences
|
|
101
|
+
err := c.call(ctx, http.MethodPut, "/native-notifications/push-preferences", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
102
|
+
return result, err
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// SendTestPush 只执行调用方显式请求,平台仍校验诊断权限与运行环境。
|
|
106
|
+
func (c *Client) SendTestPush(ctx context.Context, request TestPushRequest) (TestPushResult, error) {
|
|
107
|
+
var result TestPushResult
|
|
108
|
+
err := c.call(ctx, http.MethodPost, "/native-notifications/test-push-sends", request.UserID, request, nil, http.StatusOK, "native_write", &result)
|
|
109
|
+
return result, err
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// StartDispatch 提交冻结的产品事实,不选择模板或提供商。
|
|
113
|
+
func (c *Client) StartDispatch(ctx context.Context, request DispatchStartRequest) (DispatchStartResult, error) {
|
|
114
|
+
var result DispatchStartResult
|
|
115
|
+
err := c.call(ctx, http.MethodPost, "/notifications/dispatch-command-starts", 0, request, nil, http.StatusCreated, "dispatch_command_start", &result)
|
|
116
|
+
return result, err
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// AdvanceSource 推进产品事项版本,使旧通知失效。
|
|
120
|
+
func (c *Client) AdvanceSource(ctx context.Context, request SourceAdvanceRequest) (SourceAdvanceResult, error) {
|
|
121
|
+
var result SourceAdvanceResult
|
|
122
|
+
err := c.call(ctx, http.MethodPost, "/notifications/source-state-advancements", 0, request, nil, http.StatusOK, "advance_source_state", &result)
|
|
123
|
+
return result, err
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func (c *Client) call(ctx context.Context, method, path string, userID int64, body any, query url.Values, status int, operation string, out any) error {
|
|
127
|
+
return c.transport.Do(ctx, onexv1.Call{Method: method, Path: path, UserID: userID, Body: body, Query: query, ExpectedStatuses: []int{status}, Dependency: "notification", Operation: operation, Timeout: c.timeout}, out)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func userQuery(userID int64) url.Values {
|
|
131
|
+
return url.Values{"user_id": {strconv.FormatInt(userID, 10)}}
|
|
132
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
package notification
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"sync/atomic"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/stretchr/testify/require"
|
|
15
|
+
"go.uber.org/zap"
|
|
16
|
+
|
|
17
|
+
"{{module}}/internal/common/types"
|
|
18
|
+
"{{module}}/internal/core/httpclient"
|
|
19
|
+
onexv1 "{{module}}/internal/integrations/onex/v1"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
type wireSigner struct{}
|
|
23
|
+
|
|
24
|
+
func (wireSigner) AuthHeaders(context.Context) (map[string]string, error) {
|
|
25
|
+
return map[string]string{"Authorization": "Bearer local-contract-test"}, nil
|
|
26
|
+
}
|
|
27
|
+
func (wireSigner) AuthorizeRequest(context.Context, *http.Request) error { return nil }
|
|
28
|
+
|
|
29
|
+
const wireMessage = `{"delivery_id":10,"message_id":11,"message_no":"inm_sample","source_version":3,"title":"Status","subtitle":"Detail","content":"Ready","cover_url":"https://example.com/cover.png","action_url":"https://example.com/orders/one","category":"transactional","metadata":{"topic":"order"},"status":"unread","target_ref":"order:one","target_kind":"order","read_at":"2026-10-15T00:00:00Z","delivered_at":"2026-10-14T00:00:00Z","created_at":"2026-10-14T00:00:00Z","updated_at":"2026-10-15T00:00:00Z"}`
|
|
30
|
+
const wireDevice = `{"id":7,"installation_id":"7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60","application_code":"mobile","environment":"production","registration_revision":4,"provider":"fcm","platform":"android","app_version":"1.0","device_model":"phone","os_version":"16","status":"active","last_active_at":"2026-10-15T00:00:00Z","created_at":"2026-10-14T00:00:00Z"}`
|
|
31
|
+
const wirePreferences = `{"enabled":false,"categories":{"orders":true,"offers":false},"quiet_hours":{"enabled":true,"start":"21:00","end":"07:00","timezone":"Asia/Tokyo"}}`
|
|
32
|
+
const wireQuery = `{"user_id":42,"categories":["orders"],"target_ref":"order:one","target_scope":"any","read_status":"unread","delivery_ids":[10],"message_ids":[11],"page":2,"page_size":20}`
|
|
33
|
+
const wireSource = `{"id":"d5812c28-9907-4a7c-8b9a-09317f1c9673","type":"orders.ready","subject_ref":"order:one","subject_version":3,"occurred_at":"2026-10-15T00:00:00Z"}`
|
|
34
|
+
|
|
35
|
+
func TestClientCompleteNativeWireForTwoProducts(t *testing.T) {
|
|
36
|
+
query := InboxQuery{UserID: 42, Categories: []string{"orders"}, TargetRef: "order:one", TargetScope: "any", ReadStatus: "unread", DeliveryIDs: []int64{10}, MessageIDs: []int64{11}, Page: 2, PageSize: 20}
|
|
37
|
+
prefs := PushPreferences{Enabled: false, Categories: map[string]bool{"orders": true, "offers": false}, QuietHours: QuietHours{Enabled: true, Start: "21:00", End: "07:00", Timezone: "Asia/Tokyo"}}
|
|
38
|
+
source := SourceEvent{ID: "d5812c28-9907-4a7c-8b9a-09317f1c9673", Type: "orders.ready", SubjectRef: "order:one", SubjectVersion: 3, OccurredAt: time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)}
|
|
39
|
+
registration := DeviceRegistration{UserID: 42, SessionID: 81, ApplicationCode: "mobile", Environment: "production", InstallationID: "7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60", InstallationKey: "private-installation-key", RegistrationRevision: 4, DeviceToken: "private-device-token", Platform: "android", Provider: "fcm", AppVersion: "1.0", DeviceModel: "phone", OSVersion: "16"}
|
|
40
|
+
revocation := DeviceRevocation{ApplicationCode: "mobile", Environment: "production", InstallationID: registration.InstallationID, InstallationKey: registration.InstallationKey, RegistrationRevision: 5}
|
|
41
|
+
tests := []struct {
|
|
42
|
+
name, method, path, query, user, body, response string
|
|
43
|
+
status int
|
|
44
|
+
call func(context.Context, *Client) (any, error)
|
|
45
|
+
}{
|
|
46
|
+
{"inbox", http.MethodPost, "/native-notifications/inbox-queries", "", "42", wireQuery, `{"items":[` + wireMessage + `],"total":1,"unread_count":1,"unread_summary":{"orders":1}}`, 200,
|
|
47
|
+
func(ctx context.Context, c *Client) (any, error) { return c.QueryInbox(ctx, query) }},
|
|
48
|
+
{"message", http.MethodGet, "/native-notifications/inbox-messages/inm_sample", "user_id=42", "42", "", wireMessage, 200,
|
|
49
|
+
func(ctx context.Context, c *Client) (any, error) { return c.GetMessage(ctx, 42, "inm_sample") }},
|
|
50
|
+
{"unread", http.MethodPost, "/native-notifications/unread-queries", "", "42", wireQuery, `{"unread_count":1,"unread_summary":{"orders":1}}`, 200,
|
|
51
|
+
func(ctx context.Context, c *Client) (any, error) { return c.CountUnread(ctx, query) }},
|
|
52
|
+
{"message acknowledgement", http.MethodPost, "/native-notifications/message-acknowledgements", "", "42", `{"user_id":42,"messages":[{"message_no":"inm_sample","source_version":3}]}`, `{"updated":1,"unread_count":0,"unread_summary":{"orders":0}}`, 200,
|
|
53
|
+
func(ctx context.Context, c *Client) (any, error) {
|
|
54
|
+
return c.AcknowledgeMessages(ctx, AcknowledgeMessagesRequest{UserID: 42, Messages: []MessageVersion{{MessageNo: "inm_sample", SourceVersion: 3}}})
|
|
55
|
+
}},
|
|
56
|
+
{"scope acknowledgement", http.MethodPost, "/native-notifications/acknowledgements", "", "42", wireQuery, `{"updated":1,"unread_count":0,"unread_summary":{"orders":0}}`, 200,
|
|
57
|
+
func(ctx context.Context, c *Client) (any, error) { return c.AcknowledgeScope(ctx, query) }},
|
|
58
|
+
{"register", http.MethodPost, "/native-notifications/devices", "", "42", `{"user_id":42,"session_id":81,"application_code":"mobile","environment":"production","installation_id":"7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60","installation_key":"private-installation-key","registration_revision":4,"device_token":"private-device-token","platform":"android","provider":"fcm","app_version":"1.0","device_model":"phone","os_version":"16"}`, wireDevice, 201,
|
|
59
|
+
func(ctx context.Context, c *Client) (any, error) { return c.RegisterDevice(ctx, registration) }},
|
|
60
|
+
{"revoke", http.MethodPost, "/native-notifications/device-revocations", "", "", `{"application_code":"mobile","environment":"production","installation_id":"7b5dbd2e-c08c-4c09-b03a-e43ac0a70c60","installation_key":"private-installation-key","registration_revision":5}`, "", 204,
|
|
61
|
+
func(ctx context.Context, c *Client) (any, error) { return nil, c.RevokeDevice(ctx, revocation) }},
|
|
62
|
+
{"devices", http.MethodGet, "/native-notifications/devices", "user_id=42", "42", "", `{"devices":[` + wireDevice + `]}`, 200,
|
|
63
|
+
func(ctx context.Context, c *Client) (any, error) { return c.ListDevices(ctx, 42) }},
|
|
64
|
+
{"preferences", http.MethodPost, "/native-notifications/push-preference-queries", "", "42", `{"user_id":42,"categories":["orders","offers"],"defaults":` + wirePreferences + `}`, wirePreferences, 200,
|
|
65
|
+
func(ctx context.Context, c *Client) (any, error) {
|
|
66
|
+
return c.GetPreferences(ctx, PreferenceQuery{UserID: 42, Categories: []string{"orders", "offers"}, Defaults: prefs})
|
|
67
|
+
}},
|
|
68
|
+
{"update preferences", http.MethodPut, "/native-notifications/push-preferences", "", "42", `{"user_id":42,"preferences":` + wirePreferences + `}`, wirePreferences, 200,
|
|
69
|
+
func(ctx context.Context, c *Client) (any, error) {
|
|
70
|
+
return c.PutPreferences(ctx, PreferenceUpdate{UserID: 42, Preferences: prefs})
|
|
71
|
+
}},
|
|
72
|
+
{"explicit diagnostic", http.MethodPost, "/native-notifications/test-push-sends", "", "42", `{"user_id":42,"title":"Diagnostic","body":"Explicit test","device_ids":[7]}`, `{"succeeded":1,"failed":0}`, 200,
|
|
73
|
+
func(ctx context.Context, c *Client) (any, error) {
|
|
74
|
+
return c.SendTestPush(ctx, TestPushRequest{UserID: 42, Title: "Diagnostic", Body: "Explicit test", DeviceIDs: []int64{7}})
|
|
75
|
+
}},
|
|
76
|
+
{"start", http.MethodPost, "/notifications/dispatch-command-starts", "", "", `{"source_event":` + wireSource + `,"command_key":"orders.ready","channel":"in_app","context":{"type":"product_event","reference":"order:one:revision:3"},"recipient":{"user_public_id":"usr_recipient"},"locale":"en","target":{"kind":"order","reference":"order:one"},"business_variables":{"order_no":"one","quantity":2}}`, `{"command_uid":"ndc_one","status":"pending","expires_at":"2026-10-16T00:00:00Z","replay":true}`, 201,
|
|
77
|
+
func(ctx context.Context, c *Client) (any, error) {
|
|
78
|
+
return c.StartDispatch(ctx, DispatchStartRequest{SourceEvent: source, CommandKey: "orders.ready", Channel: "in_app", Context: InputContext{Type: "product_event", Reference: "order:one:revision:3"}, Recipient: Recipient{UserPublicID: "usr_recipient"}, Locale: "en", Target: Target{Kind: "order", Reference: "order:one"}, BusinessVariables: map[string]any{"order_no": "one", "quantity": 2}})
|
|
79
|
+
}},
|
|
80
|
+
{"advance", http.MethodPost, "/notifications/source-state-advancements", "", "", `{"source_event":` + wireSource + `,"recipient":{"user_public_id":"usr_recipient"}}`, `{"status":"superseded","source_version":4}`, 200,
|
|
81
|
+
func(ctx context.Context, c *Client) (any, error) {
|
|
82
|
+
return c.AdvanceSource(ctx, SourceAdvanceRequest{SourceEvent: source, Recipient: Recipient{UserPublicID: "usr_recipient"}})
|
|
83
|
+
}},
|
|
84
|
+
}
|
|
85
|
+
require.Len(t, tests, 13)
|
|
86
|
+
for _, product := range []string{"sample_shop", "sample_media"} {
|
|
87
|
+
for _, test := range tests {
|
|
88
|
+
t.Run(product+"/"+test.name, func(t *testing.T) {
|
|
89
|
+
var requests atomic.Int32
|
|
90
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
91
|
+
requests.Add(1)
|
|
92
|
+
require.Equal(t, test.method, r.Method)
|
|
93
|
+
require.Equal(t, "/internal/v1"+test.path, r.URL.Path)
|
|
94
|
+
require.Equal(t, test.query, r.URL.RawQuery)
|
|
95
|
+
require.Equal(t, product, r.Header.Get(types.HeaderOneXProductCode))
|
|
96
|
+
require.Equal(t, test.user, r.Header.Get(types.HeaderInternalUserID))
|
|
97
|
+
require.Equal(t, "Bearer local-contract-test", r.Header.Get("Authorization"))
|
|
98
|
+
require.Empty(t, r.Header.Get("X-Session-ID"))
|
|
99
|
+
require.Empty(t, r.Header.Get("Cookie"))
|
|
100
|
+
raw, err := io.ReadAll(r.Body)
|
|
101
|
+
require.NoError(t, err)
|
|
102
|
+
if test.body == "" {
|
|
103
|
+
require.Empty(t, raw)
|
|
104
|
+
} else {
|
|
105
|
+
require.JSONEq(t, test.body, string(raw))
|
|
106
|
+
}
|
|
107
|
+
w.Header().Set("Content-Type", "application/json")
|
|
108
|
+
w.WriteHeader(test.status)
|
|
109
|
+
_, err = w.Write([]byte(test.response))
|
|
110
|
+
require.NoError(t, err)
|
|
111
|
+
}))
|
|
112
|
+
t.Cleanup(server.Close)
|
|
113
|
+
transport, err := onexv1.NewClient(httpclient.New(zap.NewNop()), wireSigner{}, onexv1.Config{BaseURL: server.URL + "/internal/v1", ProductCode: product, Timeout: time.Second})
|
|
114
|
+
require.NoError(t, err)
|
|
115
|
+
client, err := NewClient(transport, time.Second)
|
|
116
|
+
require.NoError(t, err)
|
|
117
|
+
require.Zero(t, requests.Load())
|
|
118
|
+
result, err := test.call(t.Context(), client)
|
|
119
|
+
require.NoError(t, err)
|
|
120
|
+
require.EqualValues(t, 1, requests.Load())
|
|
121
|
+
if test.response != "" {
|
|
122
|
+
raw, err := json.Marshal(result)
|
|
123
|
+
require.NoError(t, err)
|
|
124
|
+
require.JSONEq(t, test.response, string(raw))
|
|
125
|
+
} else {
|
|
126
|
+
require.Nil(t, result)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func TestClientPreservesTypedErrorsAndDoesNotRetryWrites(t *testing.T) {
|
|
134
|
+
for _, test := range []struct {
|
|
135
|
+
name, body, code string
|
|
136
|
+
status int
|
|
137
|
+
kind onexv1.ErrorKind
|
|
138
|
+
}{
|
|
139
|
+
{"device revision", `{"error":{"type":"invalid_request_error","code":"native_device_revision_conflict","message":"private-device-token","errors":[{"field":"current_registration_revision","message":"9"}]}}`, "native_device_revision_conflict", 409, onexv1.ErrorKindConflict},
|
|
140
|
+
{"source superseded", `{"error":{"type":"invalid_request_error","code":"notification_source_superseded","message":"private-device-token"}}`, "notification_source_superseded", 409, onexv1.ErrorKindConflict},
|
|
141
|
+
{"unknown field", `{"id":7,"unexpected":true}`, "onex_response_contract_invalid", 201, onexv1.ErrorKindContract},
|
|
142
|
+
{"connection closed", "", "onex_result_unknown", 0, onexv1.ErrorKindResultUnknown},
|
|
143
|
+
} {
|
|
144
|
+
t.Run(test.name, func(t *testing.T) {
|
|
145
|
+
var requests atomic.Int32
|
|
146
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
147
|
+
requests.Add(1)
|
|
148
|
+
if test.status == 0 {
|
|
149
|
+
connection, _, err := w.(http.Hijacker).Hijack()
|
|
150
|
+
require.NoError(t, err)
|
|
151
|
+
require.NoError(t, connection.Close())
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
w.Header().Set("Content-Type", "application/json")
|
|
155
|
+
w.WriteHeader(test.status)
|
|
156
|
+
_, err := w.Write([]byte(test.body))
|
|
157
|
+
require.NoError(t, err)
|
|
158
|
+
}))
|
|
159
|
+
t.Cleanup(server.Close)
|
|
160
|
+
transport, err := onexv1.NewClient(httpclient.New(zap.NewNop()), wireSigner{}, onexv1.Config{BaseURL: server.URL + "/internal/v1", ProductCode: "sample_shop", Timeout: time.Second, SafeReadRetry: onexv1.SafeReadRetryConfig{MaxRetries: 2, InitialBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond}})
|
|
161
|
+
require.NoError(t, err)
|
|
162
|
+
client, err := NewClient(transport, time.Second)
|
|
163
|
+
require.NoError(t, err)
|
|
164
|
+
_, err = client.RegisterDevice(t.Context(), DeviceRegistration{UserID: 42, DeviceToken: "private-device-token"})
|
|
165
|
+
var upstream *onexv1.Error
|
|
166
|
+
require.ErrorAs(t, err, &upstream)
|
|
167
|
+
require.Equal(t, test.kind, upstream.Kind)
|
|
168
|
+
require.Equal(t, test.code, upstream.Code)
|
|
169
|
+
require.NotContains(t, upstream.Error(), "private-device-token")
|
|
170
|
+
if test.name == "device revision" {
|
|
171
|
+
require.NotNil(t, upstream.CurrentRegistrationRevision)
|
|
172
|
+
require.EqualValues(t, 9, *upstream.CurrentRegistrationRevision)
|
|
173
|
+
}
|
|
174
|
+
require.EqualValues(t, 1, requests.Load())
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type wireCapture struct {
|
|
180
|
+
calls []onexv1.Call
|
|
181
|
+
err error
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func (c *wireCapture) Do(_ context.Context, call onexv1.Call, _ any) error {
|
|
185
|
+
c.calls = append(c.calls, call)
|
|
186
|
+
return c.err
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestClientConstructionPathAndOperationBoundaries(t *testing.T) {
|
|
190
|
+
_, err := NewClient(nil, time.Second)
|
|
191
|
+
require.Error(t, err)
|
|
192
|
+
_, err = NewClient(&wireCapture{}, 0)
|
|
193
|
+
require.Error(t, err)
|
|
194
|
+
upstream := &onexv1.Error{Kind: onexv1.ErrorKindUnavailable, Code: "onex_dependency_unavailable"}
|
|
195
|
+
transport := &wireCapture{err: upstream}
|
|
196
|
+
client, err := NewClient(transport, 3*time.Second)
|
|
197
|
+
require.NoError(t, err)
|
|
198
|
+
for _, number := range []string{"", "..", "../inm_sample", "inm_sample/other", "inm_%2f", "inm_a?b", "inm_a\nb"} {
|
|
199
|
+
_, err := client.GetMessage(t.Context(), 42, number)
|
|
200
|
+
var typed *onexv1.Error
|
|
201
|
+
require.ErrorAs(t, err, &typed)
|
|
202
|
+
require.Equal(t, onexv1.ErrorKindValidation, typed.Kind)
|
|
203
|
+
}
|
|
204
|
+
require.Empty(t, transport.calls)
|
|
205
|
+
_, err = client.QueryInbox(t.Context(), InboxQuery{UserID: 42, TargetScope: "any"})
|
|
206
|
+
require.True(t, errors.Is(err, upstream))
|
|
207
|
+
require.Equal(t, "native_write", transport.calls[0].Operation)
|
|
208
|
+
require.Equal(t, "notification", transport.calls[0].Dependency)
|
|
209
|
+
require.Equal(t, 3*time.Second, transport.calls[0].Timeout)
|
|
210
|
+
_, err = client.AcknowledgeMessages(t.Context(), AcknowledgeMessagesRequest{UserID: 42})
|
|
211
|
+
require.ErrorIs(t, err, upstream)
|
|
212
|
+
require.Equal(t, "native_write", transport.calls[1].Operation)
|
|
213
|
+
_, err = client.StartDispatch(t.Context(), DispatchStartRequest{})
|
|
214
|
+
require.ErrorIs(t, err, upstream)
|
|
215
|
+
require.Equal(t, "dispatch_command_start", transport.calls[2].Operation)
|
|
216
|
+
_, err = client.AdvanceSource(t.Context(), SourceAdvanceRequest{})
|
|
217
|
+
require.ErrorIs(t, err, upstream)
|
|
218
|
+
require.Equal(t, "advance_source_state", transport.calls[3].Operation)
|
|
219
|
+
_, err = client.GetMessage(t.Context(), 42, "inm_sample")
|
|
220
|
+
require.ErrorIs(t, err, upstream)
|
|
221
|
+
require.Equal(t, "native_read", transport.calls[4].Operation)
|
|
222
|
+
}
|