From c73550e86caadc4be13cbe781af4487b397460df Mon Sep 17 00:00:00 2001 From: rainbend Date: Mon, 8 Jun 2026 14:23:01 +0800 Subject: [PATCH] Add App Store review queries and replies --- README.md | 74 +++++++ cmd/reviews.go | 382 +++++++++++++++++++++++++++++++++++ cmd/reviews_test.go | 117 +++++++++++ internal/api/reviews.go | 191 ++++++++++++++++++ internal/api/reviews_test.go | 88 ++++++++ internal/api/types.go | 87 ++++++++ 6 files changed, 939 insertions(+) create mode 100644 cmd/reviews.go create mode 100644 cmd/reviews_test.go create mode 100644 internal/api/reviews.go create mode 100644 internal/api/reviews_test.go diff --git a/README.md b/README.md index 3754c99..1d15f1a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - **release** — 创建新版本或更新已有版本,将本地元数据同步到 App Store Connect - **upload** — 使用 Build Upload API 上传 `.ipa` / `.pkg` 构建文件到 App Store Connect - **latest-build** — 查询指定平台最新的 App Store Connect build 号 +- **reviews** — 查询 App Store 用户评论,并创建或更新开发者回复 ## 安装 @@ -138,6 +139,55 @@ asctl latest-build --app-id --platform ios --details asctl latest-build --app-id --platform ios --state valid ``` +### 查询和回复用户评论 + +查询最近的 App Store 用户评论: + +```bash +asctl reviews list --app-id +``` + +按地区、评分和已发布回复状态过滤: + +```bash +asctl reviews list \ + --app-id \ + --territory USA,CHN \ + --rating 1,2 \ + --response-state unresponded +``` + +查看已有回复内容: + +```bash +asctl reviews list --app-id --response-state responded --include-response +``` + +查询最近一周的评论: + +```bash +asctl reviews list --app-id --days 7 +``` + +或指定起始日期: + +```bash +asctl reviews list --app-id --since 2026-06-01 +``` + +回复一条评论;如果这条评论已经有回复,Apple 会替换为新的回复内容: + +```bash +asctl reviews reply --review-id --body "感谢反馈,我们会继续改进。" +``` + +也可以从文件或标准输入读取回复内容: + +```bash +asctl reviews reply --review-id --file reply.txt +cat reply.txt | asctl reviews reply --review-id --file - +``` + ### 参数说明 **全局参数** @@ -186,6 +236,30 @@ asctl latest-build --app-id --platform ios --state valid | `--state` | — | `all` | 处理状态:`all`、`valid`、`processing`、`failed`、`invalid` | | `--details` | — | `false` | 输出 build ID、处理状态、上传时间等详细信息 | +**reviews list 命令** + +| 参数 | 简写 | 默认值 | 说明 | +|------|------|--------|------| +| `--app-id` | `-a` | `ASC_APP_ID` | App Store Connect App ID(必填) | +| `--territory` | — | — | 地区过滤,多个地区用逗号分隔,如 `USA,CHN,HKG` | +| `--rating` | — | — | 评分过滤,多个评分用逗号分隔,如 `1,2,5` | +| `--response-state` | — | `all` | 已发布回复过滤:`all`、`responded`、`unresponded` | +| `--since` | — | — | 只返回指定时间之后创建的评论,支持 `YYYY-MM-DD` 或 RFC3339 | +| `--days` | — | `0` | 只返回最近 N 天的评论,如 `7` 表示最近一周 | +| `--sort` | — | `-createdDate` | 排序:`createdDate`、`-createdDate`、`rating`、`-rating` | +| `--limit` | — | `20` | 最多返回的评论数量 | +| `--include-response` | — | `false` | 同时输出已有回复内容和状态 | +| `--json` | — | `false` | 输出 JSON | + +**reviews reply 命令** + +| 参数 | 简写 | 默认值 | 说明 | +|------|------|--------|------| +| `--review-id` | — | — | 要回复的 customer review ID(必填) | +| `--body` | — | — | 回复文本 | +| `--file` | — | — | 从文件读取回复文本,传 `-` 表示从标准输入读取 | +| `--json` | — | `false` | 输出 JSON | + ## 典型工作流 ```bash diff --git a/cmd/reviews.go b/cmd/reviews.go new file mode 100644 index 0000000..4a306dd --- /dev/null +++ b/cmd/reviews.go @@ -0,0 +1,382 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/rainbend/appstoreconnect-cli/internal/api" + "github.com/spf13/cobra" +) + +var reviewsCmd = &cobra.Command{ + Use: "reviews", + Aliases: []string{"review"}, + Short: "Query and reply to App Store customer reviews", +} + +var reviewsListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"query", "ls"}, + Short: "List App Store customer reviews", + RunE: runReviewsList, +} + +var reviewsReplyCmd = &cobra.Command{ + Use: "reply", + Aliases: []string{"respond"}, + Short: "Create or update a reply to a customer review", + RunE: runReviewsReply, +} + +func init() { + reviewsListCmd.Flags().StringP("app-id", "a", os.Getenv("ASC_APP_ID"), "App Store Connect App ID (env: ASC_APP_ID)") + reviewsListCmd.Flags().String("territory", "", "Territory filter, comma-separated App Store territory codes such as USA, CHN, HKG") + reviewsListCmd.Flags().String("rating", "", "Rating filter, comma-separated values from 1 to 5") + reviewsListCmd.Flags().String("response-state", "all", "Published response filter: all, responded, or unresponded") + reviewsListCmd.Flags().String("since", "", "Only include reviews created at or after this date/time (YYYY-MM-DD or RFC3339)") + reviewsListCmd.Flags().Int("days", 0, "Only include reviews from the last N days, such as 7 for the last week") + reviewsListCmd.Flags().String("sort", "-createdDate", "Sort: createdDate, -createdDate, rating, or -rating") + reviewsListCmd.Flags().Int("limit", 20, "Maximum number of reviews to return") + reviewsListCmd.Flags().Bool("include-response", false, "Include response body and state when available") + reviewsListCmd.Flags().Bool("json", false, "Print JSON output") + + reviewsReplyCmd.Flags().String("review-id", "", "Customer review ID to reply to") + reviewsReplyCmd.Flags().String("body", "", "Reply text") + reviewsReplyCmd.Flags().String("file", "", "Path to a file containing reply text, or '-' to read stdin") + reviewsReplyCmd.Flags().Bool("json", false, "Print JSON output") + _ = reviewsReplyCmd.MarkFlagRequired("review-id") + + reviewsCmd.AddCommand(reviewsListCmd) + reviewsCmd.AddCommand(reviewsReplyCmd) + rootCmd.AddCommand(reviewsCmd) +} + +func runReviewsList(cmd *cobra.Command, args []string) error { + if err := validateAuthFlags(); err != nil { + return err + } + + appID, _ := cmd.Flags().GetString("app-id") + if appID == "" { + return fmt.Errorf("--app-id or ASC_APP_ID environment variable is required") + } + + territoryFlag, _ := cmd.Flags().GetString("territory") + territories := parseCommaSeparatedUpper(territoryFlag) + + ratingFlag, _ := cmd.Flags().GetString("rating") + ratings, err := parseRatings(ratingFlag) + if err != nil { + return err + } + + responseState, _ := cmd.Flags().GetString("response-state") + publishedResponse, err := parsePublishedResponseFilter(responseState) + if err != nil { + return err + } + + sort, _ := cmd.Flags().GetString("sort") + if err := validateReviewSort(sort); err != nil { + return err + } + + sinceFlag, _ := cmd.Flags().GetString("since") + days, _ := cmd.Flags().GetInt("days") + createdSince, err := parseReviewCreatedSince(sinceFlag, days, time.Now()) + if err != nil { + return err + } + if createdSince != nil && sort != "-createdDate" { + return fmt.Errorf("--since and --days require --sort -createdDate") + } + + limit, _ := cmd.Flags().GetInt("limit") + if limit <= 0 { + return fmt.Errorf("--limit must be greater than 0") + } + + includeResponse, _ := cmd.Flags().GetBool("include-response") + if publishedResponse != nil && *publishedResponse { + includeResponse = true + } + + client := api.NewClient(keyID, issuerID, privateKeyPath) + result, err := client.ListCustomerReviews(appID, api.CustomerReviewsQuery{ + Territories: territories, + Ratings: ratings, + PublishedResponse: publishedResponse, + IncludeResponse: includeResponse, + Sort: sort, + Limit: limit, + CreatedSince: createdSince, + }) + if err != nil { + return fmt.Errorf("listing customer reviews: %w", err) + } + + jsonOutput, _ := cmd.Flags().GetBool("json") + if jsonOutput { + return printReviewsJSON(result) + } + + if len(result.Reviews) == 0 { + fmt.Println("No reviews found.") + return nil + } + + fmt.Printf("Found %d review(s).\n\n", len(result.Reviews)) + for _, review := range result.Reviews { + printReview(review, result.ResponsesByReviewID[review.ID]) + } + + return nil +} + +func runReviewsReply(cmd *cobra.Command, args []string) error { + if err := validateAuthFlags(); err != nil { + return err + } + + reviewID, _ := cmd.Flags().GetString("review-id") + body, _ := cmd.Flags().GetString("body") + filePath, _ := cmd.Flags().GetString("file") + if body != "" && filePath != "" { + return fmt.Errorf("use either --body or --file, not both") + } + if filePath != "" { + data, err := readReplyFile(filePath) + if err != nil { + return err + } + body = string(data) + } + if strings.TrimSpace(body) == "" { + return fmt.Errorf("--body or --file is required and must not be empty") + } + + client := api.NewClient(keyID, issuerID, privateKeyPath) + response, err := client.CreateOrUpdateCustomerReviewResponse(reviewID, body) + if err != nil { + return fmt.Errorf("creating or updating customer review response: %w", err) + } + + jsonOutput, _ := cmd.Flags().GetBool("json") + if jsonOutput { + return printJSON(response) + } + + fmt.Printf("Response updated for review %s\n", reviewID) + if response.ID != "" { + fmt.Printf("Response ID: %s\n", response.ID) + } + if response.Attributes.State != "" { + fmt.Printf("State: %s\n", response.Attributes.State) + } + if response.Attributes.LastModifiedDate != "" { + fmt.Printf("Modified: %s\n", response.Attributes.LastModifiedDate) + } + + return nil +} + +func readReplyFile(path string) ([]byte, error) { + if path == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("reading reply from stdin: %w", err) + } + return data, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading reply file: %w", err) + } + return data, nil +} + +func parseCommaSeparatedUpper(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.ToUpper(strings.TrimSpace(part)) + if part != "" { + result = append(result, part) + } + } + return result +} + +func parseRatings(value string) ([]int, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + + parts := strings.Split(value, ",") + ratings := make([]int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + rating, err := strconv.Atoi(part) + if err != nil || rating < 1 || rating > 5 { + return nil, fmt.Errorf("invalid --rating %q: values must be numbers from 1 to 5", value) + } + ratings = append(ratings, rating) + } + return ratings, nil +} + +func parsePublishedResponseFilter(value string) (*bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return nil, nil + case "responded", "replied", "published", "yes", "true": + v := true + return &v, nil + case "unresponded", "unreplied", "unpublished", "no", "false": + v := false + return &v, nil + default: + return nil, fmt.Errorf("invalid --response-state %q: must be all, responded, or unresponded", value) + } +} + +func validateReviewSort(value string) error { + switch value { + case "createdDate", "-createdDate", "rating", "-rating": + return nil + default: + return fmt.Errorf("invalid --sort %q: must be createdDate, -createdDate, rating, or -rating", value) + } +} + +func parseReviewCreatedSince(since string, days int, now time.Time) (*time.Time, error) { + since = strings.TrimSpace(since) + if days < 0 { + return nil, fmt.Errorf("--days must not be negative") + } + if since != "" && days > 0 { + return nil, fmt.Errorf("use either --since or --days, not both") + } + if days > 0 { + value := now.AddDate(0, 0, -days) + return &value, nil + } + if since == "" { + return nil, nil + } + + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + value, err := time.Parse(layout, since) + if err == nil { + return &value, nil + } + } + + value, err := time.ParseInLocation("2006-01-02", since, time.Local) + if err != nil { + return nil, fmt.Errorf("invalid --since %q: use YYYY-MM-DD or RFC3339", since) + } + return &value, nil +} + +func printReview(review api.CustomerReview, response *api.CustomerReviewResponseV1) { + attrs := review.Attributes + fmt.Printf("Review ID: %s\n", review.ID) + if attrs.CreatedDate != "" { + fmt.Printf("Created: %s\n", attrs.CreatedDate) + } + if attrs.Territory != "" { + fmt.Printf("Territory: %s\n", attrs.Territory) + } + if attrs.ReviewerNickname != "" { + fmt.Printf("Reviewer: %s\n", attrs.ReviewerNickname) + } + fmt.Printf("Rating: %d\n", attrs.Rating) + if attrs.Title != "" { + printMultiline("Title", attrs.Title) + } + if attrs.Body != "" { + printMultiline("Body", attrs.Body) + } + if response != nil { + fmt.Printf("Response ID: %s\n", response.ID) + if response.Attributes.State != "" { + fmt.Printf("Response State: %s\n", response.Attributes.State) + } + if response.Attributes.LastModifiedDate != "" { + fmt.Printf("Response Modified: %s\n", response.Attributes.LastModifiedDate) + } + if response.Attributes.ResponseBody != "" { + printMultiline("Response Body", response.Attributes.ResponseBody) + } + } + fmt.Println() +} + +func printMultiline(label, value string) { + lines := strings.Split(value, "\n") + fmt.Printf("%s: %s\n", label, lines[0]) + for _, line := range lines[1:] { + fmt.Printf(" %s\n", line) + } +} + +type reviewOutput struct { + ID string `json:"id"` + Rating int `json:"rating"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + ReviewerNickname string `json:"reviewerNickname,omitempty"` + CreatedDate string `json:"createdDate,omitempty"` + Territory string `json:"territory,omitempty"` + Response *responseOutput `json:"response,omitempty"` +} + +type responseOutput struct { + ID string `json:"id"` + ResponseBody string `json:"responseBody,omitempty"` + LastModifiedDate string `json:"lastModifiedDate,omitempty"` + State string `json:"state,omitempty"` +} + +func printReviewsJSON(result *api.CustomerReviewsResult) error { + output := make([]reviewOutput, 0, len(result.Reviews)) + for _, review := range result.Reviews { + attrs := review.Attributes + item := reviewOutput{ + ID: review.ID, + Rating: attrs.Rating, + Title: attrs.Title, + Body: attrs.Body, + ReviewerNickname: attrs.ReviewerNickname, + CreatedDate: attrs.CreatedDate, + Territory: attrs.Territory, + } + if response := result.ResponsesByReviewID[review.ID]; response != nil { + item.Response = &responseOutput{ + ID: response.ID, + ResponseBody: response.Attributes.ResponseBody, + LastModifiedDate: response.Attributes.LastModifiedDate, + State: response.Attributes.State, + } + } + output = append(output, item) + } + return printJSON(output) +} + +func printJSON(value interface{}) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/cmd/reviews_test.go b/cmd/reviews_test.go new file mode 100644 index 0000000..dc984b9 --- /dev/null +++ b/cmd/reviews_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "testing" + "time" +) + +func TestParseRatings(t *testing.T) { + ratings, err := parseRatings("1, 3,5") + if err != nil { + t.Fatalf("parse ratings: %v", err) + } + + want := []int{1, 3, 5} + if len(ratings) != len(want) { + t.Fatalf("ratings length = %d, want %d", len(ratings), len(want)) + } + for i := range want { + if ratings[i] != want[i] { + t.Fatalf("ratings[%d] = %d, want %d", i, ratings[i], want[i]) + } + } +} + +func TestParseRatingsRejectsInvalidValues(t *testing.T) { + if _, err := parseRatings("0,6"); err == nil { + t.Fatal("expected invalid rating error") + } +} + +func TestParsePublishedResponseFilter(t *testing.T) { + tests := []struct { + value string + wantNil bool + want bool + }{ + {value: "all", wantNil: true}, + {value: "responded", want: true}, + {value: "replied", want: true}, + {value: "unresponded", want: false}, + {value: "unreplied", want: false}, + } + + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + got, err := parsePublishedResponseFilter(test.value) + if err != nil { + t.Fatalf("parse filter: %v", err) + } + if test.wantNil { + if got != nil { + t.Fatalf("filter = %v, want nil", *got) + } + return + } + if got == nil { + t.Fatal("filter = nil") + } + if *got != test.want { + t.Fatalf("filter = %t, want %t", *got, test.want) + } + }) + } +} + +func TestParseReviewCreatedSinceDays(t *testing.T) { + now := time.Date(2026, 6, 8, 12, 30, 0, 0, time.UTC) + got, err := parseReviewCreatedSince("", 7, now) + if err != nil { + t.Fatalf("parse created since: %v", err) + } + + want := now.AddDate(0, 0, -7) + if got == nil || !got.Equal(want) { + t.Fatalf("created since = %v, want %v", got, want) + } +} + +func TestParseReviewCreatedSinceDate(t *testing.T) { + got, err := parseReviewCreatedSince("2026-06-01", 0, time.Now()) + if err != nil { + t.Fatalf("parse created since: %v", err) + } + if got == nil { + t.Fatal("created since = nil") + } + if got.Year() != 2026 || got.Month() != 6 || got.Day() != 1 { + t.Fatalf("created since date = %v, want 2026-06-01", got) + } + if got.Hour() != 0 || got.Minute() != 0 || got.Second() != 0 { + t.Fatalf("created since time = %v, want midnight", got) + } +} + +func TestParseReviewCreatedSinceRFC3339(t *testing.T) { + got, err := parseReviewCreatedSince("2026-06-01T08:15:30Z", 0, time.Now()) + if err != nil { + t.Fatalf("parse created since: %v", err) + } + + want := time.Date(2026, 6, 1, 8, 15, 30, 0, time.UTC) + if got == nil || !got.Equal(want) { + t.Fatalf("created since = %v, want %v", got, want) + } +} + +func TestParseReviewCreatedSinceRejectsConflicts(t *testing.T) { + if _, err := parseReviewCreatedSince("2026-06-01", 7, time.Now()); err == nil { + t.Fatal("expected conflict error") + } +} + +func TestParseReviewCreatedSinceRejectsNegativeDays(t *testing.T) { + if _, err := parseReviewCreatedSince("", -1, time.Now()); err == nil { + t.Fatal("expected negative days error") + } +} diff --git a/internal/api/reviews.go b/internal/api/reviews.go new file mode 100644 index 0000000..129ee49 --- /dev/null +++ b/internal/api/reviews.go @@ -0,0 +1,191 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + "time" +) + +const customerReviewsPageLimit = 200 + +func (c *Client) ListCustomerReviews(appID string, query CustomerReviewsQuery) (*CustomerReviewsResult, error) { + if query.Limit <= 0 { + query.Limit = 20 + } + if query.Sort == "" { + query.Sort = "-createdDate" + } + if query.CreatedSince != nil && query.Sort != "-createdDate" { + return nil, fmt.Errorf("created date filtering requires sort -createdDate") + } + + remaining := query.Limit + path := fmt.Sprintf("/apps/%s/customerReviews?%s", appID, customerReviewsQueryValues(query, minInt(remaining, customerReviewsPageLimit)).Encode()) + + var reviews []CustomerReview + var included []CustomerReviewResponseV1 + + for path != "" && remaining > 0 { + data, err := c.do("GET", path, nil) + if err != nil { + return nil, err + } + + var resp CustomerReviewsResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + if len(resp.Data) == 0 { + break + } + + foundOlderReview := false + for _, review := range resp.Data { + if query.CreatedSince != nil { + createdAt, err := customerReviewCreatedAt(review) + if err != nil { + return nil, err + } + if createdAt.Before(*query.CreatedSince) { + foundOlderReview = true + continue + } + } + + reviews = append(reviews, review) + remaining-- + if remaining == 0 { + break + } + } + included = append(included, resp.Included...) + + if remaining == 0 || foundOlderReview || resp.Links.Next == "" { + break + } + path = resp.Links.Next + } + + return &CustomerReviewsResult{ + Reviews: reviews, + ResponsesByReviewID: mapResponsesByReviewID(reviews, included), + }, nil +} + +func (c *Client) CreateOrUpdateCustomerReviewResponse(reviewID, responseBody string) (*CustomerReviewResponseV1, error) { + req := CreateCustomerReviewResponseRequest{ + Data: CreateCustomerReviewResponseData{ + Type: "customerReviewResponses", + Attributes: CreateCustomerReviewResponseAttributes{ + ResponseBody: responseBody, + }, + Relationships: CreateCustomerReviewResponseRelationships{ + Review: RelationshipData{ + Data: ResourceIdentifier{ + Type: "customerReviews", + ID: reviewID, + }, + }, + }, + }, + } + + data, err := c.do("POST", "/customerReviewResponses", req) + if err != nil { + return nil, err + } + + var resp SingleResponse[CustomerReviewResponseV1] + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &resp.Data, nil +} + +func customerReviewsQueryValues(query CustomerReviewsQuery, limit int) url.Values { + values := url.Values{} + values.Set("fields[customerReviews]", "rating,title,body,reviewerNickname,createdDate,territory,response") + values.Set("sort", query.Sort) + values.Set("limit", strconv.Itoa(limit)) + + if len(query.Territories) > 0 { + values.Set("filter[territory]", strings.Join(query.Territories, ",")) + } + if len(query.Ratings) > 0 { + ratings := make([]string, 0, len(query.Ratings)) + for _, rating := range query.Ratings { + ratings = append(ratings, strconv.Itoa(rating)) + } + values.Set("filter[rating]", strings.Join(ratings, ",")) + } + if query.PublishedResponse != nil { + values.Set("exists[publishedResponse]", strconv.FormatBool(*query.PublishedResponse)) + } + if query.IncludeResponse { + values.Set("include", "response") + values.Set("fields[customerReviewResponses]", "responseBody,lastModifiedDate,state,review") + } + + return values +} + +func customerReviewCreatedAt(review CustomerReview) (time.Time, error) { + if review.Attributes.CreatedDate == "" { + return time.Time{}, fmt.Errorf("customer review %s is missing createdDate", review.ID) + } + + createdAt, err := time.Parse(time.RFC3339, review.Attributes.CreatedDate) + if err != nil { + return time.Time{}, fmt.Errorf("parsing createdDate for customer review %s: %w", review.ID, err) + } + return createdAt, nil +} + +func mapResponsesByReviewID(reviews []CustomerReview, included []CustomerReviewResponseV1) map[string]*CustomerReviewResponseV1 { + byReviewID := make(map[string]*CustomerReviewResponseV1) + byResponseID := make(map[string]*CustomerReviewResponseV1) + + for i := range included { + response := &included[i] + byResponseID[response.ID] = response + + if response.Relationships == nil { + continue + } + + reviewID := response.Relationships.Review.Data.ID + if reviewID != "" { + byReviewID[reviewID] = response + } + } + + for _, review := range reviews { + if _, exists := byReviewID[review.ID]; exists { + continue + } + if review.Relationships.Response == nil || review.Relationships.Response.Data == nil { + continue + } + responseID := review.Relationships.Response.Data.ID + if responseID == "" { + continue + } + if response, exists := byResponseID[responseID]; exists { + byReviewID[review.ID] = response + } + } + + return byReviewID +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/api/reviews_test.go b/internal/api/reviews_test.go new file mode 100644 index 0000000..85d5e30 --- /dev/null +++ b/internal/api/reviews_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "testing" + "time" +) + +func TestCustomerReviewsQueryValues(t *testing.T) { + publishedResponse := true + values := customerReviewsQueryValues(CustomerReviewsQuery{ + Territories: []string{"USA", "CHN"}, + Ratings: []int{1, 5}, + PublishedResponse: &publishedResponse, + IncludeResponse: true, + Sort: "-createdDate", + }, 50) + + tests := map[string]string{ + "filter[territory]": "USA,CHN", + "filter[rating]": "1,5", + "exists[publishedResponse]": "true", + "include": "response", + "sort": "-createdDate", + "limit": "50", + "fields[customerReviewResponses]": "responseBody,lastModifiedDate,state,review", + } + + for key, want := range tests { + if got := values.Get(key); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } +} + +func TestMapResponsesByReviewID(t *testing.T) { + reviews := []CustomerReview{ + { + ID: "review-1", + Relationships: CustomerReviewRelationships{ + Response: &OptionalRelationshipData{ + Data: &ResourceIdentifier{Type: "customerReviewResponses", ID: "response-1"}, + }, + }, + }, + {ID: "review-2"}, + } + included := []CustomerReviewResponseV1{ + { + ID: "response-1", + Attributes: CustomerReviewResponseAttributes{ + ResponseBody: "Thanks", + }, + }, + { + ID: "response-2", + Relationships: &CustomerReviewResponseRelationships{ + Review: RelationshipData{ + Data: ResourceIdentifier{Type: "customerReviews", ID: "review-2"}, + }, + }, + }, + } + + responses := mapResponsesByReviewID(reviews, included) + if got := responses["review-1"]; got == nil || got.ID != "response-1" { + t.Fatalf("review-1 response = %#v, want response-1", got) + } + if got := responses["review-2"]; got == nil || got.ID != "response-2" { + t.Fatalf("review-2 response = %#v, want response-2", got) + } +} + +func TestCustomerReviewCreatedAt(t *testing.T) { + got, err := customerReviewCreatedAt(CustomerReview{ + ID: "review-1", + Attributes: CustomerReviewAttributes{ + CreatedDate: "2026-06-01T08:15:30-07:00", + }, + }) + if err != nil { + t.Fatalf("parse created date: %v", err) + } + + want := time.Date(2026, 6, 1, 15, 15, 30, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("created date = %v, want %v", got, want) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 64cc9cb..0f19c6f 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "time" ) type Platform string @@ -86,6 +87,16 @@ type ResourceIdentifier struct { ID string `json:"id"` } +type OptionalRelationshipData struct { + Data *ResourceIdentifier `json:"data,omitempty"` + Links RelationshipLinks `json:"links,omitempty"` +} + +type RelationshipLinks struct { + Self string `json:"self,omitempty"` + Related string `json:"related,omitempty"` +} + type CreateLocalizationRequest struct { Data CreateLocalizationData `json:"data"` } @@ -127,6 +138,82 @@ type BuildAttributes struct { UsesNonExemptEncryption bool `json:"usesNonExemptEncryption,omitempty"` } +type CustomerReview struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes CustomerReviewAttributes `json:"attributes"` + Relationships CustomerReviewRelationships `json:"relationships,omitempty"` +} + +type CustomerReviewAttributes struct { + Rating int `json:"rating,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + ReviewerNickname string `json:"reviewerNickname,omitempty"` + CreatedDate string `json:"createdDate,omitempty"` + Territory string `json:"territory,omitempty"` +} + +type CustomerReviewRelationships struct { + Response *OptionalRelationshipData `json:"response,omitempty"` +} + +type CustomerReviewResponseV1 struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes CustomerReviewResponseAttributes `json:"attributes,omitempty"` + Relationships *CustomerReviewResponseRelationships `json:"relationships,omitempty"` +} + +type CustomerReviewResponseAttributes struct { + ResponseBody string `json:"responseBody,omitempty"` + LastModifiedDate string `json:"lastModifiedDate,omitempty"` + State string `json:"state,omitempty"` +} + +type CustomerReviewResponseRelationships struct { + Review RelationshipData `json:"review,omitempty"` +} + +type CustomerReviewsResponse struct { + Data []CustomerReview `json:"data"` + Included []CustomerReviewResponseV1 `json:"included,omitempty"` + Links Links `json:"links,omitempty"` +} + +type CustomerReviewsResult struct { + Reviews []CustomerReview + ResponsesByReviewID map[string]*CustomerReviewResponseV1 +} + +type CustomerReviewsQuery struct { + Territories []string + Ratings []int + PublishedResponse *bool + IncludeResponse bool + Sort string + Limit int + CreatedSince *time.Time +} + +type CreateCustomerReviewResponseRequest struct { + Data CreateCustomerReviewResponseData `json:"data"` +} + +type CreateCustomerReviewResponseData struct { + Type string `json:"type"` + Attributes CreateCustomerReviewResponseAttributes `json:"attributes"` + Relationships CreateCustomerReviewResponseRelationships `json:"relationships"` +} + +type CreateCustomerReviewResponseAttributes struct { + ResponseBody string `json:"responseBody"` +} + +type CreateCustomerReviewResponseRelationships struct { + Review RelationshipData `json:"review"` +} + type BuildUpload struct { Type string `json:"type"` ID string `json:"id"`