Files
cmautobuy/admin/service/syb_test.go
T

639 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/syb"
)
// ── 增量边界:本工单最容易写错的地方 ─────────────────────
func TestSyncDateRange_首次同步用配置的SyncFrom(t *testing.T) {
now := time.Date(2026, 8, 9, 15, 0, 0, 0, time.UTC)
from, to, err := syncDateRange("", "2026-07-01", now)
if err != nil {
t.Fatalf("计算日期范围失败: %v", err)
}
if from != "2026-07-01" {
t.Errorf("首次同步应该用 sync_from,实际 from=%q", from)
}
if to != "2026-08-09" {
t.Errorf("结束日期应该是今天,实际 to=%q", to)
}
}
func TestSyncDateRange_首次同步且未配置SyncFrom时报错(t *testing.T) {
_, _, err := syncDateRange("", "", time.Now())
if err == nil {
t.Fatal("从未同步过又没配置 sync_from 时应该报错,而不是拿一个空日期硬拉")
}
}
func TestSyncDateRange_增量从上次同步日期当天重拉不是第二天(t *testing.T) {
// `[必须]` 这是工单 #46 明确点名"最容易写错的地方":
// 上次同步 2026-08-09 14:30,下次必须还从 2026-08-09 开始拉,
// 不能从 2026-08-10 开始——否则会漏掉 8-09 14:30 之后创建的单,
// 而且不会报错,没人会发现。
lastSyncedAt := "2026-08-09T14:30:00Z"
now := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
from, to, err := syncDateRange(lastSyncedAt, "2026-07-01", now)
if err != nil {
t.Fatalf("计算日期范围失败: %v", err)
}
if from != "2026-08-09" {
t.Fatalf("增量同步应该从上次同步的当天(2026-08-09)重新拉,实际 from=%q"+
"——如果这里算成了 2026-08-10,就是漏单且不报错的那个坑", from)
}
if to != "2026-08-10" {
t.Errorf("结束日期应该是 now 对应的日期,实际 to=%q", to)
}
}
func TestSyncDateRange_上次同步时间格式错误时报错(t *testing.T) {
_, _, err := syncDateRange("不是一个合法的时间", "2026-07-01", time.Now())
if err == nil {
t.Fatal("last_synced_at 解析失败时应该报错,不能悄悄退化成一个随便的日期")
}
}
func TestSyncDateRange_结束日期按顺运宝服务端时区UTC加8算不是UTC(t *testing.T) {
// `[必须]` 顺运宝 created 是 UTC+8(08 §5.2 有 HAR 实测证据)。
// now = 2026-08-09T23:00:00Z,也就是本地(UTC+8)2026-08-10 07:00——
// 如果日期运算错误地用了 UTC,会把"今天"算成 2026-08-09,
// 当天早晨(UTC+8)创建的单这一轮就拉不到,操作员会以为同步坏了。
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC)
_, to, err := syncDateRange("", "2026-07-01", now)
if err != nil {
t.Fatalf("计算日期范围失败: %v", err)
}
if to != "2026-08-10" {
t.Fatalf("结束日期应该按顺运宝服务端时区(UTC+8)算成 2026-08-10,实际 to=%q"+
"——如果这里算成了 2026-08-09,就是用错了 UTC 而不是 UTC+8", to)
}
}
func TestNewSybSyncOptions_指定日期校验(t *testing.T) {
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC) // UTC+8 已是 8 月 10 日
cases := []struct {
name, from, to string
wantErr string
}{
{name: "两端都空是自动增量"},
{name: "合法闭区间", from: "2026-08-01", to: "2026-08-10"},
{name: "只填开始", from: "2026-08-01", wantErr: "同时填写"},
{name: "开始格式错误", from: "2026/08/01", to: "2026-08-02", wantErr: "开始日期格式"},
{name: "结束格式错误", from: "2026-08-01", to: "2026/08/02", wantErr: "结束日期格式"},
{name: "起止倒置", from: "2026-08-03", to: "2026-08-02", wantErr: "不能晚于"},
{name: "晚于UTC加8今天", from: "2026-08-10", to: "2026-08-11", wantErr: "不能晚于"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := NewSybSyncOptions(tc.from, tc.to, now)
if tc.wantErr == "" {
if err != nil {
t.Fatalf("不应该报错: %v", err)
}
if got.From != tc.from || got.To != tc.to {
t.Fatalf("范围被意外改变:%+v", got)
}
return
}
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, err)
}
})
}
}
// ── 金额换算 ──────────────────────────────────────────
func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) {
cases := []struct {
yuan float64
want int64
}{
{239.0, 23900},
{612.5, 61250},
{5.05, 505}, // 08 §5.1 明确点名的样本
{0, 0},
}
for _, c := range cases {
got := roundYuanToCent(c.yuan)
if got != c.want {
t.Errorf("roundYuanToCent(%v) = %d,期望 %d", c.yuan, got, c.want)
}
}
}
// ── 端到端:httptest 假服务端 ─────────────────────────────
//
// `[必须]` 绝不能打真实的 shunyunbaoerp.com,见工单 #46。
// fakeDetail 是假服务端里一条货运单明细行的最小描述。
type fakeDetail struct {
ID int64
ProductID int64
ProductTitle string
ProductSpec string
ProductQty int
ProductPrice float64
ProductThumb int64
}
// fakeStock 是假服务端里一张货运单。
type fakeStock struct {
ID int64
Code string
ShopName string
Receiver string // 用来验证个人信息确实没有落库
Details []fakeDetail
}
// fakeSybServer 起一个 httptest 假服务端,模拟 listTotal / list /
// detail/listByStock 三个接口,数据来自内存里的 stocks 切片。
//
// failListPageIndex:如果 > 0,/am/stock/list 请求到这一页时返回失败,
// 用来测"中途失败不更新 last_synced_at"。
func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *httptest.Server {
t.Helper()
byID := map[int64]fakeStock{}
for _, s := range stocks {
byID[s.ID] = s
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/am/stock/listTotal":
writeEnvelope(t, w, true, "ok", len(stocks), nil)
case "/am/stock/list":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
pageIndex := int(body["pageIndex"].(float64))
length := int(body["length"].(float64))
start := int(body["start"].(float64))
if failListPageIndex > 0 && pageIndex == failListPageIndex {
writeEnvelope(t, w, false, "模拟的服务端故障", nil, "500")
return
}
end := start + length
if end > len(stocks) {
end = len(stocks)
}
var list []map[string]any
if start < len(stocks) {
for _, s := range stocks[start:end] {
list = append(list, map[string]any{
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
"receiver": s.Receiver, "orderStatus": "待出货",
})
}
}
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
case "/am/stock/detail/listByStock":
var body struct {
IDs []int64 `json:"ids"`
}
json.NewDecoder(r.Body).Decode(&body)
var list []map[string]any
for _, id := range body.IDs {
s, ok := byID[id]
if !ok {
continue
}
var details []map[string]any
for _, d := range s.Details {
details = append(details, map[string]any{
"id": d.ID, "productId": d.ProductID, "productTitle": d.ProductTitle,
"productSpec": d.ProductSpec, "productQty": d.ProductQty,
"productPrice": d.ProductPrice, "productThumb": d.ProductThumb,
})
}
list = append(list, map[string]any{
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
"receiver": s.Receiver, "details": details,
})
}
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
default:
t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
}
func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string, data any, code any) {
t.Helper()
b, err := json.Marshal(map[string]any{"status": status, "msg": msg, "data": data, "code": code})
if err != nil {
t.Fatalf("构造响应失败: %v", err)
}
w.Write(b)
}
func newSyncTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := repository.Open(t.TempDir())
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := repository.Migrate(db); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
// `[必须]` 端到端版本:整条 RunSybSync 流程跑完,人工匹配的
// shopee_sku_id 必须还在——这是工单 #46 唯一"错了要几周后才发现"的点。
srv := fakeSybServer(t, []fakeStock{
{
ID: 75104587, Code: "260728TB95MJTQ", ShopName: "测试店铺", Receiver: "张三",
Details: []fakeDetail{
{ID: 145306175, ProductID: 50209124255, ProductTitle: "蕾絲花邊拼接背心女",
ProductSpec: "白色,L【建議50-60公斤】", ProductQty: 1, ProductPrice: 239.0, ProductThumb: 190639637},
},
},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, err := syb.New(srv.URL)
if err != nil {
t.Fatalf("创建客户端失败: %v", err)
}
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report1 := RunSybSync(context.Background(), db, client, cfg, now)
if report1.Err != nil {
t.Fatalf("首次同步失败: %v", report1.Err)
}
if report1.Created != 1 {
t.Fatalf("首次同步应该新增 1 条,实际 Created=%d", report1.Created)
}
// 操作员在界面上完成规格匹配,手工写入 shopee_sku_id。
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
"MANUAL-MATCHED-SKU", "145306175"); err != nil {
t.Fatalf("模拟人工匹配失败: %v", err)
}
// 再同步一次(模拟顺运宝那边这张单信息有更新)。
report2 := RunSybSync(context.Background(), db, client, cfg, now.Add(time.Hour))
if report2.Err != nil {
t.Fatalf("二次同步失败: %v", report2.Err)
}
if report2.Updated != 1 {
t.Fatalf("二次同步应该是更新,实际 Updated=%d Created=%d", report2.Updated, report2.Created)
}
var skuID string
if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&skuID); err != nil {
t.Fatalf("查询失败: %v", err)
}
if skuID != "MANUAL-MATCHED-SKU" {
t.Fatalf("同步后 shopee_sku_id 应该还是 MANUAL-MATCHED-SKU,实际 %q"+
"——人工匹配成果被顺运宝同步洗掉了", skuID)
}
// 收件人信息不应该出现在 syb_data 里。
var sybData string
if err := db.QueryRow(`SELECT syb_data FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&sybData); err != nil {
t.Fatalf("查询失败: %v", err)
}
if strings.Contains(sybData, "张三") {
t.Errorf("syb_data 不应该包含收件人姓名,实际: %s", sybData)
}
// 价格换算:239.0 元 -> 23900 分。
var priceCent int64
if err := db.QueryRow(`SELECT price_twd_cent FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&priceCent); err != nil {
t.Fatalf("查询失败: %v", err)
}
if priceCent != 23900 {
t.Errorf("price_twd_cent 应该是 23900,实际 %d", priceCent)
}
// image_url 拼接。
var imageURL string
if err := db.QueryRow(`SELECT image_url FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&imageURL); err != nil {
t.Fatalf("查询失败: %v", err)
}
wantImage := srv.URL + "/api/p/file?id=190639637"
if imageURL != wantImage {
t.Errorf("image_url 应该是 %q,实际 %q", wantImage, imageURL)
}
}
func TestRunSybSync_中途失败不更新last_synced_at(t *testing.T) {
// 两页数据,pageSize=1,第二页请求失败——模拟"拉到一半服务端出错"。
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
{ID: 2, Code: "B", Details: []fakeDetail{{ID: 2, ProductID: 222, ProductQty: 1, ProductPrice: 1}}},
}, 2) // 第 2 页失败
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 1, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err == nil {
t.Fatal("第二页失败时同步应该报错")
}
_, found, err := repository.GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("查询同步进度失败: %v", err)
}
if found {
t.Fatal("中途失败不应该更新 last_synced_at——更新了的话下次同步会跳过这段区间," +
"漏掉的单永远补不回来")
}
}
func TestRunSybSync_全部成功后更新last_synced_at(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err != nil {
t.Fatalf("同步失败: %v", report.Err)
}
at, found, err := repository.GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("查询同步进度失败: %v", err)
}
if !found || at == "" {
t.Fatal("全部成功后应该更新 last_synced_at")
}
}
func TestRunSybSyncWithOptions_历史补拉不推进游标(t *testing.T) {
srv := fakeSybServer(t, nil, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
const original = "2026-07-20T06:30:00Z"
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
t.Fatalf("准备同步游标失败: %v", err)
}
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-01", To: "2026-07-10"})
if report.Err != nil {
t.Fatalf("历史补拉失败: %v", report.Err)
}
if !report.Specified || !strings.HasPrefix(report.Summary(), "指定日期同步完成") {
t.Fatalf("报告没有标明指定日期同步:%+v / %s", report, report.Summary())
}
at, _, err := repository.GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("读取同步游标失败: %v", err)
}
if at != original {
t.Fatalf("局部历史补拉不应推进游标,原值 %q,实际 %q", original, at)
}
}
func TestRunSybSyncWithOptions_完整覆盖自动范围才推进游标(t *testing.T) {
srv := fakeSybServer(t, nil, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
const original = "2026-07-20T06:30:00Z" // UTC+8 仍是 7 月 20 日
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
t.Fatalf("准备同步游标失败: %v", err)
}
report := RunSybSyncWithOptions(context.Background(), db, client,
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"},
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
SybSyncOptions{From: "2026-07-20", To: "2026-07-28"})
if report.Err != nil {
t.Fatalf("完整范围同步失败: %v", report.Err)
}
at, _, err := repository.GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("读取同步游标失败: %v", err)
}
if at == original || at == "" {
t.Fatalf("完整覆盖自动范围后应该推进游标,实际仍为 %q", at)
}
}
func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10},
{ID: 2, ProductID: 222, ProductQty: 0, ProductPrice: 10}, // 应该被跳过
{ID: 3, ProductID: 333, ProductQty: -1, ProductPrice: 10}, // 应该被跳过
}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err != nil {
t.Fatalf("同步失败: %v", report.Err)
}
if report.SkippedZero != 2 {
t.Fatalf("应该跳过 2 条 quantity<=0 的明细,实际 SkippedZero=%d", report.SkippedZero)
}
if len(report.Notes) != 2 {
t.Fatalf("跳过的明细应该在报告里列出来,实际 Notes=%v", report.Notes)
}
if report.Created != 1 {
t.Fatalf("只有 1 条应该真正写库,实际 Created=%d", report.Created)
}
n, err := repository.CountSybOrdersTotal(db)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if n != 1 {
t.Fatalf("库里应该只有 1 行,实际 %d 行", n)
}
}
func TestRunSybSync_超过最大条数报错不硬拉(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A"}, {ID: 2, Code: "B"}, {ID: 3, Code: "C"},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 2, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err == nil {
t.Fatal("总数 3 超过 max_matches=2 时应该报错")
}
if !strings.Contains(report.Err.Error(), "缩小") {
t.Errorf("错误信息应该提示缩小日期范围,实际: %v", report.Err)
}
if _, found, _ := repository.GetSybLastSyncedAt(db); found {
t.Error("超限报错不应该更新 last_synced_at")
}
n, _ := repository.CountSybOrdersTotal(db)
if n != 0 {
t.Errorf("超限报错不应该写入任何数据,实际写了 %d 行", n)
}
}
func TestRunSybSync_一张货运单多个商品各落一行(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10, ProductSpec: "白色,L"},
{ID: 2, ProductID: 222, ProductQty: 2, ProductPrice: 20, ProductSpec: "黑色,M"},
}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err != nil {
t.Fatalf("同步失败: %v", report.Err)
}
if report.Created != 2 {
t.Fatalf("一张货运单两个商品应该各落一行,实际 Created=%d", report.Created)
}
n, _ := repository.CountSybOrdersTotal(db)
if n != 2 {
t.Fatalf("库里应该有 2 行,实际 %d 行", n)
}
}
// ── 同步互斥标志 ──────────────────────────────────────
func TestSybSyncMutex_同一时间只允许一个同步(t *testing.T) {
t.Cleanup(func() { FinishSybSync(SyncReport{}) })
if !TryStartSybSync() {
t.Fatal("第一次应该能拿到互斥标志")
}
if TryStartSybSync() {
t.Fatal("同步进行中时,第二次不应该能拿到互斥标志")
}
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
if !TryStartSybSync() {
t.Fatal("上一次同步结束后应该能重新拿到互斥标志")
}
status := GetSybSyncStatus()
if !status.Running {
t.Error("刚拿到互斥标志后 Running 应该是 true")
}
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
status = GetSybSyncStatus()
if status.Running {
t.Error("FinishSybSync 之后 Running 应该是 false")
}
if status.Report == nil || status.Report.From != "2026-08-09" {
t.Errorf("应该能读到最近一次的报告,实际: %+v", status.Report)
}
}
// ── 会话有效性判断 ────────────────────────────────────
func TestEnsureSybSession_没有缓存时要求登录(t *testing.T) {
db := newSyncTestDB(t)
client, _ := syb.New("https://example.invalid")
err := EnsureSybSession(db, client, "tester", time.Now())
if err != ErrSybLoginRequired {
t.Fatalf("没有缓存的会话时应该返回 ErrSybLoginRequired,实际: %v", err)
}
}
func TestEnsureSybSession_已过期时要求登录(t *testing.T) {
db := newSyncTestDB(t)
if err := repository.SaveSybSession(db, "tester", `[]`, "2026-08-01T00:00:00Z"); err != nil {
t.Fatalf("保存会话失败: %v", err)
}
client, _ := syb.New("https://example.invalid")
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
err := EnsureSybSession(db, client, "tester", now)
if err != ErrSybLoginRequired {
t.Fatalf("过期会话应该返回 ErrSybLoginRequired,实际: %v", err)
}
}
func TestEnsureSybSession_未过期时恢复Cookie不要求登录(t *testing.T) {
db := newSyncTestDB(t)
cookiesJSON := `[{"name":"erp_session","value":"abc"}]`
if err := repository.SaveSybSession(db, "tester", cookiesJSON, "2026-08-10T00:00:00Z"); err != nil {
t.Fatalf("保存会话失败: %v", err)
}
client, _ := syb.New("https://example.invalid")
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
if err := EnsureSybSession(db, client, "tester", now); err != nil {
t.Fatalf("未过期的会话不应该要求重新登录: %v", err)
}
}
func TestSaveSybLoginSession_写库和读回(t *testing.T) {
db := newSyncTestDB(t)
client, _ := syb.New("https://example.invalid")
expiresAt := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)
if err := SaveSybLoginSession(db, client, "tester", expiresAt); err != nil {
t.Fatalf("保存登录会话失败: %v", err)
}
cached, err := repository.GetSybSession(db, "tester")
if err != nil || cached == nil {
t.Fatalf("应该能读到刚保存的会话: cached=%v err=%v", cached, err)
}
got, ok := model.ParseISO(cached.ExpiresAt)
if !ok || !got.Equal(expiresAt) {
t.Errorf("expires_at 应该是 %v,实际 %v(parsed=%v)", expiresAt, cached.ExpiresAt, got)
}
}