• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vocdoni / saas-backend / 22721750301

05 Mar 2026 02:09PM UTC coverage: 59.808% (-1.8%) from 61.65%
22721750301

push

github

emmdim
feat(client): add API client and CSV member import workflow

Introduce a new authenticated SaaS API client in `cmd/client` with token-based login,
and endpoint helpers for member/census operations.
Add import workflow orchestration to parse CSV rows, upsert organization members.

117 of 731 new or added lines in 5 files covered. (16.01%)

261 existing lines in 6 files now uncovered.

7348 of 12286 relevant lines covered (59.81%)

37.28 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

18.6
/cmd/client/api_client.go
1
package main
2

3
import (
4
        "bytes"
5
        "encoding/json"
6
        "fmt"
7
        "io"
8
        "net/http"
9
        "net/url"
10
        "strconv"
11
        "strings"
12
        "time"
13

14
        "github.com/ethereum/go-ethereum/common"
15
        "github.com/vocdoni/saas-backend/api/apicommon"
16
)
17

18
// Client wraps authenticated HTTP calls to the SaaS API.
19
type Client struct {
20
        http    *http.Client
21
        baseURL string
22
        token   string
23
}
24

25
func newClient(baseURL string) *Client {
2✔
26
        return &Client{
2✔
27
                http:    &http.Client{Timeout: 30 * time.Second},
2✔
28
                baseURL: strings.TrimRight(baseURL, "/"),
2✔
29
        }
2✔
30
}
2✔
31

32
// doJSON sends an HTTP request and decodes a JSON response into target when provided.
33
func (c *Client) doJSON(method, path string, query url.Values, body, target any) error {
2✔
34
        fullURL := c.baseURL + path
2✔
35
        if len(query) > 0 {
2✔
NEW
36
                fullURL = fullURL + "?" + query.Encode()
×
NEW
37
        }
×
38

39
        var bodyReader io.Reader
2✔
40
        if body != nil {
4✔
41
                payload, err := json.Marshal(body)
2✔
42
                if err != nil {
2✔
NEW
43
                        return fmt.Errorf("marshal request body: %w", err)
×
NEW
44
                }
×
45
                bodyReader = bytes.NewReader(payload)
2✔
46
        }
47

48
        req, err := http.NewRequest(method, fullURL, bodyReader)
2✔
49
        if err != nil {
2✔
NEW
50
                return fmt.Errorf("build request %s %s: %w", method, fullURL, err)
×
NEW
51
        }
×
52
        req.Header.Set("Content-Type", "application/json")
2✔
53
        if c.token != "" {
2✔
NEW
54
                req.Header.Set("Authorization", "Bearer "+c.token)
×
NEW
55
        }
×
56

57
        resp, err := c.http.Do(req)
2✔
58
        if err != nil {
2✔
NEW
59
                return fmt.Errorf("send request %s %s: %w", method, fullURL, err)
×
NEW
60
        }
×
61
        defer func() {
4✔
62
                if closeErr := resp.Body.Close(); closeErr != nil {
2✔
NEW
63
                        fmt.Printf("warning: close response body: %v\n", closeErr)
×
NEW
64
                }
×
65
        }()
66

67
        if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
2✔
NEW
68
                respBody, _ := io.ReadAll(resp.Body)
×
NEW
69
                trimmed := strings.TrimSpace(string(respBody))
×
NEW
70
                if trimmed == "" {
×
NEW
71
                        trimmed = "no response body"
×
NEW
72
                }
×
NEW
73
                return fmt.Errorf("request %s %s failed with status %d: %s", method, fullURL, resp.StatusCode, trimmed)
×
74
        }
75

76
        if target == nil {
2✔
NEW
77
                if _, err := io.Copy(io.Discard, resp.Body); err != nil {
×
NEW
78
                        return fmt.Errorf("drain response body: %w", err)
×
NEW
79
                }
×
NEW
80
                return nil
×
81
        }
82

83
        if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
2✔
NEW
84
                return fmt.Errorf("decode response for %s %s: %w", method, fullURL, err)
×
NEW
85
        }
×
86
        return nil
2✔
87
}
88

NEW
89
func (c *Client) login(email, password string) error {
×
NEW
90
        var loginResp apicommon.LoginResponse
×
NEW
91
        req := apicommon.UserInfo{
×
NEW
92
                Email:    email,
×
NEW
93
                Password: password,
×
NEW
94
        }
×
NEW
95
        if err := c.doJSON(
×
NEW
96
                http.MethodPost,
×
NEW
97
                "/auth/login",
×
NEW
98
                nil,
×
NEW
99
                req,
×
NEW
100
                &loginResp,
×
NEW
101
        ); err != nil {
×
NEW
102
                return fmt.Errorf("POST /auth/login: %w", err)
×
NEW
103
        }
×
NEW
104
        if loginResp.Token == "" {
×
NEW
105
                return fmt.Errorf("POST /auth/login: empty token in response")
×
NEW
106
        }
×
NEW
107
        c.token = loginResp.Token
×
NEW
108
        return nil
×
109
}
110

NEW
111
func (c *Client) organization(address common.Address) (*apicommon.OrganizationInfo, error) {
×
NEW
112
        var resp apicommon.OrganizationInfo
×
NEW
113
        path := fmt.Sprintf("/organizations/%s", url.PathEscape(address.Hex()))
×
NEW
114
        if err := c.doJSON(http.MethodGet, path, nil, nil, &resp); err != nil {
×
NEW
115
                return nil, fmt.Errorf("GET %s: %w", path, err)
×
NEW
116
        }
×
NEW
117
        return &resp, nil
×
118
}
119

120
func (c *Client) organizationMembers(
121
        address common.Address,
122
        search string,
123
        page int,
124
        limit int,
NEW
125
) (*apicommon.OrganizationMembersResponse, error) {
×
NEW
126
        query := url.Values{}
×
NEW
127
        if search != "" {
×
NEW
128
                query.Set("search", search)
×
NEW
129
        }
×
NEW
130
        if page > 0 {
×
NEW
131
                query.Set("page", strconv.Itoa(page))
×
NEW
132
        }
×
NEW
133
        if limit > 0 {
×
NEW
134
                query.Set("limit", strconv.Itoa(limit))
×
NEW
135
        }
×
136

NEW
137
        var resp apicommon.OrganizationMembersResponse
×
NEW
138
        path := fmt.Sprintf("/organizations/%s/members", url.PathEscape(address.Hex()))
×
NEW
139
        if err := c.doJSON(http.MethodGet, path, query, nil, &resp); err != nil {
×
NEW
140
                return nil, fmt.Errorf("GET %s: %w", path, err)
×
NEW
141
        }
×
NEW
142
        return &resp, nil
×
143
}
144

145
func (c *Client) upsertMember(address common.Address, member apicommon.OrgMember) (string, error) {
2✔
146
        var resp apicommon.OrgMember
2✔
147
        path := fmt.Sprintf("/organizations/%s/members", url.PathEscape(address.Hex()))
2✔
148
        if err := c.doJSON(http.MethodPut, path, nil, member, &resp); err != nil {
2✔
NEW
149
                return "", fmt.Errorf("PUT %s: %w", path, err)
×
NEW
150
        }
×
151
        if resp.ID == "" {
2✔
NEW
152
                return "", fmt.Errorf("PUT %s: empty member ID in response", path)
×
NEW
153
        }
×
154
        return resp.ID, nil
2✔
155
}
156

NEW
157
func (c *Client) addMembersToCensus(censusID string, memberIDs []string) (*apicommon.AddMembersResponse, error) {
×
NEW
158
        var resp apicommon.AddMembersResponse
×
NEW
159
        path := fmt.Sprintf("/census/%s", url.PathEscape(censusID))
×
NEW
160
        req := apicommon.AddCensusParticipantsRequest{MemberIDs: memberIDs}
×
NEW
161
        if err := c.doJSON(http.MethodPost, path, nil, req, &resp); err != nil {
×
NEW
162
                return nil, fmt.Errorf("POST %s: %w", path, err)
×
NEW
163
        }
×
NEW
164
        return &resp, nil
×
165
}
166

NEW
167
func (c *Client) censusParticipants(censusID string) (*apicommon.CensusParticipantsResponse, error) {
×
NEW
168
        var resp apicommon.CensusParticipantsResponse
×
NEW
169
        path := fmt.Sprintf("/census/%s/participants", url.PathEscape(censusID))
×
NEW
170
        if err := c.doJSON(http.MethodGet, path, nil, nil, &resp); err != nil {
×
NEW
171
                return nil, fmt.Errorf("GET %s: %w", path, err)
×
NEW
172
        }
×
NEW
173
        return &resp, nil
×
174
}
175

NEW
176
func (c *Client) processBundle(bundleID string) (map[string]any, error) {
×
NEW
177
        var resp map[string]any
×
NEW
178
        path := fmt.Sprintf("/process/bundle/%s", url.PathEscape(bundleID))
×
NEW
179
        if err := c.doJSON(http.MethodGet, path, nil, nil, &resp); err != nil {
×
NEW
180
                return nil, fmt.Errorf("GET %s: %w", path, err)
×
NEW
181
        }
×
NEW
182
        return resp, nil
×
183
}
184

185
// censusIDByBundle fetches a process bundle and extracts its census identifier.
186
// It supports both `census.id` and `census._id` payload shapes.
NEW
187
func (c *Client) censusIDByBundle(bundleID string) (string, error) {
×
NEW
188
        bundleID = strings.TrimSpace(bundleID)
×
NEW
189
        if bundleID == "" {
×
NEW
190
                return "", fmt.Errorf("bundle ID is empty")
×
NEW
191
        }
×
192

NEW
193
        bundle, err := c.processBundle(bundleID)
×
NEW
194
        if err != nil {
×
NEW
195
                return "", fmt.Errorf("retrieve bundle %s: %w", bundleID, err)
×
NEW
196
        }
×
197

NEW
198
        censusRaw, ok := bundle["census"]
×
NEW
199
        if !ok || censusRaw == nil {
×
NEW
200
                return "", fmt.Errorf("bundle %s missing census field", bundleID)
×
NEW
201
        }
×
202

NEW
203
        census, ok := censusRaw.(map[string]any)
×
NEW
204
        if !ok {
×
NEW
205
                return "", fmt.Errorf(
×
NEW
206
                        "bundle %s census has unexpected type %T",
×
NEW
207
                        bundleID,
×
NEW
208
                        censusRaw,
×
NEW
209
                )
×
NEW
210
        }
×
211

NEW
212
        censusID := stringField(census, "_id")
×
NEW
213
        if censusID == "" {
×
NEW
214
                censusID = stringField(census, "id")
×
NEW
215
        }
×
NEW
216
        if censusID == "" {
×
NEW
217
                return "", fmt.Errorf(
×
NEW
218
                        "bundle %s census ID not found in census._id or census.id",
×
NEW
219
                        bundleID,
×
NEW
220
                )
×
NEW
221
        }
×
NEW
222
        return censusID, nil
×
223
}
224

NEW
225
func stringField(obj map[string]any, key string) string {
×
NEW
226
        raw, ok := obj[key]
×
NEW
227
        if !ok || raw == nil {
×
NEW
228
                return ""
×
NEW
229
        }
×
230

NEW
231
        switch value := raw.(type) {
×
NEW
232
        case string:
×
NEW
233
                return strings.TrimSpace(value)
×
NEW
234
        case map[string]any:
×
NEW
235
                // Mongo Extended JSON representation, e.g. {"$oid":"..."}.
×
NEW
236
                if oid, ok := value["$oid"].(string); ok {
×
NEW
237
                        return strings.TrimSpace(oid)
×
NEW
238
                }
×
239
        }
NEW
240
        return ""
×
241
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc