2026-07-12 14:17:45 +08:00

176 lines
7.4 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 appuser
import (
"context"
"errors"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
"github.com/DATA-DOG/go-sqlmock"
mysqlDriver "github.com/go-sql-driver/mysql"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestFillLoginSnapshotsSplitsIPAndVersionScopes(t *testing.T) {
// IP 口径包含 access_resign在线用户 25s 心跳行提供最新 IP版本口径必须排除它心跳行版本恒为空
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
t.Fatalf("sqlmock failed: %v", err)
}
defer db.Close()
service := &Service{userDB: db}
columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
// 第一条查询IP 口径)带 access_resign 参数;第二条(版本口径)只有三种真实登录类型。
mock.ExpectQuery(`FROM \(`).
WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh", "lalu", int64(42), "access_resign").
WillReturnRows(sqlmock.NewRows(columns).AddRow("203.0.113.9", "SA", "", "", int64(9000)))
mock.ExpectQuery(`FROM \(`).
WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh").
WillReturnRows(sqlmock.NewRows(columns).AddRow("198.51.100.7", "SA", "1.4.2", "142", int64(7000)))
item := AppUser{}
if err := service.fillLoginSnapshots(context.Background(), &item, 42); err != nil {
t.Fatalf("fillLoginSnapshots failed: %v", err)
}
if item.LastLoginIP != "203.0.113.9" || item.LastLoginIPCountryCode != "SA" || item.LastLoginIPAtMs != 9000 {
t.Fatalf("last login ip snapshot mismatch: %+v", item)
}
if item.AppVersion != "1.4.2" || item.BuildNumber != "142" || item.AppVersionAtMs != 7000 {
t.Fatalf("app version snapshot mismatch: %+v", item)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestFillLoginSnapshotsToleratesUserWithoutAudit(t *testing.T) {
// 老用户或审计被清理时详情页仍要能打开,各字段落零值而不是 500。
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
t.Fatalf("sqlmock failed: %v", err)
}
defer db.Close()
service := &Service{userDB: db}
columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
item := AppUser{}
if err := service.fillLoginSnapshots(context.Background(), &item, 43); err != nil {
t.Fatalf("fillLoginSnapshots failed: %v", err)
}
if item.LastLoginIP != "" || item.LastLoginIPAtMs != 0 || item.AppVersion != "" || item.AppVersionAtMs != 0 {
t.Fatalf("empty audit must produce zero values: %+v", item)
}
}
func TestFillDevicesToleratesMissingTable(t *testing.T) {
// user_devices 由人工执行 DDL 引入表未创建Error 1146时详情页按空列表展示不能整页失败。
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
t.Fatalf("sqlmock failed: %v", err)
}
defer db.Close()
service := &Service{userDB: db}
mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1146, Message: "Table 'hyapp_user.user_devices' doesn't exist"})
item := AppUser{}
if err := service.fillDevices(context.Background(), &item, 42); err != nil {
t.Fatalf("fillDevices must tolerate missing table: %v", err)
}
if item.Devices == nil || len(item.Devices) != 0 {
t.Fatalf("missing table must yield empty device list: %#v", item.Devices)
}
// 其他数据库错误必须继续上抛,不能被 1146 分支吞掉。
mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1054, Message: "Unknown column"})
if err := service.fillDevices(context.Background(), &item, 42); err == nil {
t.Fatalf("non-1146 errors must propagate")
}
}
func TestFillDevicesScansRowsInLastSeenOrder(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
if err != nil {
t.Fatalf("sqlmock failed: %v", err)
}
defer db.Close()
service := &Service{userDB: db}
rows := sqlmock.NewRows([]string{"device_id", "platform", "device_model", "manufacturer", "os_version", "imei", "app_version", "build_number", "first_seen_at_ms", "last_seen_at_ms"}).
AddRow("android_abc", "android", "Redmi Note 8 Pro", "Xiaomi", "13", "", "1.4.2", "142", int64(1000), int64(9000)).
AddRow("ios_def", "ios", "iPhone17,2", "Apple", "18.1", "", "1.4.0", "140", int64(500), int64(8000))
mock.ExpectQuery(`FROM user_devices`).WithArgs("lalu", int64(42)).WillReturnRows(rows)
item := AppUser{}
if err := service.fillDevices(context.Background(), &item, 42); err != nil {
t.Fatalf("fillDevices failed: %v", err)
}
if len(item.Devices) != 2 || item.Devices[0].DeviceID != "android_abc" || item.Devices[1].Manufacturer != "Apple" {
t.Fatalf("device rows mismatch: %#v", item.Devices)
}
if item.Devices[0].LastSeenAtMs != 9000 || item.Devices[0].FirstSeenAtMs != 1000 {
t.Fatalf("device timestamps mismatch: %#v", item.Devices[0])
}
}
type fakeTokenIssuerClient struct {
response *userv1.AdminIssueUserAccessTokenResponse
err error
lastReq *userv1.AdminIssueUserAccessTokenRequest
}
func (f *fakeTokenIssuerClient) AdminIssueUserAccessToken(_ context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
f.lastReq = req
if f.err != nil {
return nil, f.err
}
return f.response, nil
}
func TestIssueAccessTokenMapsResponseAndAppCode(t *testing.T) {
fake := &fakeTokenIssuerClient{response: &userv1.AdminIssueUserAccessTokenResponse{
AccessToken: "jwt-token",
TokenType: "Bearer",
ExpiresInSec: 3600,
SessionId: "sess_1",
DeviceId: "android_abc",
SessionLastHeartbeatAtMs: 123456,
}}
service := &Service{tokenIssuerClient: fake}
result, err := service.IssueAccessToken(context.Background(), 42)
if err != nil {
t.Fatalf("IssueAccessToken failed: %v", err)
}
if result.AccessToken != "jwt-token" || result.TokenType != "Bearer" || result.ExpiresInSec != 3600 ||
result.SessionID != "sess_1" || result.DeviceID != "android_abc" || result.SessionLastHeartbeatAtMs != 123456 {
t.Fatalf("token result mismatch: %+v", result)
}
// app_code 必须同时写入显式字段和 metauser-service 端优先消费显式字段。
if fake.lastReq.GetUserId() != 42 || fake.lastReq.GetAppCode() != "lalu" || fake.lastReq.GetMeta().GetAppCode() != "lalu" {
t.Fatalf("request app scope mismatch: %+v", fake.lastReq)
}
}
func TestIssueAccessTokenPropagatesNotFound(t *testing.T) {
// 无活跃会话时 user-service 返回 NotFoundservice 层原样透传,由 handler 映射成 404 中文文案。
fake := &fakeTokenIssuerClient{err: status.Error(codes.NotFound, "active session not found")}
service := &Service{tokenIssuerClient: fake}
_, err := service.IssueAccessToken(context.Background(), 42)
if grpcStatus, ok := status.FromError(err); !ok || grpcStatus.Code() != codes.NotFound {
t.Fatalf("expected NotFound passthrough, got %v", err)
}
var missing *Service = &Service{}
if _, err := missing.IssueAccessToken(context.Background(), 42); err == nil || errors.Is(err, nil) {
t.Fatalf("nil token client must fail closed")
}
}