package logic import ( "context" "testing" "spark-server/internal/module/client/entity" userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" ) type stubClientRepo struct { legal map[string]map[string]*entity.LegalDocument feedback []entity.Feedback plans []entity.SubscriptionPlan passwords map[int64]string devApps map[string][]entity.DeveloperApp } func (s *stubClientRepo) GetLegal(_ context.Context, lang string) (*entity.LegalDocument, *entity.LegalDocument, error) { if s.legal == nil { return nil, nil, errs.ErrNotFound } byLang := s.legal[lang] if byLang == nil { return nil, nil, errs.ErrNotFound } return byLang[entity.DocPrivacy], byLang[entity.DocTerms], nil } func (s *stubClientRepo) UpsertLegal(_ context.Context, d *entity.LegalDocument) error { if s.legal == nil { s.legal = map[string]map[string]*entity.LegalDocument{} } if s.legal[d.Lang] == nil { s.legal[d.Lang] = map[string]*entity.LegalDocument{} } cp := *d s.legal[d.Lang][d.Key] = &cp return nil } func (s *stubClientRepo) InsertFeedback(_ context.Context, f *entity.Feedback) error { f.ID = int64(len(s.feedback) + 1) s.feedback = append(s.feedback, *f) return nil } func (s *stubClientRepo) ListFeedback(_ context.Context, _, _ int, authType string) ([]entity.Feedback, int64, error) { if authType == "" { return s.feedback, int64(len(s.feedback)), nil } out := make([]entity.Feedback, 0) for i := range s.feedback { if s.feedback[i].AuthType == authType { out = append(out, s.feedback[i]) } } return out, int64(len(out)), nil } func (s *stubClientRepo) UpdateFeedbackStatus(_ context.Context, id int64, status string) error { for i := range s.feedback { if s.feedback[i].ID == id { s.feedback[i].Status = status return nil } } return errs.ErrNotFound } func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) { out := make([]entity.SubscriptionPlan, 0) for i := range s.plans { p := &s.plans[i] if p.Lang == lang && p.Enabled { out = append(out, *p) } } return out, nil } func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) { if lang == "" { return s.plans, nil } out := make([]entity.SubscriptionPlan, 0) for i := range s.plans { p := &s.plans[i] if p.Lang == lang { out = append(out, *p) } } return out, nil } func (s *stubClientRepo) UpsertPlan(_ context.Context, p *entity.SubscriptionPlan) error { s.plans = append(s.plans, *p) return nil } func (s *stubClientRepo) GetPasswordHash(_ context.Context, userID int64) (string, error) { if s.passwords == nil { return "", nil } return s.passwords[userID], nil } func (s *stubClientRepo) SetPasswordHash(_ context.Context, userID int64, hash string) error { if s.passwords == nil { s.passwords = map[int64]string{} } s.passwords[userID] = hash return nil } func (s *stubClientRepo) ListDeveloperApps(_ context.Context, code string, _ bool) ([]entity.DeveloperApp, error) { if s.devApps == nil { return nil, nil } return s.devApps[code], nil } func (s *stubClientRepo) UpsertDeveloperApp(_ context.Context, a *entity.DeveloperApp) error { if a != nil && a.ID == 0 { a.ID = 1 } return nil } func (s *stubClientRepo) DeleteDeveloperApp(_ context.Context, _ int64) error { return nil } type stubUserFinder struct { byEmail map[string]*userentity.User } func (s stubUserFinder) GetByEmail(_ context.Context, email string) (*userentity.User, error) { u := s.byEmail[userentity.NormalizeEmail(email)] if u == nil { return nil, errs.ErrNotFound } return u, nil } type stubOTP struct { user *userentity.User } func (s stubOTP) AuthenticateEmail(context.Context, string, string, *userentity.LoginDevice) (*userentity.User, error) { if s.user == nil { return nil, errs.ErrUnauthorized } return s.user, nil } func ctxLang(lang string) context.Context { return reqctx.With(context.Background(), &reqctx.RequestContext{Language: lang}) } func TestLegalPlansFeedbackFallback(t *testing.T) { svc := NewService(nil, nil, nil) legal, err := svc.LegalAgreements(t.Context()) if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" { t.Fatalf("legal %+v err=%v", legal, err) } plans, err := svc.SubscriptionPlans(t.Context()) if err != nil || len(plans.Plans) != 4 { t.Fatalf("plans %+v err=%v", plans, err) } fb, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "界面很好", Contact: "a@b.com"}) if err != nil || fb.ID == 0 || fb.AuthType != entity.FeedbackAuthGuest { t.Fatalf("feedback %+v err=%v", fb, err) } if _, err := svc.SubmitFeedback(t.Context(), FeedbackInput{Content: "缺联系方式"}); err == nil { t.Fatal("guest without contact should fail") } ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, AppVersion: "1.2.3"}) loginFB, err := svc.SubmitFeedback(ctx, FeedbackInput{Content: "登录用户反馈"}) if err != nil || loginFB.AuthType != entity.FeedbackAuthLogin || loginFB.UserID == nil || *loginFB.UserID != 42 { t.Fatalf("login feedback %+v err=%v", loginFB, err) } if loginFB.AppVersion != "1.2.3" { t.Fatalf("app_version from header want 1.2.3 got %q", loginFB.AppVersion) } apps, err := svc.DeveloperApps(t.Context(), "sleepheart", "") if err != nil || apps == nil || len(apps.Items) == 0 { t.Fatalf("developer apps %+v err=%v", apps, err) } if apps.Items[0].Name == "" || apps.Items[0].StoreURL == "" { t.Fatalf("developer app item incomplete %+v", apps.Items[0]) } } func TestClientServiceAdminAndRepo(t *testing.T) { repo := &stubClientRepo{ legal: map[string]map[string]*entity.LegalDocument{ string(i18n.ZhCN): { entity.DocPrivacy: {Key: entity.DocPrivacy, Lang: string(i18n.ZhCN), Title: "P", Content: "c", Version: "1"}, entity.DocTerms: {Key: entity.DocTerms, Lang: string(i18n.ZhCN), Title: "T", Content: "c", Version: "1"}, }, }, plans: []entity.SubscriptionPlan{{PlanID: "month", Lang: string(i18n.ZhCN), Title: "月", Enabled: true}}, } svc := NewService(repo, nil, nil) if !svc.Ready() { t.Fatal("ready") } ctx := ctxLang("en") bundle, err := svc.LegalAgreements(ctx) if err != nil || bundle.Privacy.Title != "P" { t.Fatalf("legal %+v %v", bundle, err) } if err := svc.UpsertLegalAdmin(ctx, &entity.LegalDocument{Key: "bad", Lang: "en", Title: "x", Content: "y"}); err == nil { t.Fatal("bad key") } if err := svc.UpsertLegalAdmin(ctx, &entity.LegalDocument{Key: entity.DocPrivacy, Lang: "en", Title: "Privacy", Content: "body"}); err != nil { t.Fatal(err) } list, total, err := svc.ListFeedbackAdmin(ctx, 0, 10, "") if err != nil || total != 0 || len(list) != 0 { t.Fatalf("feedback list %+v %d %v", list, total, err) } _, err = svc.SubmitFeedback(ctx, FeedbackInput{Content: "ok feedback", Contact: "guest@example.com"}) if err != nil { t.Fatal(err) } if err := svc.UpdateFeedbackStatus(ctx, 1, "closed"); err != nil { t.Fatal(err) } adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN)) if err != nil || len(adminPlans) != 1 { t.Fatalf("plans %+v %v", adminPlans, err) } if err := svc.UpsertPlanAdmin(ctx, &entity.SubscriptionPlan{PlanID: "week", Lang: "en", Title: "W", PriceCents: 100}); err != nil { t.Fatal(err) } if err := svc.UpsertPlanAdmin(ctx, &entity.SubscriptionPlan{PlanID: "bad", Lang: "en"}); err == nil { t.Fatal("bad plan") } } func TestClientPasswordFlow(t *testing.T) { repo := &stubClientRepo{} user := &userentity.User{ID: 42, Status: userentity.StatusActive} email := "real@example.com" svc := NewService(repo, stubUserFinder{byEmail: map[string]*userentity.User{ userentity.NormalizeEmail(email): user, }}, stubOTP{user: user}) ctx := ctxLang(string(i18n.ZhCN)) if err := svc.SetPassword(ctx, SetPasswordInput{Email: email, VerifyCode: "1", Password: "secret1"}); err != nil { t.Fatal(err) } got, err := svc.VerifyPassword(ctx, email, "secret1") if err != nil || got.ID != user.ID { t.Fatalf("verify %+v %v", got, err) } if _, err := svc.VerifyPassword(ctx, email, "wrong"); err == nil { t.Fatal("want auth fail") } testUser := &userentity.User{ID: 7, Status: userentity.StatusActive} testEmail := "test01@spark.com" svc2 := NewService(nil, stubUserFinder{byEmail: map[string]*userentity.User{ userentity.NormalizeEmail(testEmail): testUser, }}, nil) if _, err := svc2.VerifyPassword(ctx, testEmail, "any"); err != nil { t.Fatalf("test email skip password %v", err) } } func TestLooksLikeContact(t *testing.T) { if !looksLikeContact("a@b.com") || looksLikeContact("@bad") || !looksLikeContact("+86 13800138000") { t.Fatal("contact heuristics") } } func TestDeveloperAppsAdmin(t *testing.T) { repo := &stubClientRepo{ devApps: map[string][]entity.DeveloperApp{ "spark": {{ID: 9, SourceAppCode: "spark", TargetAppName: "X", TargetPackageName: "com.x", Enabled: true}}, }, } svc := NewService(repo, nil, nil) ctx := t.Context() list, err := svc.ListDeveloperAppsAdmin(ctx, "spark") if err != nil || len(list) != 1 { t.Fatalf("list %+v %v", list, err) } created, err := svc.CreateDeveloperAppAdmin(ctx, &entity.DeveloperApp{ SourceAppCode: "sleep-heart", TargetAppName: "New App", FallbackURL: "https://store.example/app", }) if err != nil || created == nil || created.SourceAppCode != "sleepheart" || created.TargetPackageName == "" { t.Fatalf("create %+v %v", created, err) } if _, err := svc.CreateDeveloperAppAdmin(ctx, nil); err == nil { t.Fatal("nil input") } if _, err := svc.CreateDeveloperAppAdmin(ctx, &entity.DeveloperApp{SourceAppCode: "spark"}); err == nil { t.Fatal("missing name") } n, err := svc.SyncWebsiteIconsAdmin(ctx) if err != nil || n < 10 { t.Fatalf("sync n=%d err=%v", n, err) } if err := svc.DeleteDeveloperAppAdmin(ctx, 0); err == nil { t.Fatal("bad id") } if err := svc.DeleteDeveloperAppAdmin(ctx, 9); err != nil { t.Fatal(err) } apps, err := svc.DeveloperApps(ctx, "spark", "com.x") if err != nil || len(apps.Items) != 0 { t.Fatalf("exclude package %+v %v", apps, err) } cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW))) if err != nil || len(cfg.Plans) == 0 { t.Fatalf("plans %+v %v", cfg, err) } }