Skip to content

Commit 3790d37

Browse files
authored
chore(loo-4761): mailing lists (#15)
1 parent 249fccc commit 3790d37

6 files changed

Lines changed: 265 additions & 2 deletions

File tree

cmd/lists.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/loops-so/cli/internal/api"
7+
"github.com/loops-so/cli/internal/config"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func runListsList(cfg *config.Config) ([]api.MailingList, error) {
12+
return api.NewClient(cfg.EndpointURL, cfg.APIKey).ListMailingLists()
13+
}
14+
15+
var listsCmd = &cobra.Command{
16+
Use: "lists",
17+
Short: "Manage mailing lists",
18+
}
19+
20+
var listsListCmd = &cobra.Command{
21+
Use: "list",
22+
Short: "List mailing lists",
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
cfg, err := config.Load()
25+
if err != nil {
26+
return err
27+
}
28+
29+
lists, err := runListsList(cfg)
30+
if err != nil {
31+
return err
32+
}
33+
34+
if isJSONOutput() {
35+
if lists == nil {
36+
lists = []api.MailingList{}
37+
}
38+
return printJSON(cmd.OutOrStdout(), lists)
39+
}
40+
41+
if len(lists) == 0 {
42+
fmt.Fprintln(cmd.OutOrStdout(), "No mailing lists found.")
43+
return nil
44+
}
45+
46+
w := newTableWriter(cmd.OutOrStdout())
47+
fmt.Fprintln(w, "ID\tNAME\tDESCRIPTION\tPUBLIC")
48+
for _, l := range lists {
49+
fmt.Fprintf(w, "%s\t%s\t%s\t%v\n", l.ID, l.Name, l.Description, l.IsPublic)
50+
}
51+
w.Flush()
52+
53+
return nil
54+
},
55+
}
56+
57+
func init() {
58+
listsCmd.AddCommand(listsListCmd)
59+
rootCmd.AddCommand(listsCmd)
60+
}

cmd/lists_list_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package cmd
2+
3+
import (
4+
"net/http"
5+
"reflect"
6+
"testing"
7+
8+
"github.com/loops-so/cli/internal/api"
9+
)
10+
11+
func TestRunListsList(t *testing.T) {
12+
t.Run("returns lists", func(t *testing.T) {
13+
serveJSON(t, http.StatusOK, `[{"id":"list_1","name":"Newsletter","description":"Weekly updates","isPublic":true}]`)
14+
lists, err := runListsList(cfg(t))
15+
if err != nil {
16+
t.Fatalf("unexpected error: %v", err)
17+
}
18+
want := []api.MailingList{
19+
{ID: "list_1", Name: "Newsletter", Description: "Weekly updates", IsPublic: true},
20+
}
21+
if !reflect.DeepEqual(lists, want) {
22+
t.Errorf("got %+v, want %+v", lists, want)
23+
}
24+
})
25+
26+
t.Run("handles empty array", func(t *testing.T) {
27+
serveJSON(t, http.StatusOK, `[]`)
28+
lists, err := runListsList(cfg(t))
29+
if err != nil {
30+
t.Fatalf("unexpected error: %v", err)
31+
}
32+
if len(lists) != 0 {
33+
t.Errorf("expected empty slice, got %+v", lists)
34+
}
35+
})
36+
37+
t.Run("returns error on non-200 response", func(t *testing.T) {
38+
serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`)
39+
_, err := runListsList(cfg(t))
40+
if err == nil {
41+
t.Fatal("expected error, got nil")
42+
}
43+
})
44+
}

cmd/output.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"io"
7+
"text/tabwriter"
78
)
89

910
type outputFlag string
@@ -30,6 +31,10 @@ func isJSONOutput() bool {
3031
return outputFormat == "json"
3132
}
3233

34+
func newTableWriter(w io.Writer) *tabwriter.Writer {
35+
return tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
36+
}
37+
3338
func printJSON(w io.Writer, v any) error {
3439
enc := json.NewEncoder(w)
3540
enc.SetIndent("", " ")

cmd/transactional.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"os"
99
"path/filepath"
1010
"strings"
11-
"text/tabwriter"
1211

1312
"github.com/loops-so/cli/internal/api"
1413
"github.com/loops-so/cli/internal/config"
@@ -116,7 +115,7 @@ var transactionalListCmd = &cobra.Command{
116115
return nil
117116
}
118117

119-
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
118+
w := newTableWriter(cmd.OutOrStdout())
120119
fmt.Fprintln(w, "ID\tNAME\tLAST UPDATED\tVARIABLES")
121120
for _, e := range emails {
122121
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.ID, e.Name, e.LastUpdated, strings.Join(e.DataVariables, ", "))

internal/api/lists.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
)
8+
9+
type MailingList struct {
10+
ID string `json:"id"`
11+
Name string `json:"name"`
12+
Description string `json:"description"`
13+
IsPublic bool `json:"isPublic"`
14+
}
15+
16+
func (c *Client) ListMailingLists() ([]MailingList, error) {
17+
req, err := c.newRequest(http.MethodGet, "/lists", nil)
18+
if err != nil {
19+
return nil, err
20+
}
21+
22+
resp, err := c.do(req)
23+
if err != nil {
24+
return nil, err
25+
}
26+
defer resp.Body.Close()
27+
28+
if resp.StatusCode != http.StatusOK {
29+
return nil, errorFromResponse(resp)
30+
}
31+
32+
var result []MailingList
33+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
34+
return nil, fmt.Errorf("failed to decode response: %w", err)
35+
}
36+
37+
return result, nil
38+
}

internal/api/lists_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestListMailingLists(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
statusCode int
15+
body string
16+
wantAPIErr *APIError
17+
wantErrMsg string
18+
wantCount int
19+
}{
20+
{
21+
name: "success",
22+
statusCode: http.StatusOK,
23+
body: `[{"id":"list_1","name":"Newsletter","description":"Weekly updates","isPublic":true},{"id":"list_2","name":"Announcements","description":"","isPublic":false}]`,
24+
wantCount: 2,
25+
},
26+
{
27+
name: "empty list",
28+
statusCode: http.StatusOK,
29+
body: `[]`,
30+
wantCount: 0,
31+
},
32+
{
33+
name: "unauthorized",
34+
statusCode: http.StatusUnauthorized,
35+
body: `{"error":"Invalid API key"}`,
36+
wantAPIErr: &APIError{StatusCode: http.StatusUnauthorized, Message: "Invalid API key"},
37+
},
38+
{
39+
name: "invalid json",
40+
statusCode: http.StatusOK,
41+
body: `not json`,
42+
wantErrMsg: "failed to decode response",
43+
},
44+
}
45+
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49+
w.WriteHeader(tt.statusCode)
50+
w.Write([]byte(tt.body))
51+
}))
52+
defer server.Close()
53+
54+
client := NewClient(server.URL, "test-key")
55+
lists, err := client.ListMailingLists()
56+
57+
if tt.wantAPIErr != nil {
58+
var apiErr *APIError
59+
if !errors.As(err, &apiErr) {
60+
t.Fatalf("expected *APIError, got %T: %v", err, err)
61+
}
62+
if apiErr.StatusCode != tt.wantAPIErr.StatusCode {
63+
t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode)
64+
}
65+
if tt.wantAPIErr.Message != "" && apiErr.Message != tt.wantAPIErr.Message {
66+
t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message)
67+
}
68+
return
69+
}
70+
71+
if tt.wantErrMsg != "" {
72+
if err == nil {
73+
t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg)
74+
}
75+
if !strings.Contains(err.Error(), tt.wantErrMsg) {
76+
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg)
77+
}
78+
return
79+
}
80+
81+
if err != nil {
82+
t.Fatalf("unexpected error: %v", err)
83+
}
84+
if len(lists) != tt.wantCount {
85+
t.Errorf("len(lists) = %d, want %d", len(lists), tt.wantCount)
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestListMailingLists_ResponseData(t *testing.T) {
92+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
w.WriteHeader(http.StatusOK)
94+
w.Write([]byte(`[{"id":"list_1","name":"Newsletter","description":"Weekly updates","isPublic":true}]`))
95+
}))
96+
defer server.Close()
97+
98+
client := NewClient(server.URL, "test-key")
99+
lists, err := client.ListMailingLists()
100+
if err != nil {
101+
t.Fatalf("unexpected error: %v", err)
102+
}
103+
104+
l := lists[0]
105+
if l.ID != "list_1" {
106+
t.Errorf("ID = %q, want %q", l.ID, "list_1")
107+
}
108+
if l.Name != "Newsletter" {
109+
t.Errorf("Name = %q, want %q", l.Name, "Newsletter")
110+
}
111+
if l.Description != "Weekly updates" {
112+
t.Errorf("Description = %q, want %q", l.Description, "Weekly updates")
113+
}
114+
if !l.IsPublic {
115+
t.Errorf("IsPublic = false, want true")
116+
}
117+
}

0 commit comments

Comments
 (0)