176 lines
7.4 KiB
Go
176 lines
7.4 KiB
Go
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 必须同时写入显式字段和 meta,user-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 返回 NotFound;service 层原样透传,由 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")
|
||
}
|
||
}
|