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") } }