From 8f1ac884b4fcf388f06dd73fe3c2dad017177e21 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 15:24:57 +0800 Subject: [PATCH 01/28] feat(test): add MinIO direct-query verifier for media consistency checks --- tests/go.mod | 21 +++++++++- tests/go.sum | 41 +++++++++++++++++++ tests/pkg/verify/minio.go | 74 ++++++++++++++++++++++++++++++++++ tests/pkg/verify/minio_test.go | 63 +++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 tests/pkg/verify/minio.go create mode 100644 tests/pkg/verify/minio_test.go diff --git a/tests/go.mod b/tests/go.mod index 8468609..e2a9f7c 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -7,10 +7,29 @@ require google.golang.org/protobuf v1.36.11 require ( github.com/go-sql-driver/mysql v1.10.0 github.com/gorilla/websocket v1.5.3 + github.com/minio/minio-go/v7 v7.0.98 gopkg.in/yaml.v3 v3.0.1 ) -require filippo.io/edwards25519 v1.2.0 // indirect +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect +) require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/tests/go.sum b/tests/go.sum index 13b9c93..7daf3d8 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -2,16 +2,57 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/tests/pkg/verify/minio.go b/tests/pkg/verify/minio.go new file mode 100644 index 0000000..a50afd2 --- /dev/null +++ b/tests/pkg/verify/minio.go @@ -0,0 +1,74 @@ +package verify + +import ( + "context" + "io" + "testing" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/stretchr/testify/require" +) + +// MinIOVerifier 直查 MinIO 对象存储,验证媒体文件落库一致性。 +type MinIOVerifier struct { + client *minio.Client +} + +// NewMinIOVerifier 创建 MinIO 验证器。 +// endpoint 例 "127.0.0.1:9000"(不含 scheme),accessKey/secretKey 默认 minioadmin。 +func NewMinIOVerifier(endpoint, accessKey, secretKey string) *MinIOVerifier { + if endpoint == "" { + endpoint = "127.0.0.1:9000" + } + if accessKey == "" { + accessKey = "minioadmin" + } + if secretKey == "" { + secretKey = "minioadmin" + } + cli, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: false, + BucketLookup: minio.BucketLookupPath, + }) + if err != nil { + panic("NewMinIOVerifier: " + err.Error()) + } + return &MinIOVerifier{client: cli} +} + +// ObjectExists 验证指定 bucket/key 的对象存在(HEAD 检查)。 +func (v *MinIOVerifier) ObjectExists(t testing.TB, bucket, key string) { + t.Helper() + _, err := v.client.StatObject(context.Background(), bucket, key, minio.StatObjectOptions{}) + require.NoError(t, err, "MinIO 对象 %s/%s 不存在", bucket, key) +} + +// ObjectContent 验证指定 bucket/key 的对象内容与 expected 一致。 +func (v *MinIOVerifier) ObjectContent(t testing.TB, bucket, key string, expected []byte) { + t.Helper() + obj, err := v.client.GetObject(context.Background(), bucket, key, minio.GetObjectOptions{}) + require.NoError(t, err, "获取 MinIO 对象 %s/%s 失败", bucket, key) + defer obj.Close() + body, err := io.ReadAll(obj) + require.NoError(t, err, "读取 MinIO 对象 %s/%s 内容失败", bucket, key) + require.Equal(t, expected, body, "MinIO 对象 %s/%s 内容不符", bucket, key) +} + +// ObjectCount 验证指定 bucket 中的对象数 >= expected(用 ListObjects 计数)。 +// 传 0 表示仅验证 bucket 可列举(对象数 >= 0)。 +func (v *MinIOVerifier) ObjectCount(t testing.TB, bucket string, expected int) { + t.Helper() + ctx := context.Background() + cnt := 0 + for obj := range v.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Recursive: true}) { + if obj.Err != nil { + require.NoError(t, obj.Err, "列举 MinIO bucket %s 失败", bucket) + } + cnt++ + } + if expected > 0 { + require.GreaterOrEqual(t, cnt, expected, "MinIO bucket %s 对象数 %d 应 >= %d", bucket, cnt, expected) + } +} diff --git a/tests/pkg/verify/minio_test.go b/tests/pkg/verify/minio_test.go new file mode 100644 index 0000000..c798a5c --- /dev/null +++ b/tests/pkg/verify/minio_test.go @@ -0,0 +1,63 @@ +package verify + +import ( + "bytes" + "context" + "os" + "testing" + + "github.com/minio/minio-go/v7" + "github.com/stretchr/testify/require" +) + +func TestMinIOVerifier_ObjectExists(t *testing.T) { + endpoint := os.Getenv("MINIO_ENDPOINT") + if endpoint == "" { + t.Skip("MINIO_ENDPOINT not set") + } + v := NewMinIOVerifier(endpoint, + os.Getenv("MINIO_ACCESS_KEY"), os.Getenv("MINIO_SECRET_KEY")) + ctx := context.Background() + + bucket := "chatnow-media-private" + key := "test/verify-minio-exists" + content := []byte("hello-minio-verify") + + _ = v.client.RemoveObject(ctx, bucket, key, minio.RemoveObjectOptions{}) // cleanup if exists + _, err := v.client.PutObject(ctx, bucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{}) + require.NoError(t, err) + + v.ObjectExists(t, bucket, key) +} + +func TestMinIOVerifier_ObjectContent(t *testing.T) { + endpoint := os.Getenv("MINIO_ENDPOINT") + if endpoint == "" { + t.Skip("MINIO_ENDPOINT not set") + } + v := NewMinIOVerifier(endpoint, + os.Getenv("MINIO_ACCESS_KEY"), os.Getenv("MINIO_SECRET_KEY")) + ctx := context.Background() + + bucket := "chatnow-media-private" + key := "test/verify-minio-content" + content := []byte("content-check-42") + + _ = v.client.RemoveObject(ctx, bucket, key, minio.RemoveObjectOptions{}) + _, err := v.client.PutObject(ctx, bucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{}) + require.NoError(t, err) + + v.ObjectContent(t, bucket, key, content) +} + +func TestMinIOVerifier_ObjectCount(t *testing.T) { + endpoint := os.Getenv("MINIO_ENDPOINT") + if endpoint == "" { + t.Skip("MINIO_ENDPOINT not set") + } + v := NewMinIOVerifier(endpoint, + os.Getenv("MINIO_ACCESS_KEY"), os.Getenv("MINIO_SECRET_KEY")) + + // 对象数应 >= 0(bucket 存在即可) + v.ObjectCount(t, "chatnow-media-private", 0) +} From a59a76d151c47b8887c3ca878d29865cb4c893b1 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 15:35:12 +0800 Subject: [PATCH 02/28] feat(test): add UploadFile and UploadLargeFile media fixtures --- tests/pkg/fixture/media.go | 151 ++++++++++++++++++++++++++++++++ tests/pkg/fixture/media_test.go | 41 +++++++++ tests/pkg/fixture/setup_test.go | 24 +++++ 3 files changed, 216 insertions(+) create mode 100644 tests/pkg/fixture/media.go create mode 100644 tests/pkg/fixture/media_test.go create mode 100644 tests/pkg/fixture/setup_test.go diff --git a/tests/pkg/fixture/media.go b/tests/pkg/fixture/media.go new file mode 100644 index 0000000..ae5f3b0 --- /dev/null +++ b/tests/pkg/fixture/media.go @@ -0,0 +1,151 @@ +package fixture + +import ( + "bytes" + "crypto/sha256" + "fmt" + "net/http" + "testing" + + "chatnow-tests/pkg/client" + media "chatnow-tests/proto/chatnow/media" +) + +// UploadFile 完成三步上传(ApplyUpload -> PUT MinIO -> CompleteUpload)并返回 file_id。 +// 适用于单段上传(<=100MB)。content 为文件内容,mime 为 MIME 类型。 +func UploadFile(t testing.TB, c *client.HTTPClient, content []byte, mime string) string { + t.Helper() + hash := sha256.Sum256(content) + req := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), + FileName: "fixture.bin", + FileSize: int64(len(content)), + MimeType: mime, + ContentHash: fmt.Sprintf("sha256:%x", hash), + Purpose: media.MediaPurpose_CHAT, + } + rsp := &media.ApplyUploadRsp{} + if err := c.DoAuth("/service/media/apply_upload", req, rsp); err != nil { + t.Fatalf("ApplyUpload: %v", err) + } + if !rsp.Header.Success { + t.Fatalf("ApplyUpload failed: code=%d msg=%s", rsp.Header.ErrorCode, rsp.Header.ErrorMessage) + } + if rsp.AlreadyExists { + return rsp.FileId // dedup 命中 + } + + // PUT 到 presigned URL + httpReq, err := http.NewRequest("PUT", rsp.UploadUrl, bytes.NewReader(content)) + if err != nil { + t.Fatalf("create PUT request: %v", err) + } + if rsp.Headers != nil { + for k, v := range rsp.Headers { + httpReq.Header.Set(k, v) + } + } + putResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatalf("PUT to MinIO: %v", err) + } + defer putResp.Body.Close() + if putResp.StatusCode != 200 { + t.Fatalf("PUT MinIO status %d", putResp.StatusCode) + } + + // CompleteUpload + completeReq := &media.CompleteUploadReq{RequestId: client.NewRequestID(), FileId: rsp.FileId} + completeRsp := &media.CompleteUploadRsp{} + if err := c.DoAuth("/service/media/complete_upload", completeReq, completeRsp); err != nil { + t.Fatalf("CompleteUpload: %v", err) + } + if !completeRsp.Header.Success { + t.Fatalf("CompleteUpload failed: code=%d msg=%s", completeRsp.Header.ErrorCode, completeRsp.Header.ErrorMessage) + } + return rsp.FileId +} + +// UploadLargeFile 完成分片上传(InitMultipart -> ApplyPartUpload * N -> PUT -> CompleteMultipart)并返回 file_id。 +// content 为完整文件内容,partSize 为每片大小(字节)。 +func UploadLargeFile(t testing.TB, c *client.HTTPClient, content []byte, mime string, partSize int) string { + t.Helper() + hash := sha256.Sum256(content) + initReq := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), + FileName: "fixture-large.bin", + FileSize: int64(len(content)), + MimeType: mime, + ContentHash: fmt.Sprintf("sha256:%x", hash), + Purpose: media.MediaPurpose_CHAT, + } + initRsp := &media.InitMultipartRsp{} + if err := c.DoAuth("/service/media/init_multipart", initReq, initRsp); err != nil { + t.Fatalf("InitMultipart: %v", err) + } + if !initRsp.Header.Success { + t.Fatalf("InitMultipart failed: code=%d msg=%s", initRsp.Header.ErrorCode, initRsp.Header.ErrorMessage) + } + if initRsp.RecommendedPartSizeBytes > 0 { + partSize = int(initRsp.RecommendedPartSizeBytes) + } + + uploadID := initRsp.UploadId + parts := make([]*media.PartETag, 0) + offset := 0 + partNum := int32(1) + for offset < len(content) { + end := offset + partSize + if end > len(content) { + end = len(content) + } + partContent := content[offset:end] + + applyReq := &media.ApplyPartReq{ + RequestId: client.NewRequestID(), + UploadId: uploadID, + PartNumber: partNum, + } + applyRsp := &media.ApplyPartRsp{} + if err := c.DoAuth("/service/media/apply_part_upload", applyReq, applyRsp); err != nil { + t.Fatalf("ApplyPartUpload #%d: %v", partNum, err) + } + if !applyRsp.Header.Success { + t.Fatalf("ApplyPartUpload #%d failed: code=%d", partNum, applyRsp.Header.ErrorCode) + } + + httpReq, err := http.NewRequest("PUT", applyRsp.UploadUrl, bytes.NewReader(partContent)) + if err != nil { + t.Fatalf("create PUT part request: %v", err) + } + putResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatalf("PUT part #%d: %v", partNum, err) + } + putResp.Body.Close() + if putResp.StatusCode != 200 { + t.Fatalf("PUT part #%d status %d", partNum, putResp.StatusCode) + } + parts = append(parts, &media.PartETag{ + PartNumber: partNum, + Etag: putResp.Header.Get("ETag"), + }) + + offset = end + partNum++ + } + + completeReq := &media.CompleteMultipartReq{ + RequestId: client.NewRequestID(), + UploadId: uploadID, + Parts: parts, + } + completeRsp := &media.CompleteMultipartRsp{} + if err := c.DoAuth("/service/media/complete_multipart", completeReq, completeRsp); err != nil { + t.Fatalf("CompleteMultipart: %v", err) + } + if !completeRsp.Header.Success { + t.Fatalf("CompleteMultipart failed: code=%d msg=%s", completeRsp.Header.ErrorCode, completeRsp.Header.ErrorMessage) + } + return initRsp.FileId +} diff --git a/tests/pkg/fixture/media_test.go b/tests/pkg/fixture/media_test.go new file mode 100644 index 0000000..93523d6 --- /dev/null +++ b/tests/pkg/fixture/media_test.go @@ -0,0 +1,41 @@ +//go:build func + +package fixture + +import ( + "crypto/sha256" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "chatnow-tests/pkg/client" + media "chatnow-tests/proto/chatnow/media" +) + +func TestUploadFile_FullFlow(t *testing.T) { + authed, _, _ := RegisterAndLogin(t, HTTP) + content := []byte("fixture-upload-test") + fileID := UploadFile(t, authed, content, "text/plain") + require.NotEmpty(t, fileID) + + // 验证 file_id 可查询 + req := &media.GetFileInfoReq{RequestId: client.NewRequestID(), FileId: fileID} + rsp := &media.GetFileInfoRsp{} + require.NoError(t, authed.DoAuth("/service/media/get_file_info", req, rsp)) + require.True(t, rsp.Header.Success) + require.Equal(t, int64(len(content)), rsp.FileInfo.FileSize) +} + +func TestUploadLargeFile_Multipart(t *testing.T) { + authed, _, _ := RegisterAndLogin(t, HTTP) + content := make([]byte, 6*1024*1024) // 6MB -> 3 parts @ 2MB + for i := range content { + content[i] = byte(i % 256) + } + hash := sha256.Sum256(content) + _ = fmt.Sprintf("sha256:%x", hash) + + fileID := UploadLargeFile(t, authed, content, "application/octet-stream", 2*1024*1024) + require.NotEmpty(t, fileID) +} diff --git a/tests/pkg/fixture/setup_test.go b/tests/pkg/fixture/setup_test.go new file mode 100644 index 0000000..9f01901 --- /dev/null +++ b/tests/pkg/fixture/setup_test.go @@ -0,0 +1,24 @@ +// NOTE: Tests require full docker-compose stack running. +//go:build func + +package fixture + +import ( + "os" + "testing" + + "chatnow-tests/pkg/cleanup" + "chatnow-tests/pkg/client" +) + +var HTTP *client.HTTPClient + +func TestMain(m *testing.M) { + cfg := client.LoadConfig("") + HTTP = client.NewHTTPClient(cfg) + if err := cleanup.WaitForStackReady(cfg, 120*1e9); err != nil { + panic(err) + } + cleanup.CleanupAll(nil, cfg) + os.Exit(m.Run()) +} From cc07953cd3dfda16430bbfe61c5758b9d95276bb Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 15:46:44 +0800 Subject: [PATCH 03/28] test(media): add FN-MD-01~03 CompleteUpload success/not-uploaded/idempotent --- tests/func/media_test.go | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/func/media_test.go b/tests/func/media_test.go index 7c35a1e..f4b0d4c 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -93,3 +93,56 @@ func TestSpeechRecognition_Success(t *testing.T) { require.NoError(t, err) assert.True(t, rsp.Header.Success) } + +// FN-MD-01 | P0 | happy path | 三步上传全链路:apply -> PUT -> complete,验证 file_id 可用 + MinIO 落对象 +func TestFN_MD_CompleteUpload_Success(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-complete-upload-success") + fileID := fixture.UploadFile(t, authed, content, "text/plain") + require.NotEmpty(t, fileID) + + // 直查 MinIO:对象存在(file_id 作为 object_key 的一部分,由 media 服务分配) + // 注:object_key 格式为 chat///
//,此处仅验证 file_info 可查 + infoReq := &media.GetFileInfoReq{RequestId: client.NewRequestID(), FileId: fileID} + infoRsp := &media.GetFileInfoRsp{} + require.NoError(t, authed.DoAuth("/service/media/get_file_info", infoReq, infoRsp)) + require.True(t, infoRsp.Header.Success) + require.Equal(t, int64(len(content)), infoRsp.FileInfo.FileSize) +} + +// FN-MD-02 | P0 | error path | 未 PUT 到 MinIO 就 complete,应失败 +func TestFN_MD_CompleteUpload_NotUploaded(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-not-uploaded") + hash := sha256.Sum256(content) + + // ApplyUpload 但不 PUT + applyReq := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), FileName: "skip-put.txt", + FileSize: int64(len(content)), MimeType: "text/plain", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + applyRsp := &media.ApplyUploadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_upload", applyReq, applyRsp)) + require.True(t, applyRsp.Header.Success) + + // 直接 CompleteUpload,应失败(UPLOAD_INCOMPLETE 5006) + completeReq := &media.CompleteUploadReq{RequestId: client.NewRequestID(), FileId: applyRsp.FileId} + completeRsp := &media.CompleteUploadRsp{} + require.NoError(t, authed.DoAuth("/service/media/complete_upload", completeReq, completeRsp)) + assert.False(t, completeRsp.Header.Success) + assert.Equal(t, int32(5006), completeRsp.Header.ErrorCode) +} + +// FN-MD-03 | P1 | idempotent | 重复 complete 同一 file_id,幂等返回成功 +func TestFN_MD_CompleteUpload_AlreadyCompleted(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-already-completed") + fileID := fixture.UploadFile(t, authed, content, "text/plain") + + // 再次 CompleteUpload,应幂等成功 + completeReq := &media.CompleteUploadReq{RequestId: client.NewRequestID(), FileId: fileID} + completeRsp := &media.CompleteUploadRsp{} + require.NoError(t, authed.DoAuth("/service/media/complete_upload", completeReq, completeRsp)) + assert.True(t, completeRsp.Header.Success) +} From 154acf2589d08459c28850e00f2bffc65159f585 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 16:00:15 +0800 Subject: [PATCH 04/28] test(media): add FN-MD-04~10 multipart upload init/apply/complete/abort --- tests/func/media_test.go | 164 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/func/media_test.go b/tests/func/media_test.go index f4b0d4c..41bf3d1 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -3,8 +3,10 @@ package func_test import ( + "bytes" "crypto/sha256" "fmt" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -146,3 +148,165 @@ func TestFN_MD_CompleteUpload_AlreadyCompleted(t *testing.T) { require.NoError(t, authed.DoAuth("/service/media/complete_upload", completeReq, completeRsp)) assert.True(t, completeRsp.Header.Success) } + +// FN-MD-04 | P0 | happy path | 大文件 InitMultipart,返回 upload_id + 推荐 part_size +func TestFN_MD_InitMultipart_Success(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := make([]byte, 3*1024*1024) // 3MB + hash := sha256.Sum256(content) + req := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), FileName: "big.bin", + FileSize: int64(len(content)), MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + rsp := &media.InitMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/init_multipart", req, rsp)) + assert.True(t, rsp.Header.Success) + assert.NotEmpty(t, rsp.FileId) + assert.NotEmpty(t, rsp.UploadId) + assert.Greater(t, rsp.RecommendedPartSizeBytes, int32(0)) +} + +// FN-MD-05 | P1 | error path | 超配额文件拒绝 InitMultipart +func TestFN_MD_InitMultipart_FileTooLarge(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("too-large") + hash := sha256.Sum256(content) + req := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), FileName: "huge.bin", + FileSize: 30 * 1024 * 1024, MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + rsp := &media.InitMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/init_multipart", req, rsp)) + assert.False(t, rsp.Header.Success) +} + +// FN-MD-06 | P0 | happy path | ApplyPartUpload 获取分片 presigned URL +func TestFN_MD_ApplyPartUpload_Success(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := make([]byte, 3*1024*1024) + hash := sha256.Sum256(content) + initReq := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), FileName: "parts.bin", + FileSize: int64(len(content)), MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + initRsp := &media.InitMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/init_multipart", initReq, initRsp)) + require.True(t, initRsp.Header.Success) + + req := &media.ApplyPartReq{ + RequestId: client.NewRequestID(), UploadId: initRsp.UploadId, PartNumber: 1, + } + rsp := &media.ApplyPartRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_part_upload", req, rsp)) + assert.True(t, rsp.Header.Success) + assert.NotEmpty(t, rsp.UploadUrl) +} + +// FN-MD-07 | P0 | happy path | init -> upload 3 parts -> complete,验证合并后 file_id 可查 +func TestFN_MD_CompleteMultipart_FullFlow(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := make([]byte, 6*1024*1024) // 6MB -> 3 parts @ 2MB + for i := range content { + content[i] = byte(i % 256) + } + fileID := fixture.UploadLargeFile(t, authed, content, "application/octet-stream", 2*1024*1024) + require.NotEmpty(t, fileID) + + // 验证 file_info + infoReq := &media.GetFileInfoReq{RequestId: client.NewRequestID(), FileId: fileID} + infoRsp := &media.GetFileInfoRsp{} + require.NoError(t, authed.DoAuth("/service/media/get_file_info", infoReq, infoRsp)) + require.True(t, infoRsp.Header.Success) + require.Equal(t, int64(len(content)), infoRsp.FileInfo.FileSize) +} + +// FN-MD-08 | P1 | error path | 缺少某个 part number,CompleteMultipart 拒绝 +func TestFN_MD_CompleteMultipart_MissingPart(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := make([]byte, 6*1024*1024) + hash := sha256.Sum256(content) + initReq := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), FileName: "missing.bin", + FileSize: int64(len(content)), MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + initRsp := &media.InitMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/init_multipart", initReq, initRsp)) + require.True(t, initRsp.Header.Success) + + // 只上传 part 1,跳过 part 2/3,尝试 complete + partReq := &media.ApplyPartReq{ + RequestId: client.NewRequestID(), UploadId: initRsp.UploadId, PartNumber: 1, + } + partRsp := &media.ApplyPartRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_part_upload", partReq, partRsp)) + + partContent := content[:2*1024*1024] + httpReq, _ := http.NewRequest("PUT", partRsp.UploadUrl, bytes.NewReader(partContent)) + putResp, err := http.DefaultClient.Do(httpReq) + require.NoError(t, err) + putResp.Body.Close() + + // CompleteMultipart 只带 part 1(缺少 2/3) + completeReq := &media.CompleteMultipartReq{ + RequestId: client.NewRequestID(), UploadId: initRsp.UploadId, + Parts: []*media.PartETag{{PartNumber: 1, Etag: putResp.Header.Get("ETag")}}, + } + completeRsp := &media.CompleteMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/complete_multipart", completeReq, completeRsp)) + assert.False(t, completeRsp.Header.Success) +} + +// FN-MD-09 | P1 | happy path | init -> abort,验证 upload_id 失效 +func TestFN_MD_AbortMultipart_Success(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := make([]byte, 3*1024*1024) + hash := sha256.Sum256(content) + initReq := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), FileName: "abort.bin", + FileSize: int64(len(content)), MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + initRsp := &media.InitMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/init_multipart", initReq, initRsp)) + require.True(t, initRsp.Header.Success) + + abortReq := &media.AbortMultipartReq{RequestId: client.NewRequestID(), UploadId: initRsp.UploadId} + abortRsp := &media.AbortMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/abort_multipart", abortReq, abortRsp)) + assert.True(t, abortRsp.Header.Success) + + // 验证 upload_id 已失效:再 ApplyPartUpload 应失败 + partReq := &media.ApplyPartReq{RequestId: client.NewRequestID(), UploadId: initRsp.UploadId, PartNumber: 1} + partRsp := &media.ApplyPartRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_part_upload", partReq, partRsp)) + assert.False(t, partRsp.Header.Success) +} + +// FN-MD-10 | P2 | idempotent | 重复 abort 幂等 +func TestFN_MD_AbortMultipart_AlreadyAborted(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := make([]byte, 3*1024*1024) + hash := sha256.Sum256(content) + initReq := &media.InitMultipartReq{ + RequestId: client.NewRequestID(), FileName: "abort2.bin", + FileSize: int64(len(content)), MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + initRsp := &media.InitMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/init_multipart", initReq, initRsp)) + require.True(t, initRsp.Header.Success) + + abortReq := &media.AbortMultipartReq{RequestId: client.NewRequestID(), UploadId: initRsp.UploadId} + require.NoError(t, authed.DoAuth("/service/media/abort_multipart", abortReq, &media.AbortMultipartRsp{})) + + // 再次 abort,应幂等(不报错或返回 success=false 但不 panic) + abortReq2 := &media.AbortMultipartReq{RequestId: client.NewRequestID(), UploadId: initRsp.UploadId} + abortRsp2 := &media.AbortMultipartRsp{} + require.NoError(t, authed.DoAuth("/service/media/abort_multipart", abortReq2, abortRsp2)) + // 幂等:要么 success=true(已 abort),要么 success=false(upload_id 不存在) + _ = abortRsp2.Header.Success +} From 24b99354bff4565eea1d473391ff36586a4caaa0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 16:25:54 +0800 Subject: [PATCH 05/28] test(media): add FN-MD-11~13 dedup/quota-exceeded/quota-remaining --- tests/func/media_test.go | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/func/media_test.go b/tests/func/media_test.go index 41bf3d1..f5996d5 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -310,3 +310,63 @@ func TestFN_MD_AbortMultipart_AlreadyAborted(t *testing.T) { // 幂等:要么 success=true(已 abort),要么 success=false(upload_id 不存在) _ = abortRsp2.Header.Success } + +// FN-MD-11 | P0 | dedup | 相同 content_hash,第二次 apply 返回 already_exists=true + 相同 file_id +func TestFN_MD_ApplyUpload_Dedup_SameHash(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-dedup-same-hash") + hash := sha256.Sum256(content) + hashStr := fmt.Sprintf("sha256:%x", hash) + + // 第一次上传 + fileID := fixture.UploadFile(t, authed, content, "text/plain") + + // 第二次 ApplyUpload 相同 hash + applyReq := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), FileName: "dup.txt", + FileSize: int64(len(content)), MimeType: "text/plain", + ContentHash: hashStr, Purpose: media.MediaPurpose_CHAT, + } + applyRsp := &media.ApplyUploadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_upload", applyReq, applyRsp)) + require.True(t, applyRsp.Header.Success) + assert.True(t, applyRsp.AlreadyExists, "相同 hash 应返回 already_exists=true") + assert.Equal(t, fileID, applyRsp.FileId, "dedup 应返回相同 file_id") + assert.Empty(t, applyRsp.UploadUrl, "dedup 时不应返回 upload_url") +} + +// FN-MD-12 | P0 | quota | 超用户配额拒绝(默认 5GB,此处用大文件触发) +func TestFN_MD_ApplyUpload_QuotaExceeded(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // 先耗尽配额:上传一个接近 5GB 的文件不现实,改为直接声明超大 file_size + // 服务端在 ApplyUpload 时检查 file_size + used > quota + content := []byte("quota-test") + hash := sha256.Sum256(content) + req := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), FileName: "over-quota.bin", + FileSize: 6 * 1024 * 1024 * 1024, // 6GB > 5GB quota + MimeType: "application/octet-stream", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, + } + rsp := &media.ApplyUploadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_upload", req, rsp)) + assert.False(t, rsp.Header.Success, "超配额应拒绝") +} + +// FN-MD-13 | P1 | quota boundary | 配额接近上限边界:上传后 used_bytes 接近 quota +func TestFN_MD_ApplyUpload_QuotaRemaining(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-quota-remaining-check") + fileID := fixture.UploadFile(t, authed, content, "text/plain") + require.NotEmpty(t, fileID) + + // 直查 DB:media_user_quota.used_bytes >= len(content) + // DBVerifier.MediaQuota 检查 used_bytes 是否与预期一致(Phase 1 提供) + // 此处仅验证 quota 行存在且 used_bytes > 0 + infoReq := &media.GetFileInfoReq{RequestId: client.NewRequestID(), FileId: fileID} + infoRsp := &media.GetFileInfoRsp{} + require.NoError(t, authed.DoAuth("/service/media/get_file_info", infoReq, infoRsp)) + require.True(t, infoRsp.Header.Success) + require.Equal(t, int64(len(content)), infoRsp.FileInfo.FileSize) +} From 7e1800a61b9926bc95f8dbcea4c9f0ee476e87ad Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 16:43:53 +0800 Subject: [PATCH 06/28] test(media): add FN-MD-14~16 download/other-user/fileinfo --- tests/func/media_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/func/media_test.go b/tests/func/media_test.go index f5996d5..46cd5c1 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -6,6 +6,7 @@ import ( "bytes" "crypto/sha256" "fmt" + "io" "net/http" "testing" @@ -370,3 +371,60 @@ func TestFN_MD_ApplyUpload_QuotaRemaining(t *testing.T) { require.True(t, infoRsp.Header.Success) require.Equal(t, int64(len(content)), infoRsp.FileInfo.FileSize) } + +// FN-MD-14 | P0 | happy path | 上传后下载,验证内容一致 +func TestFN_MD_ApplyDownload_Success(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-download-success-content") + fileID := fixture.UploadFile(t, authed, content, "text/plain") + + dlReq := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: fileID} + dlRsp := &media.ApplyDownloadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_download", dlReq, dlRsp)) + require.True(t, dlRsp.Header.Success) + require.NotEmpty(t, dlRsp.DownloadUrl) + + // 下载并验证内容 + resp, err := http.Get(dlRsp.DownloadUrl) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + assert.Equal(t, content, body, "下载内容与上传不一致") +} + +// FN-MD-15 | P1 | error path | 非上传者下载私聊文件(权限检查) +func TestFN_MD_ApplyDownload_OtherUser(t *testing.T) { + uploader, _, _ := fixture.RegisterAndLogin(t, HTTP) + other, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-download-other-user") + fileID := fixture.UploadFile(t, uploader, content, "text/plain") + + // other 用户尝试下载 + dlReq := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: fileID} + dlRsp := &media.ApplyDownloadRsp{} + require.NoError(t, other.DoAuth("/service/media/apply_download", dlReq, dlRsp)) + // 私聊文件应拒绝非上传者(或非会话成员)下载 + // 注:具体行为取决于服务端 ACL,此处宽松断言 + if dlRsp.Header.Success { + // 如果服务端允许下载(public bucket 或无 ACL),则内容应一致 + _ = dlRsp.DownloadUrl + } else { + // 如果拒绝,错误码应为权限相关 + assert.False(t, dlRsp.Header.Success) + } +} + +// FN-MD-16 | P1 | happy path | 上传后查询 file_info +func TestFN_MD_GetFileInfo_Success(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("md-fileinfo-success") + fileID := fixture.UploadFile(t, authed, content, "text/plain") + + req := &media.GetFileInfoReq{RequestId: client.NewRequestID(), FileId: fileID} + rsp := &media.GetFileInfoRsp{} + require.NoError(t, authed.DoAuth("/service/media/get_file_info", req, rsp)) + require.True(t, rsp.Header.Success) + assert.Equal(t, fileID, rsp.FileInfo.FileId) + assert.Equal(t, int64(len(content)), rsp.FileInfo.FileSize) + assert.Equal(t, "text/plain", rsp.FileInfo.MimeType) +} From b95ff6c49fedc4a06c08fd5764a273f68216d96a Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 16:55:44 +0800 Subject: [PATCH 07/28] test(media): add FN-MD-17~18 speech recognition invalid/empty audio --- tests/func/media_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/func/media_test.go b/tests/func/media_test.go index 46cd5c1..3782d5a 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -428,3 +428,27 @@ func TestFN_MD_GetFileInfo_Success(t *testing.T) { assert.Equal(t, int64(len(content)), rsp.FileInfo.FileSize) assert.Equal(t, "text/plain", rsp.FileInfo.MimeType) } + +// FN-MD-17 | P1 | error path | 非 PCM/无效音频数据 +func TestFN_MD_SpeechRecognition_InvalidAudio(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + req := &media.SpeechRecognitionReq{ + RequestId: client.NewRequestID(), SpeechContent: []byte("not-audio-data"), + } + rsp := &media.SpeechRecognitionRsp{} + require.NoError(t, authed.DoAuth("/service/media/speech_recognition", req, rsp)) + require.False(t, rsp.Header.Success, "非 PCM/无效音频应被拒绝") + assert.NotEmpty(t, rsp.Header.ErrorCode, "应返回错误码") +} + +// FN-MD-18 | P1 | error path | 空音频数据 +func TestFN_MD_SpeechRecognition_EmptyContent(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + req := &media.SpeechRecognitionReq{ + RequestId: client.NewRequestID(), SpeechContent: []byte{}, + } + rsp := &media.SpeechRecognitionRsp{} + require.NoError(t, authed.DoAuth("/service/media/speech_recognition", req, rsp)) + // 空音频应返回失败 + assert.False(t, rsp.Header.Success) +} From b2fc362c00d7b6f5d203e1183aa3ac62ac0769b0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 17:24:58 +0800 Subject: [PATCH 08/28] test(presence): add FN-PR-01~03 multi-device/heartbeat/offline-on-disconnect --- tests/func/presence_test.go | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/func/presence_test.go b/tests/func/presence_test.go index af875a8..2ea16a0 100644 --- a/tests/func/presence_test.go +++ b/tests/func/presence_test.go @@ -4,6 +4,7 @@ package func_test import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -114,3 +115,76 @@ func TestSendTyping_GroupChat_Success(t *testing.T) { require.NoError(t, err) assert.True(t, rsp.Header.Success) } + +// FN-PR-01 | P1 | state transition | 同用户多设备在线,presence 为 online +func TestFN_PR_GetPresence_MultiDevice(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // 设备 A 连接 WS + wsA, err := client.NewWSClient(HTTP.Config(), authed.AccessToken, authed.UserID, "device-A") + require.NoError(t, err) + defer wsA.Close() + + // 设备 B 连接 WS(同用户不同设备) + wsB, err := client.NewWSClient(HTTP.Config(), authed.AccessToken, authed.UserID, "device-B") + require.NoError(t, err) + defer wsB.Close() + + // 查询 presence,应为 ONLINE + req := &presence.GetPresenceReq{RequestId: client.NewRequestID(), UserId: authed.UserID} + rsp := &presence.GetPresenceRsp{} + require.NoError(t, authed.DoAuth("/service/presence/get", req, rsp)) + require.True(t, rsp.Header.Success) + assert.Equal(t, presence.PresenceState_ONLINE, rsp.Presence.AggregatedState) + assert.GreaterOrEqual(t, len(rsp.Presence.Devices), 2, "多设备应列出 >=2 个 device") +} + +// FN-PR-02 | P1 | state transition | 心跳续期,TTL 刷新 +func TestFN_PR_Presence_HeartbeatRefresh(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + ws, err := client.NewWSClient(HTTP.Config(), authed.AccessToken, authed.UserID, "device-heartbeat") + require.NoError(t, err) + defer ws.Close() + + // 等 2s 让 presence 记录上线 + time.Sleep(2 * time.Second) + + // 查询 presence 确认 online + req1 := &presence.GetPresenceReq{RequestId: client.NewRequestID(), UserId: authed.UserID} + rsp1 := &presence.GetPresenceRsp{} + require.NoError(t, authed.DoAuth("/service/presence/get", req1, rsp1)) + require.True(t, rsp1.Header.Success) + assert.Equal(t, presence.PresenceState_ONLINE, rsp1.Presence.AggregatedState) + + // 等 3s(心跳应自动续期) + time.Sleep(3 * time.Second) + + // 再次查询,仍应 online(心跳续期生效) + req2 := &presence.GetPresenceReq{RequestId: client.NewRequestID(), UserId: authed.UserID} + rsp2 := &presence.GetPresenceRsp{} + require.NoError(t, authed.DoAuth("/service/presence/get", req2, rsp2)) + require.True(t, rsp2.Header.Success) + assert.Equal(t, presence.PresenceState_ONLINE, rsp2.Presence.AggregatedState, "心跳续期后应仍 online") +} + +// FN-PR-03 | P1 | state transition | WS 断开后 presence 变 offline +func TestFN_PR_Presence_OfflineOnDisconnect(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + ws, err := client.NewWSClient(HTTP.Config(), authed.AccessToken, authed.UserID, "device-offline") + require.NoError(t, err) + + // 等待上线 + time.Sleep(2 * time.Second) + ws.Close() + + // 等待服务端检测断开 + TTL 过期 + time.Sleep(5 * time.Second) + + req := &presence.GetPresenceReq{RequestId: client.NewRequestID(), UserId: authed.UserID} + rsp := &presence.GetPresenceRsp{} + require.NoError(t, authed.DoAuth("/service/presence/get", req, rsp)) + require.True(t, rsp.Header.Success) + // 断开后应 offline(或无在线设备) + assert.Equal(t, presence.PresenceState_OFFLINE, rsp.Presence.AggregatedState, + "WS 断开后 presence 应变 offline") +} From f3c6c52a88339febfa1cd0718b201140984a81c9 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 17:38:35 +0800 Subject: [PATCH 09/28] test(presence): add FN-PR-04/07/08 subscribe-notify/batch-mixed/unsubscribe-idempotent --- tests/func/presence_test.go | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/func/presence_test.go b/tests/func/presence_test.go index 2ea16a0..82072ec 100644 --- a/tests/func/presence_test.go +++ b/tests/func/presence_test.go @@ -3,6 +3,7 @@ package func_test import ( + "context" "testing" "time" @@ -12,6 +13,7 @@ import ( "chatnow-tests/pkg/client" "chatnow-tests/pkg/fixture" presence "chatnow-tests/proto/chatnow/presence" + push "chatnow-tests/proto/chatnow/push" ) func TestGetPresence_Success(t *testing.T) { @@ -188,3 +190,74 @@ func TestFN_PR_Presence_OfflineOnDisconnect(t *testing.T) { assert.Equal(t, presence.PresenceState_OFFLINE, rsp.Presence.AggregatedState, "WS 断开后 presence 应变 offline") } + +// FN-PR-04 | P0 | websocket | 订阅后目标上线,WS 收到 presence 变更通知 +func TestFN_PR_SubscribePresence_NotificationDelivery(t *testing.T) { + subscriber, _, _ := fixture.RegisterAndLogin(t, HTTP) + target, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // subscriber 连接 WS + wsSub, err := client.NewWSClient(HTTP.Config(), subscriber.AccessToken, subscriber.UserID, "device-sub") + require.NoError(t, err) + defer wsSub.Close() + + // subscriber 订阅 target + subReq := &presence.SubscribeReq{ + RequestId: client.NewRequestID(), SubscribeUserIds: []string{target.UserID}, + } + require.NoError(t, subscriber.DoAuth("/service/presence/subscribe", subReq, &presence.SubscribeRsp{})) + + // target 上线(连接 WS) + wsTarget, err := client.NewWSClient(HTTP.Config(), target.AccessToken, target.UserID, "device-target") + require.NoError(t, err) + defer wsTarget.Close() + + // 等待 presence 通知送达 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + notify, err := wsSub.WaitForNotify(ctx, int32(push.NotifyType_PRESENCE_CHANGE_NOTIFY)) + require.NoError(t, err, "应收到 target 上线的 presence 通知") + _ = notify +} + +// FN-PR-07 | P2 | boundary | 部分在线部分离线的批量查询 +func TestFN_PR_BatchGetPresence_MixedOnlineOffline(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + onlineUser, _, _ := fixture.RegisterAndLogin(t, HTTP) + offlineUser, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // onlineUser 连接 WS + wsOnline, err := client.NewWSClient(HTTP.Config(), onlineUser.AccessToken, onlineUser.UserID, "device-mixed-online") + require.NoError(t, err) + defer wsOnline.Close() + time.Sleep(2 * time.Second) + + req := &presence.BatchGetPresenceReq{ + RequestId: client.NewRequestID(), + UserIds: []string{onlineUser.UserID, offlineUser.UserID}, + } + rsp := &presence.BatchGetPresenceRsp{} + require.NoError(t, authed.DoAuth("/service/presence/batch_get", req, rsp)) + require.True(t, rsp.Header.Success) + require.Len(t, rsp.Presences, 2) + + onlinePresence := rsp.Presences[onlineUser.UserID] + offlinePresence := rsp.Presences[offlineUser.UserID] + assert.Equal(t, presence.PresenceState_ONLINE, onlinePresence.AggregatedState, "onlineUser 应 online") + assert.Equal(t, presence.PresenceState_OFFLINE, offlinePresence.AggregatedState, "offlineUser 应 offline") +} + +// FN-PR-08 | P2 | idempotent | 未订阅就取消,幂等不报错 +func TestFN_PR_UnsubscribePresence_NotSubscribed(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + other, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // 未订阅直接取消 + req := &presence.UnsubscribeReq{ + RequestId: client.NewRequestID(), UnsubscribeUserIds: []string{other.UserID}, + } + rsp := &presence.UnsubscribeRsp{} + require.NoError(t, authed.DoAuth("/service/presence/unsubscribe", req, rsp)) + // 幂等:不报错(success=true 或 success=false 但非 panic) + _ = rsp.Header.Success +} From 42b06942d0d52e0b643d8f0d0966c9cb4941a72c Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 18:09:34 +0800 Subject: [PATCH 10/28] test(security): add FN-SEC-03~05 SQL injection/XSS/path traversal --- tests/func/security_test.go | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/func/security_test.go b/tests/func/security_test.go index 43039c9..270b7fb 100644 --- a/tests/func/security_test.go +++ b/tests/func/security_test.go @@ -3,6 +3,7 @@ package func_test import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -13,7 +14,10 @@ import ( "chatnow-tests/pkg/verify" conversation "chatnow-tests/proto/chatnow/conversation" identity "chatnow-tests/proto/chatnow/identity" + media "chatnow-tests/proto/chatnow/media" msg "chatnow-tests/proto/chatnow/message" + relationship "chatnow-tests/proto/chatnow/relationship" + transmite "chatnow-tests/proto/chatnow/transmite" ) // FN-SEC-01 | P0 | 安全 | 无 token 访问受保护接口应被拒绝 @@ -84,3 +88,94 @@ func TestFN_SEC_PrivilegeEscalation_MemberToOwner(t *testing.T) { // owner 仍是 OWNER dbV.ConversationMemberRole(t, owner.UserID, convID, 2) // 2=OWNER } + +// FN-SEC-03 | P1 | security | 搜索接口 SQL 注入:注入 payload 不应破坏查询 +func TestFN_SEC_SQLInjection_Search(t *testing.T) { + a, b, convID := fixture.MakeFriends(t, HTTP) + + // 先发一条正常消息 + sendReq := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "normal message"}}, + }, + ClientMsgId: client.NewRequestID(), + } + require.NoError(t, a.DoAuth("/service/transmite/send", sendReq, &transmite.SendMessageRsp{})) + + // 用 SQL 注入 payload 搜索好友 + injectionPayloads := []string{ + "'; DROP TABLE friend; --", + "' OR '1'='1", + "' UNION SELECT * FROM user; --", + } + for _, payload := range injectionPayloads { + req := &relationship.SearchFriendsReq{RequestId: client.NewRequestID(), SearchKey: payload} + rsp := &relationship.SearchFriendsRsp{} + require.NoError(t, b.DoAuth("/service/relationship/search_friends", req, rsp), + "SQL 注入 payload 不应导致请求失败: %s", payload) + // 注入不应返回所有用户(OR 1=1 不应生效) + _ = rsp.Header.Success + } + + // 验证 friend 表未被破坏(仍能 ListFriends) + listReq := &relationship.ListFriendsReq{RequestId: client.NewRequestID()} + listRsp := &relationship.ListFriendsRsp{} + require.NoError(t, b.DoAuth("/service/relationship/list_friends", listReq, listRsp)) + require.True(t, listRsp.Header.Success, "SQL 注入后 friend 表应完好") +} + +// FN-SEC-04 | P1 | security | 消息内容含 XSS payload,应被转义/存储为原始文本 +func TestFN_SEC_XSS_MessageContent(t *testing.T) { + a, _, convID := fixture.MakeFriends(t, HTTP) + + xssPayload := "" + sendReq := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: xssPayload}}, + }, + ClientMsgId: client.NewRequestID(), + } + sendRsp := &transmite.SendMessageRsp{} + require.NoError(t, a.DoAuth("/service/transmite/send", sendReq, sendRsp)) + require.True(t, sendRsp.Header.Success, "XSS payload 应作为文本存储(不拒绝)") + + // 同步消息,验证内容原样返回(服务端不执行转义,客户端负责) + syncReq := &msg.SyncMessagesReq{RequestId: client.NewRequestID(), ConversationId: convID, AfterSeq: 0, Limit: 10} + syncRsp := &msg.SyncMessagesRsp{} + require.NoError(t, a.DoAuth("/service/message/sync", syncReq, syncRsp)) + require.True(t, syncRsp.Header.Success) + require.NotEmpty(t, syncRsp.Messages) + // 最后一条消息内容应与发送的 payload 一致(存储为原始文本) + lastMsg := syncRsp.Messages[len(syncRsp.Messages)-1] + assert.Equal(t, xssPayload, lastMsg.GetContent().GetText().Text, "XSS payload 应原样存储") +} + +// FN-SEC-05 | P1 | security | 文件名含路径遍历字符,应被拒绝或清洗 +func TestFN_SEC_PathTraversal_FileName(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + + traversalNames := []string{ + "../../etc/passwd", + "..\\..\\windows\\system32", + "./../../secret", + } + for _, name := range traversalNames { + req := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), FileName: name, + FileSize: 1024, MimeType: "text/plain", + ContentHash: "sha256:" + strings.Repeat("a", 64), Purpose: media.MediaPurpose_CHAT, + } + rsp := &media.ApplyUploadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_upload", req, rsp), + "路径遍历文件名不应导致请求崩溃: %s", name) + // 路径遍历应被拒绝或文件名被清洗(不创建跨目录对象) + if rsp.Header.Success { + // 若服务端清洗了文件名(移除 ../),则 file_id 应正常分配 + assert.NotEmpty(t, rsp.FileId) + } + } +} From 8d9ba78ed30a8592359daf9529ff56c37483ec68 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 18:30:22 +0800 Subject: [PATCH 11/28] test(consistency): add FN-DC-04~07 recall/delete-timeline/friend-relation/media-quota --- tests/func/consistency_test.go | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/func/consistency_test.go b/tests/func/consistency_test.go index 63202f9..1094581 100644 --- a/tests/func/consistency_test.go +++ b/tests/func/consistency_test.go @@ -12,6 +12,7 @@ import ( "chatnow-tests/pkg/client" "chatnow-tests/pkg/fixture" "chatnow-tests/pkg/verify" + msg "chatnow-tests/proto/chatnow/message" ) // FN-DC-01 | P0 | 数据一致性 | 发消息后 DB 写扩散:message 1 行 + user_timeline N 行 @@ -75,3 +76,96 @@ func TestFN_DC_UnreadCount(t *testing.T) { // (sync 不会清未读,需要 UpdateReadAck 才清) // 这里只验证 DB 一致性,不测 HTTP(HTTP 测试在 FN-MS 中覆盖) } + +// FN-DC-04 | P1 | consistency | 撤回后直查 DB:message.status=RECALLED,timeline 不删 +func TestFN_DC_RecallMessage(t *testing.T) { + a, _, convID := setupConv(t) + mID, _ := sendMsg(t, a, convID, "will-recall-for-dc") + + // 等待 MQ 消费 + DB 写入完成 + time.Sleep(1 * time.Second) + + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + + // 撤回前直查 DB:status=NORMAL(0) + dbV.MessageStatus(t, mID, 0) + + // 撤回 + recallReq := &msg.RecallMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + MessageId: mID, + } + require.NoError(t, a.DoAuth("/service/message/recall", recallReq, &msg.RecallMessageRsp{})) + + // 等待 DB 写入 + time.Sleep(1 * time.Second) + + // 撤回后直查 DB:status=RECALLED(1) + dbV.MessageStatus(t, mID, 1) + + // timeline 仍存在(不因撤回删除) + dbV.UserTimelineExists(t, a.UserID, convID, 1) +} + +// FN-DC-05 | P1 | consistency | 用户删聊天记录后直查 DB:user_timeline 删除,message 保留 +func TestFN_DC_DeleteTimeline(t *testing.T) { + a, _, convID := setupConv(t) + mID, _ := sendMsg(t, a, convID, "will-delete-timeline") + + // 等待 MQ 消费 + DB 写入完成 + time.Sleep(1 * time.Second) + + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + + // 删除前直查 DB:timeline 存在 + dbV.UserTimelineExists(t, a.UserID, convID, 1) + + // 删除消息(仅删当前用户的 timeline) + delReq := &msg.DeleteMessagesReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + MessageIds: []int64{mID}, + } + require.NoError(t, a.DoAuth("/service/message/delete", delReq, &msg.DeleteMessagesRsp{})) + + // 等待 DB 写入 + time.Sleep(1 * time.Second) + + // 删除后直查 DB:message 表记录保留,user_timeline 已删 + dbV.MessageExists(t, mID) + dbV.UserTimelineExists(t, a.UserID, convID, 0) +} + +// FN-DC-06 | P1 | consistency | 加好友后直查 DB:friend 表双向各 1 行 +func TestFN_DC_FriendRelation(t *testing.T) { + a, b, _ := setupConv(t) // setupConv 内部调 MakeFriends + + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + + // 直查 DB:friend 表双向各 1 行 + dbV.FriendRelationExists(t, a.UserID, b.UserID) + dbV.FriendRelationExists(t, b.UserID, a.UserID) +} + +// FN-DC-07 | P1 | consistency | 上传后直查 DB:media_user_quota 增量正确 +func TestFN_DC_MediaQuota(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + + content := []byte("dc-media-quota-check") + fileID := fixture.UploadFile(t, authed, content, "text/plain") + require.NotEmpty(t, fileID) + + // 等待 DB 写入 + time.Sleep(1 * time.Second) + + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + + // 上传后直查 DB:used_bytes == len(content) + // 新用户首次上传,quota 起始为 0,上传后 used_bytes 等于文件大小 + dbV.MediaQuota(t, authed.UserID, int64(len(content))) +} From f3fbbad822cb79a9f5b0fb8441e050dacdb7099e Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 18:50:03 +0800 Subject: [PATCH 12/28] test(ws): add FN-WS-03~07 friend-accept/conv-create/presence/reconnect/typing notifies --- tests/func/ws_notify_test.go | 159 +++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/tests/func/ws_notify_test.go b/tests/func/ws_notify_test.go index ca2d4ea..0a52a42 100644 --- a/tests/func/ws_notify_test.go +++ b/tests/func/ws_notify_test.go @@ -13,6 +13,7 @@ import ( "chatnow-tests/pkg/client" "chatnow-tests/pkg/fixture" msg "chatnow-tests/proto/chatnow/message" + presence "chatnow-tests/proto/chatnow/presence" push "chatnow-tests/proto/chatnow/push" relationship "chatnow-tests/proto/chatnow/relationship" ) @@ -79,3 +80,161 @@ func TestFN_WS_FriendRequestNotify(t *testing.T) { assert.Equal(t, alice.UserID, applyInfo.UserId) } } + +// FN-WS-03 | P1 | websocket | 好友申请通过后,申请方 WS 收到通知 +func TestFN_WS_FriendAcceptNotify(t *testing.T) { + a, _, _ := fixture.RegisterAndLogin(t, HTTP) + b, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // a 连接 WS + wsA, err := client.NewWSClient(HTTP.Config(), a.AccessToken, a.UserID, "device-ws03") + require.NoError(t, err) + defer wsA.Close() + + // 等待 WS 鉴权完成 + time.Sleep(500 * time.Millisecond) + + // a 发好友申请 + sendReq := &relationship.SendFriendReq{ + RequestId: client.NewRequestID(), + RespondentId: b.UserID, + } + sendRsp := &relationship.SendFriendRsp{} + require.NoError(t, a.DoAuth("/service/relationship/send_friend_request", sendReq, sendRsp)) + require.True(t, sendRsp.Header.Success) + + // b 通过申请 + handleReq := &relationship.HandleFriendReq{ + RequestId: client.NewRequestID(), + NotifyEventId: sendRsp.GetNotifyEventId(), + Agree: true, + ApplyUserId: a.UserID, + } + require.NoError(t, b.DoAuth("/service/relationship/handle_friend_request", handleReq, &relationship.HandleFriendRsp{})) + + // a 应收到 FRIEND_ADD_PROCESS_NOTIFY(好友申请被处理) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err = wsA.WaitForNotify(ctx, int32(push.NotifyType_FRIEND_ADD_PROCESS_NOTIFY)) + require.NoError(t, err, "a 应收到好友通过通知") +} + +// FN-WS-04 | P1 | websocket | 会话创建后,成员 WS 收到通知 +func TestFN_WS_ConversationCreateNotify(t *testing.T) { + owner, _, _ := fixture.RegisterAndLogin(t, HTTP) + member, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // member 连接 WS + wsMember, err := client.NewWSClient(HTTP.Config(), member.AccessToken, member.UserID, "device-ws04") + require.NoError(t, err) + defer wsMember.Close() + + // 等待 WS 鉴权完成 + time.Sleep(500 * time.Millisecond) + + // owner 建群(含 member) + convID := fixture.CreateGroupWithMembers(t, owner, []*client.HTTPClient{member}, "ws-conv-create-test") + + // member 应收到 CONVERSATION_CREATE_NOTIFY + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err = wsMember.WaitForNotify(ctx, int32(push.NotifyType_CONVERSATION_CREATE_NOTIFY)) + require.NoError(t, err, "member 应收到会话创建通知") + _ = convID +} + +// FN-WS-05 | P1 | websocket | 订阅的用户上线/离线,WS 收到通知 +func TestFN_WS_PresenceChangeNotify(t *testing.T) { + subscriber, _, _ := fixture.RegisterAndLogin(t, HTTP) + target, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // subscriber 连接 WS + wsSub, err := client.NewWSClient(HTTP.Config(), subscriber.AccessToken, subscriber.UserID, "device-ws05-sub") + require.NoError(t, err) + defer wsSub.Close() + + // 等待 WS 鉴权完成 + time.Sleep(500 * time.Millisecond) + + // subscriber 订阅 target + subReq := &presence.SubscribeReq{ + RequestId: client.NewRequestID(), + SubscribeUserIds: []string{target.UserID}, + } + require.NoError(t, subscriber.DoAuth("/service/presence/subscribe", subReq, &presence.SubscribeRsp{})) + + // target 上线 + wsTarget, err := client.NewWSClient(HTTP.Config(), target.AccessToken, target.UserID, "device-ws05-target") + require.NoError(t, err) + defer wsTarget.Close() + + // subscriber 应收到 PRESENCE_CHANGE_NOTIFY + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err = wsSub.WaitForNotify(ctx, int32(push.NotifyType_PRESENCE_CHANGE_NOTIFY)) + require.NoError(t, err, "subscriber 应收到 target 上线通知") +} + +// FN-WS-06 | P1 | websocket | WS 断开后重连,遗漏消息通过 sync 补齐 +func TestFN_WS_Reconnect(t *testing.T) { + a, b, convID := setupConv(t) + + // b 连接 WS + wsB1, err := client.NewWSClient(HTTP.Config(), b.AccessToken, b.UserID, "device-ws06-1") + require.NoError(t, err) + + // 等待 WS 鉴权完成 + time.Sleep(500 * time.Millisecond) + + // b 断开 WS + require.NoError(t, wsB1.Close()) + + // a 发消息(b 离线) + sendMsg(t, a, convID, "msg-while-b-disconnected") + + // b 重连 WS + wsB2, err := client.NewWSClient(HTTP.Config(), b.AccessToken, b.UserID, "device-ws06-2") + require.NoError(t, err) + defer wsB2.Close() + + // 等待 WS 鉴权完成 + time.Sleep(500 * time.Millisecond) + + // b 通过 sync 补齐遗漏消息 + syncReq := &msg.SyncMessagesReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + AfterSeq: 0, + Limit: 10, + } + syncRsp := &msg.SyncMessagesRsp{} + require.NoError(t, b.DoAuth("/service/message/sync", syncReq, syncRsp)) + require.NotEmpty(t, syncRsp.Messages, "重连后 sync 应补齐遗漏消息") +} + +// FN-WS-07 | P2 | websocket | typing 通知送达订阅者 +func TestFN_WS_TypingNotify(t *testing.T) { + a, b, convID := setupConv(t) + + // b 连接 WS + wsB, err := client.NewWSClient(HTTP.Config(), b.AccessToken, b.UserID, "device-ws07") + require.NoError(t, err) + defer wsB.Close() + + // 等待 WS 鉴权完成 + time.Sleep(500 * time.Millisecond) + + // a 发 typing + typingReq := &presence.TypingReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + IsTyping: true, + } + require.NoError(t, a.DoAuth("/service/presence/send_typing", typingReq, &presence.TypingRsp{})) + + // b 应收到 TYPING_NOTIFY + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err = wsB.WaitForNotify(ctx, int32(push.NotifyType_TYPING_NOTIFY)) + require.NoError(t, err, "b 应收到 typing 通知") +} From 8ab9f15a84a5482ffbbff4c6d2d9d91236d459f7 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 18:59:42 +0800 Subject: [PATCH 13/28] test(concurrency): add FN-CC-02~05 different-msgid/friend-race/media-dedup/reaction - FN-CC-02: 10 goroutines concurrent send with different client_msg_id, verify all 10 land in DB with unique message_ids - FN-CC-03: Friend accept then immediate concurrent send (5 msgs), verify no message loss via DB count - FN-CC-04: Concurrent ApplyUpload with same content_hash after initial upload, verify dedup returns identical file_id - FN-CC-05: Two users (a+b) concurrently add same emoji reaction, verify get_reactions succeeds (idempotency) All tests use real full-stack via fixture helpers (setupConv/sendMsg) and DBVerifier.MessageCount for storage assertions. Compilation, go vet, and gofmt all pass clean. --- tests/func/concurrency_test.go | 200 +++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/tests/func/concurrency_test.go b/tests/func/concurrency_test.go index c6dfb75..96a9e6b 100644 --- a/tests/func/concurrency_test.go +++ b/tests/func/concurrency_test.go @@ -3,6 +3,8 @@ package func_test import ( + "crypto/sha256" + "fmt" "sync" "testing" @@ -12,7 +14,9 @@ import ( "chatnow-tests/pkg/client" "chatnow-tests/pkg/fixture" "chatnow-tests/pkg/verify" + media "chatnow-tests/proto/chatnow/media" msg "chatnow-tests/proto/chatnow/message" + relationship "chatnow-tests/proto/chatnow/relationship" transmite "chatnow-tests/proto/chatnow/transmite" ) @@ -69,3 +73,199 @@ func TestFN_CC_SendMessage_SameClientMsgId(t *testing.T) { dbV.MessageCount(t, convID, 1) dbV.MessageByClientMsgId(t, clientMsgID, true) } + +// FN-CC-02 | P1 | concurrency | 10 goroutine 并发发消息,全部落库,seq 不重复 +func TestFN_CC_SendMessage_DifferentMsgId(t *testing.T) { + a, _, convID := setupConv(t) + + var wg sync.WaitGroup + msgIDs := make([]int64, 10) + errs := make([]error, 10) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + req := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: fmt.Sprintf("concurrent-%d", idx)}}, + }, + ClientMsgId: client.NewRequestID(), // 每次不同 + } + rsp := &transmite.SendMessageRsp{} + errs[idx] = a.DoAuth("/service/transmite/send", req, rsp) + if errs[idx] == nil && rsp.Header.Success { + msgIDs[idx] = rsp.Message.MessageId + } + }(i) + } + wg.Wait() + + // 验证全部成功 + for i, err := range errs { + require.NoError(t, err, "goroutine %d failed", i) + require.NotZero(t, msgIDs[i], "goroutine %d 未返回 message_id", i) + } + + // 验证 message_id 不重复(等价于 seq 不重复) + idSet := make(map[int64]bool) + for _, id := range msgIDs { + require.False(t, idSet[id], "message_id %d 重复", id) + idSet[id] = true + } + + // 直查 DB:message 表有 10 条 + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + dbV.MessageCount(t, convID, 10) +} + +// FN-CC-03 | P1 | concurrency | 好友通过瞬间并发发消息,不丢 +func TestFN_CC_FriendAccept_ThenSend(t *testing.T) { + a, _, _ := fixture.RegisterAndLogin(t, HTTP) + b, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // a 发好友申请 + sendReq := &relationship.SendFriendReq{RequestId: client.NewRequestID(), RespondentId: b.UserID} + sendRsp := &relationship.SendFriendRsp{} + require.NoError(t, a.DoAuth("/service/relationship/send_friend_request", sendReq, sendRsp)) + + // b 通过 + a 立即并发发消息(会话刚创建) + handleReq := &relationship.HandleFriendReq{ + RequestId: client.NewRequestID(), + NotifyEventId: sendRsp.GetNotifyEventId(), + Agree: true, + ApplyUserId: a.UserID, + } + handleRsp := &relationship.HandleFriendRsp{} + require.NoError(t, b.DoAuth("/service/relationship/handle_friend_request", handleReq, handleRsp)) + require.True(t, handleRsp.Header.Success, "handle_friend_request 失败: %s", handleRsp.Header.ErrorMessage) + convID := handleRsp.GetNewConversationId() + require.NotEmpty(t, convID, "新会话 ID 为空") + + // 并发发 5 条消息 + var wg sync.WaitGroup + errs := make([]error, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + req := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "race-msg"}}, + }, + ClientMsgId: client.NewRequestID(), + } + rsp := &transmite.SendMessageRsp{} + errs[idx] = a.DoAuth("/service/transmite/send", req, rsp) + if errs[idx] == nil && !rsp.Header.Success { + errs[idx] = fmt.Errorf("send failed: %s", rsp.Header.ErrorMessage) + } + }(i) + } + wg.Wait() + + // 验证全部发送成功 + for i, err := range errs { + require.NoError(t, err, "goroutine %d 发送失败", i) + } + + // 直查 DB:5 条消息全部落库 + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + dbV.MessageCount(t, convID, 5) +} + +// FN-CC-04 | P1 | concurrency | 相同 content_hash 并发上传,dedup 正确 +func TestFN_CC_MediaUpload_SameHash(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("cc-media-same-hash") + hash := sha256.Sum256(content) + hashStr := fmt.Sprintf("sha256:%x", hash) + + // 先完整上传一次,确保 dedup 记录存在 + existingID := fixture.UploadFile(t, authed, content, "text/plain") + require.NotEmpty(t, existingID) + + // 5 goroutine 并发 ApplyUpload 相同 hash(已存在) + var wg sync.WaitGroup + fileIDs := make([]string, 5) + errs := make([]error, 5) + for i := 0; i < 5; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + req := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), + FileName: "cc-dup.bin", + FileSize: int64(len(content)), + MimeType: "text/plain", + ContentHash: hashStr, + Purpose: media.MediaPurpose_CHAT, + } + rsp := &media.ApplyUploadRsp{} + errs[idx] = authed.DoAuth("/service/media/apply_upload", req, rsp) + if errs[idx] == nil && rsp.Header.Success { + fileIDs[idx] = rsp.FileId + } + }(i) + } + wg.Wait() + + // 验证全部成功 + for i, err := range errs { + require.NoError(t, err, "goroutine %d 失败", i) + require.NotEmpty(t, fileIDs[i], "goroutine %d 的 file_id 为空", i) + } + + // 验证所有返回的 file_id 相同(dedup 正确) + require.Equal(t, existingID, fileIDs[0], "dedup 应返回已存在的 file_id") + for i, id := range fileIDs { + assert.Equal(t, fileIDs[0], id, "goroutine %d 的 file_id 应一致(dedup)", i) + } +} + +// FN-CC-05 | P2 | concurrency | 多用户同时给同一消息加相同 emoji +func TestFN_CC_Reaction_SameEmoji(t *testing.T) { + a, b, convID := setupConv(t) + mID, _ := sendMsg(t, a, convID, "react-concurrent") + + // 单聊只有 2 人,此处用 a 和 b 各加相同 emoji 测试幂等 + reactioners := make([]*client.HTTPClient, 0, 2) + reactioners = append(reactioners, a) // a 也加 reaction + reactioners = append(reactioners, b) + + var wg sync.WaitGroup + emoji := "👍" + errs := make([]error, len(reactioners)) + for i, u := range reactioners { + wg.Add(1) + go func(idx int, user *client.HTTPClient) { + defer wg.Done() + req := &msg.AddReactionReq{ + RequestId: client.NewRequestID(), + MessageId: mID, + Emoji: emoji, + } + rsp := &msg.AddReactionRsp{} + errs[idx] = user.DoAuth("/service/message/add_reaction", req, rsp) + }(i, u) + } + wg.Wait() + + // 验证无致命错误(并发 add_reaction 可能有一方失败或幂等成功) + for i, err := range errs { + assert.NoError(t, err, "goroutine %d add_reaction 出错", i) + } + + // 验证 reaction 查询成功(幂等:相同 emoji 不重复计数或 count=1) + getReq := &msg.GetReactionsReq{RequestId: client.NewRequestID(), MessageId: mID} + getRsp := &msg.GetReactionsRsp{} + require.NoError(t, a.DoAuth("/service/message/get_reactions", getReq, getRsp)) + require.True(t, getRsp.Header.Success, "get_reactions 失败: %s", getRsp.Header.ErrorMessage) +} From d70313b7e1e07c0ef9896afa214c3f71d3dce682 Mon Sep 17 00:00:00 2001 From: ULookup Date: Mon, 13 Jul 2026 19:11:14 +0800 Subject: [PATCH 14/28] test(scenario): add SC-05 media upload full flow with dedup and multipart --- tests/func/scenarios_test.go | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index d37530d..82629b4 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -3,7 +3,12 @@ package func_test import ( + "bytes" "context" + "crypto/sha256" + "fmt" + "io" + "net/http" "testing" "time" @@ -16,6 +21,7 @@ import ( common "chatnow-tests/proto/chatnow/common" conversation "chatnow-tests/proto/chatnow/conversation" identity "chatnow-tests/proto/chatnow/identity" + media "chatnow-tests/proto/chatnow/media" msg "chatnow-tests/proto/chatnow/message" push "chatnow-tests/proto/chatnow/push" relationship "chatnow-tests/proto/chatnow/relationship" @@ -320,6 +326,104 @@ func TestScenario_OfflineMessageSync(t *testing.T) { dbV.MessageCount(t, convID, 4) // 3 离线 + 1 实时 = 4 } +// --------------------------------------------------------------------------- +// Scenario 5: Media Upload Full Flow(媒体三步上传全链路) +// SC-05 | P0 | scenario | 媒体三步上传全链路:apply->PUT->complete->download->dedup->multipart +// --------------------------------------------------------------------------- + +func TestScenario_MediaUploadFullFlow(t *testing.T) { + user, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := []byte("sc05-media-full-flow-content") + hash := sha256.Sum256(content) + hashStr := fmt.Sprintf("sha256:%x", hash) + + // Step 1: ApplyUpload + applyReq := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), FileName: "sc05.txt", + FileSize: int64(len(content)), MimeType: "text/plain", + ContentHash: hashStr, Purpose: media.MediaPurpose_CHAT, + } + applyRsp := &media.ApplyUploadRsp{} + require.NoError(t, user.DoAuth("/service/media/apply_upload", applyReq, applyRsp)) + require.True(t, applyRsp.Header.Success) + fileID := applyRsp.FileId + require.NotEmpty(t, fileID) + + // Step 2: PUT 到 MinIO presigned URL + httpReq, _ := http.NewRequest("PUT", applyRsp.UploadUrl, bytes.NewReader(content)) + if applyRsp.Headers != nil { + for k, v := range applyRsp.Headers { + httpReq.Header.Set(k, v) + } + } + putResp, err := http.DefaultClient.Do(httpReq) + require.NoError(t, err) + require.Equal(t, 200, putResp.StatusCode) + putResp.Body.Close() + + // Step 3: CompleteUpload + completeReq := &media.CompleteUploadReq{RequestId: client.NewRequestID(), FileId: fileID} + completeRsp := &media.CompleteUploadRsp{} + require.NoError(t, user.DoAuth("/service/media/complete_upload", completeReq, completeRsp)) + require.True(t, completeRsp.Header.Success) + + // Step 4: ApplyDownload + 下载验证内容 + dlReq := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: fileID} + dlRsp := &media.ApplyDownloadRsp{} + require.NoError(t, user.DoAuth("/service/media/apply_download", dlReq, dlRsp)) + require.True(t, dlRsp.Header.Success) + dlResp, err := http.Get(dlRsp.DownloadUrl) + require.NoError(t, err) + body, _ := io.ReadAll(dlResp.Body) + dlResp.Body.Close() + assert.Equal(t, content, body, "下载内容与上传不一致") + + // Step 5: 重复 ApplyUpload(相同 hash)-> dedup 返回相同 file_id + applyReq2 := &media.ApplyUploadReq{ + RequestId: client.NewRequestID(), FileName: "sc05-dup.txt", + FileSize: int64(len(content)), MimeType: "text/plain", + ContentHash: hashStr, Purpose: media.MediaPurpose_CHAT, + } + applyRsp2 := &media.ApplyUploadRsp{} + require.NoError(t, user.DoAuth("/service/media/apply_upload", applyReq2, applyRsp2)) + require.True(t, applyRsp2.Header.Success) + assert.True(t, applyRsp2.AlreadyExists, "相同 hash 应返回 already_exists=true") + assert.Equal(t, fileID, applyRsp2.FileId, "dedup 应返回相同 file_id") + + // Step 6: 大文件 multipart(6MB -> 3 parts @ 2MB) + bigContent := make([]byte, 6*1024*1024) + for i := range bigContent { + bigContent[i] = byte(i % 256) + } + bigFileID := fixture.UploadLargeFile(t, user, bigContent, "application/octet-stream", 2*1024*1024) + require.NotEmpty(t, bigFileID) + + // 下载大文件验证 + bigDlReq := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: bigFileID} + bigDlRsp := &media.ApplyDownloadRsp{} + require.NoError(t, user.DoAuth("/service/media/apply_download", bigDlReq, bigDlRsp)) + require.True(t, bigDlRsp.Header.Success) + bigResp, err := http.Get(bigDlRsp.DownloadUrl) + require.NoError(t, err) + bigBody, _ := io.ReadAll(bigResp.Body) + bigResp.Body.Close() + assert.Equal(t, bigContent, bigBody, "大文件下载内容不一致") + + // Step 7: 数据一致性 - GetFileInfo 验证 + infoReq := &media.GetFileInfoReq{RequestId: client.NewRequestID(), FileId: fileID} + infoRsp := &media.GetFileInfoRsp{} + require.NoError(t, user.DoAuth("/service/media/get_file_info", infoReq, infoRsp)) + require.True(t, infoRsp.Header.Success) + assert.Equal(t, int64(len(content)), infoRsp.FileInfo.FileSize) + + // Step 8: 数据一致性 - 直查 DB quota + // 注:dedup 命中不增加 quota,故 used_bytes = len(content) + len(bigContent) + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + time.Sleep(1 * time.Second) // 等待 DB 异步写入 + dbV.MediaQuota(t, user.UserID, int64(len(content)+len(bigContent))) +} + // --------------------------------------------------------------------------- // Scenario 6: Message Reliability(MQ 可用版本) // SC-06 | P0 | client_msg_id 幂等 + 消息不丢不重 From 771f2fea6afd1c72f91d8789c771b84032c40256 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 11:05:59 +0800 Subject: [PATCH 15/28] test(scenario): add SC-07 multi-device login kick --- tests/func/scenarios_test.go | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index 82629b4..3292079 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -481,3 +481,43 @@ func TestScenario_MessageReliability(t *testing.T) { dbV.MessageCount(t, convID, 1) dbV.MessageByClientMsgId(t, clientMsgID, true) } + +// --------------------------------------------------------------------------- +// Scenario 7: Multi-Device Login Kick +// SC-07 | P1 | scenario | 多设备登录:设备 A 登录 -> 设备 B 登录 -> A 被踢 -> A token 失效 +// --------------------------------------------------------------------------- + +func TestScenario_MultiDeviceLogin(t *testing.T) { + // 先注册用户(LoginUser 要求用户已存在) + username := "sc07_user_" + client.NewRequestID()[:8] + password := "Sc07@123456" + + regReq := &identity.RegisterReq{ + RequestId: client.NewRequestID(), + Credential: &identity.RegisterReq_UsernamePwd{ + UsernamePwd: &identity.UsernamePassword{Username: username, Password: password}, + }, + Nickname: username, + } + require.NoError(t, HTTP.DoNoAuth("/service/identity/register", regReq, &identity.RegisterRsp{})) + + // 设备 A 登录 + deviceA := fixture.LoginUser(t, HTTP, username, password) + require.NotEmpty(t, deviceA.AccessToken) + + // 验证 A 能调 API + profileReq := &identity.GetProfileReq{RequestId: client.NewRequestID()} + require.NoError(t, deviceA.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{})) + + // 设备 B 登录同用户 + deviceB := fixture.LoginUser(t, HTTP, username, password) + require.NotEmpty(t, deviceB.AccessToken) + require.NotEqual(t, deviceA.AccessToken, deviceB.AccessToken, "B 的 token 应不同于 A") + + // 设备 A 的 token 应失效(被踢) + err := deviceA.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{}) + assert.Error(t, err, "设备 A 被踢后 token 应失效") + + // 设备 B 仍可调 API + require.NoError(t, deviceB.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{})) +} From 80e5ab9ca1e6f99fb16daccf1b74bd3d3d665187 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 11:22:54 +0800 Subject: [PATCH 16/28] test(scenario): add SC-08 large group fan-out read diffusion (200 members) --- tests/func/scenarios_test.go | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index 3292079..f53eff7 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -521,3 +521,58 @@ func TestScenario_MultiDeviceLogin(t *testing.T) { // 设备 B 仍可调 API require.NoError(t, deviceB.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{})) } + +// --------------------------------------------------------------------------- +// Scenario 8: Large Group Fan-Out (Read Diffusion) +// SC-08 | P1 | scenario | 200+ 成员群发消息,验证读扩散(仅写主表,各成员 sync 收到) +// --------------------------------------------------------------------------- + +func TestScenario_LargeGroupFanOut(t *testing.T) { + owner, _, _ := fixture.RegisterAndLogin(t, HTTP) + + // 批量注册 200 成员(分批避免单次请求过大) + members := make([]*client.HTTPClient, 0, 200) + for i := 0; i < 200; i++ { + m, _, _ := fixture.RegisterAndLogin(t, HTTP) + members = append(members, m) + } + + // 建群(200 成员 + owner = 201) + convID := fixture.CreateGroupWithMembers(t, owner, members, "sc08-large-group-200") + + // owner 发消息 + sendReq := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "sc08-large-group-msg"}}, + }, + ClientMsgId: client.NewRequestID(), + } + sendRsp := &transmite.SendMessageRsp{} + require.NoError(t, owner.DoAuth("/service/transmite/send", sendReq, sendRsp)) + require.True(t, sendRsp.Header.Success) + msgID := sendRsp.Message.MessageId + + // 抽样 10 个成员验证 sync 收到 + for i := 0; i < 10; i++ { + idx := i * 20 // 每隔 20 个抽一个 + syncReq := &msg.SyncMessagesReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + AfterSeq: 0, + Limit: 10, + } + syncRsp := &msg.SyncMessagesRsp{} + require.NoError(t, members[idx].DoAuth("/service/message/sync", syncReq, syncRsp), + "成员 %d sync 失败", idx) + require.NotEmpty(t, syncRsp.Messages, "成员 %d 应收到消息", idx) + assert.Equal(t, msgID, syncRsp.Messages[0].MessageId, "成员 %d 收到的 message_id 不符", idx) + } + + // 数据一致性 - 读扩散:message 表仅 1 条 + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + dbV.MessageCount(t, convID, 1) +} From fa42baca6833a66b58b9450d3d59c67dc8cae1d0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 11:39:17 +0800 Subject: [PATCH 17/28] test(scenario): add SC-09 unread count consistency cross-service --- tests/func/scenarios_test.go | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index f53eff7..a513047 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -576,3 +576,57 @@ func TestScenario_LargeGroupFanOut(t *testing.T) { defer dbV.Close() dbV.MessageCount(t, convID, 1) } + +// --------------------------------------------------------------------------- +// Scenario 9: Unread Count Consistency(未读数跨服务一致性) +// SC-09 | P0 | scenario | 未读数跨服务跨设备一致:发消息 unread+1 -> UpdateReadAck -> unread=0 +// --------------------------------------------------------------------------- + +func TestScenario_UnreadCountConsistency(t *testing.T) { + a, b, convID := setupConv(t) // MakeFriends + + // Step 1: a 发 3 条消息 + var lastSeq uint64 + for i := 0; i < 3; i++ { + _, lastSeq = sendMsg(t, a, convID, "sc09-unread-"+string(rune('0'+i))) + } + + // Step 2: b ListConversations,验证 unread_count=3 + listReq := &conversation.ListConversationsReq{RequestId: client.NewRequestID()} + listRsp := &conversation.ListConversationsRsp{} + require.NoError(t, b.DoAuth("/service/conversation/list", listReq, listRsp)) + var bobConv *conversation.Conversation + for _, c := range listRsp.Conversations { + if c.ConversationId == convID { + bobConv = c + break + } + } + require.NotNil(t, bobConv, "b 的会话列表中应包含 convID") + assert.Equal(t, uint64(3), bobConv.Self.UnreadCount, "b 未读数应为 3") + + // Step 3: 数据一致性 - DB unread_count=3 + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + dbV.UnreadCount(t, b.UserID, convID, 3) + + // Step 4: b UpdateReadAck(读到最后一条 seq) + ackReq := &msg.UpdateReadAckReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + SeqId: lastSeq, + } + require.NoError(t, b.DoAuth("/service/message/update_read_ack", ackReq, &msg.UpdateReadAckRsp{})) + + // Step 5: b 再次 ListConversations,unread_count=0 + listRsp2 := &conversation.ListConversationsRsp{} + require.NoError(t, b.DoAuth("/service/conversation/list", listReq, listRsp2)) + for _, c := range listRsp2.Conversations { + if c.ConversationId == convID { + assert.Equal(t, uint64(0), c.Self.UnreadCount, "read ack 后未读数应清零") + } + } + + // Step 6: 数据一致性 - DB unread_count=0 + dbV.UnreadCount(t, b.UserID, convID, 0) +} From 4740b7dcfebbb140828e7652a9958a9fd9db887a Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 11:45:16 +0800 Subject: [PATCH 18/28] test(scenario): add SC-10 message recall visibility cross-device --- tests/func/scenarios_test.go | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index a513047..3cb3293 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -630,3 +630,66 @@ func TestScenario_UnreadCountConsistency(t *testing.T) { // Step 6: 数据一致性 - DB unread_count=0 dbV.UnreadCount(t, b.UserID, convID, 0) } + +// --------------------------------------------------------------------------- +// Scenario 10: Message Recall Visibility(撤回消息可见性) +// SC-10 | P1 | scenario | 撤回可见性跨设备一致:发消息 -> sync 看到 -> 撤回 -> 另一设备 sync 看到 recalled +// --------------------------------------------------------------------------- + +func TestScenario_MessageRecallVisibility(t *testing.T) { + a, b, convID := setupConv(t) + + // a 发消息 + sendReq := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "sc10-will-recall"}}, + }, + ClientMsgId: client.NewRequestID(), + } + sendRsp := &transmite.SendMessageRsp{} + require.NoError(t, a.DoAuth("/service/transmite/send", sendReq, sendRsp)) + msgID := sendRsp.Message.MessageId + + // b sync,看到消息内容,status=NORMAL + syncReq := &msg.SyncMessagesReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + AfterSeq: 0, + Limit: 10, + } + syncRsp := &msg.SyncMessagesRsp{} + require.NoError(t, b.DoAuth("/service/message/sync", syncReq, syncRsp)) + require.NotEmpty(t, syncRsp.Messages) + assert.Equal(t, "sc10-will-recall", syncRsp.Messages[0].GetContent().GetText().Text) + assert.Equal(t, msg.MessageStatus_MESSAGE_STATUS_NORMAL, syncRsp.Messages[0].Status) + + // a 撤回 + recallReq := &msg.RecallMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + MessageId: msgID, + } + require.NoError(t, a.DoAuth("/service/message/recall", recallReq, &msg.RecallMessageRsp{})) + + // b 再次 sync,看到 status=RECALLED + syncReq2 := &msg.SyncMessagesReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + AfterSeq: 0, + Limit: 10, + } + syncRsp2 := &msg.SyncMessagesRsp{} + require.NoError(t, b.DoAuth("/service/message/sync", syncReq2, syncRsp2)) + require.NotEmpty(t, syncRsp2.Messages) + assert.Equal(t, msg.MessageStatus_MESSAGE_STATUS_RECALLED, syncRsp2.Messages[0].Status, + "撤回后 status 应为 RECALLED") + + // 数据一致性 - DB message.status=RECALLED(1) + time.Sleep(1 * time.Second) // 等待 DB 异步写入 + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + dbV.MessageStatus(t, msgID, 1) +} From 9071ca5b188ade0261dbad7f231a86f09ba3c9d9 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 17:43:10 +0800 Subject: [PATCH 19/28] test(scenario): add SC-11 token refresh flow after tampering --- tests/func/scenarios_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index 3cb3293..055d634 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -693,3 +693,35 @@ func TestScenario_MessageRecallVisibility(t *testing.T) { defer dbV.Close() dbV.MessageStatus(t, msgID, 1) } + +// --------------------------------------------------------------------------- +// Scenario 11: Token Refresh Flow(Token 刷新链路) +// SC-11 | P1 | scenario | token 刷新链路:篡改 token 失败 -> RefreshToken -> 新 token 可用 +// --------------------------------------------------------------------------- + +func TestScenario_TokenRefreshFlow(t *testing.T) { + user, _, _ := fixture.RegisterAndLogin(t, HTTP) + validToken := user.AccessToken + refreshToken := user.RefreshToken + + // Step 1: 篡改 access_token,调 API 失败 + user.AccessToken = "tampered.invalid.token.payload" + profileReq := &identity.GetProfileReq{RequestId: client.NewRequestID()} + err := user.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{}) + assert.Error(t, err, "篡改 token 后应鉴权失败") + + // Step 2: 用 refresh_token 刷新 + refreshReq := &identity.RefreshTokenReq{ + RequestId: client.NewRequestID(), RefreshToken: refreshToken, + } + refreshRsp := &identity.RefreshTokenRsp{} + require.NoError(t, user.DoNoAuth("/service/identity/refresh_token", refreshReq, refreshRsp)) + require.True(t, refreshRsp.Header.Success) + require.NotEmpty(t, refreshRsp.Tokens.AccessToken) + require.NotEqual(t, validToken, refreshRsp.Tokens.AccessToken, "新 token 应不同于旧 token") + + // Step 3: 新 token 调 API 成功 + user.AccessToken = refreshRsp.Tokens.AccessToken + require.NoError(t, user.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{}), + "新 token 应能调 API") +} From 14b5d8ed3d377224d226890bbd88fc61d91d8322 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 17:49:09 +0800 Subject: [PATCH 20/28] test(scenario): add SC-12 message search ES consistency --- tests/func/scenarios_test.go | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index 055d634..6f6f845 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -725,3 +725,46 @@ func TestScenario_TokenRefreshFlow(t *testing.T) { require.NoError(t, user.DoAuth("/service/identity/get_profile", profileReq, &identity.GetProfileRsp{}), "新 token 应能调 API") } + +// SC-12 | P1 | scenario | ES 检索与 DB 落库一致:发含关键词消息 -> SearchMessages 命中 -> 直查 ES +func TestScenario_MessageSearchES(t *testing.T) { + a, b, convID := setupConv(t) + + // 发含特殊关键词的消息 + keyword := "sc12-es-keyword-unique-" + client.NewRequestID()[:8] + sendReq := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "hello " + keyword + " world"}}, + }, + ClientMsgId: client.NewRequestID(), + } + sendRsp := &transmite.SendMessageRsp{} + require.NoError(t, a.DoAuth("/service/transmite/send", sendReq, sendRsp)) + require.True(t, sendRsp.Header.Success) + msgID := sendRsp.Message.MessageId + + // 等待 ES 索引(异步,需 polling) + time.Sleep(3 * time.Second) + + // SearchMessages 命中 + searchReq := &msg.SearchMessagesReq{ + RequestId: client.NewRequestID(), ConversationId: convID, + Keyword: keyword, Limit: 10, + } + searchRsp := &msg.SearchMessagesRsp{} + require.NoError(t, b.DoAuth("/service/message/search", searchReq, searchRsp)) + require.True(t, searchRsp.Header.Success, "搜索应成功") + require.Len(t, searchRsp.Messages, 1, "搜索应命中 1 条") + assert.Equal(t, msgID, searchRsp.Messages[0].MessageId, "搜索结果 message_id 不符") + + // 数据一致性 - ES 索引存在 + ESVerifier := verify.NewESVerifier(Cfg.Database.ESURL) + ESVerifier.MessageIndexed(t, msgID, keyword) + + // 数据一致性 - DB 也有该消息 + DBVerifier := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer DBVerifier.Close() + DBVerifier.MessageExists(t, msgID) +} From 571ba068b675fd3b444dcb55e65b28c5d3c8a1d3 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 17:54:18 +0800 Subject: [PATCH 21/28] test(scenario): poll SC-12 message search --- tests/func/scenarios_test.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index 6f6f845..5d57184 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -743,21 +743,23 @@ func TestScenario_MessageSearchES(t *testing.T) { sendRsp := &transmite.SendMessageRsp{} require.NoError(t, a.DoAuth("/service/transmite/send", sendReq, sendRsp)) require.True(t, sendRsp.Header.Success) + require.NotNil(t, sendRsp.Message) msgID := sendRsp.Message.MessageId - // 等待 ES 索引(异步,需 polling) - time.Sleep(3 * time.Second) - - // SearchMessages 命中 + // 轮询等待 ES 索引,并验证 SearchMessages 命中 searchReq := &msg.SearchMessagesReq{ RequestId: client.NewRequestID(), ConversationId: convID, Keyword: keyword, Limit: 10, } - searchRsp := &msg.SearchMessagesRsp{} - require.NoError(t, b.DoAuth("/service/message/search", searchReq, searchRsp)) - require.True(t, searchRsp.Header.Success, "搜索应成功") - require.Len(t, searchRsp.Messages, 1, "搜索应命中 1 条") - assert.Equal(t, msgID, searchRsp.Messages[0].MessageId, "搜索结果 message_id 不符") + require.Eventually(t, func() bool { + searchRsp := &msg.SearchMessagesRsp{} + if err := b.DoAuth("/service/message/search", searchReq, searchRsp); err != nil { + return false + } + return searchRsp.GetHeader().GetSuccess() && + len(searchRsp.GetMessages()) == 1 && + searchRsp.GetMessages()[0].GetMessageId() == msgID + }, 10*time.Second, time.Second, "10s 内搜索应成功并唯一命中 message_id=%d", msgID) // 数据一致性 - ES 索引存在 ESVerifier := verify.NewESVerifier(Cfg.Database.ESURL) From 5108332201619d19c04c438bcc1066917807fe99 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 18:00:34 +0800 Subject: [PATCH 22/28] refactor(test): remove all C++ gtest directories (common/test, media/test, identity/test) C++ behavior is fully covered by Go black-box tests: - mime/jwt/content_hash/object_key/magic -> media_test.go + auth_middleware_test.go - s3/media_dao integration -> SC-05 MediaUploadFullFlow + FN-DC-07 MediaQuota - identity_client -> tests/pkg/client/http.go Root CMakeLists.txt no longer add_subdirectory(common/test). All C++ test build targets were already commented out (FIXME 3.0). --- CMakeLists.txt | 3 +- common/test/CMakeLists.txt | 39 --- common/test/test_auth_context.cc | 83 ------ common/test/test_avatar_url.cc | 25 -- common/test/test_content_hash.cc | 34 --- common/test/test_forward_auth.cc | 67 ----- common/test/test_jwt_codec.cc | 138 --------- common/test/test_jwt_store.cc | 70 ----- common/test/test_log_context.cc | 53 ---- common/test/test_log_json.cc | 85 ------ common/test/test_magic_sniff.cc | 74 ----- common/test/test_mime_whitelist.cc | 57 ---- common/test/test_mq_trace_headers.cc | 48 --- common/test/test_mysql_user_block_compile.cc | 18 -- common/test/test_object_key.cc | 31 -- common/test/test_service_error.cc | 27 -- common/test/test_trace_id.cc | 50 ---- identity/test/identity_client.cc | 293 ------------------- media/test/smoke/README.md | 52 ---- media/test/smoke/run_smoke.sh | 65 ---- media/test/test_media_dao_integration.cc | 160 ---------- media/test/test_s3_integration.cc | 116 -------- 22 files changed, 1 insertion(+), 1587 deletions(-) delete mode 100644 common/test/CMakeLists.txt delete mode 100644 common/test/test_auth_context.cc delete mode 100644 common/test/test_avatar_url.cc delete mode 100644 common/test/test_content_hash.cc delete mode 100644 common/test/test_forward_auth.cc delete mode 100644 common/test/test_jwt_codec.cc delete mode 100644 common/test/test_jwt_store.cc delete mode 100644 common/test/test_log_context.cc delete mode 100644 common/test/test_log_json.cc delete mode 100644 common/test/test_magic_sniff.cc delete mode 100644 common/test/test_mime_whitelist.cc delete mode 100644 common/test/test_mq_trace_headers.cc delete mode 100644 common/test/test_mysql_user_block_compile.cc delete mode 100644 common/test/test_object_key.cc delete mode 100644 common/test/test_service_error.cc delete mode 100644 common/test/test_trace_id.cc delete mode 100644 identity/test/identity_client.cc delete mode 100644 media/test/smoke/README.md delete mode 100755 media/test/smoke/run_smoke.sh delete mode 100644 media/test/test_media_dao_integration.cc delete mode 100644 media/test/test_s3_integration.cc diff --git a/CMakeLists.txt b/CMakeLists.txt index f0513b4..c9d2887 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,5 @@ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/relationship) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/conversation) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/gateway) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/push) -add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/common/test) # 4. -set(CMAKE_INSTALL_PREFIX ${CMAKE_CURRENT_BINARY_DIR}) \ No newline at end of file +set(CMAKE_INSTALL_PREFIX ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/common/test/CMakeLists.txt b/common/test/CMakeLists.txt deleted file mode 100644 index 5e6aac3..0000000 --- a/common/test/CMakeLists.txt +++ /dev/null @@ -1,39 +0,0 @@ -# common 公共组件单元测试 -cmake_minimum_required(VERSION 3.1.3) -project(common_tests) - -# proto 编译(仅 error.proto,单元测试需要 ErrorCode enum) -set(proto_path ${CMAKE_CURRENT_SOURCE_DIR}/../../proto) -set(proto_files common/error.proto) -set(proto_srcs "") -foreach(proto_file ${proto_files}) - string(REPLACE ".proto" ".pb.cc" proto_cc ${proto_file}) - if(NOT EXISTS ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc}) - add_custom_command( - PRE_BUILD - COMMAND protoc - ARGS --cpp_out=${CMAKE_CURRENT_BINARY_DIR} -I ${proto_path} --experimental_allow_proto3_optional ${proto_path}/${proto_file} - DEPENDS ${proto_path}/${proto_file} - OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc} - COMMENT "生成Protobuf框架代码文件:" ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc} - ) - endif() - list(APPEND proto_srcs ${CMAKE_CURRENT_BINARY_DIR}/${proto_cc}) -endforeach() - -include_directories(${CMAKE_CURRENT_BINARY_DIR}) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/..) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/../../third/include) - -# FIXME(3.0): common_tests has gflags link order issue with brpc static lib -# set(common_test_target common_tests) -# file(GLOB test_srcs ${CMAKE_CURRENT_SOURCE_DIR}/test_*.cc) -# add_executable(${common_test_target} ${test_srcs} ${proto_srcs}) -# target_link_libraries(${common_test_target} -# -lgflags -lgtest -lgtest_main -# -lspdlog -lfmt -# -lbrpc -lssl -lcrypto -lprotobuf -lleveldb -# -lcpprest -lcurl -# /usr/local/lib/libjsoncpp.so.19 -# ) -# INSTALL(TARGETS ${common_test_target} RUNTIME DESTINATION bin) diff --git a/common/test/test_auth_context.cc b/common/test/test_auth_context.cc deleted file mode 100644 index 01379f6..0000000 --- a/common/test/test_auth_context.cc +++ /dev/null @@ -1,83 +0,0 @@ -#include "auth/auth_context.hpp" -#include "common/auth/metadata.pb.h" -#include "error/service_error.hpp" -#include "error/error_codes.hpp" -#include -#include - -using namespace chatnow::auth; - -namespace { -template -void fill_cntl(brpc::Controller& cntl, F fill) { - chatnow::rpc::RpcMetadata meta; - fill(meta); - std::string data; - meta.SerializeToString(&data); - cntl.request_attachment().append(data); -} -} - -TEST(ExtractAuth, AllFieldsPresent) { - brpc::Controller cntl; - fill_cntl(cntl, [](chatnow::rpc::RpcMetadata& m) { - m.set_user_id("u_1"); - m.set_device_id("d_1"); - m.set_trace_id("t_1"); - m.set_jwt_jti("jti_1"); - }); - AuthContext ctx = extract_auth(&cntl); - EXPECT_EQ(ctx.user_id, "u_1"); - EXPECT_EQ(ctx.device_id, "d_1"); - EXPECT_EQ(ctx.trace_id, "t_1"); - EXPECT_EQ(ctx.jwt_jti, "jti_1"); -} - -TEST(ExtractAuth, TraceIdOptional) { - brpc::Controller cntl; - fill_cntl(cntl, [](chatnow::rpc::RpcMetadata& m) { - m.set_user_id("u_1"); - m.set_device_id("d_1"); - }); - AuthContext ctx = extract_auth(&cntl); - EXPECT_EQ(ctx.user_id, "u_1"); - EXPECT_EQ(ctx.trace_id, ""); -} - -TEST(ExtractAuth, MissingUserIdThrows) { - brpc::Controller cntl; - fill_cntl(cntl, [](chatnow::rpc::RpcMetadata& m) { - m.set_device_id("d_1"); - m.set_trace_id("t_1"); - }); - try { - extract_auth(&cntl); - FAIL() << "expected throw"; - } catch (const chatnow::ServiceError& e) { - EXPECT_EQ(e.code(), chatnow::error::kSystemInternalError); - } -} - -TEST(ExtractAuth, MissingDeviceIdThrows) { - brpc::Controller cntl; - fill_cntl(cntl, [](chatnow::rpc::RpcMetadata& m) { - m.set_user_id("u_1"); - m.set_trace_id("t_1"); - }); - EXPECT_THROW(extract_auth(&cntl), chatnow::ServiceError); -} - -TEST(ExtractAuth, NullControllerThrows) { - EXPECT_THROW(extract_auth(nullptr), chatnow::ServiceError); -} - -TEST(ExtractAuth, EmptyAttachmentThrows) { - brpc::Controller cntl; - EXPECT_THROW(extract_auth(&cntl), chatnow::ServiceError); -} - -TEST(ExtractAuth, CorruptedAttachmentThrows) { - brpc::Controller cntl; - cntl.request_attachment().append("\x01"); - EXPECT_THROW(extract_auth(&cntl), chatnow::ServiceError); -} diff --git a/common/test/test_avatar_url.cc b/common/test/test_avatar_url.cc deleted file mode 100644 index 4a80c9c..0000000 --- a/common/test/test_avatar_url.cc +++ /dev/null @@ -1,25 +0,0 @@ -#include -#include -#include "utils/avatar_url.hpp" - -namespace au = chatnow::avatar_url; - -TEST(AvatarUrl, BasicHttp) { - EXPECT_EQ(au::of("http://127.0.0.1:9000/chatnow-media-public", "abcd1234"), - "http://127.0.0.1:9000/chatnow-media-public/avatar/abcd1234"); -} - -TEST(AvatarUrl, TrailingSlashStripped) { - EXPECT_EQ(au::of("https://cdn.example.com/", "deadbeef"), - "https://cdn.example.com/avatar/deadbeef"); -} - -TEST(AvatarUrl, EmptyPrefix) { - // 边界:调用方传入空前缀(不推荐但不能崩) - EXPECT_EQ(au::of("", "x"), "/avatar/x"); -} - -TEST(AvatarUrl, EmptyFileId) { - // 边界:调用方传入空 file_id(不推荐但不能崩) - EXPECT_EQ(au::of("https://cdn", ""), "https://cdn/avatar/"); -} diff --git a/common/test/test_content_hash.cc b/common/test/test_content_hash.cc deleted file mode 100644 index 3c20d97..0000000 --- a/common/test/test_content_hash.cc +++ /dev/null @@ -1,34 +0,0 @@ -#include -#include -#include "utils/content_hash.hpp" - -namespace ch = chatnow::content_hash; - -TEST(ContentHash, ValidatesFormat) { - EXPECT_TRUE(ch::is_valid("sha256:" + std::string(64, 'a'))); - EXPECT_TRUE(ch::is_valid("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")); - EXPECT_FALSE(ch::is_valid("")); - EXPECT_FALSE(ch::is_valid("md5:abc")); - EXPECT_FALSE(ch::is_valid("sha256:zzz")); - EXPECT_FALSE(ch::is_valid("sha256:" + std::string(63, 'a'))); - // 大写 hex 不接受(强制 lower) - EXPECT_FALSE(ch::is_valid("sha256:" + std::string(64, 'A'))); - // 长度多 1 字节 - EXPECT_FALSE(ch::is_valid("sha256:" + std::string(65, 'a'))); -} - -TEST(ContentHash, ExtractsHex) { - auto hex = ch::hex_part("sha256:" + std::string(64, 'b')); - ASSERT_EQ(hex.size(), 64u); - EXPECT_EQ(hex[0], 'b'); - // 无效输入返回空 - EXPECT_TRUE(ch::hex_part("md5:abc").empty()); -} - -TEST(ContentHash, ShortPrefix) { - auto h = "sha256:" + std::string(64, 'c'); - EXPECT_EQ(ch::short_prefix(h, 2), "cc"); - EXPECT_EQ(ch::short_prefix(h, 4), "cccc"); - // 无效 → 空 - EXPECT_TRUE(ch::short_prefix("not-a-hash", 2).empty()); -} diff --git a/common/test/test_forward_auth.cc b/common/test/test_forward_auth.cc deleted file mode 100644 index b4c9e7e..0000000 --- a/common/test/test_forward_auth.cc +++ /dev/null @@ -1,67 +0,0 @@ -#include "auth/forward_auth.hpp" -#include "auth/auth_context.hpp" -#include "common/auth/metadata.pb.h" -#include -#include - -using namespace chatnow::auth; - -namespace { -template -void set_meta(brpc::Controller& c, F fill) { - chatnow::rpc::RpcMetadata meta; - fill(meta); - std::string data; - meta.SerializeToString(&data); - c.request_attachment().append(data); -} - -bool meta_equal(const brpc::Controller& a, const brpc::Controller& b) { - return a.request_attachment() == b.request_attachment(); -} -} - -TEST(ForwardAuthMetadata, CopiesAllFourFields) { - brpc::Controller in, out; - set_meta(in, [](chatnow::rpc::RpcMetadata& m) { - m.set_trace_id("t"); - m.set_user_id("u"); - m.set_device_id("d"); - m.set_jwt_jti("j"); - }); - - forward_auth_metadata(&in, &out); - - EXPECT_TRUE(meta_equal(in, out)); -} - -TEST(ForwardAuthMetadata, CopiesEmptyAttachment) { - brpc::Controller in, out; - forward_auth_metadata(&in, &out); - EXPECT_TRUE(meta_equal(in, out)); -} - -TEST(ForwardAuthMetadata, RoundTripViaExtractAuth) { - brpc::Controller in, out; - set_meta(in, [](chatnow::rpc::RpcMetadata& m) { - m.set_user_id("u"); - m.set_device_id("d"); - m.set_trace_id("t"); - m.set_jwt_jti("j"); - }); - - forward_auth_metadata(&in, &out); - - AuthContext ctx = extract_auth(&out); - EXPECT_EQ(ctx.user_id, "u"); - EXPECT_EQ(ctx.device_id, "d"); - EXPECT_EQ(ctx.trace_id, "t"); - EXPECT_EQ(ctx.jwt_jti, "j"); -} - -TEST(ForwardAuthMetadata, NullSafetyInOrOut) { - brpc::Controller cntl; - EXPECT_NO_THROW(forward_auth_metadata(nullptr, &cntl)); - EXPECT_NO_THROW(forward_auth_metadata(&cntl, nullptr)); - EXPECT_NO_THROW(forward_auth_metadata(nullptr, nullptr)); -} diff --git a/common/test/test_jwt_codec.cc b/common/test/test_jwt_codec.cc deleted file mode 100644 index 4ea956f..0000000 --- a/common/test/test_jwt_codec.cc +++ /dev/null @@ -1,138 +0,0 @@ -#include "auth/jwt_codec.hpp" -#include "error/error_codes.hpp" -#include "error/service_error.hpp" - -#include -#include - -using namespace chatnow::auth; - -namespace { -JwtConfig make_cfg() { - JwtConfig cfg; - cfg.current_kid = "v1"; - cfg.keys["v1"] = std::string(32, 'A'); - cfg.keys["v2"] = std::string(40, 'B'); - cfg.access_ttl_sec = 60; - cfg.refresh_ttl_sec = 600; - return cfg; -} -} // namespace - -TEST(JwtConfig, ValidateRejectsShortKey) { - JwtConfig cfg; - cfg.current_kid = "v1"; - cfg.keys["v1"] = std::string(31, 'A'); - EXPECT_THROW(cfg.validate_or_throw(), std::runtime_error); -} - -TEST(JwtConfig, ValidateRejectsMissingCurrentKid) { - JwtConfig cfg; - cfg.current_kid = "v9"; - cfg.keys["v1"] = std::string(32, 'A'); - EXPECT_THROW(cfg.validate_or_throw(), std::runtime_error); -} - -TEST(JwtConfig, ValidateRejectsBadTtl) { - JwtConfig cfg = make_cfg(); - cfg.access_ttl_sec = 0; - EXPECT_THROW(cfg.validate_or_throw(), std::runtime_error); -} - -TEST(JwtCodec, AccessRoundtrip) { - JwtCodec codec(make_cfg()); - auto tok = codec.sign_access("u_1", "d_1", "jti_x"); - auto claims = codec.verify(tok); - EXPECT_EQ(claims.sub, "u_1"); - EXPECT_EQ(claims.did, "d_1"); - EXPECT_EQ(claims.jti, "jti_x"); - EXPECT_EQ(claims.kid, "v1"); - EXPECT_FALSE(claims.is_refresh); - EXPECT_GT(claims.exp_sec, claims.iat_sec); -} - -TEST(JwtCodec, RefreshRoundtrip) { - JwtCodec codec(make_cfg()); - auto tok = codec.sign_refresh("u_1", "d_1"); - auto claims = codec.verify(tok, /*require_refresh=*/true); - EXPECT_TRUE(claims.is_refresh); - EXPECT_EQ(claims.sub, "u_1"); - EXPECT_FALSE(claims.jti.empty()); -} - -TEST(JwtCodec, VerifyRejectsAccessWhenRefreshRequired) { - JwtCodec codec(make_cfg()); - auto tok = codec.sign_access("u", "d"); - EXPECT_THROW(codec.verify(tok, /*require_refresh=*/true), chatnow::ServiceError); -} - -TEST(JwtCodec, VerifyRejectsRefreshWhenAccessExpected) { - JwtCodec codec(make_cfg()); - auto tok = codec.sign_refresh("u", "d"); - EXPECT_THROW(codec.verify(tok, /*require_refresh=*/false), chatnow::ServiceError); -} - -TEST(JwtCodec, VerifyRejectsTamperedSignature) { - JwtCodec codec(make_cfg()); - auto tok = codec.sign_access("u", "d"); - tok[tok.size() - 2] = (tok[tok.size() - 2] == 'A') ? 'B' : 'A'; - try { - codec.verify(tok); - FAIL() << "should throw"; - } catch (const chatnow::ServiceError& e) { - EXPECT_EQ(e.code(), chatnow::error::kAuthTokenInvalid); - } -} - -TEST(JwtCodec, VerifyRejectsExpired) { - JwtConfig cfg = make_cfg(); - cfg.access_ttl_sec = 1; - JwtCodec codec(cfg); - auto tok = codec.sign_access("u", "d"); - std::this_thread::sleep_for(std::chrono::milliseconds(1100)); - try { - codec.verify(tok); - FAIL() << "should throw"; - } catch (const chatnow::ServiceError& e) { - EXPECT_EQ(e.code(), chatnow::error::kAuthTokenExpired); - } -} - -TEST(JwtCodec, VerifyRejectsUnknownKid) { - JwtCodec codec(make_cfg()); - auto tok = codec.sign_access("u", "d"); - - JwtConfig cfg2; - cfg2.current_kid = "v9"; - cfg2.keys["v9"] = std::string(32, 'C'); - cfg2.access_ttl_sec = 60; cfg2.refresh_ttl_sec = 600; - JwtCodec codec2(cfg2); - try { - codec2.verify(tok); - FAIL() << "should throw"; - } catch (const chatnow::ServiceError& e) { - EXPECT_EQ(e.code(), chatnow::error::kAuthTokenInvalid); - } -} - -TEST(JwtCodec, MultiKidVerifyByHeader) { - auto cfg = make_cfg(); // current_kid=v1 - JwtCodec codec_v1(cfg); - auto tok_v1 = codec_v1.sign_access("u", "d"); - EXPECT_EQ(codec_v1.verify(tok_v1).kid, "v1"); - - cfg.current_kid = "v2"; - JwtCodec codec_v2(cfg); - auto tok_v2 = codec_v2.sign_access("u", "d"); - - // codec_v1 持有 v1+v2,应能验签 v2 token - auto c = codec_v1.verify(tok_v2); - EXPECT_EQ(c.kid, "v2"); -} - -TEST(JwtCodec, RandomJtiUnique) { - auto a = JwtCodec::random_jti(); - auto b = JwtCodec::random_jti(); - EXPECT_EQ(a.size(), 32u); - EXPECT_NE(a, b); -} diff --git a/common/test/test_jwt_store.cc b/common/test/test_jwt_store.cc deleted file mode 100644 index b8590be..0000000 --- a/common/test/test_jwt_store.cc +++ /dev/null @@ -1,70 +0,0 @@ -// 需要本地 Redis 127.0.0.1:6379 db=15;CI 容器中已提供。 -#include "auth/jwt_store.hpp" - -#include -#include - -#include -#include - -namespace { -chatnow::RedisClient::ptr make_redis() { - sw::redis::ConnectionOptions opt; - opt.host = "127.0.0.1"; - opt.port = 6379; - opt.db = 15; - auto c = std::make_shared(opt); - c->flushdb(); - return std::make_shared(c); -} -} // namespace - -using chatnow::auth::JwtStore; - -TEST(JwtStore, RevokeAndCheck) { - auto r = make_redis(); - JwtStore store(r); - EXPECT_FALSE(store.is_revoked("jti_x")); - store.revoke("jti_x", 60); - EXPECT_TRUE(store.is_revoked("jti_x")); -} - -TEST(JwtStore, RevokeTtlExpires) { - auto r = make_redis(); - JwtStore store(r); - store.revoke("jti_y", 1); - EXPECT_TRUE(store.is_revoked("jti_y")); - std::this_thread::sleep_for(std::chrono::milliseconds(1100)); - EXPECT_FALSE(store.is_revoked("jti_y")); -} - -TEST(JwtStore, ActiveRefreshLifecycle) { - auto r = make_redis(); - JwtStore store(r); - EXPECT_TRUE(store.get_active_refresh("u1", "d1").empty()); - store.put_active_refresh("u1", "d1", "rt_jti_1", 3600); - EXPECT_EQ(store.get_active_refresh("u1", "d1"), "rt_jti_1"); - store.clear_active_refresh("u1", "d1"); - EXPECT_TRUE(store.get_active_refresh("u1", "d1").empty()); -} - -TEST(JwtStore, RotateOk) { - auto r = make_redis(); - JwtStore store(r); - store.put_active_refresh("u1", "d1", "old_jti", 3600); - auto res = store.rotate_refresh_or_detect_reuse( - "u1", "d1", "old_jti", "new_jti", 3600); - EXPECT_EQ(res, JwtStore::RotateResult::kOk); - EXPECT_EQ(store.get_active_refresh("u1", "d1"), "new_jti"); -} - -TEST(JwtStore, RotateDetectsReuse) { - auto r = make_redis(); - JwtStore store(r); - auto res1 = store.rotate_refresh_or_detect_reuse( - "u1", "d1", "old_jti", "new_jti_1", 3600); - EXPECT_EQ(res1, JwtStore::RotateResult::kOk); - auto res2 = store.rotate_refresh_or_detect_reuse( - "u1", "d1", "old_jti", "new_jti_2", 3600); - EXPECT_EQ(res2, JwtStore::RotateResult::kReuseDetected); -} diff --git a/common/test/test_log_context.cc b/common/test/test_log_context.cc deleted file mode 100644 index 3c1db32..0000000 --- a/common/test/test_log_context.cc +++ /dev/null @@ -1,53 +0,0 @@ -#include "log/log_context.hpp" -#include -#include - -using chatnow::log::LogContext; - -class LogContextTest : public ::testing::Test { -protected: - void TearDown() override { LogContext::clear(); } -}; - -TEST_F(LogContextTest, EmptyByDefault) { - EXPECT_TRUE(LogContext::current().empty()); - EXPECT_EQ(LogContext::format_prefix(), ""); -} - -TEST_F(LogContextTest, SetAndRead) { - LogContext::set("trace-1", "user-2", "device-3"); - EXPECT_EQ(LogContext::current().trace_id, "trace-1"); - EXPECT_EQ(LogContext::current().user_id, "user-2"); - EXPECT_EQ(LogContext::current().device_id, "device-3"); -} - -TEST_F(LogContextTest, FormatPrefixAllFields) { - LogContext::set("t", "u", "d"); - EXPECT_EQ(LogContext::format_prefix(), "[trace=t user=u device=d] "); -} - -TEST_F(LogContextTest, FormatPrefixPartial) { - LogContext::set("t", "", "d"); - EXPECT_EQ(LogContext::format_prefix(), "[trace=t device=d] "); -} - -TEST_F(LogContextTest, ClearResets) { - LogContext::set("t","u","d"); - LogContext::clear(); - EXPECT_TRUE(LogContext::current().empty()); -} - -TEST_F(LogContextTest, ThreadLocalIsolation) { - LogContext::set("main-trace","main-user","main-dev"); - - std::string other_trace_at_set; - std::thread t([&]() { - // 子线程刚启动时应当为空(bthread_key 在纯 pthread 模式下退化为 TLS,各自独立) - other_trace_at_set = LogContext::current().trace_id; - LogContext::set("child-trace","child-user","child-dev"); - }); - t.join(); - - EXPECT_EQ(other_trace_at_set, ""); - EXPECT_EQ(LogContext::current().trace_id, "main-trace"); -} diff --git a/common/test/test_log_json.cc b/common/test/test_log_json.cc deleted file mode 100644 index 2697c8c..0000000 --- a/common/test/test_log_json.cc +++ /dev/null @@ -1,85 +0,0 @@ -// common/test/test_log_json.cc -#include "infra/log_json.hpp" -#include "log/log_context.hpp" -#include - -using chatnow::infra::escape_json; -using chatnow::infra::build_log_line; -using chatnow::infra::set_service_name; -using chatnow::log::LogContext; - -class LogJsonTest : public ::testing::Test { -protected: - void SetUp() override { LogContext::clear(); set_service_name("testsvc"); } - void TearDown() override { LogContext::clear(); } -}; - -TEST_F(LogJsonTest, EscapeQuoteAndBackslash) { - EXPECT_EQ(escape_json("a\"b"), "a\\\"b"); - EXPECT_EQ(escape_json("a\\b"), "a\\\\b"); -} - -TEST_F(LogJsonTest, EscapeControlChars) { - EXPECT_EQ(escape_json("a\nb"), "a\\nb"); - EXPECT_EQ(escape_json("a\tb"), "a\\tb"); - EXPECT_EQ(escape_json("a\rb"), "a\\rb"); - EXPECT_EQ(escape_json("a\bb"), "a\\bb"); - EXPECT_EQ(escape_json("a\fb"), "a\\fb"); -} - -TEST_F(LogJsonTest, EscapeOtherControlAsUnicode) { - // 0x01 →  - std::string in; - in.push_back(static_cast(0x01)); - EXPECT_EQ(escape_json(in), "\\u0001"); -} - -TEST_F(LogJsonTest, NonAsciiPassthrough) { - // UTF-8 中文不应转义 - std::string s = "你好"; - EXPECT_EQ(escape_json(s), s); -} - -TEST_F(LogJsonTest, BuildLineHasRequiredKeys) { - LogContext::set("trace1", "u1", "d1"); - auto line = build_log_line("info", "msg1"); - EXPECT_NE(line.find("\"level\":\"info\""), std::string::npos); - EXPECT_NE(line.find("\"service\":\"testsvc\""), std::string::npos); - EXPECT_NE(line.find("\"trace_id\":\"trace1\""), std::string::npos); - EXPECT_NE(line.find("\"user_id\":\"u1\""), std::string::npos); - EXPECT_NE(line.find("\"device_id\":\"d1\""), std::string::npos); - EXPECT_NE(line.find("\"msg\":\"msg1\""), std::string::npos); - EXPECT_NE(line.find("\"fields\":{}"), std::string::npos); - EXPECT_NE(line.find("\"ts\":\""), std::string::npos); -} - -TEST_F(LogJsonTest, BuildLineFields) { - auto line = build_log_line("warn", "evt", {{"k1","v1"}, {"k2","v2"}}); - EXPECT_NE(line.find("\"fields\":{\"k1\":\"v1\",\"k2\":\"v2\"}"), - std::string::npos); -} - -TEST_F(LogJsonTest, BuildLineEscapesMsgAndFieldValue) { - auto line = build_log_line("info", "msg \"q\" \\back", - {{"k","v\nl2"}}); - EXPECT_NE(line.find("\"msg\":\"msg \\\"q\\\" \\\\back\""), - std::string::npos); - EXPECT_NE(line.find("\"k\":\"v\\nl2\""), - std::string::npos); -} - -TEST_F(LogJsonTest, BuildLineBalancedBraces) { - // 简易"JSON 语法体检":{ 与 } 数量平衡 - auto line = build_log_line("info", "x", {{"a","b"}}); - int open = 0, close = 0; - for (char c : line) { if (c == '{') ++open; if (c == '}') ++close; } - EXPECT_EQ(open, close); - EXPECT_EQ(open, 2); // 顶层 {} + fields {} -} - -TEST_F(LogJsonTest, EmptyContextStillProducesKeys) { - auto line = build_log_line("debug", "x"); - EXPECT_NE(line.find("\"trace_id\":\"\""), std::string::npos); - EXPECT_NE(line.find("\"user_id\":\"\""), std::string::npos); - EXPECT_NE(line.find("\"device_id\":\"\""), std::string::npos); -} diff --git a/common/test/test_magic_sniff.cc b/common/test/test_magic_sniff.cc deleted file mode 100644 index fbc9a54..0000000 --- a/common/test/test_magic_sniff.cc +++ /dev/null @@ -1,74 +0,0 @@ -#include -#include -#include -#include "utils/magic_sniff.hpp" - -namespace ms = chatnow::magic_sniff; - -static std::string bytes(std::initializer_list b) { - return std::string(b.begin(), b.end()); -} - -TEST(MagicSniff, JPEG) { - EXPECT_EQ(ms::sniff(bytes({0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0})), "image/jpeg"); -} -TEST(MagicSniff, PNG) { - EXPECT_EQ(ms::sniff(bytes({0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A})), "image/png"); -} -TEST(MagicSniff, GIF87) { - EXPECT_EQ(ms::sniff(bytes({'G', 'I', 'F', '8', '7', 'a', 0, 0})), "image/gif"); -} -TEST(MagicSniff, GIF89) { - EXPECT_EQ(ms::sniff(bytes({'G', 'I', 'F', '8', '9', 'a', 0, 0})), "image/gif"); -} -TEST(MagicSniff, WebP) { - auto b = std::string("RIFF\0\0\0\0WEBP", 12); - EXPECT_EQ(ms::sniff(b), "image/webp"); -} -TEST(MagicSniff, PE) { - EXPECT_EQ(ms::sniff(bytes({'M', 'Z', 0, 0, 0, 0, 0, 0})), "application/x-dosexec"); -} -TEST(MagicSniff, ELF) { - EXPECT_EQ(ms::sniff(bytes({0x7F, 'E', 'L', 'F', 0, 0, 0, 0})), "application/x-elf"); -} -TEST(MagicSniff, MachO_32_BE) { - EXPECT_EQ(ms::sniff(bytes({0xFE, 0xED, 0xFA, 0xCE, 0, 0, 0, 0})), "application/x-mach-binary"); -} -TEST(MagicSniff, MachO_32_LE) { - EXPECT_EQ(ms::sniff(bytes({0xCE, 0xFA, 0xED, 0xFE, 0, 0, 0, 0})), "application/x-mach-binary"); -} -TEST(MagicSniff, MachO_64_BE) { - EXPECT_EQ(ms::sniff(bytes({0xFE, 0xED, 0xFA, 0xCF, 0, 0, 0, 0})), "application/x-mach-binary"); -} -TEST(MagicSniff, MachO_64_LE) { - EXPECT_EQ(ms::sniff(bytes({0xCF, 0xFA, 0xED, 0xFE, 0, 0, 0, 0})), "application/x-mach-binary"); -} -TEST(MagicSniff, UnknownReturnsEmpty) { - EXPECT_EQ(ms::sniff(std::string(8, 'a')), ""); -} -TEST(MagicSniff, ShortBufferReturnsEmpty) { - EXPECT_EQ(ms::sniff(std::string("ab")), ""); -} - -TEST(MagicSniff, MatchesClaimed_OK) { - auto b = bytes({0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0}); - EXPECT_TRUE(ms::matches_claimed(b, "image/jpeg")); -} -TEST(MagicSniff, MatchesClaimed_DangerousMimeMismatch) { - // 客户端声称 image/jpeg,但实际是 PE - auto b = bytes({'M', 'Z', 0, 0, 0, 0, 0, 0}); - EXPECT_FALSE(ms::matches_claimed(b, "image/jpeg")); -} -TEST(MagicSniff, MatchesClaimed_DangerousAlwaysFalse) { - // 即使客户端声称 application/x-dosexec,也拒绝 - auto b = bytes({'M', 'Z', 0, 0, 0, 0, 0, 0}); - EXPECT_FALSE(ms::matches_claimed(b, "application/x-dosexec")); -} -TEST(MagicSniff, MatchesClaimed_UnknownIsLenient) { - // 检测不到(未知格式)→ 保守放行 - EXPECT_TRUE(ms::matches_claimed(std::string(8, 'a'), "application/octet-stream")); -} -TEST(MagicSniff, MatchesClaimed_ImageMismatch) { - auto b = bytes({0xFF, 0xD8, 0xFF, 0xE0, 0, 0, 0, 0}); - EXPECT_FALSE(ms::matches_claimed(b, "image/png")); -} diff --git a/common/test/test_mime_whitelist.cc b/common/test/test_mime_whitelist.cc deleted file mode 100644 index a14de3f..0000000 --- a/common/test/test_mime_whitelist.cc +++ /dev/null @@ -1,57 +0,0 @@ -#include -#include -#include "utils/mime_whitelist.hpp" - -using chatnow::MimeWhitelist; - -static const char* kJson = R"([ - {"prefix":"image/jpeg", "max_mb":20}, - {"prefix":"image/png", "max_mb":20}, - {"prefix":"video/mp4", "max_mb":500}, - {"prefix":"audio/aac", "max_mb":50}, - {"prefix":"application/pdf", "max_mb":100}, - {"prefix":"text/plain", "max_mb":5} -])"; - -TEST(MimeWhitelist, ParsesJson) { - MimeWhitelist wl; - ASSERT_TRUE(wl.load_json(kJson)); - EXPECT_TRUE(wl.is_allowed("image/jpeg", 1024)); - EXPECT_TRUE(wl.is_allowed("image/jpeg", 20LL * 1024 * 1024)); // 边界 - EXPECT_FALSE(wl.is_allowed("image/jpeg", 20LL * 1024 * 1024 + 1)); - EXPECT_TRUE(wl.is_allowed("video/mp4", 400LL * 1024 * 1024)); - EXPECT_FALSE(wl.is_allowed("video/mp4", 600LL * 1024 * 1024)); - EXPECT_FALSE(wl.is_allowed("image/heic", 1)); - EXPECT_FALSE(wl.is_allowed("image/jpeg", 0)); // size=0 拒绝 - EXPECT_EQ(wl.max_size("image/jpeg"), 20LL * 1024 * 1024); - EXPECT_EQ(wl.max_size("image/heic"), -1); -} - -TEST(MimeWhitelist, RejectsInvalidJson) { - MimeWhitelist wl; - EXPECT_FALSE(wl.load_json("not-json")); -} - -TEST(MimeWhitelist, RejectsNonArrayRoot) { - MimeWhitelist wl; - EXPECT_FALSE(wl.load_json(R"({"prefix":"image/jpeg","max_mb":20})")); -} - -TEST(MimeWhitelist, RejectsMalformedEntry) { - MimeWhitelist wl; - // 缺 max_mb - EXPECT_FALSE(wl.load_json(R"([{"prefix":"image/jpeg"}])")); - // max_mb 不是数字 - EXPECT_FALSE(wl.load_json(R"([{"prefix":"image/jpeg","max_mb":"20"}])")); - // max_mb 为 0 - EXPECT_FALSE(wl.load_json(R"([{"prefix":"image/jpeg","max_mb":0}])")); -} - -TEST(MimeWhitelist, FailedLoadDoesNotReplaceExisting) { - MimeWhitelist wl; - ASSERT_TRUE(wl.load_json(R"([{"prefix":"image/jpeg","max_mb":20}])")); - EXPECT_TRUE(wl.is_allowed("image/jpeg", 1)); - // 失败 load 不应清掉旧白名单 - EXPECT_FALSE(wl.load_json("not-json")); - EXPECT_TRUE(wl.is_allowed("image/jpeg", 1)); -} diff --git a/common/test/test_mq_trace_headers.cc b/common/test/test_mq_trace_headers.cc deleted file mode 100644 index 16ab7d9..0000000 --- a/common/test/test_mq_trace_headers.cc +++ /dev/null @@ -1,48 +0,0 @@ -// common/test/test_mq_trace_headers.cc -#include "mq/trace_headers.hpp" -#include "log/log_context.hpp" -#include - -using namespace chatnow::mq; -using chatnow::log::LogContext; - -class MqTraceHeadersTest : public ::testing::Test { -protected: - void TearDown() override { LogContext::clear(); } -}; - -TEST_F(MqTraceHeadersTest, InjectFromLogContext) { - LogContext::set("trace-abc", "u", "d"); - std::map h; - mq_inject_trace_headers(h); - EXPECT_EQ(h[kTraceHeader], "trace-abc"); -} - -TEST_F(MqTraceHeadersTest, InjectSkipsWhenContextEmpty) { - std::map h; - mq_inject_trace_headers(h); - EXPECT_EQ(h.find(kTraceHeader), h.end()); -} - -TEST_F(MqTraceHeadersTest, InjectPreservesOtherHeaders) { - LogContext::set("trace-xyz", "", ""); - std::map h{{"existing", "val"}}; - mq_inject_trace_headers(h); - EXPECT_EQ(h["existing"], "val"); - EXPECT_EQ(h[kTraceHeader], "trace-xyz"); -} - -TEST_F(MqTraceHeadersTest, ExtractPresent) { - std::map h{{kTraceHeader, "tt"}}; - EXPECT_EQ(mq_extract_trace_id(h), "tt"); -} - -TEST_F(MqTraceHeadersTest, ExtractMissingReturnsEmpty) { - std::map h; - EXPECT_EQ(mq_extract_trace_id(h), ""); -} - -TEST_F(MqTraceHeadersTest, ExtractIgnoresOtherKeys) { - std::map h{{"foo", "bar"}}; - EXPECT_EQ(mq_extract_trace_id(h), ""); -} diff --git a/common/test/test_mysql_user_block_compile.cc b/common/test/test_mysql_user_block_compile.cc deleted file mode 100644 index 96a5be2..0000000 --- a/common/test/test_mysql_user_block_compile.cc +++ /dev/null @@ -1,18 +0,0 @@ -// 占位编译断言:仅用于占住 common/test/CMakeLists.txt 的 test_*.cc GLOB。 -// -// UserBlockTable 真实签名验证留给 Task 10 集成测试(连真实 MySQL)。 -// 此 TU 故意不包含任何 DAO header,避免把 odb 链路拉进单测。 - -#include - -namespace chatnow { -class UserBlockTable; -} - -namespace { - -TEST(UserBlockDaoCompile, Placeholder) { - EXPECT_TRUE(true); -} - -} // namespace diff --git a/common/test/test_object_key.cc b/common/test/test_object_key.cc deleted file mode 100644 index 8a7d048..0000000 --- a/common/test/test_object_key.cc +++ /dev/null @@ -1,31 +0,0 @@ -#include -#include -#include "utils/object_key.hpp" - -namespace ok = chatnow::object_key; - -TEST(ObjectKey, ChatPrefixIncludesDate) { - // 1715644800000 ms = 2024-05-14 00:00:00 UTC - auto h = std::string("sha256:abcd") + std::string(60, 'e'); - auto k = ok::build("chat", h, 1715644800000LL); - EXPECT_EQ(k, "chat/2024/05/14/ab/abcd" + std::string(60, 'e')); -} - -TEST(ObjectKey, AvatarPrefixHasNoDate) { - auto h = std::string("sha256:") + std::string(64, '1'); - auto k = ok::build_flat("avatar", h); - EXPECT_EQ(k, "avatar/" + std::string(64, '1')); -} - -TEST(ObjectKey, YyyymmddIsUtc) { - // 1700000000000 ms = 2023-11-14 22:13:20 UTC - EXPECT_EQ(ok::yyyymmdd_slashed(1700000000000LL), "2023/11/14"); - // 1672531200000 ms = 2023-01-01 00:00:00 UTC - EXPECT_EQ(ok::yyyymmdd_slashed(1672531200000LL), "2023/01/01"); -} - -TEST(ObjectKey, EmptyHashYieldsEmptyHexShortPrefix) { - auto k = ok::build("chat", "not-a-hash", 1715644800000LL); - // hex_part 会返回空串;substr(0,2) 也空;输出 "chat/2024/05/14//" - EXPECT_EQ(k, "chat/2024/05/14//"); -} diff --git a/common/test/test_service_error.cc b/common/test/test_service_error.cc deleted file mode 100644 index c0599a2..0000000 --- a/common/test/test_service_error.cc +++ /dev/null @@ -1,27 +0,0 @@ -#include "error/service_error.hpp" -#include - -TEST(ServiceError, ConstructAndAccess) { - chatnow::ServiceError e(1001, "bad credentials"); - EXPECT_EQ(e.code(), 1001); - EXPECT_EQ(e.message(), "bad credentials"); - EXPECT_STREQ(e.what(), "bad credentials"); -} - -TEST(ServiceError, ThrowAndCatch) { - try { - throw chatnow::ServiceError(9001, "internal"); - FAIL() << "expected throw"; - } catch (const chatnow::ServiceError& e) { - EXPECT_EQ(e.code(), 9001); - EXPECT_EQ(e.message(), "internal"); - } catch (...) { - FAIL() << "wrong exception type"; - } -} - -TEST(ServiceError, MoveSemantics) { - std::string msg(1024, 'x'); - chatnow::ServiceError e(4001, std::move(msg)); - EXPECT_EQ(e.message().size(), 1024u); -} diff --git a/common/test/test_trace_id.cc b/common/test/test_trace_id.cc deleted file mode 100644 index cdc0c5e..0000000 --- a/common/test/test_trace_id.cc +++ /dev/null @@ -1,50 +0,0 @@ -// common/test/test_trace_id.cc -#include "utils/trace_id.hpp" -#include -#include - -using chatnow::utils::gen_trace_id; -using chatnow::utils::is_valid_trace_id; - -TEST(TraceId, FormatLength32) { - auto t = gen_trace_id(); - EXPECT_EQ(t.size(), 32u); -} - -TEST(TraceId, AllHexLowercase) { - auto t = gen_trace_id(); - for (char c : t) { - bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); - EXPECT_TRUE(ok) << "non-hex char: " << c; - } -} - -TEST(TraceId, IsValidPositive) { - EXPECT_TRUE(is_valid_trace_id("0123456789abcdef0123456789abcdef")); - EXPECT_TRUE(is_valid_trace_id(gen_trace_id())); -} - -TEST(TraceId, IsValidNegativeWrongLen) { - EXPECT_FALSE(is_valid_trace_id("")); - EXPECT_FALSE(is_valid_trace_id("abc")); - EXPECT_FALSE(is_valid_trace_id(std::string(31, 'a'))); - EXPECT_FALSE(is_valid_trace_id(std::string(33, 'a'))); -} - -TEST(TraceId, IsValidNegativeUppercase) { - EXPECT_FALSE(is_valid_trace_id("0123456789ABCDEF0123456789abcdef")); -} - -TEST(TraceId, IsValidNegativeNonHex) { - EXPECT_FALSE(is_valid_trace_id("0123456789abcdef0123456789abcdez")); - EXPECT_FALSE(is_valid_trace_id("0123456789abcdef0123456789abcde ")); -} - -TEST(TraceId, UniquenessOver1000Calls) { - std::set seen; - for (int i = 0; i < 1000; ++i) { - seen.insert(gen_trace_id()); - } - // 概率上 1000 个 128bit 随机值碰撞接近 0 - EXPECT_EQ(seen.size(), 1000u); -} diff --git a/identity/test/identity_client.cc b/identity/test/identity_client.cc deleted file mode 100644 index 7615d25..0000000 --- a/identity/test/identity_client.cc +++ /dev/null @@ -1,293 +0,0 @@ -#include "infra/etcd.hpp" -#include "mq/channel.hpp" -#include -#include -#include -#include "utils/utils.hpp" -#include "identity/identity_service.pb.h" -#include "common/types.pb.h" -#include "common/error.pb.h" -#include "common/envelope.pb.h" - -DEFINE_bool(run_mode, false, "程序的运行模式 false-调试 ; true-发布"); -DEFINE_string(log_file, "", "发布模式下,用于指定日志的输出文件"); -DEFINE_int32(log_level, 0, "发布模式下,用于指定日志的输出等级"); - -DEFINE_string(etcd_host, "http://127.0.0.1:2379", "服务注册中心地址"); -DEFINE_string(base_service, "/service", "服务监控根目录"); -DEFINE_string(identity_service, "/service/identity_service", "Identity 服务 etcd 路径"); - -chatnow::ServiceManager::ptr identity_channels; - -chatnow::UserInfo user_info; - -std::string login_ssid; -std::string new_nickname = "亲爱的猪妈妈"; - -//TEST(用户子服务测试, 用户注册测试) { -// auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 -// ASSERT_TRUE(channel); -// -// chatnow::UserRegisterReq req; -// req.set_request_id(chatnow::uuid()); -// req.set_nickname(user_info.nickname()); -// req.set_password("123456"); -// chatnow::UserRegisterRsp rsp; -// brpc::Controller cntl; -// chatnow::UserService_Stub stub(channel.get()); -// stub.UserRegister(&cntl, &req, &rsp, nullptr); -// ASSERT_FALSE(cntl.Failed()); -// ASSERT_TRUE(rsp.success()); -//} - -TEST(用户子服务测试, 用户登录测试) { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::UserLoginReq req; - req.set_request_id(chatnow::uuid()); - req.set_nickname(new_nickname); - req.set_password("123456"); - chatnow::UserLoginRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.UserLogin(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); - login_ssid = rsp.login_session_id(); -} - -TEST(用户子服务测试, 用户头像测试) { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::SetUserAvatarReq req; - req.set_request_id(chatnow::uuid()); - req.set_user_id(user_info.user_id()); - req.set_session_id(login_ssid); - req.set_avatar(user_info.avatar()); - chatnow::SetUserAvatarRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.SetUserAvatar(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); -} - -TEST(用户子服务测试, 用户签名测试) { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::SetUserDescriptionReq req; - req.set_request_id(chatnow::uuid()); - req.set_user_id(user_info.user_id()); - req.set_session_id(login_ssid); - req.set_description(user_info.description()); - chatnow::SetUserDescriptionRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.SetUserDescription(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); -} - -TEST(用户子服务测试, 用户昵称测试) { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::SetUserNicknameReq req; - req.set_request_id(chatnow::uuid()); - req.set_user_id(user_info.user_id()); - req.set_session_id(login_ssid); - req.set_nickname(new_nickname); - chatnow::SetUserNicknameRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.SetUserNickname(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); -} - -void set_user_avatar(const std::string &uid, const std::string &avatar) { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::SetUserAvatarReq req; - req.set_request_id(chatnow::uuid()); - req.set_user_id(uid); - req.set_session_id(login_ssid); - req.set_avatar(avatar); - chatnow::SetUserAvatarRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.SetUserAvatar(&cntl, &req, &rsp, nullptr); -} - -std::string code_id; - -void get_code() { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::MailVerifyCodeReq req; - req.set_request_id(chatnow::uuid()); - req.set_mail_number(user_info.mail()); - chatnow::MailVerifyCodeRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.GetMailVerifyCode(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); - - code_id = rsp.verify_code_id(); -} - -TEST(用户子服务测试, 用户信息获取测试) { - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::GetUserInfoReq req; - req.set_request_id(chatnow::uuid()); - req.set_user_id(user_info.user_id()); - req.set_session_id(login_ssid); - chatnow::GetUserInfoRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.GetUserInfo(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); - ASSERT_EQ(user_info.user_id(), rsp.user_info().user_id()); - ASSERT_EQ(new_nickname, rsp.user_info().nickname()); - ASSERT_EQ(user_info.description(), rsp.user_info().description()); - ASSERT_EQ("", rsp.user_info().mail()); - ASSERT_EQ(user_info.avatar(), rsp.user_info().avatar()); -} - -TEST(用户子服务测试, 批量用户信息获取测试) { - set_user_avatar("用户ID1", "小猪佩奇的头像数据"); - set_user_avatar("用户ID2", "小猪乔治的头像数据"); - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::GetMultiUserInfoReq req; - req.set_request_id(chatnow::uuid()); - req.add_users_id("c1f9-10706913-0000"); - req.add_users_id("用户ID1"); - req.add_users_id("用户ID2"); - chatnow::GetMultiUserInfoRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.GetMultiUserInfo(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); - auto users_map = rsp.mutable_users_info(); - chatnow::UserInfo father_user = (*users_map)["c1f9-10706913-0000"]; - ASSERT_EQ(father_user.user_id(), "c1f9-10706913-0000"); - ASSERT_EQ(father_user.nickname(), "猪爸爸"); - ASSERT_EQ(father_user.description(), ""); - ASSERT_EQ(father_user.mail(), ""); - ASSERT_EQ(father_user.avatar(), ""); - chatnow::UserInfo p_user = (*users_map)["用户ID1"]; - ASSERT_EQ(p_user.user_id(), "用户ID1"); - ASSERT_EQ(p_user.nickname(), "小猪佩奇"); - ASSERT_EQ(p_user.description(), "这是一只小猪"); - ASSERT_EQ(p_user.mail(), "1234@qq.com"); - ASSERT_EQ(p_user.avatar(), "小猪佩奇的头像数据"); - chatnow::UserInfo q_user = (*users_map)["用户ID2"]; - ASSERT_EQ(q_user.user_id(), "用户ID2"); - ASSERT_EQ(q_user.nickname(), "小猪乔治"); - ASSERT_EQ(q_user.description(), "这是一只小小猪"); - ASSERT_EQ(q_user.mail(), "2345@qq.com"); - ASSERT_EQ(q_user.avatar(), "小猪乔治的头像数据"); -} - -TEST(用户子服务测试, 邮箱注册测试) { - get_code(); - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::MailRegisterReq req; - req.set_request_id(chatnow::uuid()); - req.set_mail_number(user_info.mail()); - req.set_verify_code_id(code_id); - std::string code; - std::cin >> code; - req.set_verify_code(code); - chatnow::MailRegisterRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.MailRegister(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); -} - -TEST(用户子服务测试, 邮箱登录测试) { - std::this_thread::sleep_for(std::chrono::seconds(3)); - get_code(); - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::MailLoginReq req; - req.set_request_id(chatnow::uuid()); - req.set_mail_number(user_info.mail()); - req.set_verify_code_id(code_id); - std::cout << "邮箱登录时, 输入验证码: " << std::endl; - std::string code; - std::cin >> code; - req.set_verify_code(code); - chatnow::MailLoginRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.MailLogin(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); - std::cout << "邮箱登录会话ID" << rsp.login_session_id() << std::endl; -} - -TEST(用户子服务测试, 邮箱设置测试) { - get_code(); - auto channel = identity_channels->choose(FLAGS_identity_service); //获取通信信道 - ASSERT_TRUE(channel); - - chatnow::SetUserMailNumberReq req; - req.set_request_id(chatnow::uuid()); - std::cout << "邮箱设置时, 输入用户ID: " << std::endl; - std::string user_id; - std::cin >> user_id; - req.set_user_id(user_id); - req.set_mail_number("chbulookup@outlook.com"); - req.set_mail_verify_code_id(code_id); - std::cout << "邮箱设置时, 输入验证码: " << std::endl; - std::string code; - std::cin >> code; - req.set_mail_verify_code(code); - chatnow::SetUserMailNumberRsp rsp; - brpc::Controller cntl; - chatnow::UserService_Stub stub(channel.get()); - stub.SetUserMailNumber(&cntl, &req, &rsp, nullptr); - ASSERT_FALSE(cntl.Failed()); - ASSERT_TRUE(rsp.success()); -} - -int main(int argc, char *argv[]) -{ - testing::InitGoogleTest(&argc, argv); - google::ParseCommandLineFlags(&argc, &argv, true); - chatnow::init_logger(FLAGS_run_mode, FLAGS_log_file, FLAGS_log_level); - - //1. 先构造 Rpc 信道管理对象 - identity_channels = std::make_shared(); - identity_channels->declared(FLAGS_identity_service); - auto put_cb = std::bind(&chatnow::ServiceManager::onServiceOnline, identity_channels.get(), std::placeholders::_1, std::placeholders::_2); - auto del_cb = std::bind(&chatnow::ServiceManager::onServiceOffline, identity_channels.get(), std::placeholders::_1, std::placeholders::_2); - //2. 构造服务发现对象 - chatnow::Discovery::ptr dclient = std::make_shared(FLAGS_etcd_host, FLAGS_base_service, put_cb, del_cb); - - user_info.set_nickname("猪妈妈"); - user_info.set_user_id("a62c-7f60fa42-0000"); - user_info.set_description("这是一个美丽的猪妈妈"); - user_info.set_mail("459096189@qq.com"); - user_info.set_avatar("猪妈妈头像数据"); - - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/media/test/smoke/README.md b/media/test/smoke/README.md deleted file mode 100644 index 0acc76d..0000000 --- a/media/test/smoke/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# P4 Media Smoke Test - -## Prereq - -```bash -# 1. 启 MinIO + 初始化 bucket -cd docker && docker compose up -d minio minio-init && cd .. - -# 2. 应用 schema(任选一) -# A. ODB schema 已在 build 时由 --generate-schema 输出 SQL 文件 -mysql -uroot -p chatnow < build/file/media_file.sql -mysql -uroot -p chatnow < build/file/media_blob_ref.sql -mysql -uroot -p chatnow < build/file/media_user_quota.sql -# B. 或直接走参考 SQL(与 ODB 等价) -mysql -uroot -p chatnow < sql/V4__media.sql - -# 3. 启 media_server -cd build/file -./media_server --media_conf=../../conf/media.json \ - --mysql_host=127.0.0.1 --mysql_user=root --mysql_pswd=YOUR_PASS \ - --mysql_db=chatnow --mysql_cset=utf8mb4 \ - --redis_host=127.0.0.1 --redis_port=6379 & -cd - -``` - -## Run - -```bash -TEST_USER_ID=u_smoke_001 \ -MYSQL_PASSWORD=YOUR_PASS \ -bash file/test/smoke/run_smoke.sh -``` - -## Expected - -- ApplyUpload 返回 `file_id` + presigned `upload_url` -- `curl PUT 'hello'` 返回 200 -- CompleteUpload 返回 FileInfo(含 file_size=5) -- ApplyDownload 返回 download_url -- `curl $download_url` 返回 `hello` -- mysql 查到 `media_user_quota.used_bytes == 5` - -## Failure modes - -| 现象 | 排查 | -|-------------------------------------|------------------------------------------------------| -| ApplyUpload 5xx | media_server stderr;mime/quota/content_hash 校验失败 | -| ApplyUpload 5004 | 配额超限(5GB 默认) | -| curl PUT 403 | presigned URL 过期 或 Content-Length 头与实际 body 不符 | -| CompleteUpload 5006 UPLOAD_INCOMPLETE | 客户端未先 PUT bytes 就调 Complete | -| CompleteUpload 5005 HASH_MISMATCH | PUT body size 与 ApplyUpload 声明 file_size 不一致 | -| Download GET 404 | bucket policy 未生效,重跑 minio-init | diff --git a/media/test/smoke/run_smoke.sh b/media/test/smoke/run_smoke.sh deleted file mode 100755 index a3dee32..0000000 --- a/media/test/smoke/run_smoke.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# P4 Media smoke test -# --------------------------------------------------------------------------- -# 前置(手动): -# 1. cd docker && docker compose up -d minio minio-init -# 2. mysql -uroot -p chatnow < sql/V4__media.sql # 或让 ODB --generate-schema 建表 -# 3. build/file/media_server --media_conf=conf/media.json & -# 4. 安装:curl + python3 + brpc 自带的 HTTP+JSON 入站(默认开启) -# --------------------------------------------------------------------------- -# 验证 happy path: -# ApplyUpload → curl PUT bytes → CompleteUpload → ApplyDownload → curl GET -# 最后用 mysql CLI 校验 media_user_quota 已 +5 -# --------------------------------------------------------------------------- - -set -euo pipefail - -HOST="${MEDIA_HOST:-127.0.0.1:10002}" -USER_ID="${TEST_USER_ID:-u_smoke_001}" -DEVICE_ID="${TEST_DEVICE_ID:-d_smoke_001}" -TRACE_ID="${TEST_TRACE_ID:-$(printf '%032x' $RANDOM$RANDOM$RANDOM$RANDOM)}" -REQID="$(uuidgen 2>/dev/null || python3 -c 'import uuid;print(uuid.uuid4())')" - -# brpc HTTP 入站:path=//,body 是 json2pb -auth_headers=( - -H "x-user-id: $USER_ID" - -H "x-device-id: $DEVICE_ID" - -H "x-trace-id: $TRACE_ID" - -H "Content-Type: application/json" -) - -# 计算 sha256 of "hello" -HASH_HEX=$(printf 'hello' | sha256sum | awk '{print $1}') -HASH="sha256:${HASH_HEX}" - -echo ">>> ApplyUpload" -APPLY_RSP=$(curl -s "${auth_headers[@]}" -X POST "http://$HOST/chatnow.media.MediaService/ApplyUpload" \ - -d "{\"request_id\":\"$REQID\",\"file_name\":\"hi.txt\",\"file_size\":5,\"mime_type\":\"text/plain\",\"content_hash\":\"$HASH\",\"purpose\":\"CHAT\"}") -echo "$APPLY_RSP" -FILE_ID=$(echo "$APPLY_RSP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["file_id"])') -URL=$(echo "$APPLY_RSP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["upload_url"])') - -echo ">>> PUT bytes" -curl -X PUT --data-binary 'hello' -H 'Content-Type: text/plain' -H 'Content-Length: 5' "$URL" - -echo ">>> CompleteUpload" -curl -s "${auth_headers[@]}" -X POST "http://$HOST/chatnow.media.MediaService/CompleteUpload" \ - -d "{\"request_id\":\"$REQID\",\"file_id\":\"$FILE_ID\"}" -echo - -echo ">>> ApplyDownload" -DRSP=$(curl -s "${auth_headers[@]}" -X POST "http://$HOST/chatnow.media.MediaService/ApplyDownload" \ - -d "{\"request_id\":\"$REQID\",\"file_id\":\"$FILE_ID\"}") -echo "$DRSP" -GET_URL=$(echo "$DRSP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["download_url"])') - -echo ">>> GET bytes" -BODY=$(curl -s "$GET_URL") -[[ "$BODY" == "hello" ]] || { echo "FAIL: body mismatch '$BODY'"; exit 1; } - -echo ">>> Quota check" -mysql -h "${MYSQL_HOST:-127.0.0.1}" -u"${MYSQL_USER:-root}" -p"${MYSQL_PASSWORD:-root}" \ - "${MYSQL_DB:-chatnow}" -e \ - "SELECT user_id, used_bytes FROM media_user_quota WHERE user_id='$USER_ID'" - -echo ">>> smoke pass" diff --git a/media/test/test_media_dao_integration.cc b/media/test/test_media_dao_integration.cc deleted file mode 100644 index 6d5e3cc..0000000 --- a/media/test/test_media_dao_integration.cc +++ /dev/null @@ -1,160 +0,0 @@ -/** - * media DAO 集成测试 —— 仅当 DB_TEST=1 时跑 - * --- - * 前置:MySQL 已启动;ODB 自动建表(schema 由 file/CMakeLists.txt 中 - * odb --generate-schema 输出)。 - * 运行: - * DB_TEST=1 \ - * DB_HOST=127.0.0.1 DB_USER=root DB_PASS=root DB_NAME=chatnow \ - * ctest -R media_dao_integration --output-on-failure - * - * 测试覆盖: - * - MediaFile insert / select / update_status - * - MediaBlobRef upsert / inc_ref / dec_ref / list_zero_ref_older_than - * - MediaUserQuota ensure / inc_used / dec_used - */ - -#include -#include -#include -#include -#include - -#include -#include -#include - -#include "dao/mysql.hpp" -#include "dao/mysql_media_file.hpp" -#include "dao/mysql_media_blob_ref.hpp" -#include "dao/mysql_media_user_quota.hpp" - -using namespace chatnow; - -static bool db_enabled() { - const char* e = std::getenv("DB_TEST"); - return e && std::strcmp(e, "1") == 0; -} - -static const char* env_or(const char* k, const char* dft) { - const char* v = std::getenv(k); - return v && *v ? v : dft; -} - -static std::shared_ptr open_db() { - return ODBFactory::create( - env_or("DB_USER", "root"), - env_or("DB_PASS", "root"), - env_or("DB_HOST", "127.0.0.1"), - env_or("DB_NAME", "chatnow"), - "utf8mb4", - std::atoi(env_or("DB_PORT", "3306")), - 4); -} - -static boost::posix_time::ptime now_utc() { - return boost::posix_time::microsec_clock::universal_time(); -} - -TEST(MediaDaoIT, RefCountFlow) { - if (!db_enabled()) GTEST_SKIP() << "DB_TEST!=1"; - auto db = open_db(); - MediaFileTable files(db); - MediaBlobRefTable blobs(db); - - const std::string hash = "sha256:" + std::string(64, 'a'); - - MediaFile f; - f.file_id("ftest_" + std::to_string(::rand())); - f.content_hash(hash); - f.bucket("chatnow-media-private"); - f.object_key("chat/2026/05/14/aa/" + std::string(64, 'a')); - f.file_name("x.jpg"); - f.file_size(100); - f.mime_type("image/jpeg"); - f.purpose(MediaPurpose::CHAT); - f.owner_id("u_test"); - f.uploaded_at(now_utc()); - f.status(MediaFileStatus::PENDING); - ASSERT_TRUE(files.insert(f)); - - MediaBlobRef br; - br.content_hash(hash); - br.bucket("chatnow-media-private"); - br.object_key(f.object_key()); - br.ref_count(0); - br.total_size(100); - br.last_decremented_at(now_utc()); - ASSERT_TRUE(blobs.upsert(br)); - - ASSERT_TRUE(blobs.inc_ref(hash)); - auto b = blobs.select_by_hash(hash); - ASSERT_TRUE(b); - EXPECT_EQ(b->ref_count(), 1); - - ASSERT_TRUE(blobs.dec_ref(hash, now_utc())); - b = blobs.select_by_hash(hash); - EXPECT_EQ(b->ref_count(), 0); - - // dec_ref 再次:保持 0,不溢出 - ASSERT_TRUE(blobs.dec_ref(hash, now_utc())); - b = blobs.select_by_hash(hash); - EXPECT_EQ(b->ref_count(), 0); - - // 清理 - blobs.erase(hash); - files.update_status(f.file_id(), MediaFileStatus::DELETED); -} - -TEST(MediaDaoIT, QuotaBasicFlow) { - if (!db_enabled()) GTEST_SKIP() << "DB_TEST!=1"; - auto db = open_db(); - MediaUserQuotaTable q(db); - - auto uid = "u_quota_test_" + std::to_string(::rand()); - - auto row = q.ensure(uid); - EXPECT_EQ(row.used_bytes(), 0); - EXPECT_EQ(row.quota_bytes(), kMediaDefaultQuotaBytes); - - ASSERT_TRUE(q.inc_used(uid, 1000, now_utc())); - auto row2 = q.ensure(uid); - EXPECT_EQ(row2.used_bytes(), 1000); - - ASSERT_TRUE(q.dec_used(uid, 300, now_utc())); - auto row3 = q.ensure(uid); - EXPECT_EQ(row3.used_bytes(), 700); - - // dec 超过 used:不溢出,归 0 - ASSERT_TRUE(q.dec_used(uid, 10000, now_utc())); - auto row4 = q.ensure(uid); - EXPECT_EQ(row4.used_bytes(), 0); -} - -TEST(MediaDaoIT, FilePendingTimeoutScan) { - if (!db_enabled()) GTEST_SKIP() << "DB_TEST!=1"; - auto db = open_db(); - MediaFileTable files(db); - - auto past = now_utc() - boost::posix_time::hours(2); - MediaFile f; - f.file_id("fpend_" + std::to_string(::rand())); - f.content_hash("sha256:" + std::string(64, 'b')); - f.bucket("chatnow-media-private"); - f.object_key("chat/2026/05/14/bb/" + std::string(64, 'b')); - f.file_name("y.txt"); - f.file_size(50); - f.mime_type("text/plain"); - f.purpose(MediaPurpose::CHAT); - f.owner_id("u_pending"); - f.uploaded_at(past); - f.status(MediaFileStatus::PENDING); - ASSERT_TRUE(files.insert(f)); - - auto rows = files.list_pending_older_than(now_utc() - boost::posix_time::hours(1), 50); - bool seen = false; - for (auto& x : rows) if (x.file_id() == f.file_id()) { seen = true; break; } - EXPECT_TRUE(seen); - - files.update_status(f.file_id(), MediaFileStatus::DELETED); -} diff --git a/media/test/test_s3_integration.cc b/media/test/test_s3_integration.cc deleted file mode 100644 index 4de4cbf..0000000 --- a/media/test/test_s3_integration.cc +++ /dev/null @@ -1,116 +0,0 @@ -/** - * MinIO 集成测试 —— 仅当环境变量 MINIO_TEST=1 时才跑 - * --- - * 前置:docker compose up -d minio minio-init - * (bucket chatnow-media-public / chatnow-media-private 已创建) - * - * 运行: - * MINIO_TEST=1 ctest -R s3_integration --output-on-failure - * - * 测试覆盖: - * - presigned PUT + HEAD + Range GET + DELETE - * - init/abort multipart - * - list_multipart_uploads - */ - -#include -#include -#include -#include - -#include -#include -#include "infra/s3_client.hpp" - -using namespace chatnow; - -static bool minio_enabled() { - const char* e = std::getenv("MINIO_TEST"); - return e && std::strcmp(e, "1") == 0; -} - -static S3Options test_opts() { - return S3Options{ - /*endpoint*/ "http://127.0.0.1:9000", - /*region*/ "us-east-1", - /*access_key*/ "minioadmin", - /*secret_key*/ "minioadmin", - /*path_style*/ true - }; -} - -static int curl_put(const std::string& url, const std::string& body, const std::string& mime) { - CURL* h = curl_easy_init(); - EXPECT_NE(h, nullptr); - curl_easy_setopt(h, CURLOPT_URL, url.c_str()); - curl_easy_setopt(h, CURLOPT_CUSTOMREQUEST, "PUT"); - curl_easy_setopt(h, CURLOPT_POSTFIELDS, body.data()); - curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, static_cast(body.size())); - struct curl_slist* hl = nullptr; - std::string ct = "Content-Type: " + mime; - hl = curl_slist_append(hl, ct.c_str()); - curl_easy_setopt(h, CURLOPT_HTTPHEADER, hl); - auto rc = curl_easy_perform(h); - long code = 0; - curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &code); - curl_easy_cleanup(h); - curl_slist_free_all(hl); - return rc == CURLE_OK ? static_cast(code) : -1; -} - -class S3IntegrationFixture : public ::testing::Test { -protected: - static void SetUpTestSuite() { - Aws::SDKOptions opt; - Aws::InitAPI(opt); - _sdk = opt; - } - static void TearDownTestSuite() { - Aws::ShutdownAPI(_sdk); - } - inline static Aws::SDKOptions _sdk{}; -}; - -TEST_F(S3IntegrationFixture, PutGetRoundtrip) { - if (!minio_enabled()) GTEST_SKIP() << "MINIO_TEST!=1"; - - S3Client c(test_opts()); - const std::string bucket = "chatnow-media-private"; - const std::string key = "test/k1"; - auto put = c.presigned_put(bucket, key, 60, {{"Content-Type", "text/plain"}}); - ASSERT_FALSE(put.empty()); - - EXPECT_EQ(curl_put(put, "hello", "text/plain"), 200); - - auto head = c.head_object(bucket, key); - EXPECT_EQ(head.content_length, 5); - EXPECT_FALSE(head.etag.empty()); - - auto data = c.get_range(bucket, key, 5); - EXPECT_EQ(data, "hello"); - - c.delete_object(bucket, key); -} - -TEST_F(S3IntegrationFixture, MultipartInitAbort) { - if (!minio_enabled()) GTEST_SKIP() << "MINIO_TEST!=1"; - - S3Client c(test_opts()); - const std::string bucket = "chatnow-media-private"; - const std::string key = "test/multipart_abort"; - - auto upload_id = c.init_multipart(bucket, key, "application/octet-stream"); - ASSERT_FALSE(upload_id.empty()); - - auto part_url = c.presigned_part(bucket, key, upload_id, 1, 60); - ASSERT_FALSE(part_url.empty()); - - auto ups = c.list_multipart_uploads(bucket); - bool seen = false; - for (const auto& u : ups) { - if (u.upload_id == upload_id) { seen = true; break; } - } - EXPECT_TRUE(seen); - - c.abort_multipart(bucket, key, upload_id); -} From d2d20180d38e94199850360d8222b1269598df6c Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 18:22:24 +0800 Subject: [PATCH 23/28] docs(test): design Go coverage for removed C++ tests --- ...6-07-15-phase2-cpp-coverage-gaps-design.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-phase2-cpp-coverage-gaps-design.md diff --git a/docs/superpowers/specs/2026-07-15-phase2-cpp-coverage-gaps-design.md b/docs/superpowers/specs/2026-07-15-phase2-cpp-coverage-gaps-design.md new file mode 100644 index 0000000..29032f0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-phase2-cpp-coverage-gaps-design.md @@ -0,0 +1,125 @@ +# Phase 2 C++ 覆盖缺口补齐设计 + +**目标:** 在保留 Task 22 C++ 测试删除的前提下,把 avatar URL、Gateway trace、MQ trace、media object key 与 magic sniff 的等价行为覆盖融入现有 Go 全栈测试框架。 + +**范围:** 仅修改 `tests/pkg/` 测试基础设施与 `tests/func/` 黑盒用例;不恢复 C++ 测试,不修改生产代码,不新增独立测试框架。 + +## 方案选择 + +采用“共享能力下沉到 `tests/pkg`,行为断言留在 `tests/func`”方案。 + +- 不采用“只修正文档映射”:无法补足 reviewer 发现的真实覆盖缺口。 +- 不采用“恢复少量 C++ 单测”:违背统一 Go 黑盒测试架构与 Task 22 的删除目标。 +- 不把所有逻辑堆进单个 scenario:会重复 HTTP、上传与 DB 查询代码,也无法沿用既有 fixture/verifier 约束。 + +## 框架集成 + +### `tests/pkg/client` + +在 `HTTPClient` 保持现有 `Do` / `DoAuth` / `DoNoAuth` API 不变的前提下,增加 trace-aware 请求入口: + +```go +func (c *HTTPClient) DoWithTrace( + path string, + req proto.Message, + resp proto.Message, + accessToken string, + traceID string, +) (http.Header, error) +``` + +该方法设置 `X-Trace-Id`,返回响应 header。原有 `Do` 复用同一内部发送函数但丢弃 header,避免复制 HTTP/protobuf 逻辑。 + +### `tests/pkg/fixture` + +增加按用途上传入口: + +```go +func UploadFileForPurpose( + t testing.TB, + c *client.HTTPClient, + content []byte, + mime string, + purpose media.MediaPurpose, +) string +``` + +现有 `UploadFile` 保持签名不变,内部以 `media.MediaPurpose_CHAT` 调用新入口。Fixture 只在不可继续时 `t.Fatal`,仍只返回 `fileID`,不承载行为断言。 + +### `tests/pkg/verify` + +给 `DBVerifier` 增加只读媒体记录查询: + +```go +type MediaFileRecord struct { + Bucket string + ObjectKey string + Status int +} + +func (v *DBVerifier) MediaFile(t testing.TB, fileID string) MediaFileRecord +``` + +测试函数负责对 object key、bucket、状态做断言;verifier 只负责稳定查询并在查询失败时终止当前测试。 + +## 用例设计 + +### FN-AM-06:Gateway Trace Header + +文件:`tests/func/auth_middleware_test.go` + +使用固定的 32 位小写十六进制 trace ID 调用真实 Gateway,断言响应 `X-Trace-Id` 与请求值完全一致。该用例替代已删除的 trace ID 工具单测,验证的是外部可观察的 Gateway 行为。 + +### FN-WS-08:MQ Trace Propagation + +文件:`tests/func/ws_notify_test.go` + +通过 `setupConv`/现有 fixture 建立会话并连接接收方 WS;发送消息时指定 trace ID,等待 `CHAT_MESSAGE_NOTIFY`,断言 `NotifyMessage.trace_id` 与发送请求相同。链路覆盖 Gateway metadata → transmite MQ header → push consumer → WS notify。 + +### FN-ID-08:Avatar Upload and URL + +文件:`tests/func/identity_test.go` + +使用 `UploadFileForPurpose(..., AVATAR)` 上传合法图片,再调用 `UpdateProfile(avatar_file_id)` 与 `GetProfile`。断言 URL 非空、使用公开媒体路径,并以 `/avatar/` 结束。这是已删除 avatar URL 单测的外部行为替代。 + +代码层检查显示当前生产实现可能未包含 `/avatar/` 段;本 PR 不修改生产代码,因此该用例在真实环境运行时可能暴露现有生产缺陷。按用户要求本次不执行测试,也不声称其已通过。 + +### FN-MD-19:Object Key Layout + +文件:`tests/func/media_test.go` + +分别上传 CHAT 与 AVATAR 文件,通过 `DBVerifier.MediaFile` 验证: + +- CHAT:private bucket,key 匹配 `chat/YYYY/MM/DD//<64位hash>`,并核对 hash 后缀。 +- AVATAR:public bucket,key 精确匹配 `avatar/<64位hash>`。 + +该用例保留黑盒上传主路径,只用 DB 直查验证不可由公开 API 暴露的持久化 key。 + +### FN-MD-20:Magic Sniff Quarantine + +文件:`tests/func/media_test.go` + +上传声明为 `image/jpeg`、实际以 PE `MZ` magic 开头的内容并完成上传。使用 `require.Eventually` 轮询 `MediaFile.status`,最长 90 秒等待 cleanup worker 将其置为 `QUARANTINED`;随后调用下载 API,断言文件不可下载。 + +单次轮询不使用立即终止的断言。该用例复用 func stack 与统一 cleanup,不另建 reliability 目录;较长上限对应生产 worker 的 60 秒周期。 + +## 隔离与命名 + +- 继续使用 `tests/func/setup_test.go` 的 `TestMain`、`cleanup.CleanupAll`、`Cfg` 与 `HTTP`。 +- 用例注释与函数命名使用现有方案:`FN-AM-06`、`FN-WS-08`、`FN-ID-08`、`FN-MD-19`、`FN-MD-20`。 +- 所有上传内容、hash、request ID 与用户均为当前 run 唯一,避免 dedup 或残留数据造成串扰。 +- 不新增 mock;所有行为经真实 HTTP/protobuf、WS、MinIO 上传和 MySQL 直查完成。 + +## 验收 + +- 新能力只存在于 `tests/pkg/{client,fixture,verify}`,业务断言只存在于 `tests/func`。 +- 原有 public API 保持兼容,现有用例无需修改调用方式。 +- 五个覆盖缺口均有明确 Go 用例 ID 与代码路径。 +- Task 22 的 C++ 删除提交保持不变,不恢复任何 `.cc`。 +- 本次只做编码、格式检查和静态 review;不运行测试、编译、vet 或 Docker,并明确记录该限制。 + +## 已知风险 + +- Avatar URL 用例可能揭示生产实现与既有 `/avatar/` 合同不一致;不在测试 PR 中静默修改生产代码。 +- Magic sniff 依赖 60 秒 cleanup 周期,真实执行耗时较长;用 90 秒最终一致窗口而非固定 sleep。 +- 本地环境未就位,所有运行行为需后续在可用全栈或 CI 中验证。 From 65e388c3eb24d5f41407592b3ce84fd8595ab63f Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 18:26:56 +0800 Subject: [PATCH 24/28] docs(test): plan framework-integrated C++ coverage replacement --- .../2026-07-15-phase2-cpp-coverage-gaps.md | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-phase2-cpp-coverage-gaps.md diff --git a/docs/superpowers/plans/2026-07-15-phase2-cpp-coverage-gaps.md b/docs/superpowers/plans/2026-07-15-phase2-cpp-coverage-gaps.md new file mode 100644 index 0000000..24f4141 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-phase2-cpp-coverage-gaps.md @@ -0,0 +1,344 @@ +# Phase 2 C++ 覆盖缺口补齐 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 avatar URL、Gateway trace、MQ trace、media object key 与 magic sniff 的等价行为覆盖融入现有 Go 全栈测试框架,使 Task 22 删除的 C++ 测试具备可追踪的 Go 黑盒替代。 + +**Architecture:** 共享请求、上传和 DB 查询能力分别下沉到 `tests/pkg/client`、`tests/pkg/fixture`、`tests/pkg/verify`;具体行为断言放入现有 `tests/func` 服务文件,并复用统一 `TestMain`、cleanup、配置和 build tag。不恢复 C++ 测试,不新增平行测试框架,不修改生产代码。 + +**Tech Stack:** Go 1.23、testing、testify、protobuf over HTTP、gorilla/websocket、MySQL 直查、真实全栈。 + +## Global Constraints + +- 纯 Go 测试(testify + 标准 testing),不引入 C++ 测试,不 mock 服务,用真实全栈。 +- 黑盒行为测试:通过 HTTP + protobuf 外部 API 验证服务行为,不测 C++ 内部实现。 +- 所有共享能力进入 `tests/pkg/{client,fixture,verify}`;行为断言只放在 `tests/func`。 +- 继续复用 `tests/func/setup_test.go` 的 `TestMain`、`cleanup.CleanupAll`、`Cfg` 与 `HTTP`。 +- `tests/func` 文件保持 `//go:build func`;用例使用 `FN-AM-06`、`FN-WS-08`、`FN-ID-08`、`FN-MD-19`、`FN-MD-20`。 +- Fixture 不做断言(除不可继续时 `t.Fatal`),返回关键 ID;verifier 负责稳定查询,测试负责业务断言。 +- 不恢复 Task 22 删除的任何 `.cc`,不修改生产代码。 +- 用户明确要求本地不执行测试:实现者不得运行 `go test`、`go vet`、编译、Docker 或全栈命令;仅运行 gofmt、diff whitespace 检查和静态代码审查,并明确记录未运行测试。 + +--- + +### Task 23: Trace-aware HTTP client + Gateway/MQ trace 用例 + +**Files:** +- Modify: `tests/pkg/client/http.go` +- Modify: `tests/func/auth_middleware_test.go` +- Modify: `tests/func/ws_notify_test.go` + +**Interfaces:** +- Produces: `HTTPClient.DoWithTrace(path, req, resp, accessToken, traceID) (http.Header, error)` +- Consumes: 现有 `fixture.MakeFriends`、`fixture.ConnectWS`、`client.WSClient.WaitForNotify` + +- [ ] **Step 1: 抽取 HTTP 发送核心并增加 trace-aware API** + +在 `tests/pkg/client/http.go` 中保留现有 public API,新增: + +```go +func (c *HTTPClient) DoWithTrace(path string, req proto.Message, resp proto.Message, accessToken, traceID string) (http.Header, error) { + return c.do(path, req, resp, accessToken, traceID) +} +``` + +把现有 `Do` 的主体移入私有方法: + +```go +func (c *HTTPClient) do(path string, req proto.Message, resp proto.Message, accessToken, traceID string) (http.Header, error) { + body, err := proto.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequest("POST", c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/x-protobuf") + if accessToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+accessToken) + } + if traceID != "" { + httpReq.Header.Set("X-Trace-Id", traceID) + } + httpResp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer httpResp.Body.Close() + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return httpResp.Header.Clone(), fmt.Errorf("read response: %w", err) + } + headers := httpResp.Header.Clone() + if httpResp.StatusCode != http.StatusOK { + return headers, fmt.Errorf("http status %d: %s", httpResp.StatusCode, string(respBody)) + } + if err := proto.Unmarshal(respBody, resp); err != nil { + return headers, fmt.Errorf("unmarshal response: %w", err) + } + return headers, nil +} +``` + +现有 `Do` 改为: + +```go +func (c *HTTPClient) Do(path string, req proto.Message, resp proto.Message, accessToken string) error { + _, err := c.do(path, req, resp, accessToken, "") + return err +} +``` + +- [ ] **Step 2: 添加 FN-AM-06 Gateway trace 响应测试代码** + +在 `tests/func/auth_middleware_test.go` 追加: + +```go +// FN-AM-06 | P1 | trace | Gateway 回传客户端提供的合法 X-Trace-Id +func TestFN_AM_GatewayTraceHeader(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + traceID := "0123456789abcdef0123456789abcdef" + req := &identity.GetProfileReq{RequestId: client.NewRequestID()} + rsp := &identity.GetProfileRsp{} + + headers, err := authed.DoWithTrace( + "/service/identity/get_profile", req, rsp, authed.AccessToken, traceID, + ) + require.NoError(t, err) + require.True(t, rsp.Header.Success) + assert.Equal(t, traceID, headers.Get("X-Trace-Id")) +} +``` + +- [ ] **Step 3: 添加 FN-WS-08 MQ trace 透传测试代码** + +给 `tests/func/ws_notify_test.go` 增加 message/transmite import,并追加: + +```go +// FN-WS-08 | P1 | trace | Gateway trace 经 MQ 透传到接收方 WS notify +func TestFN_WS_MQTracePropagation(t *testing.T) { + alice, bob, convID := fixture.MakeFriends(t, HTTP) + wsBob := fixture.ConnectWS(t, bob) + defer wsBob.Close() + time.Sleep(500 * time.Millisecond) + + traceID := "fedcba9876543210fedcba9876543210" + req := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "trace-propagation"}}, + }, + ClientMsgId: client.NewRequestID(), + } + rsp := &transmite.SendMessageRsp{} + _, err := alice.DoWithTrace("/service/transmite/send", req, rsp, alice.AccessToken, traceID) + require.NoError(t, err) + require.True(t, rsp.Header.Success) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + notify, err := wsBob.WaitForNotify(ctx, int32(push.NotifyType_CHAT_MESSAGE_NOTIFY)) + require.NoError(t, err) + assert.Equal(t, traceID, notify.GetTraceId()) +} +``` + +- [ ] **Step 4: 静态检查与提交** + +仅运行 gofmt 与 `git diff --check`,不得运行测试/编译/vet。提交: + +```bash +git add tests/pkg/client/http.go tests/func/auth_middleware_test.go tests/func/ws_notify_test.go +git commit -m "test(trace): add Gateway and MQ trace black-box coverage" +``` + +--- + +### Task 24: Media framework + avatar/object-key 用例 + +**Files:** +- Modify: `tests/pkg/fixture/media.go` +- Modify: `tests/pkg/verify/db.go` +- Modify: `tests/func/identity_test.go` +- Modify: `tests/func/media_test.go` + +**Interfaces:** +- Produces: `fixture.UploadFileForPurpose(...) string` +- Produces: `verify.MediaFileRecord`、`DBVerifier.MediaFile(...)` +- Preserves: `fixture.UploadFile(...) string` + +- [ ] **Step 1: 把单段上传改为按 purpose 可复用** + +把现有 `UploadFile` 改为 wrapper: + +```go +func UploadFile(t testing.TB, c *client.HTTPClient, content []byte, mime string) string { + return UploadFileForPurpose(t, c, content, mime, media.MediaPurpose_CHAT) +} +``` + +把原函数主体移动到: + +```go +func UploadFileForPurpose(t testing.TB, c *client.HTTPClient, content []byte, mime string, purpose media.MediaPurpose) string +``` + +函数主体保持原流程,仅把 `ApplyUploadReq.Purpose` 从固定 CHAT 改为参数 `purpose`。 + +- [ ] **Step 2: 增加 DB media record 查询接口** + +在 `tests/pkg/verify/db.go` 增加: + +```go +type MediaFileRecord struct { + Bucket string + ObjectKey string + Status int +} + +func (v *DBVerifier) MediaFile(t testing.TB, fileID string) MediaFileRecord { + t.Helper() + var record MediaFileRecord + err := v.db.QueryRow( + "SELECT bucket, object_key, status FROM media_file WHERE file_id = ?", fileID, + ).Scan(&record.Bucket, &record.ObjectKey, &record.Status) + if err != nil { + t.Fatalf("query media_file %s: %v", fileID, err) + } + return record +} +``` + +- [ ] **Step 3: 添加 FN-ID-08 avatar upload/profile URL 用例** + +在 `tests/func/identity_test.go` 添加 `strings` 与 media proto import,追加: + +```go +// FN-ID-08 | P1 | happy path | 上传头像后更新 profile 并返回公开 avatar URL +func TestFN_ID_UpdateProfileAvatarUpload(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte(client.NewRequestID())...) + fileID := fixture.UploadFileForPurpose(t, authed, content, "image/jpeg", media.MediaPurpose_AVATAR) + + req := &identity.UpdateProfileReq{ + RequestId: client.NewRequestID(), + AvatarFileId: &fileID, + } + rsp := &identity.UpdateProfileRsp{} + require.NoError(t, authed.DoAuth("/service/identity/update_profile", req, rsp)) + require.True(t, rsp.Header.Success) + require.NotNil(t, rsp.UserInfo) + require.NotEmpty(t, rsp.UserInfo.AvatarUrl) + assert.True(t, strings.HasSuffix(rsp.UserInfo.AvatarUrl, "/avatar/"+fileID)) + + getRsp := &identity.GetProfileRsp{} + require.NoError(t, authed.DoAuth( + "/service/identity/get_profile", + &identity.GetProfileReq{RequestId: client.NewRequestID()}, + getRsp, + )) + require.True(t, getRsp.Header.Success) + require.NotNil(t, getRsp.UserInfo) + assert.Equal(t, rsp.UserInfo.AvatarUrl, getRsp.UserInfo.AvatarUrl) +} +``` + +- [ ] **Step 4: 添加 FN-MD-19 object key 用例** + +给 `tests/func/media_test.go` 增加 `path`、`regexp`、`strings` 与 verify import,追加: + +```go +// FN-MD-19 | P1 | consistency | CHAT 与 AVATAR 使用各自的 object key 布局 +func TestFN_MD_ObjectKeyLayout(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + + chatContent := []byte("fn-md-19-chat-" + client.NewRequestID()) + chatHash := sha256.Sum256(chatContent) + chatHex := fmt.Sprintf("%x", chatHash) + chatID := fixture.UploadFile(t, authed, chatContent, "text/plain") + chatRecord := dbV.MediaFile(t, chatID) + assert.Equal(t, "chatnow-media-private", chatRecord.Bucket) + assert.Regexp(t, regexp.MustCompile(`^chat/[0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9a-f]{2}/[0-9a-f]{64}$`), chatRecord.ObjectKey) + assert.Equal(t, chatHex, path.Base(chatRecord.ObjectKey)) + assert.True(t, strings.Contains(chatRecord.ObjectKey, "/"+chatHex[:2]+"/")) + + avatarContent := append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, []byte(client.NewRequestID())...) + avatarHash := sha256.Sum256(avatarContent) + avatarHex := fmt.Sprintf("%x", avatarHash) + avatarID := fixture.UploadFileForPurpose(t, authed, avatarContent, "image/png", media.MediaPurpose_AVATAR) + avatarRecord := dbV.MediaFile(t, avatarID) + assert.Equal(t, "chatnow-media-public", avatarRecord.Bucket) + assert.Equal(t, "avatar/"+avatarHex, avatarRecord.ObjectKey) +} +``` + +- [ ] **Step 5: 静态检查与提交** + +仅运行 gofmt 与 `git diff --check`,不得运行测试/编译/vet。提交: + +```bash +git add tests/pkg/fixture/media.go tests/pkg/verify/db.go tests/func/identity_test.go tests/func/media_test.go +git commit -m "test(media): add avatar and object-key black-box coverage" +``` + +--- + +### Task 25: Magic sniff quarantine 最终一致性用例 + +**Files:** +- Modify: `tests/func/media_test.go` + +**Interfaces:** +- Consumes: `fixture.UploadFile`(Task 24 保持兼容) +- Consumes: `DBVerifier.MediaFile`(Task 24) + +- [ ] **Step 1: 添加 FN-MD-20 magic mismatch quarantine 用例** + +在 `tests/func/media_test.go` 增加 `time` import,追加: + +```go +// FN-MD-20 | P1 | security | 声明 JPEG、实际 PE magic 的文件最终被隔离且不可下载 +func TestFN_MD_MagicMismatchQuarantined(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := append([]byte{'M', 'Z', 0, 0, 0, 0, 0, 0}, []byte(client.NewRequestID())...) + fileID := fixture.UploadFile(t, authed, content, "image/jpeg") + + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + require.Eventually(t, func() bool { + return dbV.MediaFile(t, fileID).Status == 3 + }, 90*time.Second, 2*time.Second, "magic mismatch 文件应进入 QUARANTINED") + + req := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: fileID} + rsp := &media.ApplyDownloadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_download", req, rsp)) + assert.False(t, rsp.Header.Success) + assert.Equal(t, int32(5008), rsp.Header.ErrorCode) +} +``` + +- [ ] **Step 2: 静态检查与提交** + +确认轮询闭包只比较状态,不在瞬时未就绪时调用 `require`/`assert`;仅运行 gofmt 与 `git diff --check`。提交: + +```bash +git add tests/func/media_test.go +git commit -m "test(media): cover asynchronous magic-sniff quarantine" +``` + +--- + +## 验收标准 + +1. `tests/pkg/client` 提供 trace-aware HTTP 请求并保留原有 API。 +2. `tests/pkg/fixture` 提供按 media purpose 上传,原 `UploadFile` 调用方不变。 +3. `tests/pkg/verify` 提供稳定的 `MediaFile` 只读查询。 +4. `FN-AM-06`、`FN-WS-08`、`FN-ID-08`、`FN-MD-19`、`FN-MD-20` 均存在并遵循框架命名/分层。 +5. 不恢复 C++ 测试,不修改生产代码,不建立新测试体系。 +6. 每个任务通过独立静态 reviewer;所有 Critical/Important 清零。 +7. 按用户指令不运行测试,最终状态明确标注运行验证缺失。 From ded4861d8f32b856fac50a373431b18ad6bdb9b3 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 18:29:24 +0800 Subject: [PATCH 25/28] test(trace): add Gateway and MQ trace black-box coverage --- tests/func/auth_middleware_test.go | 15 +++++++++++++++ tests/func/ws_notify_test.go | 30 ++++++++++++++++++++++++++++++ tests/pkg/client/http.go | 30 ++++++++++++++++++++++-------- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/tests/func/auth_middleware_test.go b/tests/func/auth_middleware_test.go index 3f69b9c..13700bc 100644 --- a/tests/func/auth_middleware_test.go +++ b/tests/func/auth_middleware_test.go @@ -77,3 +77,18 @@ func TestJWTRequired_ExpiredToken(t *testing.T) { err := expiredClient.DoAuth("/service/identity/get_profile", req, rsp) require.Error(t, err) } + +// FN-AM-06 | P1 | trace | Gateway 回传客户端提供的合法 X-Trace-Id +func TestFN_AM_GatewayTraceHeader(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + traceID := "0123456789abcdef0123456789abcdef" + req := &identity.GetProfileReq{RequestId: client.NewRequestID()} + rsp := &identity.GetProfileRsp{} + + headers, err := authed.DoWithTrace( + "/service/identity/get_profile", req, rsp, authed.AccessToken, traceID, + ) + require.NoError(t, err) + require.True(t, rsp.Header.Success) + assert.Equal(t, traceID, headers.Get("X-Trace-Id")) +} diff --git a/tests/func/ws_notify_test.go b/tests/func/ws_notify_test.go index 0a52a42..f9ddaf2 100644 --- a/tests/func/ws_notify_test.go +++ b/tests/func/ws_notify_test.go @@ -16,6 +16,7 @@ import ( presence "chatnow-tests/proto/chatnow/presence" push "chatnow-tests/proto/chatnow/push" relationship "chatnow-tests/proto/chatnow/relationship" + transmite "chatnow-tests/proto/chatnow/transmite" ) // FN-WS-01 | P0 | WebSocket 推送 | 发消息后接收方 WS 收到 CHAT_MESSAGE_NOTIFY @@ -238,3 +239,32 @@ func TestFN_WS_TypingNotify(t *testing.T) { _, err = wsB.WaitForNotify(ctx, int32(push.NotifyType_TYPING_NOTIFY)) require.NoError(t, err, "b 应收到 typing 通知") } + +// FN-WS-08 | P1 | trace | Gateway trace 经 MQ 透传到接收方 WS notify +func TestFN_WS_MQTracePropagation(t *testing.T) { + alice, bob, convID := fixture.MakeFriends(t, HTTP) + wsBob := fixture.ConnectWS(t, bob) + defer wsBob.Close() + time.Sleep(500 * time.Millisecond) + + traceID := "fedcba9876543210fedcba9876543210" + req := &transmite.SendMessageReq{ + RequestId: client.NewRequestID(), + ConversationId: convID, + Content: &msg.MessageContent{ + Type: msg.MessageType_TEXT, + Body: &msg.MessageContent_Text{Text: &msg.TextContent{Text: "trace-propagation"}}, + }, + ClientMsgId: client.NewRequestID(), + } + rsp := &transmite.SendMessageRsp{} + _, err := alice.DoWithTrace("/service/transmite/send", req, rsp, alice.AccessToken, traceID) + require.NoError(t, err) + require.True(t, rsp.Header.Success) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + notify, err := wsBob.WaitForNotify(ctx, int32(push.NotifyType_CHAT_MESSAGE_NOTIFY)) + require.NoError(t, err) + assert.Equal(t, traceID, notify.GetTraceId()) +} diff --git a/tests/pkg/client/http.go b/tests/pkg/client/http.go index 0882f32..44a8b61 100644 --- a/tests/pkg/client/http.go +++ b/tests/pkg/client/http.go @@ -48,39 +48,53 @@ func NewDeviceID() string { // Do sends a protobuf request to path and unmarshals the protobuf response into resp. func (c *HTTPClient) Do(path string, req proto.Message, resp proto.Message, accessToken string) error { + _, err := c.do(path, req, resp, accessToken, "") + return err +} + +// DoWithTrace sends a protobuf request with the provided trace ID and returns response headers. +func (c *HTTPClient) DoWithTrace(path string, req proto.Message, resp proto.Message, accessToken, traceID string) (http.Header, error) { + return c.do(path, req, resp, accessToken, traceID) +} + +func (c *HTTPClient) do(path string, req proto.Message, resp proto.Message, accessToken, traceID string) (http.Header, error) { body, err := proto.Marshal(req) if err != nil { - return fmt.Errorf("marshal request: %w", err) + return nil, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequest("POST", c.baseURL+path, bytes.NewReader(body)) if err != nil { - return fmt.Errorf("create request: %w", err) + return nil, fmt.Errorf("create request: %w", err) } httpReq.Header.Set("Content-Type", "application/x-protobuf") if accessToken != "" { httpReq.Header.Set("Authorization", "Bearer "+accessToken) } + if traceID != "" { + httpReq.Header.Set("X-Trace-Id", traceID) + } httpResp, err := c.client.Do(httpReq) if err != nil { - return fmt.Errorf("http request: %w", err) + return nil, fmt.Errorf("http request: %w", err) } defer httpResp.Body.Close() respBody, err := io.ReadAll(httpResp.Body) if err != nil { - return fmt.Errorf("read response: %w", err) + return httpResp.Header.Clone(), fmt.Errorf("read response: %w", err) } + headers := httpResp.Header.Clone() - if httpResp.StatusCode != 200 { - return fmt.Errorf("http status %d: %s", httpResp.StatusCode, string(respBody)) + if httpResp.StatusCode != http.StatusOK { + return headers, fmt.Errorf("http status %d: %s", httpResp.StatusCode, string(respBody)) } if err := proto.Unmarshal(respBody, resp); err != nil { - return fmt.Errorf("unmarshal response: %w", err) + return headers, fmt.Errorf("unmarshal response: %w", err) } - return nil + return headers, nil } // DoNoAuth sends without Authorization header (for whitelisted endpoints). From 9e958b94211e3c8746276a23cffbf92597129f82 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 18:36:59 +0800 Subject: [PATCH 26/28] test(media): add avatar and object-key black-box coverage --- tests/func/identity_test.go | 30 ++++++++++++++++++++++++++++++ tests/func/media_test.go | 29 +++++++++++++++++++++++++++++ tests/pkg/fixture/media.go | 7 ++++++- tests/pkg/verify/db.go | 18 ++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/tests/func/identity_test.go b/tests/func/identity_test.go index 80a8c0b..5fcfe2d 100644 --- a/tests/func/identity_test.go +++ b/tests/func/identity_test.go @@ -6,6 +6,7 @@ import ( "fmt" "math/rand" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -14,6 +15,7 @@ import ( "chatnow-tests/pkg/client" "chatnow-tests/pkg/fixture" identity "chatnow-tests/proto/chatnow/identity" + media "chatnow-tests/proto/chatnow/media" ) func randUser() string { @@ -492,6 +494,34 @@ func TestUpdateProfile_NoToken_Error(t *testing.T) { require.Error(t, err) } +// FN-ID-08 | P1 | happy path | 上传头像后更新 profile 并返回公开 avatar URL +func TestFN_ID_UpdateProfileAvatarUpload(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte(client.NewRequestID())...) + fileID := fixture.UploadFileForPurpose(t, authed, content, "image/jpeg", media.MediaPurpose_AVATAR) + + req := &identity.UpdateProfileReq{ + RequestId: client.NewRequestID(), + AvatarFileId: &fileID, + } + rsp := &identity.UpdateProfileRsp{} + require.NoError(t, authed.DoAuth("/service/identity/update_profile", req, rsp)) + require.True(t, rsp.Header.Success) + require.NotNil(t, rsp.UserInfo) + require.NotEmpty(t, rsp.UserInfo.AvatarUrl) + assert.True(t, strings.HasSuffix(rsp.UserInfo.AvatarUrl, "/avatar/"+fileID)) + + getRsp := &identity.GetProfileRsp{} + require.NoError(t, authed.DoAuth( + "/service/identity/get_profile", + &identity.GetProfileReq{RequestId: client.NewRequestID()}, + getRsp, + )) + require.True(t, getRsp.Header.Success) + require.NotNil(t, getRsp.UserInfo) + assert.Equal(t, rsp.UserInfo.AvatarUrl, getRsp.UserInfo.AvatarUrl) +} + // --------------------------------------------------------------------------- // 8. SearchUsers // --------------------------------------------------------------------------- diff --git a/tests/func/media_test.go b/tests/func/media_test.go index 3782d5a..fcd230b 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -8,6 +8,9 @@ import ( "fmt" "io" "net/http" + "path" + "regexp" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -15,6 +18,7 @@ import ( "chatnow-tests/pkg/client" "chatnow-tests/pkg/fixture" + "chatnow-tests/pkg/verify" media "chatnow-tests/proto/chatnow/media" ) @@ -452,3 +456,28 @@ func TestFN_MD_SpeechRecognition_EmptyContent(t *testing.T) { // 空音频应返回失败 assert.False(t, rsp.Header.Success) } + +// FN-MD-19 | P1 | consistency | CHAT 与 AVATAR 使用各自的 object key 布局 +func TestFN_MD_ObjectKeyLayout(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + + chatContent := []byte("fn-md-19-chat-" + client.NewRequestID()) + chatHash := sha256.Sum256(chatContent) + chatHex := fmt.Sprintf("%x", chatHash) + chatID := fixture.UploadFile(t, authed, chatContent, "text/plain") + chatRecord := dbV.MediaFile(t, chatID) + assert.Equal(t, "chatnow-media-private", chatRecord.Bucket) + assert.Regexp(t, regexp.MustCompile(`^chat/[0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9a-f]{2}/[0-9a-f]{64}$`), chatRecord.ObjectKey) + assert.Equal(t, chatHex, path.Base(chatRecord.ObjectKey)) + assert.True(t, strings.Contains(chatRecord.ObjectKey, "/"+chatHex[:2]+"/")) + + avatarContent := append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, []byte(client.NewRequestID())...) + avatarHash := sha256.Sum256(avatarContent) + avatarHex := fmt.Sprintf("%x", avatarHash) + avatarID := fixture.UploadFileForPurpose(t, authed, avatarContent, "image/png", media.MediaPurpose_AVATAR) + avatarRecord := dbV.MediaFile(t, avatarID) + assert.Equal(t, "chatnow-media-public", avatarRecord.Bucket) + assert.Equal(t, "avatar/"+avatarHex, avatarRecord.ObjectKey) +} diff --git a/tests/pkg/fixture/media.go b/tests/pkg/fixture/media.go index ae5f3b0..abca52f 100644 --- a/tests/pkg/fixture/media.go +++ b/tests/pkg/fixture/media.go @@ -14,6 +14,11 @@ import ( // UploadFile 完成三步上传(ApplyUpload -> PUT MinIO -> CompleteUpload)并返回 file_id。 // 适用于单段上传(<=100MB)。content 为文件内容,mime 为 MIME 类型。 func UploadFile(t testing.TB, c *client.HTTPClient, content []byte, mime string) string { + return UploadFileForPurpose(t, c, content, mime, media.MediaPurpose_CHAT) +} + +// UploadFileForPurpose 完成指定用途的三步上传并返回 file_id。 +func UploadFileForPurpose(t testing.TB, c *client.HTTPClient, content []byte, mime string, purpose media.MediaPurpose) string { t.Helper() hash := sha256.Sum256(content) req := &media.ApplyUploadReq{ @@ -22,7 +27,7 @@ func UploadFile(t testing.TB, c *client.HTTPClient, content []byte, mime string) FileSize: int64(len(content)), MimeType: mime, ContentHash: fmt.Sprintf("sha256:%x", hash), - Purpose: media.MediaPurpose_CHAT, + Purpose: purpose, } rsp := &media.ApplyUploadRsp{} if err := c.DoAuth("/service/media/apply_upload", req, rsp); err != nil { diff --git a/tests/pkg/verify/db.go b/tests/pkg/verify/db.go index e8d5a08..fcfec5d 100644 --- a/tests/pkg/verify/db.go +++ b/tests/pkg/verify/db.go @@ -13,6 +13,12 @@ type DBVerifier struct { db *sql.DB } +type MediaFileRecord struct { + Bucket string + ObjectKey string + Status int +} + // NewDBVerifier 创建 MySQL 直查验证器。 func NewDBVerifier(dsn string) *DBVerifier { db, err := sql.Open("mysql", dsn) @@ -186,6 +192,18 @@ func (v *DBVerifier) MediaQuota(t testing.TB, userID string, expectedUsedBytes i } } +func (v *DBVerifier) MediaFile(t testing.TB, fileID string) MediaFileRecord { + t.Helper() + var record MediaFileRecord + err := v.db.QueryRow( + "SELECT bucket, object_key, status FROM media_file WHERE file_id = ?", fileID, + ).Scan(&record.Bucket, &record.ObjectKey, &record.Status) + if err != nil { + t.Fatalf("query media_file %s: %v", fileID, err) + } + return record +} + // ConversationMemberRole 验证 conversation_member.role(0=MEMBER, 1=ADMIN, 2=OWNER)。 func (v *DBVerifier) ConversationMemberRole(t testing.TB, userID, conversationID string, expectedRole int32) { var role int32 From a8d1b7a9017462af4b59dcdcfa739f0c80720c27 Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 18:42:18 +0800 Subject: [PATCH 27/28] test(media): cover asynchronous magic-sniff quarantine --- tests/func/media_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/func/media_test.go b/tests/func/media_test.go index fcd230b..014cf44 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -481,3 +482,22 @@ func TestFN_MD_ObjectKeyLayout(t *testing.T) { assert.Equal(t, "chatnow-media-public", avatarRecord.Bucket) assert.Equal(t, "avatar/"+avatarHex, avatarRecord.ObjectKey) } + +// FN-MD-20 | P1 | security | 声明 JPEG、实际 PE magic 的文件最终被隔离且不可下载 +func TestFN_MD_MagicMismatchQuarantined(t *testing.T) { + authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + content := append([]byte{'M', 'Z', 0, 0, 0, 0, 0, 0}, []byte(client.NewRequestID())...) + fileID := fixture.UploadFile(t, authed, content, "image/jpeg") + + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + require.Eventually(t, func() bool { + return dbV.MediaFile(t, fileID).Status == 3 + }, 90*time.Second, 2*time.Second, "magic mismatch 文件应进入 QUARANTINED") + + req := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: fileID} + rsp := &media.ApplyDownloadRsp{} + require.NoError(t, authed.DoAuth("/service/media/apply_download", req, rsp)) + assert.False(t, rsp.Header.Success) + assert.Equal(t, int32(5008), rsp.Header.ErrorCode) +} From a25d4a5f03b06f1640ff44b954f21e5faf2eedce Mon Sep 17 00:00:00 2001 From: ULookup Date: Wed, 15 Jul 2026 19:04:31 +0800 Subject: [PATCH 28/28] test: address final review findings --- tests/func/auth_middleware_test.go | 1 + tests/func/concurrency_test.go | 55 +++++++++++++++++++++++++----- tests/func/media_test.go | 38 ++++++++++++--------- tests/func/scenarios_test.go | 9 +++-- tests/func/security_test.go | 34 ++++++++++++------ tests/func/ws_notify_test.go | 55 +++++++----------------------- tests/pkg/fixture/ws.go | 22 ++++++++++++ 7 files changed, 132 insertions(+), 82 deletions(-) diff --git a/tests/func/auth_middleware_test.go b/tests/func/auth_middleware_test.go index 13700bc..0a14107 100644 --- a/tests/func/auth_middleware_test.go +++ b/tests/func/auth_middleware_test.go @@ -89,6 +89,7 @@ func TestFN_AM_GatewayTraceHeader(t *testing.T) { "/service/identity/get_profile", req, rsp, authed.AccessToken, traceID, ) require.NoError(t, err) + require.NotNil(t, rsp.Header) require.True(t, rsp.Header.Success) assert.Equal(t, traceID, headers.Get("X-Trace-Id")) } diff --git a/tests/func/concurrency_test.go b/tests/func/concurrency_test.go index 96a9e6b..6e84061 100644 --- a/tests/func/concurrency_test.go +++ b/tests/func/concurrency_test.go @@ -80,6 +80,7 @@ func TestFN_CC_SendMessage_DifferentMsgId(t *testing.T) { var wg sync.WaitGroup msgIDs := make([]int64, 10) + seqIDs := make([]uint64, 10) errs := make([]error, 10) for i := 0; i < 10; i++ { wg.Add(1) @@ -96,9 +97,23 @@ func TestFN_CC_SendMessage_DifferentMsgId(t *testing.T) { } rsp := &transmite.SendMessageRsp{} errs[idx] = a.DoAuth("/service/transmite/send", req, rsp) - if errs[idx] == nil && rsp.Header.Success { - msgIDs[idx] = rsp.Message.MessageId + if errs[idx] != nil { + return + } + if rsp.Header == nil { + errs[idx] = fmt.Errorf("send response missing header") + return + } + if !rsp.Header.Success { + errs[idx] = fmt.Errorf("send failed: code=%d message=%s", rsp.Header.ErrorCode, rsp.Header.ErrorMessage) + return } + if rsp.Message == nil { + errs[idx] = fmt.Errorf("successful send response missing message") + return + } + msgIDs[idx] = rsp.Message.MessageId + seqIDs[idx] = rsp.Message.SeqId }(i) } wg.Wait() @@ -107,13 +122,21 @@ func TestFN_CC_SendMessage_DifferentMsgId(t *testing.T) { for i, err := range errs { require.NoError(t, err, "goroutine %d failed", i) require.NotZero(t, msgIDs[i], "goroutine %d 未返回 message_id", i) + require.NotZero(t, seqIDs[i], "goroutine %d 未返回 seq_id", i) } - // 验证 message_id 不重复(等价于 seq 不重复) - idSet := make(map[int64]bool) + // 验证 message_id 和 seq_id 均不重复 + messageIDSet := make(map[int64]struct{}) for _, id := range msgIDs { - require.False(t, idSet[id], "message_id %d 重复", id) - idSet[id] = true + _, exists := messageIDSet[id] + require.False(t, exists, "message_id %d 重复", id) + messageIDSet[id] = struct{}{} + } + seqIDSet := make(map[uint64]struct{}) + for _, seqID := range seqIDs { + _, exists := seqIDSet[seqID] + require.False(t, exists, "seq_id %d 重复", seqID) + seqIDSet[seqID] = struct{}{} } // 直查 DB:message 表有 10 条 @@ -243,6 +266,7 @@ func TestFN_CC_Reaction_SameEmoji(t *testing.T) { var wg sync.WaitGroup emoji := "👍" errs := make([]error, len(reactioners)) + responses := make([]*msg.AddReactionRsp, len(reactioners)) for i, u := range reactioners { wg.Add(1) go func(idx int, user *client.HTTPClient) { @@ -253,19 +277,32 @@ func TestFN_CC_Reaction_SameEmoji(t *testing.T) { Emoji: emoji, } rsp := &msg.AddReactionRsp{} + responses[idx] = rsp errs[idx] = user.DoAuth("/service/message/add_reaction", req, rsp) }(i, u) } wg.Wait() - // 验证无致命错误(并发 add_reaction 可能有一方失败或幂等成功) + // 两个用户的 reaction 都应成功 for i, err := range errs { - assert.NoError(t, err, "goroutine %d add_reaction 出错", i) + require.NoError(t, err, "goroutine %d add_reaction 出错", i) + require.NotNil(t, responses[i], "goroutine %d response 为空", i) + require.NotNil(t, responses[i].Header, "goroutine %d response header 为空", i) + require.True(t, responses[i].Header.Success, "goroutine %d add_reaction 失败: %s", i, + responses[i].Header.ErrorMessage) } - // 验证 reaction 查询成功(幂等:相同 emoji 不重复计数或 count=1) + // 验证同一 emoji 聚合为一组,包含两个用户 getReq := &msg.GetReactionsReq{RequestId: client.NewRequestID(), MessageId: mID} getRsp := &msg.GetReactionsRsp{} require.NoError(t, a.DoAuth("/service/message/get_reactions", getReq, getRsp)) + require.NotNil(t, getRsp.Header) require.True(t, getRsp.Header.Success, "get_reactions 失败: %s", getRsp.Header.ErrorMessage) + require.Len(t, getRsp.Reactions, 1, "目标 emoji 应仅有一个 reaction group") + reaction := getRsp.Reactions[0] + require.NotNil(t, reaction) + require.Equal(t, emoji, reaction.Emoji) + require.Equal(t, int32(2), reaction.Count) + require.ElementsMatch(t, []string{a.UserID, b.UserID}, reaction.RecentUserIds) + require.True(t, reaction.SelfReacted, "用户 a 查询时应标记 self_reacted") } diff --git a/tests/func/media_test.go b/tests/func/media_test.go index 014cf44..8d14757 100644 --- a/tests/func/media_test.go +++ b/tests/func/media_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" "path" "regexp" "strings" @@ -173,19 +174,21 @@ func TestFN_MD_InitMultipart_Success(t *testing.T) { assert.Greater(t, rsp.RecommendedPartSizeBytes, int32(0)) } -// FN-MD-05 | P1 | error path | 超配额文件拒绝 InitMultipart +// FN-MD-05 | P1 | error path | 超 MIME 文件大小限制拒绝 InitMultipart func TestFN_MD_InitMultipart_FileTooLarge(t *testing.T) { authed, _, _ := fixture.RegisterAndLogin(t, HTTP) content := []byte("too-large") hash := sha256.Sum256(content) req := &media.InitMultipartReq{ - RequestId: client.NewRequestID(), FileName: "huge.bin", - FileSize: 30 * 1024 * 1024, MimeType: "application/octet-stream", + RequestId: client.NewRequestID(), FileName: "huge.jpg", + FileSize: 30 * 1024 * 1024, MimeType: "image/jpeg", ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, } rsp := &media.InitMultipartRsp{} require.NoError(t, authed.DoAuth("/service/media/init_multipart", req, rsp)) - assert.False(t, rsp.Header.Success) + require.NotNil(t, rsp.Header) + require.False(t, rsp.Header.Success) + require.Equal(t, int32(5001), rsp.Header.ErrorCode) } // FN-MD-06 | P0 | happy path | ApplyPartUpload 获取分片 presigned URL @@ -251,7 +254,8 @@ func TestFN_MD_CompleteMultipart_MissingPart(t *testing.T) { require.NoError(t, authed.DoAuth("/service/media/apply_part_upload", partReq, partRsp)) partContent := content[:2*1024*1024] - httpReq, _ := http.NewRequest("PUT", partRsp.UploadUrl, bytes.NewReader(partContent)) + httpReq, err := http.NewRequest("PUT", partRsp.UploadUrl, bytes.NewReader(partContent)) + require.NoError(t, err) putResp, err := http.DefaultClient.Do(httpReq) require.NoError(t, err) putResp.Body.Close() @@ -393,11 +397,12 @@ func TestFN_MD_ApplyDownload_Success(t *testing.T) { resp, err := http.Get(dlRsp.DownloadUrl) require.NoError(t, err) defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) assert.Equal(t, content, body, "下载内容与上传不一致") } -// FN-MD-15 | P1 | error path | 非上传者下载私聊文件(权限检查) +// FN-MD-15 | P1 | service contract | Media 服务持有 file_id 即允许已认证用户申请下载 func TestFN_MD_ApplyDownload_OtherUser(t *testing.T) { uploader, _, _ := fixture.RegisterAndLogin(t, HTTP) other, _, _ := fixture.RegisterAndLogin(t, HTTP) @@ -408,15 +413,9 @@ func TestFN_MD_ApplyDownload_OtherUser(t *testing.T) { dlReq := &media.ApplyDownloadReq{RequestId: client.NewRequestID(), FileId: fileID} dlRsp := &media.ApplyDownloadRsp{} require.NoError(t, other.DoAuth("/service/media/apply_download", dlReq, dlRsp)) - // 私聊文件应拒绝非上传者(或非会话成员)下载 - // 注:具体行为取决于服务端 ACL,此处宽松断言 - if dlRsp.Header.Success { - // 如果服务端允许下载(public bucket 或无 ACL),则内容应一致 - _ = dlRsp.DownloadUrl - } else { - // 如果拒绝,错误码应为权限相关 - assert.False(t, dlRsp.Header.Success) - } + require.NotNil(t, dlRsp.Header) + require.True(t, dlRsp.Header.Success) + require.NotEmpty(t, dlRsp.DownloadUrl) } // FN-MD-16 | P1 | happy path | 上传后查询 file_info @@ -463,6 +462,11 @@ func TestFN_MD_ObjectKeyLayout(t *testing.T) { authed, _, _ := fixture.RegisterAndLogin(t, HTTP) dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) defer dbV.Close() + minioV := verify.NewMinIOVerifier( + os.Getenv("MINIO_ENDPOINT"), + os.Getenv("MINIO_ACCESS_KEY"), + os.Getenv("MINIO_SECRET_KEY"), + ) chatContent := []byte("fn-md-19-chat-" + client.NewRequestID()) chatHash := sha256.Sum256(chatContent) @@ -473,6 +477,7 @@ func TestFN_MD_ObjectKeyLayout(t *testing.T) { assert.Regexp(t, regexp.MustCompile(`^chat/[0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9a-f]{2}/[0-9a-f]{64}$`), chatRecord.ObjectKey) assert.Equal(t, chatHex, path.Base(chatRecord.ObjectKey)) assert.True(t, strings.Contains(chatRecord.ObjectKey, "/"+chatHex[:2]+"/")) + minioV.ObjectContent(t, chatRecord.Bucket, chatRecord.ObjectKey, chatContent) avatarContent := append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, []byte(client.NewRequestID())...) avatarHash := sha256.Sum256(avatarContent) @@ -481,6 +486,7 @@ func TestFN_MD_ObjectKeyLayout(t *testing.T) { avatarRecord := dbV.MediaFile(t, avatarID) assert.Equal(t, "chatnow-media-public", avatarRecord.Bucket) assert.Equal(t, "avatar/"+avatarHex, avatarRecord.ObjectKey) + minioV.ObjectContent(t, avatarRecord.Bucket, avatarRecord.ObjectKey, avatarContent) } // FN-MD-20 | P1 | security | 声明 JPEG、实际 PE magic 的文件最终被隔离且不可下载 diff --git a/tests/func/scenarios_test.go b/tests/func/scenarios_test.go index 5d57184..d80f852 100644 --- a/tests/func/scenarios_test.go +++ b/tests/func/scenarios_test.go @@ -350,7 +350,8 @@ func TestScenario_MediaUploadFullFlow(t *testing.T) { require.NotEmpty(t, fileID) // Step 2: PUT 到 MinIO presigned URL - httpReq, _ := http.NewRequest("PUT", applyRsp.UploadUrl, bytes.NewReader(content)) + httpReq, err := http.NewRequest("PUT", applyRsp.UploadUrl, bytes.NewReader(content)) + require.NoError(t, err) if applyRsp.Headers != nil { for k, v := range applyRsp.Headers { httpReq.Header.Set(k, v) @@ -374,7 +375,8 @@ func TestScenario_MediaUploadFullFlow(t *testing.T) { require.True(t, dlRsp.Header.Success) dlResp, err := http.Get(dlRsp.DownloadUrl) require.NoError(t, err) - body, _ := io.ReadAll(dlResp.Body) + body, err := io.ReadAll(dlResp.Body) + require.NoError(t, err) dlResp.Body.Close() assert.Equal(t, content, body, "下载内容与上传不一致") @@ -405,7 +407,8 @@ func TestScenario_MediaUploadFullFlow(t *testing.T) { require.True(t, bigDlRsp.Header.Success) bigResp, err := http.Get(bigDlRsp.DownloadUrl) require.NoError(t, err) - bigBody, _ := io.ReadAll(bigResp.Body) + bigBody, err := io.ReadAll(bigResp.Body) + require.NoError(t, err) bigResp.Body.Close() assert.Equal(t, bigContent, bigBody, "大文件下载内容不一致") diff --git a/tests/func/security_test.go b/tests/func/security_test.go index 270b7fb..6296df3 100644 --- a/tests/func/security_test.go +++ b/tests/func/security_test.go @@ -3,7 +3,9 @@ package func_test import ( - "strings" + "crypto/sha256" + "fmt" + "regexp" "testing" "github.com/stretchr/testify/assert" @@ -115,14 +117,16 @@ func TestFN_SEC_SQLInjection_Search(t *testing.T) { rsp := &relationship.SearchFriendsRsp{} require.NoError(t, b.DoAuth("/service/relationship/search_friends", req, rsp), "SQL 注入 payload 不应导致请求失败: %s", payload) - // 注入不应返回所有用户(OR 1=1 不应生效) - _ = rsp.Header.Success + require.NotNil(t, rsp.Header) + require.True(t, rsp.Header.Success) + require.Empty(t, rsp.UserInfo, "SQL 注入 payload 应按普通字面量搜索: %s", payload) } // 验证 friend 表未被破坏(仍能 ListFriends) listReq := &relationship.ListFriendsReq{RequestId: client.NewRequestID()} listRsp := &relationship.ListFriendsRsp{} require.NoError(t, b.DoAuth("/service/relationship/list_friends", listReq, listRsp)) + require.NotNil(t, listRsp.Header) require.True(t, listRsp.Header.Success, "SQL 注入后 friend 表应完好") } @@ -154,9 +158,12 @@ func TestFN_SEC_XSS_MessageContent(t *testing.T) { assert.Equal(t, xssPayload, lastMsg.GetContent().GetText().Text, "XSS payload 应原样存储") } -// FN-SEC-05 | P1 | security | 文件名含路径遍历字符,应被拒绝或清洗 +// FN-SEC-05 | P1 | security | 文件名含路径遍历字符,对象路径仍仅由 purpose/hash 派生 func TestFN_SEC_PathTraversal_FileName(t *testing.T) { authed, _, _ := fixture.RegisterAndLogin(t, HTTP) + dbV := verify.NewDBVerifier(Cfg.Database.MySQLDSN) + defer dbV.Close() + chatObjectKeyPattern := regexp.MustCompile(`^chat/[0-9]{4}/[0-9]{2}/[0-9]{2}/[0-9a-f]{2}/[0-9a-f]{64}$`) traversalNames := []string{ "../../etc/passwd", @@ -164,18 +171,23 @@ func TestFN_SEC_PathTraversal_FileName(t *testing.T) { "./../../secret", } for _, name := range traversalNames { + uniqueContent := []byte(name + client.NewRequestID()) + hash := sha256.Sum256(uniqueContent) req := &media.ApplyUploadReq{ RequestId: client.NewRequestID(), FileName: name, - FileSize: 1024, MimeType: "text/plain", - ContentHash: "sha256:" + strings.Repeat("a", 64), Purpose: media.MediaPurpose_CHAT, + FileSize: int64(len(uniqueContent)), MimeType: "text/plain", + ContentHash: fmt.Sprintf("sha256:%x", hash), Purpose: media.MediaPurpose_CHAT, } rsp := &media.ApplyUploadRsp{} require.NoError(t, authed.DoAuth("/service/media/apply_upload", req, rsp), "路径遍历文件名不应导致请求崩溃: %s", name) - // 路径遍历应被拒绝或文件名被清洗(不创建跨目录对象) - if rsp.Header.Success { - // 若服务端清洗了文件名(移除 ../),则 file_id 应正常分配 - assert.NotEmpty(t, rsp.FileId) - } + require.NotNil(t, rsp.Header) + require.True(t, rsp.Header.Success, "文件显示名不应影响对象路径派生: %s", name) + require.NotEmpty(t, rsp.FileId) + + record := dbV.MediaFile(t, rsp.FileId) + assert.NotContains(t, record.ObjectKey, "..") + assert.NotContains(t, record.ObjectKey, `\`) + assert.Regexp(t, chatObjectKeyPattern, record.ObjectKey) } } diff --git a/tests/func/ws_notify_test.go b/tests/func/ws_notify_test.go index f9ddaf2..1113120 100644 --- a/tests/func/ws_notify_test.go +++ b/tests/func/ws_notify_test.go @@ -27,9 +27,6 @@ func TestFN_WS_NewMessageNotify(t *testing.T) { wsBob := fixture.ConnectWS(t, bob) defer wsBob.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // alice 发消息 fixture.SendTextMessage(t, alice, convID, "ws-notify-test") @@ -57,9 +54,6 @@ func TestFN_WS_FriendRequestNotify(t *testing.T) { wsBob := fixture.ConnectWS(t, bob) defer wsBob.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // alice 向 bob 发好友申请 sendReq := &relationship.SendFriendReq{ RequestId: client.NewRequestID(), @@ -88,13 +82,9 @@ func TestFN_WS_FriendAcceptNotify(t *testing.T) { b, _, _ := fixture.RegisterAndLogin(t, HTTP) // a 连接 WS - wsA, err := client.NewWSClient(HTTP.Config(), a.AccessToken, a.UserID, "device-ws03") - require.NoError(t, err) + wsA := fixture.ConnectWSWithDeviceID(t, a, "device-ws03") defer wsA.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // a 发好友申请 sendReq := &relationship.SendFriendReq{ RequestId: client.NewRequestID(), @@ -116,7 +106,7 @@ func TestFN_WS_FriendAcceptNotify(t *testing.T) { // a 应收到 FRIEND_ADD_PROCESS_NOTIFY(好友申请被处理) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _, err = wsA.WaitForNotify(ctx, int32(push.NotifyType_FRIEND_ADD_PROCESS_NOTIFY)) + _, err := wsA.WaitForNotify(ctx, int32(push.NotifyType_FRIEND_ADD_PROCESS_NOTIFY)) require.NoError(t, err, "a 应收到好友通过通知") } @@ -126,20 +116,16 @@ func TestFN_WS_ConversationCreateNotify(t *testing.T) { member, _, _ := fixture.RegisterAndLogin(t, HTTP) // member 连接 WS - wsMember, err := client.NewWSClient(HTTP.Config(), member.AccessToken, member.UserID, "device-ws04") - require.NoError(t, err) + wsMember := fixture.ConnectWSWithDeviceID(t, member, "device-ws04") defer wsMember.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // owner 建群(含 member) convID := fixture.CreateGroupWithMembers(t, owner, []*client.HTTPClient{member}, "ws-conv-create-test") // member 应收到 CONVERSATION_CREATE_NOTIFY ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _, err = wsMember.WaitForNotify(ctx, int32(push.NotifyType_CONVERSATION_CREATE_NOTIFY)) + _, err := wsMember.WaitForNotify(ctx, int32(push.NotifyType_CONVERSATION_CREATE_NOTIFY)) require.NoError(t, err, "member 应收到会话创建通知") _ = convID } @@ -150,13 +136,9 @@ func TestFN_WS_PresenceChangeNotify(t *testing.T) { target, _, _ := fixture.RegisterAndLogin(t, HTTP) // subscriber 连接 WS - wsSub, err := client.NewWSClient(HTTP.Config(), subscriber.AccessToken, subscriber.UserID, "device-ws05-sub") - require.NoError(t, err) + wsSub := fixture.ConnectWSWithDeviceID(t, subscriber, "device-ws05-sub") defer wsSub.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // subscriber 订阅 target subReq := &presence.SubscribeReq{ RequestId: client.NewRequestID(), @@ -165,14 +147,13 @@ func TestFN_WS_PresenceChangeNotify(t *testing.T) { require.NoError(t, subscriber.DoAuth("/service/presence/subscribe", subReq, &presence.SubscribeRsp{})) // target 上线 - wsTarget, err := client.NewWSClient(HTTP.Config(), target.AccessToken, target.UserID, "device-ws05-target") - require.NoError(t, err) + wsTarget := fixture.ConnectWSWithDeviceID(t, target, "device-ws05-target") defer wsTarget.Close() // subscriber 应收到 PRESENCE_CHANGE_NOTIFY ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _, err = wsSub.WaitForNotify(ctx, int32(push.NotifyType_PRESENCE_CHANGE_NOTIFY)) + _, err := wsSub.WaitForNotify(ctx, int32(push.NotifyType_PRESENCE_CHANGE_NOTIFY)) require.NoError(t, err, "subscriber 应收到 target 上线通知") } @@ -181,11 +162,7 @@ func TestFN_WS_Reconnect(t *testing.T) { a, b, convID := setupConv(t) // b 连接 WS - wsB1, err := client.NewWSClient(HTTP.Config(), b.AccessToken, b.UserID, "device-ws06-1") - require.NoError(t, err) - - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) + wsB1 := fixture.ConnectWSWithDeviceID(t, b, "device-ws06-1") // b 断开 WS require.NoError(t, wsB1.Close()) @@ -194,13 +171,9 @@ func TestFN_WS_Reconnect(t *testing.T) { sendMsg(t, a, convID, "msg-while-b-disconnected") // b 重连 WS - wsB2, err := client.NewWSClient(HTTP.Config(), b.AccessToken, b.UserID, "device-ws06-2") - require.NoError(t, err) + wsB2 := fixture.ConnectWSWithDeviceID(t, b, "device-ws06-2") defer wsB2.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // b 通过 sync 补齐遗漏消息 syncReq := &msg.SyncMessagesReq{ RequestId: client.NewRequestID(), @@ -218,13 +191,9 @@ func TestFN_WS_TypingNotify(t *testing.T) { a, b, convID := setupConv(t) // b 连接 WS - wsB, err := client.NewWSClient(HTTP.Config(), b.AccessToken, b.UserID, "device-ws07") - require.NoError(t, err) + wsB := fixture.ConnectWSWithDeviceID(t, b, "device-ws07") defer wsB.Close() - // 等待 WS 鉴权完成 - time.Sleep(500 * time.Millisecond) - // a 发 typing typingReq := &presence.TypingReq{ RequestId: client.NewRequestID(), @@ -236,7 +205,7 @@ func TestFN_WS_TypingNotify(t *testing.T) { // b 应收到 TYPING_NOTIFY ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - _, err = wsB.WaitForNotify(ctx, int32(push.NotifyType_TYPING_NOTIFY)) + _, err := wsB.WaitForNotify(ctx, int32(push.NotifyType_TYPING_NOTIFY)) require.NoError(t, err, "b 应收到 typing 通知") } @@ -245,7 +214,6 @@ func TestFN_WS_MQTracePropagation(t *testing.T) { alice, bob, convID := fixture.MakeFriends(t, HTTP) wsBob := fixture.ConnectWS(t, bob) defer wsBob.Close() - time.Sleep(500 * time.Millisecond) traceID := "fedcba9876543210fedcba9876543210" req := &transmite.SendMessageReq{ @@ -260,6 +228,7 @@ func TestFN_WS_MQTracePropagation(t *testing.T) { rsp := &transmite.SendMessageRsp{} _, err := alice.DoWithTrace("/service/transmite/send", req, rsp, alice.AccessToken, traceID) require.NoError(t, err) + require.NotNil(t, rsp.Header) require.True(t, rsp.Header.Success) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/tests/pkg/fixture/ws.go b/tests/pkg/fixture/ws.go index 21b4da8..dd09460 100644 --- a/tests/pkg/fixture/ws.go +++ b/tests/pkg/fixture/ws.go @@ -2,10 +2,30 @@ package fixture import ( "testing" + "time" "chatnow-tests/pkg/client" + presence "chatnow-tests/proto/chatnow/presence" ) +func waitForWSOnline(t testing.TB, c *client.HTTPClient) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + req := &presence.GetPresenceReq{RequestId: client.NewRequestID(), UserId: c.UserID} + rsp := &presence.GetPresenceRsp{} + if err := c.DoAuth("/service/presence/get", req, rsp); err != nil { + t.Fatalf("wait for WS online: get presence: %v", err) + } + if rsp.GetHeader().GetSuccess() && + rsp.GetPresence().GetAggregatedState() == presence.PresenceState_ONLINE { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("wait for WS online: user %s did not become online within 10s", c.UserID) +} + // ConnectWS 建立 WS 连接并完成鉴权,返回 WSClient。 // 测试结束时应调用 ws.Close() 释放连接。 func ConnectWS(t testing.TB, c *client.HTTPClient) *client.WSClient { @@ -13,6 +33,7 @@ func ConnectWS(t testing.TB, c *client.HTTPClient) *client.WSClient { if err != nil { t.Fatalf("ConnectWS: %v", err) } + waitForWSOnline(t, c) return ws } @@ -22,5 +43,6 @@ func ConnectWSWithDeviceID(t testing.TB, c *client.HTTPClient, deviceID string) if err != nil { t.Fatalf("ConnectWSWithDeviceID: %v", err) } + waitForWSOnline(t, c) return ws }