user_profile_identity_repo_contract_test.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "errors"
  6. "fmt"
  7. "testing"
  8. "time"
  9. dbent "github.com/Wei-Shaw/sub2api/ent"
  10. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  11. "github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/stretchr/testify/suite"
  14. )
  15. type UserProfileIdentityRepoSuite struct {
  16. suite.Suite
  17. ctx context.Context
  18. client *dbent.Client
  19. repo *userRepository
  20. }
  21. func TestUserProfileIdentityRepoSuite(t *testing.T) {
  22. suite.Run(t, new(UserProfileIdentityRepoSuite))
  23. }
  24. func (s *UserProfileIdentityRepoSuite) SetupTest() {
  25. s.ctx = context.Background()
  26. s.client = testEntClient(s.T())
  27. s.repo = newUserRepositoryWithSQL(s.client, integrationDB)
  28. _, err := integrationDB.ExecContext(s.ctx, `
  29. TRUNCATE TABLE
  30. identity_adoption_decisions,
  31. auth_identity_channels,
  32. auth_identities,
  33. pending_auth_sessions,
  34. user_provider_default_grants,
  35. user_avatars
  36. RESTART IDENTITY`)
  37. s.Require().NoError(err)
  38. }
  39. func (s *UserProfileIdentityRepoSuite) mustCreateUser(label string) *dbent.User {
  40. s.T().Helper()
  41. user, err := s.client.User.Create().
  42. SetEmail(fmt.Sprintf("%s-%d@example.com", label, time.Now().UnixNano())).
  43. SetPasswordHash("test-password-hash").
  44. SetRole("user").
  45. SetStatus("active").
  46. Save(s.ctx)
  47. s.Require().NoError(err)
  48. return user
  49. }
  50. func (s *UserProfileIdentityRepoSuite) mustCreatePendingAuthSession(key AuthIdentityKey) *dbent.PendingAuthSession {
  51. s.T().Helper()
  52. session, err := s.client.PendingAuthSession.Create().
  53. SetSessionToken(fmt.Sprintf("pending-%d", time.Now().UnixNano())).
  54. SetIntent("bind_current_user").
  55. SetProviderType(key.ProviderType).
  56. SetProviderKey(key.ProviderKey).
  57. SetProviderSubject(key.ProviderSubject).
  58. SetExpiresAt(time.Now().UTC().Add(15 * time.Minute)).
  59. SetUpstreamIdentityClaims(map[string]any{"provider_subject": key.ProviderSubject}).
  60. SetLocalFlowState(map[string]any{"step": "pending"}).
  61. Save(s.ctx)
  62. s.Require().NoError(err)
  63. return session
  64. }
  65. func (s *UserProfileIdentityRepoSuite) TestCreateAndLookupCanonicalAndChannelIdentity() {
  66. user := s.mustCreateUser("canonical-channel")
  67. verifiedAt := time.Now().UTC().Truncate(time.Second)
  68. created, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
  69. UserID: user.ID,
  70. Canonical: AuthIdentityKey{
  71. ProviderType: "wechat",
  72. ProviderKey: "wechat-open",
  73. ProviderSubject: "union-123",
  74. },
  75. Channel: &AuthIdentityChannelKey{
  76. ProviderType: "wechat",
  77. ProviderKey: "wechat-open",
  78. Channel: "mp",
  79. ChannelAppID: "wx-app",
  80. ChannelSubject: "openid-123",
  81. },
  82. Issuer: stringPtr("https://issuer.example"),
  83. VerifiedAt: &verifiedAt,
  84. Metadata: map[string]any{"unionid": "union-123"},
  85. ChannelMetadata: map[string]any{"openid": "openid-123"},
  86. })
  87. s.Require().NoError(err)
  88. s.Require().NotNil(created.Identity)
  89. s.Require().NotNil(created.Channel)
  90. canonical, err := s.repo.GetUserByCanonicalIdentity(s.ctx, created.IdentityRef())
  91. s.Require().NoError(err)
  92. s.Require().Equal(user.ID, canonical.User.ID)
  93. s.Require().Equal(created.Identity.ID, canonical.Identity.ID)
  94. s.Require().Equal("union-123", canonical.Identity.ProviderSubject)
  95. channel, err := s.repo.GetUserByChannelIdentity(s.ctx, *created.ChannelRef())
  96. s.Require().NoError(err)
  97. s.Require().Equal(user.ID, channel.User.ID)
  98. s.Require().Equal(created.Identity.ID, channel.Identity.ID)
  99. s.Require().Equal(created.Channel.ID, channel.Channel.ID)
  100. }
  101. func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_IsIdempotentAndRejectsOtherOwners() {
  102. owner := s.mustCreateUser("owner")
  103. other := s.mustCreateUser("other")
  104. first, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
  105. UserID: owner.ID,
  106. Canonical: AuthIdentityKey{
  107. ProviderType: "linuxdo",
  108. ProviderKey: "linuxdo-main",
  109. ProviderSubject: "subject-1",
  110. },
  111. Channel: &AuthIdentityChannelKey{
  112. ProviderType: "linuxdo",
  113. ProviderKey: "linuxdo-main",
  114. Channel: "oauth",
  115. ChannelAppID: "linuxdo-web",
  116. ChannelSubject: "subject-1",
  117. },
  118. Metadata: map[string]any{"username": "first"},
  119. ChannelMetadata: map[string]any{"scope": "read"},
  120. })
  121. s.Require().NoError(err)
  122. second, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
  123. UserID: owner.ID,
  124. Canonical: AuthIdentityKey{
  125. ProviderType: "linuxdo",
  126. ProviderKey: "linuxdo-main",
  127. ProviderSubject: "subject-1",
  128. },
  129. Channel: &AuthIdentityChannelKey{
  130. ProviderType: "linuxdo",
  131. ProviderKey: "linuxdo-main",
  132. Channel: "oauth",
  133. ChannelAppID: "linuxdo-web",
  134. ChannelSubject: "subject-1",
  135. },
  136. Metadata: map[string]any{"username": "second"},
  137. ChannelMetadata: map[string]any{"scope": "write"},
  138. })
  139. s.Require().NoError(err)
  140. s.Require().Equal(first.Identity.ID, second.Identity.ID)
  141. s.Require().Equal(first.Channel.ID, second.Channel.ID)
  142. s.Require().Equal("second", second.Identity.Metadata["username"])
  143. s.Require().Equal("write", second.Channel.Metadata["scope"])
  144. _, err = s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
  145. UserID: other.ID,
  146. Canonical: AuthIdentityKey{
  147. ProviderType: "linuxdo",
  148. ProviderKey: "linuxdo-main",
  149. ProviderSubject: "subject-1",
  150. },
  151. })
  152. s.Require().ErrorIs(err, ErrAuthIdentityOwnershipConflict)
  153. _, err = s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
  154. UserID: other.ID,
  155. Canonical: AuthIdentityKey{
  156. ProviderType: "linuxdo",
  157. ProviderKey: "linuxdo-main",
  158. ProviderSubject: "subject-2",
  159. },
  160. Channel: &AuthIdentityChannelKey{
  161. ProviderType: "linuxdo",
  162. ProviderKey: "linuxdo-main",
  163. Channel: "oauth",
  164. ChannelAppID: "linuxdo-web",
  165. ChannelSubject: "subject-1",
  166. },
  167. })
  168. s.Require().ErrorIs(err, ErrAuthIdentityChannelOwnershipConflict)
  169. }
  170. func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_ReusesLegacyWeChatAliasRecords() {
  171. user := s.mustCreateUser("wechat-legacy-alias")
  172. legacyIdentity, err := s.client.AuthIdentity.Create().
  173. SetUserID(user.ID).
  174. SetProviderType("wechat").
  175. SetProviderKey("wechat").
  176. SetProviderSubject("union-legacy-123").
  177. SetMetadata(map[string]any{"source": "legacy-alias"}).
  178. Save(s.ctx)
  179. s.Require().NoError(err)
  180. legacyChannel, err := s.client.AuthIdentityChannel.Create().
  181. SetIdentityID(legacyIdentity.ID).
  182. SetProviderType("wechat").
  183. SetProviderKey("wechat").
  184. SetChannel("oa").
  185. SetChannelAppID("wx-app-legacy").
  186. SetChannelSubject("openid-legacy-123").
  187. SetMetadata(map[string]any{"scene": "legacy-alias"}).
  188. Save(s.ctx)
  189. s.Require().NoError(err)
  190. bound, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
  191. UserID: user.ID,
  192. Canonical: AuthIdentityKey{
  193. ProviderType: "wechat",
  194. ProviderKey: "wechat-main",
  195. ProviderSubject: "union-legacy-123",
  196. },
  197. Channel: &AuthIdentityChannelKey{
  198. ProviderType: "wechat",
  199. ProviderKey: "wechat-main",
  200. Channel: "oa",
  201. ChannelAppID: "wx-app-legacy",
  202. ChannelSubject: "openid-legacy-123",
  203. },
  204. Metadata: map[string]any{"source": "canonical-bind"},
  205. ChannelMetadata: map[string]any{"scene": "canonical-bind"},
  206. })
  207. s.Require().NoError(err)
  208. s.Require().NotNil(bound)
  209. s.Require().NotNil(bound.Identity)
  210. s.Require().NotNil(bound.Channel)
  211. s.Require().Equal(legacyIdentity.ID, bound.Identity.ID)
  212. s.Require().Equal(legacyChannel.ID, bound.Channel.ID)
  213. s.Require().Equal("wechat-main", bound.Identity.ProviderKey)
  214. s.Require().Equal("wechat-main", bound.Channel.ProviderKey)
  215. s.Require().Equal("canonical-bind", bound.Identity.Metadata["source"])
  216. s.Require().Equal("canonical-bind", bound.Channel.Metadata["scene"])
  217. identityCount, err := s.client.AuthIdentity.Query().
  218. Where(
  219. authidentity.UserIDEQ(user.ID),
  220. authidentity.ProviderTypeEQ("wechat"),
  221. authidentity.ProviderSubjectEQ("union-legacy-123"),
  222. ).
  223. Count(s.ctx)
  224. s.Require().NoError(err)
  225. s.Require().Equal(1, identityCount)
  226. channelCount, err := s.client.AuthIdentityChannel.Query().
  227. Where(
  228. authidentitychannel.ProviderTypeEQ("wechat"),
  229. authidentitychannel.ChannelEQ("oa"),
  230. authidentitychannel.ChannelAppIDEQ("wx-app-legacy"),
  231. authidentitychannel.ChannelSubjectEQ("openid-legacy-123"),
  232. ).
  233. Count(s.ctx)
  234. s.Require().NoError(err)
  235. s.Require().Equal(1, channelCount)
  236. }
  237. func (s *UserProfileIdentityRepoSuite) TestCreateAuthIdentity_RejectsChannelProviderMismatch() {
  238. user := s.mustCreateUser("provider-mismatch-create")
  239. _, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
  240. UserID: user.ID,
  241. Canonical: AuthIdentityKey{
  242. ProviderType: "wechat",
  243. ProviderKey: "wechat-main",
  244. ProviderSubject: "union-create-mismatch",
  245. },
  246. Channel: &AuthIdentityChannelKey{
  247. ProviderType: "linuxdo",
  248. ProviderKey: "linuxdo-main",
  249. Channel: "oauth",
  250. ChannelAppID: "app-mismatch",
  251. ChannelSubject: "openid-create-mismatch",
  252. },
  253. })
  254. s.Require().ErrorIs(err, ErrAuthIdentityChannelProviderMismatch)
  255. }
  256. func (s *UserProfileIdentityRepoSuite) TestBindAuthIdentityToUser_RejectsChannelProviderMismatch() {
  257. user := s.mustCreateUser("provider-mismatch-bind")
  258. _, err := s.repo.BindAuthIdentityToUser(s.ctx, BindAuthIdentityInput{
  259. UserID: user.ID,
  260. Canonical: AuthIdentityKey{
  261. ProviderType: "wechat",
  262. ProviderKey: "wechat-main",
  263. ProviderSubject: "union-bind-mismatch",
  264. },
  265. Channel: &AuthIdentityChannelKey{
  266. ProviderType: "wechat",
  267. ProviderKey: "wechat-legacy",
  268. Channel: "oa",
  269. ChannelAppID: "wx-app-bind-mismatch",
  270. ChannelSubject: "openid-bind-mismatch",
  271. },
  272. })
  273. s.Require().ErrorIs(err, ErrAuthIdentityChannelProviderMismatch)
  274. }
  275. func (s *UserProfileIdentityRepoSuite) TestWithUserProfileIdentityTx_RollsBackIdentityAndGrantOnError() {
  276. user := s.mustCreateUser("tx-rollback")
  277. expectedErr := errors.New("rollback")
  278. err := s.repo.WithUserProfileIdentityTx(s.ctx, func(txCtx context.Context) error {
  279. _, err := s.repo.CreateAuthIdentity(txCtx, CreateAuthIdentityInput{
  280. UserID: user.ID,
  281. Canonical: AuthIdentityKey{
  282. ProviderType: "oidc",
  283. ProviderKey: "https://issuer.example",
  284. ProviderSubject: "subject-rollback",
  285. },
  286. })
  287. s.Require().NoError(err)
  288. inserted, err := s.repo.RecordProviderGrant(txCtx, ProviderGrantRecordInput{
  289. UserID: user.ID,
  290. ProviderType: "oidc",
  291. GrantReason: ProviderGrantReasonFirstBind,
  292. })
  293. s.Require().NoError(err)
  294. s.Require().True(inserted)
  295. return expectedErr
  296. })
  297. s.Require().ErrorIs(err, expectedErr)
  298. _, err = s.repo.GetUserByCanonicalIdentity(s.ctx, AuthIdentityKey{
  299. ProviderType: "oidc",
  300. ProviderKey: "https://issuer.example",
  301. ProviderSubject: "subject-rollback",
  302. })
  303. s.Require().True(dbent.IsNotFound(err))
  304. var count int
  305. s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
  306. SELECT COUNT(*)
  307. FROM user_provider_default_grants
  308. WHERE user_id = $1 AND provider_type = $2 AND grant_reason = $3`,
  309. user.ID,
  310. "oidc",
  311. string(ProviderGrantReasonFirstBind),
  312. ).Scan(&count))
  313. s.Require().Zero(count)
  314. }
  315. func (s *UserProfileIdentityRepoSuite) TestRecordProviderGrant_IsIdempotentPerReason() {
  316. user := s.mustCreateUser("grant")
  317. inserted, err := s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
  318. UserID: user.ID,
  319. ProviderType: "wechat",
  320. GrantReason: ProviderGrantReasonFirstBind,
  321. })
  322. s.Require().NoError(err)
  323. s.Require().True(inserted)
  324. inserted, err = s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
  325. UserID: user.ID,
  326. ProviderType: "wechat",
  327. GrantReason: ProviderGrantReasonFirstBind,
  328. })
  329. s.Require().NoError(err)
  330. s.Require().False(inserted)
  331. inserted, err = s.repo.RecordProviderGrant(s.ctx, ProviderGrantRecordInput{
  332. UserID: user.ID,
  333. ProviderType: "wechat",
  334. GrantReason: ProviderGrantReasonSignup,
  335. })
  336. s.Require().NoError(err)
  337. s.Require().True(inserted)
  338. var count int
  339. s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
  340. SELECT COUNT(*)
  341. FROM user_provider_default_grants
  342. WHERE user_id = $1 AND provider_type = $2`,
  343. user.ID,
  344. "wechat",
  345. ).Scan(&count))
  346. s.Require().Equal(2, count)
  347. }
  348. func (s *UserProfileIdentityRepoSuite) TestUpsertIdentityAdoptionDecision_PersistsAndLinksIdentity() {
  349. user := s.mustCreateUser("adoption")
  350. identity, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
  351. UserID: user.ID,
  352. Canonical: AuthIdentityKey{
  353. ProviderType: "wechat",
  354. ProviderKey: "wechat-open",
  355. ProviderSubject: "union-adoption",
  356. },
  357. })
  358. s.Require().NoError(err)
  359. session := s.mustCreatePendingAuthSession(identity.IdentityRef())
  360. first, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
  361. PendingAuthSessionID: session.ID,
  362. AdoptDisplayName: true,
  363. AdoptAvatar: false,
  364. })
  365. s.Require().NoError(err)
  366. s.Require().True(first.AdoptDisplayName)
  367. s.Require().False(first.AdoptAvatar)
  368. s.Require().Nil(first.IdentityID)
  369. second, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
  370. PendingAuthSessionID: session.ID,
  371. IdentityID: &identity.Identity.ID,
  372. AdoptDisplayName: true,
  373. AdoptAvatar: true,
  374. })
  375. s.Require().NoError(err)
  376. s.Require().Equal(first.ID, second.ID)
  377. s.Require().NotNil(second.IdentityID)
  378. s.Require().Equal(identity.Identity.ID, *second.IdentityID)
  379. s.Require().True(second.AdoptAvatar)
  380. loaded, err := s.repo.GetIdentityAdoptionDecisionByPendingAuthSessionID(s.ctx, session.ID)
  381. s.Require().NoError(err)
  382. s.Require().Equal(second.ID, loaded.ID)
  383. s.Require().Equal(identity.Identity.ID, *loaded.IdentityID)
  384. }
  385. func (s *UserProfileIdentityRepoSuite) TestUpsertIdentityAdoptionDecision_ReassignsExistingIdentityReference() {
  386. user := s.mustCreateUser("adoption-reassign")
  387. identity, err := s.repo.CreateAuthIdentity(s.ctx, CreateAuthIdentityInput{
  388. UserID: user.ID,
  389. Canonical: AuthIdentityKey{
  390. ProviderType: "wechat",
  391. ProviderKey: "wechat-open",
  392. ProviderSubject: "union-adoption-reassign",
  393. },
  394. })
  395. s.Require().NoError(err)
  396. firstSession := s.mustCreatePendingAuthSession(identity.IdentityRef())
  397. firstDecision, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
  398. PendingAuthSessionID: firstSession.ID,
  399. IdentityID: &identity.Identity.ID,
  400. AdoptDisplayName: true,
  401. AdoptAvatar: false,
  402. })
  403. s.Require().NoError(err)
  404. s.Require().NotNil(firstDecision.IdentityID)
  405. s.Require().Equal(identity.Identity.ID, *firstDecision.IdentityID)
  406. secondSession := s.mustCreatePendingAuthSession(identity.IdentityRef())
  407. secondDecision, err := s.repo.UpsertIdentityAdoptionDecision(s.ctx, IdentityAdoptionDecisionInput{
  408. PendingAuthSessionID: secondSession.ID,
  409. IdentityID: &identity.Identity.ID,
  410. AdoptDisplayName: false,
  411. AdoptAvatar: true,
  412. })
  413. s.Require().NoError(err)
  414. s.Require().NotNil(secondDecision.IdentityID)
  415. s.Require().Equal(identity.Identity.ID, *secondDecision.IdentityID)
  416. reloadedFirst, err := s.repo.GetIdentityAdoptionDecisionByPendingAuthSessionID(s.ctx, firstSession.ID)
  417. s.Require().NoError(err)
  418. s.Require().Nil(reloadedFirst.IdentityID)
  419. }
  420. func (s *UserProfileIdentityRepoSuite) TestWithUserProfileIdentityTx_AllowsAvatarOnlyProfileUpdate() {
  421. user := s.mustCreateUser("avatar-only-update")
  422. model, err := s.repo.GetByID(s.ctx, user.ID)
  423. s.Require().NoError(err)
  424. s.Require().NotNil(model)
  425. err = s.repo.WithUserProfileIdentityTx(s.ctx, func(txCtx context.Context) error {
  426. _, err := s.repo.UpsertUserAvatar(txCtx, user.ID, service.UpsertUserAvatarInput{
  427. StorageProvider: "remote_url",
  428. URL: "https://cdn.example.com/avatar.png",
  429. })
  430. if err != nil {
  431. return err
  432. }
  433. // 只改头像时用户行没有任何列需要写,掩码为空——与 UserService.updateProfile 一致。
  434. return s.repo.Update(txCtx, model, service.UserUpdateFields{})
  435. })
  436. s.Require().NoError(err)
  437. avatar, err := s.repo.GetUserAvatar(s.ctx, user.ID)
  438. s.Require().NoError(err)
  439. s.Require().NotNil(avatar)
  440. s.Require().Equal("https://cdn.example.com/avatar.png", avatar.URL)
  441. }
  442. func (s *UserProfileIdentityRepoSuite) TestUserAvatarCRUDAndUserLookup() {
  443. user := s.mustCreateUser("avatar")
  444. inlineAvatar, err := s.repo.UpsertUserAvatar(s.ctx, user.ID, service.UpsertUserAvatarInput{
  445. StorageProvider: "inline",
  446. URL: "data:image/png;base64,QUJD",
  447. ContentType: "image/png",
  448. ByteSize: 3,
  449. SHA256: "902fbdd2b1df0c4f70b4a5d23525e932",
  450. })
  451. s.Require().NoError(err)
  452. s.Require().Equal("inline", inlineAvatar.StorageProvider)
  453. s.Require().Equal("data:image/png;base64,QUJD", inlineAvatar.URL)
  454. loadedAvatar, err := s.repo.GetUserAvatar(s.ctx, user.ID)
  455. s.Require().NoError(err)
  456. s.Require().NotNil(loadedAvatar)
  457. s.Require().Equal("image/png", loadedAvatar.ContentType)
  458. s.Require().Equal(3, loadedAvatar.ByteSize)
  459. _, err = s.repo.UpsertUserAvatar(s.ctx, user.ID, service.UpsertUserAvatarInput{
  460. StorageProvider: "remote_url",
  461. URL: "https://cdn.example.com/avatar.png",
  462. })
  463. s.Require().NoError(err)
  464. loadedAvatar, err = s.repo.GetUserAvatar(s.ctx, user.ID)
  465. s.Require().NoError(err)
  466. s.Require().NotNil(loadedAvatar)
  467. s.Require().Equal("remote_url", loadedAvatar.StorageProvider)
  468. s.Require().Equal("https://cdn.example.com/avatar.png", loadedAvatar.URL)
  469. s.Require().Zero(loadedAvatar.ByteSize)
  470. s.Require().NoError(s.repo.DeleteUserAvatar(s.ctx, user.ID))
  471. loadedAvatar, err = s.repo.GetUserAvatar(s.ctx, user.ID)
  472. s.Require().NoError(err)
  473. s.Require().Nil(loadedAvatar)
  474. }
  475. func (s *UserProfileIdentityRepoSuite) TestUpdateUserLastLoginAndActiveAt_UsesDedicatedColumns() {
  476. user := s.mustCreateUser("activity")
  477. loginAt := time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)
  478. activeAt := loginAt.Add(5 * time.Minute)
  479. s.Require().NoError(s.repo.UpdateUserLastLoginAt(s.ctx, user.ID, loginAt))
  480. s.Require().NoError(s.repo.UpdateUserLastActiveAt(s.ctx, user.ID, activeAt))
  481. var storedLoginAt sqlNullTime
  482. var storedActiveAt sqlNullTime
  483. s.Require().NoError(integrationDB.QueryRowContext(s.ctx, `
  484. SELECT last_login_at, last_active_at
  485. FROM users
  486. WHERE id = $1`,
  487. user.ID,
  488. ).Scan(&storedLoginAt, &storedActiveAt))
  489. s.Require().True(storedLoginAt.Valid)
  490. s.Require().True(storedActiveAt.Valid)
  491. s.Require().True(storedLoginAt.Time.Equal(loginAt))
  492. s.Require().True(storedActiveAt.Time.Equal(activeAt))
  493. }
  494. type sqlNullTime struct {
  495. Time time.Time
  496. Valid bool
  497. }
  498. func (t *sqlNullTime) Scan(value any) error {
  499. switch v := value.(type) {
  500. case time.Time:
  501. t.Time = v
  502. t.Valid = true
  503. return nil
  504. case nil:
  505. t.Time = time.Time{}
  506. t.Valid = false
  507. return nil
  508. default:
  509. return fmt.Errorf("unsupported scan type %T", value)
  510. }
  511. }
  512. func stringPtr(v string) *string {
  513. return &v
  514. }