feat: update skill fix friend request reject
This commit is contained in:
parent
1edcb959b7
commit
d5c0d81e22
15
.cursor/rules/git-commit-gate.mdc
Normal file
15
.cursor/rules/git-commit-gate.mdc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
description: Require make test before git commit; never bypass pre-commit without explicit user request
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git commit gate
|
||||||
|
|
||||||
|
Before **`git commit`** (including when the user asks the agent to commit):
|
||||||
|
|
||||||
|
1. From the repo root, run **`make test`** and confirm exit code **0** (`make test` already runs `make lint` first).
|
||||||
|
2. Only then stage and commit. Do **not** commit while tests are failing or were not run for this change set.
|
||||||
|
3. Do **not** use `git commit --no-verify` / `-n` unless the user **explicitly** requests skipping hooks in that message.
|
||||||
|
4. If the local repo has no pre-commit hook yet, run **`make install-git-hooks`** once (installs `scripts/git-hooks/pre-commit`).
|
||||||
|
|
||||||
|
Docs-only commits with zero compile/test surface may skip `make test` only when the change cannot affect build or CI (same bar as `verify-before-done.mdc`).
|
||||||
@ -11,5 +11,6 @@ Before writing code:
|
|||||||
5. MySQL: all tables `t_*`; migrations replay-all — details in `.cursor/skills/spark-im-v3/mysql.md`. After changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself.
|
5. MySQL: all tables `t_*`; migrations replay-all — details in `.cursor/skills/spark-im-v3/mysql.md`. After changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself.
|
||||||
6. Before finishing any code change, run `make test` at the repo root (runs `lint` first, logic coverage ≥65%). Do not report the task as OK until it passes. `make deploy` chains through `test`.
|
6. Before finishing any code change, run `make test` at the repo root (runs `lint` first, logic coverage ≥65%). Do not report the task as OK until it passes. `make deploy` chains through `test`.
|
||||||
7. After edits that touch the repo, load `.cursor/skills/daily-change-list/SKILL.md` and append `docs/daily/{yyyy}-{mm}-{dd}-change-list.md` before handoff.
|
7. After edits that touch the repo, load `.cursor/skills/daily-change-list/SKILL.md` and append `docs/daily/{yyyy}-{mm}-{dd}-change-list.md` before handoff.
|
||||||
|
8. Before **`git commit`**, `make test` must pass; install enforcement with **`make install-git-hooks`** (see `.cursor/rules/git-commit-gate.mdc`). Do not use `--no-verify` unless the user explicitly asks.
|
||||||
|
|
||||||
Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports.
|
Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports.
|
||||||
|
|||||||
10
Makefile
10
Makefile
@ -9,7 +9,7 @@ GOLANGCI_LINT := golangci-lint
|
|||||||
CONFIG ?= configs/config.yaml
|
CONFIG ?= configs/config.yaml
|
||||||
LOGIC_COVER_MIN ?= 65
|
LOGIC_COVER_MIN ?= 65
|
||||||
|
|
||||||
.PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback
|
.PHONY: all build image test lint fmt vet tidy migrate seed start run deploy deploy-rollback install-git-hooks
|
||||||
|
|
||||||
all: test build
|
all: test build
|
||||||
|
|
||||||
@ -77,3 +77,11 @@ deploy: test
|
|||||||
|
|
||||||
deploy-rollback:
|
deploy-rollback:
|
||||||
bash deploy/docker/rollback.sh
|
bash deploy/docker/rollback.sh
|
||||||
|
|
||||||
|
# Copy pre-commit hook: runs `make test` before every commit (see .cursor/rules/git-commit-gate.mdc).
|
||||||
|
install-git-hooks:
|
||||||
|
@test -d .git || { echo "not a git repository" >&2; exit 1; }
|
||||||
|
@mkdir -p .git/hooks
|
||||||
|
@cp scripts/git-hooks/pre-commit .git/hooks/pre-commit
|
||||||
|
@chmod +x scripts/git-hooks/pre-commit .git/hooks/pre-commit
|
||||||
|
@echo "Installed .git/hooks/pre-commit → make test"
|
||||||
|
|||||||
@ -76,3 +76,47 @@
|
|||||||
### 关联
|
### 关联
|
||||||
|
|
||||||
- Spark Messenger 全页面接入检查
|
- Spark Messenger 全页面接入检查
|
||||||
|
|
||||||
|
## 14:48 — 修复 make lint / make test
|
||||||
|
|
||||||
|
### 概要
|
||||||
|
|
||||||
|
- 修复 `friend_test` 中 `Accept` 双返回值与测试邮箱 OTP
|
||||||
|
- 修复 `client` 模块 goconst/gocritic/goimports;`UpsertLegal`/`UpsertPlan` 改为指针入参
|
||||||
|
- 补充 `client`/`auth` logic 单测以满足 65% 覆盖率门禁
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
- `internal/module/friend/logic/friend_test.go`
|
||||||
|
- `internal/module/client/logic/service.go`、`model/repo.go`
|
||||||
|
- `internal/module/client/logic/service_test.go`
|
||||||
|
- `internal/module/auth/logic/refresh_test.go`、`static_test.go`
|
||||||
|
- `internal/bootstrap/app.go`、`handler/friend/handler.go`、`handler/admin/client.go`
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `make lint`: pass
|
||||||
|
- `make test`: pass
|
||||||
|
|
||||||
|
## 15:22 — Git commit 前必须 make test
|
||||||
|
|
||||||
|
### 概要
|
||||||
|
|
||||||
|
- 新增 always-on 规则 `git-commit-gate.mdc`:Agent 与用户 commit 前须 `make test` 通过,禁止擅自 `--no-verify`
|
||||||
|
- 新增 `scripts/git-hooks/pre-commit` 与 `make install-git-hooks` 安装到 `.git/hooks`
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
- `.cursor/rules/git-commit-gate.mdc`
|
||||||
|
- `scripts/git-hooks/pre-commit`
|
||||||
|
- `Makefile`、`AGENTS.md`
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `make test`: pass
|
||||||
|
|
||||||
|
### 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install-git-hooks # 本机只需一次
|
||||||
|
```
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import (
|
|||||||
adminlogic "spark-server/internal/module/admin/logic"
|
adminlogic "spark-server/internal/module/admin/logic"
|
||||||
adminmodel "spark-server/internal/module/admin/model"
|
adminmodel "spark-server/internal/module/admin/model"
|
||||||
authlogic "spark-server/internal/module/auth/logic"
|
authlogic "spark-server/internal/module/auth/logic"
|
||||||
|
clientlogic "spark-server/internal/module/client/logic"
|
||||||
|
clientmodel "spark-server/internal/module/client/model"
|
||||||
convlogic "spark-server/internal/module/conversation/logic"
|
convlogic "spark-server/internal/module/conversation/logic"
|
||||||
convmodel "spark-server/internal/module/conversation/model"
|
convmodel "spark-server/internal/module/conversation/model"
|
||||||
deliverlogic "spark-server/internal/module/deliver/logic"
|
deliverlogic "spark-server/internal/module/deliver/logic"
|
||||||
@ -43,8 +45,6 @@ import (
|
|||||||
momentmodel "spark-server/internal/module/moment/model"
|
momentmodel "spark-server/internal/module/moment/model"
|
||||||
musiclogic "spark-server/internal/module/music/logic"
|
musiclogic "spark-server/internal/module/music/logic"
|
||||||
musicmodel "spark-server/internal/module/music/model"
|
musicmodel "spark-server/internal/module/music/model"
|
||||||
clientlogic "spark-server/internal/module/client/logic"
|
|
||||||
clientmodel "spark-server/internal/module/client/model"
|
|
||||||
smslogic "spark-server/internal/module/sms/logic"
|
smslogic "spark-server/internal/module/sms/logic"
|
||||||
squarelogic "spark-server/internal/module/square/logic"
|
squarelogic "spark-server/internal/module/square/logic"
|
||||||
squaremodel "spark-server/internal/module/square/model"
|
squaremodel "spark-server/internal/module/square/model"
|
||||||
|
|||||||
44
internal/module/auth/logic/refresh_test.go
Normal file
44
internal/module/auth/logic/refresh_test.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParsePairKey(t *testing.T) {
|
||||||
|
did, aid, ok := parsePairKey("1:9:2:3", "1:")
|
||||||
|
if !ok || did != 9 || aid != 2 {
|
||||||
|
t.Fatalf("triple %+v %v %v", did, aid, ok)
|
||||||
|
}
|
||||||
|
did, aid, ok = parsePairKey("1:9:0", "1:")
|
||||||
|
if !ok || did != 9 || aid != 0 {
|
||||||
|
t.Fatalf("pair mode key %+v %v %v", did, aid, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := parsePairKey("x", "p:"); ok {
|
||||||
|
t.Fatal("bad key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisPairsDeleteAllOnDeviceNil(t *testing.T) {
|
||||||
|
p := NewRedisPairs(nil, "")
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := p.DeleteAllOnDevice(ctx, 1, 1); err == nil {
|
||||||
|
t.Fatal("expected unavailable")
|
||||||
|
}
|
||||||
|
if _, err := p.ListByUser(ctx, 1); err == nil {
|
||||||
|
t.Fatal("expected list error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPairsDeleteAllOnDevicePairMode(t *testing.T) {
|
||||||
|
p := NewMemoryPairsMode(PairKeyPair)
|
||||||
|
ctx := t.Context()
|
||||||
|
_ = p.Put(ctx, 1, 9, 0, TokenPair{Access: "a", Refresh: "r"}, time.Minute)
|
||||||
|
if err := p.DeleteAllOnDevice(ctx, 1, 9); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := p.Get(ctx, 1, 9, 0)
|
||||||
|
if err != nil || got.Access != "" {
|
||||||
|
t.Fatalf("cleared %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
internal/module/auth/logic/static_test.go
Normal file
55
internal/module/auth/logic/static_test.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStaticUserAuthenticateEmail(t *testing.T) {
|
||||||
|
s := StaticUser{Email: "ops@spark.com", Code: "42", UserID: 99, SparkID: "sp99"}
|
||||||
|
ctx := context.Background()
|
||||||
|
u, err := s.AuthenticateEmail(ctx, "ops@spark.com", "42", nil)
|
||||||
|
if err != nil || u.ID != 99 || u.SparkID != "sp99" {
|
||||||
|
t.Fatalf("ok %+v %v", u, err)
|
||||||
|
}
|
||||||
|
if _, err := s.AuthenticateEmail(ctx, "other@spark.com", "42", nil); err == nil {
|
||||||
|
t.Fatal("wrong email")
|
||||||
|
}
|
||||||
|
if _, err := s.AuthenticateEmail(ctx, "ops@spark.com", "bad", nil); err == nil {
|
||||||
|
t.Fatal("wrong code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRedisPairsDefaultPrefix(t *testing.T) {
|
||||||
|
p := NewRedisPairsMode(nil, "", PairKeyTriple)
|
||||||
|
if p.prefix != "refresh:" {
|
||||||
|
t.Fatalf("prefix %q", p.prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPairsPutSkipsInvalid(t *testing.T) {
|
||||||
|
p := NewMemoryPairs()
|
||||||
|
ctx := t.Context()
|
||||||
|
if err := p.Put(ctx, 0, 1, 1, TokenPair{Access: "a"}, time.Minute); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := p.Put(ctx, 1, 0, 1, TokenPair{Access: "a"}, time.Minute); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := p.Get(ctx, 1, 1, 1)
|
||||||
|
if err != nil || got.Access != "" {
|
||||||
|
t.Fatalf("empty %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPairsListByUserDropsExpired(t *testing.T) {
|
||||||
|
p := NewMemoryPairs()
|
||||||
|
ctx := t.Context()
|
||||||
|
_ = p.Put(ctx, 3, 4, 1, TokenPair{Access: "x"}, time.Millisecond)
|
||||||
|
time.Sleep(3 * time.Millisecond)
|
||||||
|
rows, err := p.ListByUser(ctx, 3)
|
||||||
|
if err != nil || len(rows) != 0 {
|
||||||
|
t.Fatalf("expired listed %+v %v", rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,13 +18,13 @@ import (
|
|||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error)
|
GetLegal(ctx context.Context, lang string) (privacy, terms *entity.LegalDocument, err error)
|
||||||
UpsertLegal(ctx context.Context, d entity.LegalDocument) error
|
UpsertLegal(ctx context.Context, d *entity.LegalDocument) error
|
||||||
InsertFeedback(ctx context.Context, f *entity.Feedback) error
|
InsertFeedback(ctx context.Context, f *entity.Feedback) error
|
||||||
ListFeedback(ctx context.Context, page, size int) ([]entity.Feedback, int64, error)
|
ListFeedback(ctx context.Context, page, size int) ([]entity.Feedback, int64, error)
|
||||||
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
|
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
|
||||||
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
||||||
ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
||||||
UpsertPlan(ctx context.Context, p entity.SubscriptionPlan) error
|
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
|
||||||
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
||||||
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
||||||
}
|
}
|
||||||
@ -70,8 +70,8 @@ func (s *Service) LegalAgreements(ctx context.Context) (*entity.LegalBundle, err
|
|||||||
if err == nil && privacy != nil && terms != nil {
|
if err == nil && privacy != nil && terms != nil {
|
||||||
return &entity.LegalBundle{Privacy: *privacy, Terms: *terms}, nil
|
return &entity.LegalBundle{Privacy: *privacy, Terms: *terms}, nil
|
||||||
}
|
}
|
||||||
if lang != "zh-CN" {
|
if lang != string(i18n.ZhCN) {
|
||||||
privacy, terms, err = s.repo.GetLegal(ctx, "zh-CN")
|
privacy, terms, err = s.repo.GetLegal(ctx, string(i18n.ZhCN))
|
||||||
if err == nil && privacy != nil && terms != nil {
|
if err == nil && privacy != nil && terms != nil {
|
||||||
return &entity.LegalBundle{Privacy: *privacy, Terms: *terms}, nil
|
return &entity.LegalBundle{Privacy: *privacy, Terms: *terms}, nil
|
||||||
}
|
}
|
||||||
@ -80,10 +80,13 @@ func (s *Service) LegalAgreements(ctx context.Context) (*entity.LegalBundle, err
|
|||||||
return defaultLegal(lang), nil
|
return defaultLegal(lang), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpsertLegalAdmin(ctx context.Context, d entity.LegalDocument) error {
|
func (s *Service) UpsertLegalAdmin(ctx context.Context, d *entity.LegalDocument) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return errs.ErrAvailable
|
return errs.ErrAvailable
|
||||||
}
|
}
|
||||||
|
if d == nil {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
d.Key = strings.TrimSpace(d.Key)
|
d.Key = strings.TrimSpace(d.Key)
|
||||||
d.Lang = normalizeLang(d.Lang)
|
d.Lang = normalizeLang(d.Lang)
|
||||||
if d.Key != entity.DocPrivacy && d.Key != entity.DocTerms {
|
if d.Key != entity.DocPrivacy && d.Key != entity.DocTerms {
|
||||||
@ -98,8 +101,11 @@ func (s *Service) SubmitFeedback(ctx context.Context, in FeedbackInput) (*entity
|
|||||||
}
|
}
|
||||||
f := &entity.Feedback{
|
f := &entity.Feedback{
|
||||||
Email: strings.TrimSpace(strings.ToLower(in.Email)),
|
Email: strings.TrimSpace(strings.ToLower(in.Email)),
|
||||||
Content: strings.TrimSpace(in.Content), AppVersion: strings.TrimSpace(in.AppVersion),
|
Content: strings.TrimSpace(in.Content),
|
||||||
Language: normalizeLang(reqctx.Language(ctx)), Status: "open", CreatedAt: time.Now(),
|
AppVersion: strings.TrimSpace(in.AppVersion),
|
||||||
|
Language: normalizeLang(reqctx.Language(ctx)),
|
||||||
|
Status: "open",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if rc := reqctx.From(ctx); rc != nil && rc.UserID > 0 {
|
if rc := reqctx.From(ctx); rc != nil && rc.UserID > 0 {
|
||||||
uid := rc.UserID
|
uid := rc.UserID
|
||||||
@ -141,8 +147,8 @@ func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionCo
|
|||||||
if err == nil && len(plans) > 0 {
|
if err == nil && len(plans) > 0 {
|
||||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
||||||
}
|
}
|
||||||
if lang != "zh-CN" {
|
if lang != string(i18n.ZhCN) {
|
||||||
plans, err = s.repo.ListPlans(ctx, "zh-CN")
|
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN))
|
||||||
if err == nil && len(plans) > 0 {
|
if err == nil && len(plans) > 0 {
|
||||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
||||||
}
|
}
|
||||||
@ -158,10 +164,13 @@ func (s *Service) ListPlansAdmin(ctx context.Context, lang string) ([]entity.Sub
|
|||||||
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang))
|
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpsertPlanAdmin(ctx context.Context, p entity.SubscriptionPlan) error {
|
func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPlan) error {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return errs.ErrAvailable
|
return errs.ErrAvailable
|
||||||
}
|
}
|
||||||
|
if p == nil {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
p.Lang = normalizeLang(p.Lang)
|
p.Lang = normalizeLang(p.Lang)
|
||||||
p.PlanID = strings.TrimSpace(p.PlanID)
|
p.PlanID = strings.TrimSpace(p.PlanID)
|
||||||
switch p.PlanID {
|
switch p.PlanID {
|
||||||
@ -249,7 +258,7 @@ func normalizeLang(lang string) string {
|
|||||||
case "zh-TW", "zh-HK":
|
case "zh-TW", "zh-HK":
|
||||||
return "zh-TW"
|
return "zh-TW"
|
||||||
default:
|
default:
|
||||||
return "zh-CN"
|
return string(i18n.ZhCN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -264,9 +273,9 @@ func defaultLegal(lang string) *entity.LegalBundle {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return &entity.LegalBundle{
|
return &entity.LegalBundle{
|
||||||
Privacy: entity.LegalDocument{Key: entity.DocPrivacy, Lang: "zh-CN", Title: "隐私政策",
|
Privacy: entity.LegalDocument{Key: entity.DocPrivacy, Lang: string(i18n.ZhCN), Title: "隐私政策",
|
||||||
Content: "SleepHeart 收集邮箱与设备数据用于登录与推荐,不会出售个人信息。您可随时注销账号。", Version: "1.0"},
|
Content: "SleepHeart 收集邮箱与设备数据用于登录与推荐,不会出售个人信息。您可随时注销账号。", Version: "1.0"},
|
||||||
Terms: entity.LegalDocument{Key: entity.DocTerms, Lang: "zh-CN", Title: "用户协议",
|
Terms: entity.LegalDocument{Key: entity.DocTerms, Lang: string(i18n.ZhCN), Title: "用户协议",
|
||||||
Content: "使用 SleepHeart 即表示同意社区规范。会员权益以订阅页展示为准。", Version: "1.0"},
|
Content: "使用 SleepHeart 即表示同意社区规范。会员权益以订阅页展示为准。", Version: "1.0"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,137 @@
|
|||||||
package logic
|
package logic
|
||||||
|
|
||||||
import "testing"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ([]entity.Feedback, int64, error) {
|
||||||
|
return s.feedback, int64(len(s.feedback)), 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestLegalPlansFeedbackFallback(t *testing.T) {
|
||||||
svc := NewService(nil, nil, nil)
|
svc := NewService(nil, nil, nil)
|
||||||
@ -17,3 +148,79 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
|
|||||||
t.Fatalf("feedback %+v err=%v", fb, err)
|
t.Fatalf("feedback %+v err=%v", fb, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -90,7 +91,10 @@ func (r *Repo) GetLegal(ctx context.Context, lang string) (privacy, terms *entit
|
|||||||
return privacy, terms, nil
|
return privacy, terms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) UpsertLegal(ctx context.Context, d entity.LegalDocument) error {
|
func (r *Repo) UpsertLegal(ctx context.Context, d *entity.LegalDocument) error {
|
||||||
|
if d == nil {
|
||||||
|
return errors.New("client: nil legal document")
|
||||||
|
}
|
||||||
row := legalRow{
|
row := legalRow{
|
||||||
DocKey: d.Key, Lang: d.Lang, Title: d.Title, Content: d.Content,
|
DocKey: d.Key, Lang: d.Lang, Title: d.Title, Content: d.Content,
|
||||||
Version: d.Version, UpdatedAt: time.Now(),
|
Version: d.Version, UpdatedAt: time.Now(),
|
||||||
@ -135,7 +139,8 @@ func (r *Repo) ListFeedback(ctx context.Context, page, size int) ([]entity.Feedb
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
out := make([]entity.Feedback, 0, len(rows))
|
out := make([]entity.Feedback, 0, len(rows))
|
||||||
for _, row := range rows {
|
for i := range rows {
|
||||||
|
row := &rows[i]
|
||||||
out = append(out, entity.Feedback{
|
out = append(out, entity.Feedback{
|
||||||
ID: row.ID, UserID: row.UserID, Email: row.Email, Content: row.Content,
|
ID: row.ID, UserID: row.UserID, Email: row.Email, Content: row.Content,
|
||||||
AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt,
|
AppVersion: row.AppVersion, Language: row.Language, Status: row.Status, CreatedAt: row.CreatedAt,
|
||||||
@ -174,7 +179,8 @@ func (r *Repo) ListPlansAdmin(ctx context.Context, lang string) ([]entity.Subscr
|
|||||||
|
|
||||||
func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
||||||
out := make([]entity.SubscriptionPlan, 0, len(rows))
|
out := make([]entity.SubscriptionPlan, 0, len(rows))
|
||||||
for _, row := range rows {
|
for i := range rows {
|
||||||
|
row := &rows[i]
|
||||||
out = append(out, entity.SubscriptionPlan{
|
out = append(out, entity.SubscriptionPlan{
|
||||||
PlanID: row.PlanID, Lang: row.Lang, Title: row.Title, Subtitle: row.Subtitle,
|
PlanID: row.PlanID, Lang: row.Lang, Title: row.Title, Subtitle: row.Subtitle,
|
||||||
PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency,
|
PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency,
|
||||||
@ -185,7 +191,10 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) UpsertPlan(ctx context.Context, p entity.SubscriptionPlan) error {
|
func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error {
|
||||||
|
if p == nil {
|
||||||
|
return errors.New("client: nil subscription plan")
|
||||||
|
}
|
||||||
row := planRow{
|
row := planRow{
|
||||||
PlanID: p.PlanID, Lang: p.Lang, Title: p.Title, Subtitle: p.Subtitle,
|
PlanID: p.PlanID, Lang: p.Lang, Title: p.Title, Subtitle: p.Subtitle,
|
||||||
PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency,
|
PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency,
|
||||||
|
|||||||
@ -288,11 +288,11 @@ func TestSystemGreeterWritesMessage(t *testing.T) {
|
|||||||
func TestRejectFriendRequest(t *testing.T) {
|
func TestRejectFriendRequest(t *testing.T) {
|
||||||
svc, users, _ := testFriends(t)
|
svc, users, _ := testFriends(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
a, err := users.AuthenticateEmail(ctx, "rej-a@spark.com", "000000", &userentity.LoginDevice{DeviceID: 1, AppID: 1, DeviceType: "ios"})
|
a, err := users.AuthenticateEmail(ctx, "test03@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, AppID: 1, DeviceType: "ios"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
b, err := users.AuthenticateEmail(ctx, "rej-b@spark.com", "000000", &userentity.LoginDevice{DeviceID: 2, AppID: 1, DeviceType: "ios"})
|
b, err := users.AuthenticateEmail(ctx, "test04@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, AppID: 1, DeviceType: "ios"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -306,7 +306,7 @@ func TestRejectFriendRequest(t *testing.T) {
|
|||||||
if err := svc.Reject(ctx, b.ID, RejectInput{RequestID: out.RequestID}); err != nil {
|
if err := svc.Reject(ctx, b.ID, RejectInput{RequestID: out.RequestID}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: out.RequestID}); err == nil {
|
if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: out.RequestID}); err == nil {
|
||||||
t.Fatal("expected accept after reject to fail")
|
t.Fatal("expected accept after reject to fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ func (h *ClientHandler) PutLegal(c *gin.Context) {
|
|||||||
Fail(c, http.StatusBadRequest, "无效 JSON")
|
Fail(c, http.StatusBadRequest, "无效 JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.svc.UpsertLegalAdmin(c.Request.Context(), body); err != nil {
|
if err := h.svc.UpsertLegalAdmin(c.Request.Context(), &body); err != nil {
|
||||||
Fail(c, http.StatusBadRequest, err.Error())
|
Fail(c, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -77,7 +77,7 @@ func (h *ClientHandler) PutPlan(c *gin.Context) {
|
|||||||
Fail(c, http.StatusBadRequest, "无效 JSON")
|
Fail(c, http.StatusBadRequest, "无效 JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.svc.UpsertPlanAdmin(c.Request.Context(), body); err != nil {
|
if err := h.svc.UpsertPlanAdmin(c.Request.Context(), &body); err != nil {
|
||||||
Fail(c, http.StatusBadRequest, err.Error())
|
Fail(c, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,7 +65,6 @@ func (h *Handler) ListRequests(c *gin.Context) {
|
|||||||
respond.JSON(c, out)
|
respond.JSON(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func (h *Handler) Reject(c *gin.Context) {
|
func (h *Handler) Reject(c *gin.Context) {
|
||||||
uid, ok := currentUID(c)
|
uid, ok := currentUID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
9
scripts/git-hooks/pre-commit
Executable file
9
scripts/git-hooks/pre-commit
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs before every git commit. Install: make install-git-hooks
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "pre-commit: make test (lint + go test + logic coverage)…" >&2
|
||||||
|
make test
|
||||||
Loading…
Reference in New Issue
Block a user