feat(t237): import freight orders synchronously
This commit is contained in:
@@ -201,7 +201,7 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
||||
TLSPrivateKey: cleanOptionalPath(tlsPrivateKey),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
WriteTimeout: 70 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
|
||||
@@ -29,6 +29,9 @@ func TestLoadUsesSafeDefaults(t *testing.T) {
|
||||
cfg.MaxHeaderBytes <= 0 {
|
||||
t.Fatal("server safety limits must all be positive")
|
||||
}
|
||||
if cfg.WriteTimeout != 70*time.Second {
|
||||
t.Fatalf("WriteTimeout = %s", cfg.WriteTimeout)
|
||||
}
|
||||
if cfg.ClaimLease != 10*time.Minute ||
|
||||
cfg.RunningLease != 30*time.Minute ||
|
||||
cfg.ReadinessTTL != 2*time.Minute {
|
||||
|
||||
@@ -852,6 +852,8 @@ func writeUsecaseError(ctx *gin.Context, err error) {
|
||||
status = http.StatusBadGateway
|
||||
case "OCR_SERVICE_INVALID", "ERP_UNAVAILABLE":
|
||||
status = http.StatusServiceUnavailable
|
||||
case "FREIGHT_SYNC_TIMEOUT":
|
||||
status = http.StatusGatewayTimeout
|
||||
}
|
||||
message := typed.Message
|
||||
if preflightMessage, ok := freightPreflightPublicMessage(typed.Code); ok {
|
||||
@@ -883,6 +885,10 @@ func freightPreflightPublicMessage(code string) (string, bool) {
|
||||
return "OCR service is invalid", true
|
||||
case "ERP_UNAVAILABLE":
|
||||
return "ERP is temporarily unavailable", true
|
||||
case "FREIGHT_SYNC_TIMEOUT":
|
||||
return "freight order sync exceeded 55 seconds", true
|
||||
case "FREIGHT_SYNC_BUSY":
|
||||
return "another freight order sync is already running", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -405,7 +405,9 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
),
|
||||
"freight-sync-1",
|
||||
)
|
||||
if create.Code != http.StatusAccepted {
|
||||
if create.Code != http.StatusCreated ||
|
||||
create.Header().Get("Location") != "/api/v1/freight-orders" ||
|
||||
!strings.Contains(create.Body.String(), `"status":"SUCCEEDED"`) {
|
||||
t.Fatalf("create status/body = %d / %s", create.Code, create.Body)
|
||||
}
|
||||
var createBody struct {
|
||||
@@ -486,8 +488,9 @@ func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) {
|
||||
),
|
||||
"freight-sync-1",
|
||||
)
|
||||
if replay.Code != http.StatusAccepted ||
|
||||
if replay.Code != http.StatusOK ||
|
||||
!strings.Contains(replay.Body.String(), `"replayed":true`) ||
|
||||
!strings.Contains(replay.Body.String(), `"status":"SUCCEEDED"`) ||
|
||||
!strings.Contains(replay.Body.String(), createBody.Sync.ID) {
|
||||
t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body)
|
||||
}
|
||||
@@ -515,6 +518,9 @@ func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) {
|
||||
"freight-date-sync-1",
|
||||
)
|
||||
requireAdminStatus(t, create, http.StatusAccepted)
|
||||
if !strings.Contains(create.Body.String(), `"status":"PENDING"`) {
|
||||
t.Fatalf("date sync create response = %s", create.Body)
|
||||
}
|
||||
var created struct {
|
||||
Sync struct {
|
||||
ID string `json:"id"`
|
||||
@@ -589,31 +595,9 @@ func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) {
|
||||
),
|
||||
"procurement-freight-sync",
|
||||
)
|
||||
var syncBody struct {
|
||||
Sync struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sync"`
|
||||
}
|
||||
decodeResponse(t, createSync, &syncBody)
|
||||
succeeded := false
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
status := performAdminRequest(
|
||||
t,
|
||||
fixture.router,
|
||||
http.MethodGet,
|
||||
"/api/v1/freight-syncs/"+syncBody.Sync.ID,
|
||||
"",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if strings.Contains(status.Body.String(), `"status":"SUCCEEDED"`) {
|
||||
succeeded = true
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !succeeded {
|
||||
t.Fatal("freight sync did not succeed")
|
||||
requireAdminStatus(t, createSync, http.StatusCreated)
|
||||
if !strings.Contains(createSync.Body.String(), `"status":"SUCCEEDED"`) {
|
||||
t.Fatalf("freight sync response = %s", createSync.Body)
|
||||
}
|
||||
orders := performAdminRequest(
|
||||
t,
|
||||
|
||||
@@ -106,7 +106,15 @@ func (h *adminHandlers) createFreightSync(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusAccepted, gin.H{
|
||||
status := http.StatusAccepted
|
||||
if request.Mode == domain.FreightSyncOrderNumber {
|
||||
status = http.StatusCreated
|
||||
if result.Replayed {
|
||||
status = http.StatusOK
|
||||
}
|
||||
ctx.Header("Location", "/api/v1/freight-orders")
|
||||
}
|
||||
ctx.JSON(status, gin.H{
|
||||
"sync": freightSyncResponse(result.Run),
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestWriteUsecaseErrorUsesPreflightStatusAndCode(t *testing.T) {
|
||||
{"ERP_RESPONSE_INVALID", http.StatusBadGateway},
|
||||
{"OCR_SERVICE_INVALID", http.StatusServiceUnavailable},
|
||||
{"ERP_UNAVAILABLE", http.StatusServiceUnavailable},
|
||||
{"FREIGHT_SYNC_TIMEOUT", http.StatusGatewayTimeout},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.code, func(t *testing.T) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -319,6 +320,7 @@ func (h *Handler) ListFreight(ctx *gin.Context) {
|
||||
CSRFToken: token,
|
||||
},
|
||||
Orders: orders,
|
||||
Notice: freightNotice(ctx.Query("notice")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -443,6 +445,10 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if mode == domain.FreightSyncOrderNumber && run.Status == "SUCCEEDED" {
|
||||
ctx.Redirect(http.StatusSeeOther, "/freight?notice=import-succeeded")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID))
|
||||
}
|
||||
|
||||
@@ -452,7 +458,7 @@ func (h *Handler) logFreightImportFailure(code string, status int) {
|
||||
return
|
||||
}
|
||||
h.logEvent(
|
||||
"freight_import_preflight_failed code=" + code +
|
||||
"freight_import_failed code=" + code +
|
||||
" status=" + strconv.Itoa(status),
|
||||
)
|
||||
}
|
||||
@@ -463,12 +469,20 @@ func freightImportError(err error) (string, string, string) {
|
||||
return "OCR_SERVICE_INVALID", "OCR 服务无效", "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。"
|
||||
case errors.Is(err, ErrERPNotConfigured):
|
||||
return "ERP_NOT_CONFIGURED", "ERP 凭证未配置", "ERP 账号或密码未配置,请检查 backend-api/.env 后重试。"
|
||||
case errors.Is(err, ErrERPSessionNeeded):
|
||||
return "ERP_SESSION_REQUIRED", "ERP 会话已失效", "ERP 会话已失效,请使用相同提交标识重试。"
|
||||
case errors.Is(err, ErrERPFreightNotFound):
|
||||
return "ERP_FREIGHT_NOT_FOUND", "未找到货运单", "ERP 中没有找到该完整单号,请检查后重试。"
|
||||
case errors.Is(err, ErrERPLoginRejected):
|
||||
return "ERP_LOGIN_REJECTED", "ERP 登录被拒绝", "请检查 ERP 账号密码及 OCR 识别结果后重试。"
|
||||
case errors.Is(err, ErrERPProtocol):
|
||||
return "ERP_RESPONSE_INVALID", "ERP 响应无效", "ERP 返回格式无法确认,请稍后重试。"
|
||||
case errors.Is(err, ErrERPUnavailable):
|
||||
return "ERP_UNAVAILABLE", "ERP 暂时不可用", "ERP 服务暂时不可用,请稍后使用相同提交标识重试。"
|
||||
case errors.Is(err, ErrFreightSyncBusy):
|
||||
return "FREIGHT_SYNC_BUSY", "ERP 正在同步", "已有完整单号正在同步,请等待完成后重试。"
|
||||
case errors.Is(err, ErrFreightSyncTimeout):
|
||||
return "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时", "完整单号同步超过 55 秒,请使用相同提交标识重试。"
|
||||
default:
|
||||
return "", "", "同步任务创建失败,请稍后使用相同提交标识重试。"
|
||||
}
|
||||
@@ -646,6 +660,8 @@ func freightNotice(value string) string {
|
||||
return "参考图已绑定,可以生成采购任务。"
|
||||
case "task-conflict":
|
||||
return "需求状态或来源已变化,当前不能生成任务。"
|
||||
case "import-succeeded":
|
||||
return "ERP 货运单已同步,货运信息和商品明细已更新。"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -1270,10 +1286,16 @@ func serviceErrorStatus(err error) int {
|
||||
return http.StatusServiceUnavailable
|
||||
case errors.Is(err, ErrERPNotConfigured), errors.Is(err, ErrERPLoginRejected):
|
||||
return http.StatusUnprocessableEntity
|
||||
case errors.Is(err, ErrERPFreightNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, ErrERPProtocol):
|
||||
return http.StatusBadGateway
|
||||
case errors.Is(err, ErrERPUnavailable):
|
||||
case errors.Is(err, ErrERPSessionNeeded), errors.Is(err, ErrERPUnavailable):
|
||||
return http.StatusServiceUnavailable
|
||||
case errors.Is(err, ErrFreightSyncBusy):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, ErrFreightSyncTimeout):
|
||||
return http.StatusGatewayTimeout
|
||||
case errors.Is(err, ErrConflict):
|
||||
return http.StatusConflict
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
@@ -1336,6 +1358,7 @@ type pageView struct {
|
||||
type freightPage struct {
|
||||
Page pageView
|
||||
Orders []FreightOrder
|
||||
Notice string
|
||||
}
|
||||
|
||||
type freightImportPage struct {
|
||||
|
||||
@@ -923,7 +923,7 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T) {
|
||||
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
|
||||
service := &fakeFreightService{
|
||||
fakeService: &fakeService{},
|
||||
@@ -938,7 +938,7 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
}},
|
||||
createResult: FreightSync{
|
||||
ID: testTaskID,
|
||||
Status: "PENDING",
|
||||
Status: "SUCCEEDED",
|
||||
CreatedAt: now,
|
||||
},
|
||||
}
|
||||
@@ -987,7 +987,7 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/import?sync="+testTaskID {
|
||||
"/freight?notice=import-succeeded" {
|
||||
t.Fatalf(
|
||||
"create sync status/location = %d / %q",
|
||||
response.Code,
|
||||
@@ -998,6 +998,17 @@ func TestFreightPagesEscapeSourceDataAndCreateAsyncSync(t *testing.T) {
|
||||
service.createInput.IdempotencyKey != idempotencyKey {
|
||||
t.Fatalf("create input = %+v", service.createInput)
|
||||
}
|
||||
list = performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/freight?notice=import-succeeded",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if !strings.Contains(list.Body.String(), "ERP 货运单已同步") {
|
||||
t.Fatalf("freight success notice = %s", list.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDateFormSubmitsManualRange(t *testing.T) {
|
||||
@@ -1042,6 +1053,8 @@ func TestFreightDateFormSubmitsManualRange(t *testing.T) {
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/freight/import?sync="+testTaskID ||
|
||||
service.createInput.Mode != "CREATED_RANGE" ||
|
||||
service.createInput.CreatedFrom != "2026-07-22" ||
|
||||
service.createInput.CreatedTo != "2026-07-28" {
|
||||
@@ -1116,9 +1129,13 @@ func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) {
|
||||
}{
|
||||
{"ocr", ErrOCRServiceInvalid, http.StatusServiceUnavailable, "OCR_SERVICE_INVALID", "OCR 服务无效"},
|
||||
{"not configured", ErrERPNotConfigured, http.StatusUnprocessableEntity, "ERP_NOT_CONFIGURED", "ERP 凭证未配置"},
|
||||
{"session", ErrERPSessionNeeded, http.StatusServiceUnavailable, "ERP_SESSION_REQUIRED", "ERP 会话已失效"},
|
||||
{"not found", ErrERPFreightNotFound, http.StatusNotFound, "ERP_FREIGHT_NOT_FOUND", "未找到货运单"},
|
||||
{"login rejected", ErrERPLoginRejected, http.StatusUnprocessableEntity, "ERP_LOGIN_REJECTED", "ERP 登录被拒绝"},
|
||||
{"protocol", ErrERPProtocol, http.StatusBadGateway, "ERP_RESPONSE_INVALID", "ERP 响应无效"},
|
||||
{"unavailable", ErrERPUnavailable, http.StatusServiceUnavailable, "ERP_UNAVAILABLE", "ERP 暂时不可用"},
|
||||
{"busy", ErrFreightSyncBusy, http.StatusConflict, "FREIGHT_SYNC_BUSY", "ERP 正在同步"},
|
||||
{"timeout", ErrFreightSyncTimeout, http.StatusGatewayTimeout, "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时"},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
@@ -1151,7 +1168,7 @@ func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) {
|
||||
service.createInput.OrderNumber != "ORDER-123" {
|
||||
t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput)
|
||||
}
|
||||
wantEvent := "freight_import_preflight_failed code=" +
|
||||
wantEvent := "freight_import_failed code=" +
|
||||
testCase.code + " status=" + strconv.Itoa(testCase.status)
|
||||
if event != wantEvent || strings.Contains(event, "private") {
|
||||
t.Fatalf("event = %q, want %q", event, wantEvent)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) {
|
||||
@@ -14,6 +15,8 @@ func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) {
|
||||
}{
|
||||
{domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid},
|
||||
{domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured},
|
||||
{domain.ErrFreightSourceSessionNeeded, ErrERPSessionNeeded},
|
||||
{domain.ErrFreightSourceNotFound, ErrERPFreightNotFound},
|
||||
{domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected},
|
||||
{domain.ErrFreightSourceProtocol, ErrERPProtocol},
|
||||
{domain.ErrFreightSourceUnavailable, ErrERPUnavailable},
|
||||
@@ -33,6 +36,8 @@ func TestMapFreightCreateErrorPreservesPreflightErrors(t *testing.T) {
|
||||
}{
|
||||
{domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid},
|
||||
{domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured},
|
||||
{domain.ErrFreightSourceSessionNeeded, ErrERPSessionNeeded},
|
||||
{domain.ErrFreightSourceNotFound, ErrERPFreightNotFound},
|
||||
{domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected},
|
||||
{domain.ErrFreightSourceProtocol, ErrERPProtocol},
|
||||
{domain.ErrFreightSourceUnavailable, ErrERPUnavailable},
|
||||
@@ -43,3 +48,23 @@ func TestMapFreightCreateErrorPreservesPreflightErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapFreightCreateErrorUsesStableSyncErrors(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
code string
|
||||
want error
|
||||
}{
|
||||
{"FREIGHT_SYNC_BUSY", ErrFreightSyncBusy},
|
||||
{"FREIGHT_SYNC_TIMEOUT", ErrFreightSyncTimeout},
|
||||
} {
|
||||
source := &usecase.Error{
|
||||
Kind: usecase.ErrorKindUnavailable,
|
||||
Code: testCase.code,
|
||||
Fields: map[string]string{},
|
||||
}
|
||||
actual := mapFreightCreateError(source)
|
||||
if !errors.Is(actual, testCase.want) || !errors.Is(actual, source) {
|
||||
t.Fatalf("mapFreightCreateError(%s) = %v", testCase.code, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<input id="order-number" name="order_number" value="{{.OrderNumber}}"
|
||||
maxlength="128" autocomplete="off" required>
|
||||
</div>
|
||||
<button class="button primary" type="submit" data-loading-label="正在创建…">同步此单号</button>
|
||||
<button class="button primary" type="submit" data-loading-label="正在同步…">同步此单号</button>
|
||||
</form>
|
||||
<form class="form-panel" method="post" action="/freight/import" data-loading-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
<a class="button primary" href="/freight/import">导入货运单</a>
|
||||
</div>
|
||||
{{if .Notice}}<div class="notice" role="status">{{.Notice}}</div>{{end}}
|
||||
<section class="table-region" aria-labelledby="freight-table-title">
|
||||
<h2 id="freight-table-title" class="visually-hidden">货运单列表</h2>
|
||||
{{if .Orders}}
|
||||
|
||||
@@ -8,19 +8,22 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("resource forbidden")
|
||||
ErrConflict = errors.New("resource conflict")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
ErrERPNotConfigured = errors.New("ERP is not configured")
|
||||
ErrERPSessionNeeded = errors.New("ERP session is required")
|
||||
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
||||
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
||||
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
||||
ErrERPUnavailable = errors.New("ERP is unavailable")
|
||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("resource forbidden")
|
||||
ErrConflict = errors.New("resource conflict")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
ErrERPNotConfigured = errors.New("ERP is not configured")
|
||||
ErrERPSessionNeeded = errors.New("ERP session is required")
|
||||
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
||||
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
||||
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
||||
ErrERPUnavailable = errors.New("ERP is unavailable")
|
||||
ErrERPFreightNotFound = errors.New("ERP freight order was not found")
|
||||
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
|
||||
ErrFreightSyncBusy = errors.New("freight sync is busy")
|
||||
ErrFreightSyncTimeout = errors.New("freight sync timed out")
|
||||
)
|
||||
|
||||
// Service is the application boundary required by the server-rendered admin UI.
|
||||
|
||||
@@ -334,6 +334,10 @@ func mapFreightPreflightError(err error) error {
|
||||
public = ErrOCRServiceInvalid
|
||||
case errors.Is(err, domain.ErrFreightSourceNotConfigured):
|
||||
public = ErrERPNotConfigured
|
||||
case errors.Is(err, domain.ErrFreightSourceSessionNeeded):
|
||||
public = ErrERPSessionNeeded
|
||||
case errors.Is(err, domain.ErrFreightSourceNotFound):
|
||||
public = ErrERPFreightNotFound
|
||||
case errors.Is(err, domain.ErrFreightSourceLoginRejected):
|
||||
public = ErrERPLoginRejected
|
||||
case errors.Is(err, domain.ErrFreightSourceProtocol):
|
||||
@@ -388,6 +392,19 @@ func mapFreightCreateError(err error) error {
|
||||
if mapped := mapFreightPreflightError(err); mapped != nil {
|
||||
return mapped
|
||||
}
|
||||
var typed *usecase.Error
|
||||
if errors.As(err, &typed) {
|
||||
var public error
|
||||
switch typed.Code {
|
||||
case "FREIGHT_SYNC_BUSY":
|
||||
public = ErrFreightSyncBusy
|
||||
case "FREIGHT_SYNC_TIMEOUT":
|
||||
public = ErrFreightSyncTimeout
|
||||
}
|
||||
if public != nil {
|
||||
return &adapterError{public: public, cause: err}
|
||||
}
|
||||
}
|
||||
return mapUsecaseError(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,19 @@ const (
|
||||
maxFreightItemsPerOrder = 1000
|
||||
maxFreightWindowDays = 7
|
||||
freightWatermarkOverlap = 10 * time.Minute
|
||||
orderFreightSyncTimeout = 55 * time.Second
|
||||
freightCleanupTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type FreightService struct {
|
||||
repository FreightRepository
|
||||
source FreightSource
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
timeout time.Duration
|
||||
repository FreightRepository
|
||||
source FreightSource
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
timeout time.Duration
|
||||
orderTimeout time.Duration
|
||||
cleanupTimeout time.Duration
|
||||
orderSyncGate chan struct{}
|
||||
}
|
||||
|
||||
type FreightSourcePreflight interface {
|
||||
@@ -67,11 +72,14 @@ func NewFreightService(
|
||||
return nil, errors.New("freight service dependencies are required")
|
||||
}
|
||||
return &FreightService{
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
timeout: timeout,
|
||||
repository: repository,
|
||||
source: source,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
timeout: timeout,
|
||||
orderTimeout: orderFreightSyncTimeout,
|
||||
cleanupTimeout: freightCleanupTimeout,
|
||||
orderSyncGate: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -105,7 +113,16 @@ func (service *FreightService) CreateOrderSync(
|
||||
fields,
|
||||
)
|
||||
}
|
||||
if err := service.ensureSource(ctx); err != nil {
|
||||
if !service.acquireOrderSync() {
|
||||
return CreateFreightSyncResult{}, freightSyncBusyError()
|
||||
}
|
||||
defer service.releaseOrderSync()
|
||||
syncCtx, cancel := context.WithTimeout(ctx, service.orderTimeout)
|
||||
defer cancel()
|
||||
if err := service.ensureSource(syncCtx); err != nil {
|
||||
if syncErr := freightContextError(syncCtx); syncErr != nil {
|
||||
return CreateFreightSyncResult{}, syncErr
|
||||
}
|
||||
return CreateFreightSyncResult{}, err
|
||||
}
|
||||
runID, err := service.ids.NewID()
|
||||
@@ -121,7 +138,7 @@ func (service *FreightService) CreateOrderSync(
|
||||
OrderNumber string `json:"order_number"`
|
||||
}{command.OrderNumber})
|
||||
run, created, err := service.repository.CreateFreightSync(
|
||||
ctx,
|
||||
syncCtx,
|
||||
domain.FreightSyncRun{
|
||||
ID: runID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
@@ -138,10 +155,31 @@ func (service *FreightService) CreateOrderSync(
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
if created {
|
||||
go service.execute(run)
|
||||
if !created {
|
||||
if run.Status != domain.FreightSyncSucceeded &&
|
||||
run.Status != domain.FreightSyncFailed {
|
||||
return CreateFreightSyncResult{}, freightSyncBusyError()
|
||||
}
|
||||
return CreateFreightSyncResult{Run: run, Replayed: true}, nil
|
||||
}
|
||||
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
|
||||
completed, err := service.executeRun(syncCtx, run)
|
||||
if err != nil {
|
||||
return CreateFreightSyncResult{}, err
|
||||
}
|
||||
return CreateFreightSyncResult{Run: completed}, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) acquireOrderSync() bool {
|
||||
select {
|
||||
case service.orderSyncGate <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (service *FreightService) releaseOrderSync() {
|
||||
<-service.orderSyncGate
|
||||
}
|
||||
|
||||
func (service *FreightService) CreateDateSync(
|
||||
@@ -310,10 +348,24 @@ func (service *FreightService) ensureSource(ctx context.Context) error {
|
||||
func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
|
||||
defer cancel()
|
||||
_, _ = service.executeRun(ctx, run)
|
||||
}
|
||||
|
||||
func (service *FreightService) executeRun(
|
||||
ctx context.Context,
|
||||
run domain.FreightSyncRun,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
now := service.clock.Now().UTC()
|
||||
if err := service.repository.StartFreightSync(ctx, run.ID, now); err != nil {
|
||||
return
|
||||
code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE")
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
code,
|
||||
wrapRepositoryError(err),
|
||||
)
|
||||
}
|
||||
run.Status = domain.FreightSyncRunning
|
||||
run.StartedAt = &now
|
||||
var source domain.FreightSourceBatch
|
||||
var err error
|
||||
if run.Mode == domain.FreightSyncCreatedRange {
|
||||
@@ -322,23 +374,21 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
source, err = service.source.QueryOrder(ctx, run.OrderNumber)
|
||||
}
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
code := freightFailureCode(ctx, freightSourceErrorCode(err))
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
freightSourceErrorCode(err),
|
||||
service.clock.Now().UTC(),
|
||||
code,
|
||||
freightExecutionError(code, err),
|
||||
)
|
||||
return
|
||||
}
|
||||
batch, err := service.normalize(source)
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
code := freightFailureCode(ctx, "ERP_RESPONSE_INVALID")
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
"ERP_RESPONSE_INVALID",
|
||||
service.clock.Now().UTC(),
|
||||
code,
|
||||
freightExecutionError(code, domain.ErrFreightSourceProtocol),
|
||||
)
|
||||
return
|
||||
}
|
||||
finishedAt := service.clock.Now().UTC()
|
||||
if run.Mode == domain.FreightSyncCreatedRange &&
|
||||
@@ -359,13 +409,107 @@ func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
_ = service.repository.FailFreightSync(
|
||||
ctx,
|
||||
code := freightFailureCode(ctx, "STORAGE_UNAVAILABLE")
|
||||
return domain.FreightSyncRun{}, service.finishFreightFailure(
|
||||
run.ID,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
service.clock.Now().UTC(),
|
||||
code,
|
||||
wrapRepositoryError(err),
|
||||
)
|
||||
}
|
||||
itemCount := 0
|
||||
for _, order := range batch.Orders {
|
||||
itemCount += len(order.Items)
|
||||
}
|
||||
run.Status = domain.FreightSyncSucceeded
|
||||
run.OrderCount = len(batch.Orders)
|
||||
run.ItemCount = itemCount
|
||||
run.FinishedAt = &finishedAt
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (service *FreightService) finishFreightFailure(
|
||||
runID, code string,
|
||||
cause error,
|
||||
) error {
|
||||
timeout := service.cleanupTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = freightCleanupTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := service.repository.FailFreightSync(
|
||||
ctx,
|
||||
runID,
|
||||
code,
|
||||
service.clock.Now().UTC(),
|
||||
); err != nil {
|
||||
return wrapRepositoryError(err)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func freightFailureCode(ctx context.Context, fallback string) string {
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
return "FREIGHT_SYNC_TIMEOUT"
|
||||
case context.Canceled:
|
||||
return "FREIGHT_SYNC_CANCELED"
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func freightContextError(ctx context.Context) error {
|
||||
switch ctx.Err() {
|
||||
case context.DeadlineExceeded:
|
||||
return freightExecutionError(
|
||||
"FREIGHT_SYNC_TIMEOUT",
|
||||
context.DeadlineExceeded,
|
||||
)
|
||||
case context.Canceled:
|
||||
return freightExecutionError(
|
||||
"FREIGHT_SYNC_CANCELED",
|
||||
context.Canceled,
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func freightSyncBusyError() error {
|
||||
result := newError(
|
||||
ErrorKindConflict,
|
||||
"FREIGHT_SYNC_BUSY",
|
||||
"another freight order sync is already running",
|
||||
nil,
|
||||
)
|
||||
result.Retryable = true
|
||||
return result
|
||||
}
|
||||
|
||||
func freightExecutionError(code string, cause error) error {
|
||||
var kind ErrorKind
|
||||
var message string
|
||||
switch code {
|
||||
case "FREIGHT_SYNC_TIMEOUT":
|
||||
kind = ErrorKindUnavailable
|
||||
message = "freight order sync exceeded its time limit"
|
||||
case "FREIGHT_SYNC_CANCELED":
|
||||
kind = ErrorKindUnavailable
|
||||
message = "freight order sync was canceled"
|
||||
case "ERP_FREIGHT_NOT_FOUND":
|
||||
kind = ErrorKindNotFound
|
||||
message = "ERP freight order was not found"
|
||||
default:
|
||||
kind = ErrorKindUnavailable
|
||||
message = freightPreflightMessage(code)
|
||||
}
|
||||
result := newError(kind, code, message, cause)
|
||||
result.Retryable = code == "FREIGHT_SYNC_TIMEOUT" ||
|
||||
code == "FREIGHT_SYNC_CANCELED" ||
|
||||
code == "ERP_UNAVAILABLE" ||
|
||||
code == "OCR_SERVICE_INVALID"
|
||||
return result
|
||||
}
|
||||
|
||||
func (service *FreightService) queryCreatedRange(
|
||||
|
||||
@@ -3,6 +3,7 @@ package usecase
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -98,6 +99,176 @@ func TestCreateFreightOrderSyncStopsBeforePersistingWhenOCRIsInvalid(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
&recordingDateSource{},
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
if service.orderTimeout != 55*time.Second {
|
||||
t.Fatalf("order timeout = %s", service.orderTimeout)
|
||||
}
|
||||
result, err := service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-success"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrderSync() error = %v", err)
|
||||
}
|
||||
if result.Replayed || result.Run.Status != domain.FreightSyncSucceeded ||
|
||||
result.Run.OrderCount != 1 || result.Run.ItemCount != 1 ||
|
||||
result.Run.StartedAt == nil || result.Run.FinishedAt == nil {
|
||||
t.Fatalf("CreateOrderSync() result = %+v", result)
|
||||
}
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.startCalls != 1 || repository.completeCalls != 1 ||
|
||||
repository.failCalls != 0 {
|
||||
t.Fatalf(
|
||||
"start/complete/fail calls = %d/%d/%d",
|
||||
repository.startCalls,
|
||||
repository.completeCalls,
|
||||
repository.failCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
source,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
service.orderTimeout = 10 * time.Millisecond
|
||||
_, err = service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-timeout"),
|
||||
)
|
||||
assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_TIMEOUT")
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.failCalls != 1 ||
|
||||
repository.failCode != "FREIGHT_SYNC_TIMEOUT" ||
|
||||
repository.failContextErr != nil {
|
||||
t.Fatalf(
|
||||
"failure calls/code/context = %d/%q/%v",
|
||||
repository.failCalls,
|
||||
repository.failCode,
|
||||
repository.failContextErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncCancellationUsesLiveCleanupContext(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{started: make(chan struct{})}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
source,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, runErr := service.CreateOrderSync(
|
||||
ctx,
|
||||
validFreightOrderSyncCommand("sync-canceled"),
|
||||
)
|
||||
result <- runErr
|
||||
}()
|
||||
select {
|
||||
case <-source.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("order sync did not reach source")
|
||||
}
|
||||
cancel()
|
||||
err = <-result
|
||||
assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_CANCELED")
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
if repository.failCalls != 1 ||
|
||||
repository.failCode != "FREIGHT_SYNC_CANCELED" ||
|
||||
repository.failContextErr != nil {
|
||||
t.Fatalf(
|
||||
"failure calls/code/context = %d/%q/%v",
|
||||
repository.failCalls,
|
||||
repository.failCode,
|
||||
repository.failContextErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFreightOrderSyncRejectsConcurrentRequest(t *testing.T) {
|
||||
repository := &syncTrackingRepository{}
|
||||
source := &blockingOrderSource{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
service, err := NewFreightService(
|
||||
repository,
|
||||
source,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFreightService() error = %v", err)
|
||||
}
|
||||
firstResult := make(chan error, 1)
|
||||
go func() {
|
||||
_, runErr := service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-first"),
|
||||
)
|
||||
firstResult <- runErr
|
||||
}()
|
||||
select {
|
||||
case <-source.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("first order sync did not reach source")
|
||||
}
|
||||
startedAt := time.Now()
|
||||
_, err = service.CreateOrderSync(
|
||||
context.Background(),
|
||||
validFreightOrderSyncCommand("sync-second"),
|
||||
)
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_SYNC_BUSY")
|
||||
if time.Since(startedAt) > 100*time.Millisecond {
|
||||
t.Fatalf("busy response took %s", time.Since(startedAt))
|
||||
}
|
||||
close(source.release)
|
||||
if err := <-firstResult; err != nil {
|
||||
t.Fatalf("first CreateOrderSync() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncCommand {
|
||||
return CreateFreightSyncCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
ActorUserID: "00000000-0000-4000-8000-000000000099",
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OrderNumber: "ORDER-123",
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
|
||||
source := &recordingDateSource{}
|
||||
service := &FreightService{source: source}
|
||||
@@ -261,6 +432,144 @@ type recordingDateSource struct {
|
||||
ensureErr error
|
||||
}
|
||||
|
||||
type blockingOrderSource struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (source *blockingOrderSource) QueryOrder(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
if source.started != nil {
|
||||
source.once.Do(func() {
|
||||
close(source.started)
|
||||
})
|
||||
}
|
||||
if source.release != nil {
|
||||
select {
|
||||
case <-source.release:
|
||||
case <-ctx.Done():
|
||||
return domain.FreightSourceBatch{}, ctx.Err()
|
||||
}
|
||||
} else {
|
||||
<-ctx.Done()
|
||||
return domain.FreightSourceBatch{}, ctx.Err()
|
||||
}
|
||||
return validFreightSource(), nil
|
||||
}
|
||||
|
||||
func (*blockingOrderSource) QueryCreatedRange(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSourceBatch, error) {
|
||||
return domain.FreightSourceBatch{}, errors.New("unexpected date query")
|
||||
}
|
||||
|
||||
type syncTrackingRepository struct {
|
||||
mu sync.Mutex
|
||||
startCalls int
|
||||
completeCalls int
|
||||
failCalls int
|
||||
failCode string
|
||||
failContextErr error
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) CreateFreightSync(
|
||||
_ context.Context,
|
||||
run domain.FreightSyncRun,
|
||||
_, _ string,
|
||||
) (domain.FreightSyncRun, bool, error) {
|
||||
return run, true, nil
|
||||
}
|
||||
|
||||
func (repository *syncTrackingRepository) StartFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
time.Time,
|
||||
) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.startCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repository *syncTrackingRepository) CompleteFreightSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
domain.FreightImportBatch,
|
||||
time.Time,
|
||||
) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.completeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) CompleteFreightDateSync(
|
||||
context.Context,
|
||||
domain.FreightSyncRun,
|
||||
domain.FreightImportBatch,
|
||||
time.Time,
|
||||
time.Time,
|
||||
) error {
|
||||
return errors.New("unexpected date completion")
|
||||
}
|
||||
|
||||
func (repository *syncTrackingRepository) FailFreightSync(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
code string,
|
||||
_ time.Time,
|
||||
) error {
|
||||
repository.mu.Lock()
|
||||
defer repository.mu.Unlock()
|
||||
repository.failCalls++
|
||||
repository.failCode = code
|
||||
repository.failContextErr = ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) RecoverFreightSyncs(
|
||||
context.Context,
|
||||
time.Time,
|
||||
) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) GetFreightSync(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightSyncRun, error) {
|
||||
return domain.FreightSyncRun{}, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) GetFreightSyncWatermark(
|
||||
context.Context,
|
||||
string,
|
||||
) (*domain.FreightSyncWatermark, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) ListFreightOrders(
|
||||
context.Context,
|
||||
string,
|
||||
int,
|
||||
) ([]domain.FreightOrder, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*syncTrackingRepository) GetFreightOrder(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.FreightOrderDetail, error) {
|
||||
return domain.FreightOrderDetail{}, nil
|
||||
}
|
||||
|
||||
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
|
||||
source.ensureCalls++
|
||||
return source.ensureErr
|
||||
|
||||
Reference in New Issue
Block a user