@@ -654,6 +654,112 @@ func TestUpdateAccountSchedulerUpdatesRuntimeOverrides(t *testing.T) {
654654 }
655655}
656656
657+ // AT-only 账号(没有 refresh_token,只靠 access_token)是规避 Codex Plus "add
658+ // phone" 流程的常用形态。导出/迁移以前会因为 rt=="" 直接跳过这些账号,导致
659+ // issue #123 中的迁移丢号。下面两个测试保护已修好的过滤逻辑。
660+ func TestExportAccountsIncludesATOnly (t * testing.T ) {
661+ gin .SetMode (gin .TestMode )
662+
663+ db := newTestAdminDB (t )
664+
665+ rtID , err := db .InsertAccount (context .Background (), "rt-account" , "rt_value" , "" )
666+ if err != nil {
667+ t .Fatalf ("insert rt account: %v" , err )
668+ }
669+ if err := db .UpdateCredentials (context .Background (), rtID , map [string ]interface {}{
670+ "email" : "rt@example.com" ,
671+ "access_token" : "at_for_rt" ,
672+ }); err != nil {
673+ t .Fatalf ("update rt credentials: %v" , err )
674+ }
675+
676+ atID , err := db .InsertAccount (context .Background (), "at-account" , "" , "" )
677+ if err != nil {
678+ t .Fatalf ("insert at-only account: %v" , err )
679+ }
680+ if err := db .UpdateCredentials (context .Background (), atID , map [string ]interface {}{
681+ "email" : "at@example.com" ,
682+ "access_token" : "at_only_value" ,
683+ }); err != nil {
684+ t .Fatalf ("update at-only credentials: %v" , err )
685+ }
686+
687+ handler := & Handler {db : db }
688+
689+ recorder := httptest .NewRecorder ()
690+ ctx , _ := gin .CreateTestContext (recorder )
691+ ctx .Request = httptest .NewRequest (http .MethodGet , "/api/admin/accounts/export?filter=all" , nil )
692+
693+ handler .ExportAccounts (ctx )
694+
695+ if recorder .Code != http .StatusOK {
696+ t .Fatalf ("status = %d, want %d: %s" , recorder .Code , http .StatusOK , recorder .Body .String ())
697+ }
698+
699+ var entries []cpaExportEntry
700+ if err := json .Unmarshal (recorder .Body .Bytes (), & entries ); err != nil {
701+ t .Fatalf ("decode response: %v" , err )
702+ }
703+
704+ if len (entries ) != 2 {
705+ t .Fatalf ("got %d entries, want 2 (rt + at-only)" , len (entries ))
706+ }
707+
708+ byEmail := make (map [string ]cpaExportEntry , len (entries ))
709+ for _ , e := range entries {
710+ byEmail [e .Email ] = e
711+ }
712+
713+ rt , ok := byEmail ["rt@example.com" ]
714+ if ! ok {
715+ t .Fatal ("rt-based account missing from export" )
716+ }
717+ if rt .RefreshToken != "rt_value" || rt .AccessToken != "at_for_rt" {
718+ t .Fatalf ("rt entry tokens = (rt=%q, at=%q), want (rt_value, at_for_rt)" , rt .RefreshToken , rt .AccessToken )
719+ }
720+
721+ at , ok := byEmail ["at@example.com" ]
722+ if ! ok {
723+ t .Fatal ("AT-only account missing from export" )
724+ }
725+ if at .RefreshToken != "" {
726+ t .Fatalf ("AT-only RefreshToken = %q, want empty" , at .RefreshToken )
727+ }
728+ if at .AccessToken != "at_only_value" {
729+ t .Fatalf ("AT-only AccessToken = %q, want at_only_value" , at .AccessToken )
730+ }
731+ }
732+
733+ func TestExportAccountsSkipsAccountsWithoutCredentials (t * testing.T ) {
734+ gin .SetMode (gin .TestMode )
735+
736+ db := newTestAdminDB (t )
737+
738+ if _ , err := db .InsertAccount (context .Background (), "empty-account" , "" , "" ); err != nil {
739+ t .Fatalf ("insert empty account: %v" , err )
740+ }
741+
742+ handler := & Handler {db : db }
743+
744+ recorder := httptest .NewRecorder ()
745+ ctx , _ := gin .CreateTestContext (recorder )
746+ ctx .Request = httptest .NewRequest (http .MethodGet , "/api/admin/accounts/export?filter=all" , nil )
747+
748+ handler .ExportAccounts (ctx )
749+
750+ if recorder .Code != http .StatusOK {
751+ t .Fatalf ("status = %d, want %d: %s" , recorder .Code , http .StatusOK , recorder .Body .String ())
752+ }
753+
754+ var entries []cpaExportEntry
755+ if err := json .Unmarshal (recorder .Body .Bytes (), & entries ); err != nil {
756+ t .Fatalf ("decode response: %v" , err )
757+ }
758+ if len (entries ) != 0 {
759+ t .Fatalf ("got %d entries, want 0 (account has no credentials)" , len (entries ))
760+ }
761+ }
762+
657763func newTestAdminDB (t * testing.T ) * database.DB {
658764 t .Helper ()
659765
0 commit comments