diff --git a/api/private.go b/api/private.go index 8f7049b..294c903 100644 --- a/api/private.go +++ b/api/private.go @@ -19,12 +19,13 @@ import ( // ListManager is the private API's view of the list service. type ListManager interface { + All(ctx context.Context) ([]domain.MailingList, error) Create(ctx context.Context, name string) (*domain.MailingList, error) - Get(ctx context.Context, id uint) (*domain.MailingList, error) - Rename(ctx context.Context, id uint, newName string) (*domain.MailingList, error) - Delete(ctx context.Context, id uint) error - CountUsers(ctx context.Context, listID uint) (domain.UserCounts, error) - Users(ctx context.Context, listID uint) ([]domain.User, error) + Get(ctx context.Context, name string) (*domain.MailingList, error) + Rename(ctx context.Context, name, newName string) (*domain.MailingList, error) + Delete(ctx context.Context, name string) error + CountUsers(ctx context.Context, listName string) (domain.UserCounts, error) + Users(ctx context.Context, listName string) ([]domain.User, error) } // MailDispatcher is the private API's view of the mail service. @@ -51,11 +52,12 @@ func NewPrivateHandler(lists ListManager, mail MailDispatcher, publicKey ed25519 // Routes returns the mux for all private API endpoints. func (h *PrivateHandler) Routes() *http.ServeMux { mux := http.NewServeMux() + mux.Handle("GET /lists", h.auth(h.handleAllLists)) mux.Handle("POST /lists", h.auth(h.handleCreateList)) - mux.Handle("GET /lists/{id}", h.auth(h.handleGetList)) - mux.Handle("PUT /lists/{id}", h.auth(h.handleRenameList)) - mux.Handle("DELETE /lists/{id}", h.auth(h.handleDeleteList)) - mux.Handle("GET /lists/{id}/users", h.auth(h.handleListUsers)) + mux.Handle("GET /lists/{name}", h.auth(h.handleGetList)) + mux.Handle("PUT /lists/{name}", h.auth(h.handleRenameList)) + mux.Handle("DELETE /lists/{name}", h.auth(h.handleDeleteList)) + mux.Handle("GET /lists/{name}/users", h.auth(h.handleListUsers)) mux.Handle("POST /lists/{name}/send", h.auth(h.handleSendToList)) mux.Handle("POST /mail/test", h.auth(h.handleSendTestMail)) return mux @@ -64,12 +66,10 @@ func (h *PrivateHandler) Routes() *http.ServeMux { // --- request/response types --- type listResponse struct { - ID uint `json:"id"` Name string `json:"name"` } type listDetailResponse struct { - ID uint `json:"id"` Name string `json:"name"` Subscribers struct { Total int `json:"total"` @@ -100,6 +100,20 @@ type testMailRequest struct { // --- handlers --- +func (h *PrivateHandler) handleAllLists(w http.ResponseWriter, r *http.Request) { + lists, err := h.lists.All(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "get all lists failed", slog.Any("error", err)) + writeError(w, http.StatusInternalServerError, "failed to load lists") + return + } + resp := make([]listResponse, len(lists)) + for i, l := range lists { + resp[i] = listResponse{Name: l.Name} + } + writeJSON(w, http.StatusOK, resp) +} + func (h *PrivateHandler) handleCreateList(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` @@ -116,40 +130,34 @@ func (h *PrivateHandler) handleCreateList(w http.ResponseWriter, r *http.Request return } - writeJSON(w, http.StatusCreated, listResponse{ID: list.ID, Name: list.Name}) + writeJSON(w, http.StatusCreated, listResponse{Name: list.Name}) } func (h *PrivateHandler) handleGetList(w http.ResponseWriter, r *http.Request) { - id, ok := parseUintPath(w, r, "id") - if !ok { - return - } + name := r.PathValue("name") - list, err := h.lists.Get(r.Context(), id) + list, err := h.lists.Get(r.Context(), name) if err != nil { - h.logger.ErrorContext(r.Context(), "get list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + h.logger.ErrorContext(r.Context(), "get list failed", slog.String("name", name), slog.Any("error", err)) writeError(w, http.StatusNotFound, "list not found") return } - counts, err := h.lists.CountUsers(r.Context(), id) + counts, err := h.lists.CountUsers(r.Context(), name) if err != nil { - h.logger.ErrorContext(r.Context(), "count users failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + h.logger.ErrorContext(r.Context(), "count users failed", slog.String("name", name), slog.Any("error", err)) writeError(w, http.StatusInternalServerError, "failed to load list stats") return } - resp := listDetailResponse{ID: list.ID, Name: list.Name} + resp := listDetailResponse{Name: list.Name} resp.Subscribers.Total = counts.Total resp.Subscribers.Confirmed = counts.Confirmed writeJSON(w, http.StatusOK, resp) } func (h *PrivateHandler) handleRenameList(w http.ResponseWriter, r *http.Request) { - id, ok := parseUintPath(w, r, "id") - if !ok { - return - } + name := r.PathValue("name") var body struct { Name string `json:"name"` @@ -159,24 +167,21 @@ func (h *PrivateHandler) handleRenameList(w http.ResponseWriter, r *http.Request return } - list, err := h.lists.Rename(r.Context(), id, body.Name) + list, err := h.lists.Rename(r.Context(), name, body.Name) if err != nil { - h.logger.ErrorContext(r.Context(), "rename list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + h.logger.ErrorContext(r.Context(), "rename list failed", slog.String("name", name), slog.Any("error", err)) writeError(w, http.StatusInternalServerError, "failed to rename list") return } - writeJSON(w, http.StatusOK, listResponse{ID: list.ID, Name: list.Name}) + writeJSON(w, http.StatusOK, listResponse{Name: list.Name}) } func (h *PrivateHandler) handleDeleteList(w http.ResponseWriter, r *http.Request) { - id, ok := parseUintPath(w, r, "id") - if !ok { - return - } + name := r.PathValue("name") - if err := h.lists.Delete(r.Context(), id); err != nil { - h.logger.ErrorContext(r.Context(), "delete list failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + if err := h.lists.Delete(r.Context(), name); err != nil { + h.logger.ErrorContext(r.Context(), "delete list failed", slog.String("name", name), slog.Any("error", err)) writeError(w, http.StatusInternalServerError, "failed to delete list") return } @@ -185,14 +190,11 @@ func (h *PrivateHandler) handleDeleteList(w http.ResponseWriter, r *http.Request } func (h *PrivateHandler) handleListUsers(w http.ResponseWriter, r *http.Request) { - id, ok := parseUintPath(w, r, "id") - if !ok { - return - } + name := r.PathValue("name") - users, err := h.lists.Users(r.Context(), id) + users, err := h.lists.Users(r.Context(), name) if err != nil { - h.logger.ErrorContext(r.Context(), "list users failed", slog.Uint64("id", uint64(id)), slog.Any("error", err)) + h.logger.ErrorContext(r.Context(), "list users failed", slog.String("name", name), slog.Any("error", err)) writeError(w, http.StatusInternalServerError, "failed to load users") return } @@ -301,15 +303,3 @@ func buildSignedMessage(timestamp, method, path string, body []byte) []byte { bodyHash := hex.EncodeToString(sum[:]) return []byte(timestamp + "\n" + method + "\n" + path + "\n" + bodyHash) } - -// --- helpers --- - -func parseUintPath(w http.ResponseWriter, r *http.Request, key string) (uint, bool) { - raw := r.PathValue(key) - n, err := strconv.ParseUint(raw, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid %s", key)) - return 0, false - } - return uint(n), true -} diff --git a/api/private_client.go b/api/private_client.go index 82c4f2b..13bcaae 100644 --- a/api/private_client.go +++ b/api/private_client.go @@ -34,13 +34,11 @@ func NewPrivateClient(baseURL string, privateKey ed25519.PrivateKey) *PrivateCli // ListResponse is returned by list creation and rename endpoints. type ListResponse struct { - ID uint `json:"id"` Name string `json:"name"` } // ListDetailResponse is returned by the get-list endpoint. type ListDetailResponse struct { - ID uint `json:"id"` Name string `json:"name"` Subscribers struct { Total int `json:"total"` @@ -62,6 +60,25 @@ type RecipientInput struct { Email string `json:"email"` } +// GetAllLists returns all mailing lists. +func (c *PrivateClient) GetAllLists(ctx context.Context) ([]ListResponse, error) { + resp, err := c.do(ctx, http.MethodGet, "/lists", nil) + if err != nil { + return nil, fmt.Errorf("get all lists: %w", err) + } + defer resp.Body.Close() + + if err := expectStatus(resp, http.StatusOK); err != nil { + return nil, fmt.Errorf("get all lists: %w", err) + } + + var out []ListResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("get all lists: decode response: %w", err) + } + return out, nil +} + // CreateList creates a new mailing list. func (c *PrivateClient) CreateList(ctx context.Context, name string) (*ListResponse, error) { resp, err := c.do(ctx, http.MethodPost, "/lists", map[string]string{"name": name}) @@ -81,9 +98,9 @@ func (c *PrivateClient) CreateList(ctx context.Context, name string) (*ListRespo return &out, nil } -// GetList returns the mailing list with the given id along with subscriber stats. -func (c *PrivateClient) GetList(ctx context.Context, id uint) (*ListDetailResponse, error) { - resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%d", id), nil) +// GetList returns the mailing list with the given name along with subscriber stats. +func (c *PrivateClient) GetList(ctx context.Context, name string) (*ListDetailResponse, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%s", name), nil) if err != nil { return nil, fmt.Errorf("get list: %w", err) } @@ -100,9 +117,9 @@ func (c *PrivateClient) GetList(ctx context.Context, id uint) (*ListDetailRespon return &out, nil } -// RenameList renames the mailing list with the given id. -func (c *PrivateClient) RenameList(ctx context.Context, id uint, name string) (*ListResponse, error) { - resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/lists/%d", id), map[string]string{"name": name}) +// RenameList renames the mailing list identified by name. +func (c *PrivateClient) RenameList(ctx context.Context, name, newName string) (*ListResponse, error) { + resp, err := c.do(ctx, http.MethodPut, fmt.Sprintf("/lists/%s", name), map[string]string{"name": newName}) if err != nil { return nil, fmt.Errorf("rename list: %w", err) } @@ -119,9 +136,9 @@ func (c *PrivateClient) RenameList(ctx context.Context, id uint, name string) (* return &out, nil } -// DeleteList deletes the mailing list with the given id. -func (c *PrivateClient) DeleteList(ctx context.Context, id uint) error { - resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/lists/%d", id), nil) +// DeleteList deletes the mailing list with the given name. +func (c *PrivateClient) DeleteList(ctx context.Context, name string) error { + resp, err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/lists/%s", name), nil) if err != nil { return fmt.Errorf("delete list: %w", err) } @@ -133,9 +150,9 @@ func (c *PrivateClient) DeleteList(ctx context.Context, id uint) error { return nil } -// GetUsers returns all subscribers (confirmed or not) for the given list id. -func (c *PrivateClient) GetUsers(ctx context.Context, listID uint) ([]UserItem, error) { - resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%d/users", listID), nil) +// GetUsers returns all subscribers (confirmed or not) for the named list. +func (c *PrivateClient) GetUsers(ctx context.Context, listName string) ([]UserItem, error) { + resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/lists/%s/users", listName), nil) if err != nil { return nil, fmt.Errorf("get users: %w", err) } diff --git a/api/private_test.go b/api/private_test.go index 0715aeb..ff71160 100644 --- a/api/private_test.go +++ b/api/private_test.go @@ -21,11 +21,11 @@ import ( // --- fakes --- type fakeListManager struct { - lists map[uint]*domain.MailingList - nextID uint - users []*domain.User + lists map[string]*domain.MailingList + users []*domain.User createErr error + getAllErr error getErr error renameErr error deleteErr error @@ -34,67 +34,76 @@ type fakeListManager struct { } func newFakeListManager(lists ...*domain.MailingList) *fakeListManager { - m := &fakeListManager{lists: make(map[uint]*domain.MailingList), nextID: 1} + m := &fakeListManager{lists: make(map[string]*domain.MailingList)} for _, l := range lists { - m.lists[l.ID] = l - if l.ID >= m.nextID { - m.nextID = l.ID + 1 - } + m.lists[l.Name] = l } return m } +func (f *fakeListManager) All(_ context.Context) ([]domain.MailingList, error) { + if f.getAllErr != nil { + return nil, f.getAllErr + } + out := make([]domain.MailingList, 0, len(f.lists)) + for _, l := range f.lists { + out = append(out, *l) + } + return out, nil +} + func (f *fakeListManager) Create(_ context.Context, name string) (*domain.MailingList, error) { if f.createErr != nil { return nil, f.createErr } - l := &domain.MailingList{ID: f.nextID, Name: name} - f.nextID++ - f.lists[l.ID] = l + l := &domain.MailingList{Name: name} + f.lists[name] = l return l, nil } -func (f *fakeListManager) Get(_ context.Context, id uint) (*domain.MailingList, error) { +func (f *fakeListManager) Get(_ context.Context, name string) (*domain.MailingList, error) { if f.getErr != nil { return nil, f.getErr } - l, ok := f.lists[id] + l, ok := f.lists[name] if !ok { - return nil, fmt.Errorf("list %d not found", id) + return nil, fmt.Errorf("list %q not found", name) } return l, nil } -func (f *fakeListManager) Rename(_ context.Context, id uint, name string) (*domain.MailingList, error) { +func (f *fakeListManager) Rename(_ context.Context, name, newName string) (*domain.MailingList, error) { if f.renameErr != nil { return nil, f.renameErr } - l, ok := f.lists[id] + l, ok := f.lists[name] if !ok { - return nil, fmt.Errorf("list %d not found", id) + return nil, fmt.Errorf("list %q not found", name) } - l.Name = name + delete(f.lists, name) + l.Name = newName + f.lists[newName] = l return l, nil } -func (f *fakeListManager) Delete(_ context.Context, id uint) error { +func (f *fakeListManager) Delete(_ context.Context, name string) error { if f.deleteErr != nil { return f.deleteErr } - if _, ok := f.lists[id]; !ok { - return fmt.Errorf("list %d not found", id) + if _, ok := f.lists[name]; !ok { + return fmt.Errorf("list %q not found", name) } - delete(f.lists, id) + delete(f.lists, name) return nil } -func (f *fakeListManager) CountUsers(_ context.Context, listID uint) (domain.UserCounts, error) { +func (f *fakeListManager) CountUsers(_ context.Context, listName string) (domain.UserCounts, error) { if f.countUsersErr != nil { return domain.UserCounts{}, f.countUsersErr } var total, confirmed int for _, u := range f.users { - if u.MailingListID == listID { + if u.MailingListName == listName { total++ if u.IsConfirmed() { confirmed++ @@ -104,13 +113,13 @@ func (f *fakeListManager) CountUsers(_ context.Context, listID uint) (domain.Use return domain.UserCounts{Total: total, Confirmed: confirmed}, nil } -func (f *fakeListManager) Users(_ context.Context, listID uint) ([]domain.User, error) { +func (f *fakeListManager) Users(_ context.Context, listName string) ([]domain.User, error) { if f.usersErr != nil { return nil, f.usersErr } var out []domain.User for _, u := range f.users { - if u.MailingListID == listID { + if u.MailingListName == listName { out = append(out, *u) } } @@ -190,6 +199,35 @@ func decodeJSON(t *testing.T, w *httptest.ResponseRecorder, v any) { } } +func TestPrivateHandler_AllLists(t *testing.T) { + t.Run("returns all lists", func(t *testing.T) { + m := newFakeListManager( + &domain.MailingList{Name: "weekly"}, + &domain.MailingList{Name: "monthly"}, + ) + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + w := privateRequest(t, h, http.MethodGet, "/lists", nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp []listResponse + decodeJSON(t, w, &resp) + if len(resp) != 2 { + t.Errorf("expected 2 lists, got %d", len(resp)) + } + }) + + t.Run("returns 500 on service error", func(t *testing.T) { + m := newFakeListManager() + m.getAllErr = errors.New("db failure") + h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) + w := privateRequest(t, h, http.MethodGet, "/lists", nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } + }) +} + // --- list tests --- func TestPrivateHandler_CreateList(t *testing.T) { @@ -227,17 +265,17 @@ func TestPrivateHandler_CreateList(t *testing.T) { func TestPrivateHandler_GetList(t *testing.T) { now := time.Now() - list := &domain.MailingList{ID: 1, Name: "weekly"} + list := &domain.MailingList{Name: "weekly"} t.Run("returns list with stats", func(t *testing.T) { m := newFakeListManager(list) m.users = []*domain.User{ - {ID: 1, MailingListID: 1, Email: "a@test.com", ConfirmedAt: &now}, - {ID: 2, MailingListID: 1, Email: "b@test.com"}, + {ID: 1, MailingListName: "weekly", Email: "a@test.com", ConfirmedAt: &now}, + {ID: 2, MailingListName: "weekly", Email: "b@test.com"}, } h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodGet, "/lists/1", nil) - req.SetPathValue("id", "1") + req := httptest.NewRequest(http.MethodGet, "/lists/weekly", nil) + req.SetPathValue("name", "weekly") w := httptest.NewRecorder() h.handleGetList(w, req) @@ -246,7 +284,7 @@ func TestPrivateHandler_GetList(t *testing.T) { } var resp listDetailResponse decodeJSON(t, w, &resp) - if resp.ID != 1 || resp.Name != "weekly" { + if resp.Name != "weekly" { t.Errorf("unexpected list: %+v", resp) } if resp.Subscribers.Total != 2 || resp.Subscribers.Confirmed != 1 { @@ -256,8 +294,8 @@ func TestPrivateHandler_GetList(t *testing.T) { t.Run("returns 404 when list not found", func(t *testing.T) { h := newPrivateTestHandler(newFakeListManager(), &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodGet, "/lists/99", nil) - req.SetPathValue("id", "99") + req := httptest.NewRequest(http.MethodGet, "/lists/ghost", nil) + req.SetPathValue("name", "ghost") w := httptest.NewRecorder() h.handleGetList(w, req) if w.Code != http.StatusNotFound { @@ -268,11 +306,11 @@ func TestPrivateHandler_GetList(t *testing.T) { func TestPrivateHandler_RenameList(t *testing.T) { t.Run("returns updated list", func(t *testing.T) { - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "old"}) + m := newFakeListManager(&domain.MailingList{Name: "old"}) h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodPut, "/lists/1", jsonBody(t, map[string]string{"name": "new"})) + req := httptest.NewRequest(http.MethodPut, "/lists/old", jsonBody(t, map[string]string{"name": "new"})) req.Header.Set("Content-Type", "application/json") - req.SetPathValue("id", "1") + req.SetPathValue("name", "old") w := httptest.NewRecorder() h.handleRenameList(w, req) @@ -287,11 +325,11 @@ func TestPrivateHandler_RenameList(t *testing.T) { }) t.Run("returns 400 when name missing", func(t *testing.T) { - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "old"}) + m := newFakeListManager(&domain.MailingList{Name: "old"}) h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodPut, "/lists/1", jsonBody(t, map[string]string{})) + req := httptest.NewRequest(http.MethodPut, "/lists/old", jsonBody(t, map[string]string{})) req.Header.Set("Content-Type", "application/json") - req.SetPathValue("id", "1") + req.SetPathValue("name", "old") w := httptest.NewRecorder() h.handleRenameList(w, req) if w.Code != http.StatusBadRequest { @@ -302,26 +340,26 @@ func TestPrivateHandler_RenameList(t *testing.T) { func TestPrivateHandler_DeleteList(t *testing.T) { t.Run("returns 204", func(t *testing.T) { - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "bye"}) + m := newFakeListManager(&domain.MailingList{Name: "bye"}) h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodDelete, "/lists/1", nil) - req.SetPathValue("id", "1") + req := httptest.NewRequest(http.MethodDelete, "/lists/bye", nil) + req.SetPathValue("name", "bye") w := httptest.NewRecorder() h.handleDeleteList(w, req) if w.Code != http.StatusNoContent { t.Errorf("expected 204, got %d", w.Code) } - if _, exists := m.lists[1]; exists { + if _, exists := m.lists["bye"]; exists { t.Error("list should have been deleted") } }) t.Run("returns 500 on service error", func(t *testing.T) { - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "bye"}) + m := newFakeListManager(&domain.MailingList{Name: "bye"}) m.deleteErr = errors.New("db down") h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodDelete, "/lists/1", nil) - req.SetPathValue("id", "1") + req := httptest.NewRequest(http.MethodDelete, "/lists/bye", nil) + req.SetPathValue("name", "bye") w := httptest.NewRecorder() h.handleDeleteList(w, req) if w.Code != http.StatusInternalServerError { @@ -332,17 +370,17 @@ func TestPrivateHandler_DeleteList(t *testing.T) { func TestPrivateHandler_ListUsers(t *testing.T) { now := time.Now() - list := &domain.MailingList{ID: 1, Name: "weekly"} + list := &domain.MailingList{Name: "weekly"} t.Run("returns all users with confirmed flag", func(t *testing.T) { m := newFakeListManager(list) m.users = []*domain.User{ - {ID: 1, MailingListID: 1, Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, - {ID: 2, MailingListID: 1, Name: "Bob", Email: "b@test.com"}, + {ID: 1, MailingListName: "weekly", Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, + {ID: 2, MailingListName: "weekly", Name: "Bob", Email: "b@test.com"}, } h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodGet, "/lists/1/users", nil) - req.SetPathValue("id", "1") + req := httptest.NewRequest(http.MethodGet, "/lists/weekly/users", nil) + req.SetPathValue("name", "weekly") w := httptest.NewRecorder() h.handleListUsers(w, req) @@ -366,8 +404,8 @@ func TestPrivateHandler_ListUsers(t *testing.T) { m := newFakeListManager(list) m.usersErr = errors.New("db down") h := newPrivateTestHandler(m, &fakeMailDispatcher{}, nil) - req := httptest.NewRequest(http.MethodGet, "/lists/1/users", nil) - req.SetPathValue("id", "1") + req := httptest.NewRequest(http.MethodGet, "/lists/weekly/users", nil) + req.SetPathValue("name", "weekly") w := httptest.NewRecorder() h.handleListUsers(w, req) if w.Code != http.StatusInternalServerError { @@ -506,9 +544,9 @@ func TestPrivateClient_Integration(t *testing.T) { } now := time.Now() - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "weekly"}) + m := newFakeListManager(&domain.MailingList{Name: "weekly"}) m.users = []*domain.User{ - {ID: 1, MailingListID: 1, Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, + {ID: 1, MailingListName: "weekly", Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, } mail := &fakeMailDispatcher{} @@ -529,7 +567,7 @@ func TestPrivateClient_Integration(t *testing.T) { }) t.Run("GetList", func(t *testing.T) { - resp, err := client.GetList(ctx, 1) + resp, err := client.GetList(ctx, "weekly") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -542,7 +580,7 @@ func TestPrivateClient_Integration(t *testing.T) { }) t.Run("GetUsers", func(t *testing.T) { - users, err := client.GetUsers(ctx, 1) + users, err := client.GetUsers(ctx, "weekly") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -570,7 +608,7 @@ func TestPrivateClient_Integration(t *testing.T) { }) t.Run("RenameList", func(t *testing.T) { - resp, err := client.RenameList(ctx, 1, "renamed") + resp, err := client.RenameList(ctx, "weekly", "renamed") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -581,16 +619,10 @@ func TestPrivateClient_Integration(t *testing.T) { t.Run("DeleteList", func(t *testing.T) { _, _ = client.CreateList(ctx, "todelete") - var deleteID uint - for id := range m.lists { - if m.lists[id].Name == "todelete" { - deleteID = id - } - } - if err := client.DeleteList(ctx, deleteID); err != nil { + if err := client.DeleteList(ctx, "todelete"); err != nil { t.Fatalf("unexpected error: %v", err) } - if _, exists := m.lists[deleteID]; exists { + if _, exists := m.lists["todelete"]; exists { t.Error("list should have been deleted") } }) diff --git a/cli/cli.go b/cli/cli.go index 70475b8..91f299e 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "os" - "strconv" "strings" "github.com/5000K/5000mails/api" @@ -20,11 +19,12 @@ Global flags: --private-key-path PATH Path to Ed25519 private key file for authentication Commands: + list all List all mailing lists list create --name NAME Create a mailing list - list get --id ID Get list details and stats - list rename --id ID --name NAME Rename a mailing list - list delete --id ID Delete a mailing list - list users --id ID List subscribers + list get --name NAME Get list details and stats + list rename --name NAME --new-name NEWNAME Rename a mailing list + list delete --name NAME Delete a mailing list + list users --name NAME List subscribers send list --list NAME --raw-path PATH Send mail to all confirmed subscribers send test --name NAME --email EMAIL Send a test mail @@ -76,7 +76,7 @@ func Run(args []string, stdout, stderr io.Writer) int { func runList(args []string, serverURL, keyPath string, stdout, stderr io.Writer) int { if len(args) == 0 { - fmt.Fprintln(stderr, "usage: 5kmcli list [flags]") + fmt.Fprintln(stderr, "usage: 5kmcli list [flags]") return 1 } @@ -90,6 +90,8 @@ func runList(args []string, serverURL, keyPath string, stdout, stderr io.Writer) flags := args[1:] switch sub { + case "all": + return listAll(client, stdout, stderr) case "create": return listCreate(flags, client, stdout, stderr) case "get": @@ -161,6 +163,16 @@ func runKeys(args []string, stdout, stderr io.Writer) int { return 0 } +func listAll(client *api.PrivateClient, stdout, stderr io.Writer) int { + resp, err := client.GetAllLists(context.Background()) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + printJSON(stdout, resp) + return 0 +} + func listCreate(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { name := flagValue(args, "--name") if name == "" { @@ -177,12 +189,12 @@ func listCreate(args []string, client *api.PrivateClient, stdout, stderr io.Writ } func listGet(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { - id, ok := flagUint(args, "--id") - if !ok { - fmt.Fprintln(stderr, "usage: 5kmcli list get --id ID") + name := flagValue(args, "--name") + if name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli list get --name NAME") return 1 } - resp, err := client.GetList(context.Background(), id) + resp, err := client.GetList(context.Background(), name) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 @@ -192,13 +204,13 @@ func listGet(args []string, client *api.PrivateClient, stdout, stderr io.Writer) } func listRename(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { - id, ok := flagUint(args, "--id") name := flagValue(args, "--name") - if !ok || name == "" { - fmt.Fprintln(stderr, "usage: 5kmcli list rename --id ID --name NAME") + newName := flagValue(args, "--new-name") + if name == "" || newName == "" { + fmt.Fprintln(stderr, "usage: 5kmcli list rename --name NAME --new-name NEWNAME") return 1 } - resp, err := client.RenameList(context.Background(), id, name) + resp, err := client.RenameList(context.Background(), name, newName) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 @@ -208,12 +220,12 @@ func listRename(args []string, client *api.PrivateClient, stdout, stderr io.Writ } func listDelete(args []string, client *api.PrivateClient, _, stderr io.Writer) int { - id, ok := flagUint(args, "--id") - if !ok { - fmt.Fprintln(stderr, "usage: 5kmcli list delete --id ID") + name := flagValue(args, "--name") + if name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli list delete --name NAME") return 1 } - if err := client.DeleteList(context.Background(), id); err != nil { + if err := client.DeleteList(context.Background(), name); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } @@ -221,12 +233,12 @@ func listDelete(args []string, client *api.PrivateClient, _, stderr io.Writer) i } func listUsers(args []string, client *api.PrivateClient, stdout, stderr io.Writer) int { - id, ok := flagUint(args, "--id") - if !ok { - fmt.Fprintln(stderr, "usage: 5kmcli list users --id ID") + name := flagValue(args, "--name") + if name == "" { + fmt.Fprintln(stderr, "usage: 5kmcli list users --name NAME") return 1 } - resp, err := client.GetUsers(context.Background(), id) + resp, err := client.GetUsers(context.Background(), name) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 @@ -322,18 +334,6 @@ func flagValue(args []string, name string) string { return "" } -func flagUint(args []string, name string) (uint, bool) { - v := flagValue(args, name) - if v == "" { - return 0, false - } - n, err := strconv.ParseUint(v, 10, 64) - if err != nil { - return 0, false - } - return uint(n), true -} - func collectData(args []string) map[string]any { data := make(map[string]any) for i := 0; i < len(args)-1; i++ { diff --git a/cli/cli_test.go b/cli/cli_test.go index 4f8d08f..2048b52 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -22,58 +22,63 @@ import ( // --- fakes (same shape as api tests) --- type fakeListManager struct { - lists map[uint]*domain.MailingList - nextID uint - users []*domain.User + lists map[string]*domain.MailingList + users []*domain.User } func newFakeListManager(lists ...*domain.MailingList) *fakeListManager { - m := &fakeListManager{lists: make(map[uint]*domain.MailingList), nextID: 1} + m := &fakeListManager{lists: make(map[string]*domain.MailingList)} for _, l := range lists { - m.lists[l.ID] = l - if l.ID >= m.nextID { - m.nextID = l.ID + 1 - } + m.lists[l.Name] = l } return m } +func (f *fakeListManager) All(_ context.Context) ([]domain.MailingList, error) { + out := make([]domain.MailingList, 0, len(f.lists)) + for _, l := range f.lists { + out = append(out, *l) + } + return out, nil +} + func (f *fakeListManager) Create(_ context.Context, name string) (*domain.MailingList, error) { - l := &domain.MailingList{ID: f.nextID, Name: name} - f.nextID++ - f.lists[l.ID] = l + l := &domain.MailingList{Name: name} + f.lists[name] = l return l, nil } -func (f *fakeListManager) Get(_ context.Context, id uint) (*domain.MailingList, error) { - l, ok := f.lists[id] +func (f *fakeListManager) Get(_ context.Context, name string) (*domain.MailingList, error) { + l, ok := f.lists[name] if !ok { - return nil, fmt.Errorf("list %d not found", id) + return nil, fmt.Errorf("list %q not found", name) } return l, nil } -func (f *fakeListManager) Rename(_ context.Context, id uint, name string) (*domain.MailingList, error) { - l, ok := f.lists[id] +func (f *fakeListManager) Rename(_ context.Context, name, newName string) (*domain.MailingList, error) { + l, ok := f.lists[name] if !ok { - return nil, fmt.Errorf("list %d not found", id) + return nil, fmt.Errorf("list %q not found", name) } - l.Name = name + delete(f.lists, name) + l.Name = newName + f.lists[newName] = l return l, nil } -func (f *fakeListManager) Delete(_ context.Context, id uint) error { - if _, ok := f.lists[id]; !ok { - return fmt.Errorf("list %d not found", id) +func (f *fakeListManager) Delete(_ context.Context, name string) error { + if _, ok := f.lists[name]; !ok { + return fmt.Errorf("list %q not found", name) } - delete(f.lists, id) + delete(f.lists, name) return nil } -func (f *fakeListManager) CountUsers(_ context.Context, listID uint) (domain.UserCounts, error) { +func (f *fakeListManager) CountUsers(_ context.Context, listName string) (domain.UserCounts, error) { var total, confirmed int for _, u := range f.users { - if u.MailingListID == listID { + if u.MailingListName == listName { total++ if u.IsConfirmed() { confirmed++ @@ -83,10 +88,10 @@ func (f *fakeListManager) CountUsers(_ context.Context, listID uint) (domain.Use return domain.UserCounts{Total: total, Confirmed: confirmed}, nil } -func (f *fakeListManager) Users(_ context.Context, listID uint) ([]domain.User, error) { +func (f *fakeListManager) Users(_ context.Context, listName string) ([]domain.User, error) { var out []domain.User for _, u := range f.users { - if u.MailingListID == listID { + if u.MailingListName == listName { out = append(out, *u) } } @@ -223,6 +228,28 @@ func TestRunUnknownCommand(t *testing.T) { // --- list subcommand integration --- +func TestListAll(t *testing.T) { + m := newFakeListManager( + &domain.MailingList{Name: "weekly"}, + &domain.MailingList{Name: "monthly"}, + ) + srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) + defer srv.Close() + + var stdout, stderr bytes.Buffer + code := Run([]string{"--server", srv.URL, "list", "all"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected 0, got %d: %s", code, stderr.String()) + } + var resp []api.ListResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(resp) != 2 { + t.Errorf("expected 2 lists, got %d", len(resp)) + } +} + func TestListCreate(t *testing.T) { m := newFakeListManager() srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) @@ -244,15 +271,15 @@ func TestListCreate(t *testing.T) { func TestListGet(t *testing.T) { now := time.Now() - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "weekly"}) + m := newFakeListManager(&domain.MailingList{Name: "weekly"}) m.users = []*domain.User{ - {ID: 1, MailingListID: 1, Email: "a@test.com", ConfirmedAt: &now}, + {ID: 1, MailingListName: "weekly", Email: "a@test.com", ConfirmedAt: &now}, } srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) defer srv.Close() var stdout, stderr bytes.Buffer - code := Run([]string{"--server", srv.URL, "list", "get", "--id", "1"}, &stdout, &stderr) + code := Run([]string{"--server", srv.URL, "list", "get", "--name", "weekly"}, &stdout, &stderr) if code != 0 { t.Fatalf("expected 0, got %d: %s", code, stderr.String()) } @@ -266,12 +293,12 @@ func TestListGet(t *testing.T) { } func TestListRename(t *testing.T) { - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "old"}) + m := newFakeListManager(&domain.MailingList{Name: "old"}) srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) defer srv.Close() var stdout, stderr bytes.Buffer - code := Run([]string{"--server", srv.URL, "list", "rename", "--id", "1", "--name", "new"}, &stdout, &stderr) + code := Run([]string{"--server", srv.URL, "list", "rename", "--name", "old", "--new-name", "new"}, &stdout, &stderr) if code != 0 { t.Fatalf("expected 0, got %d: %s", code, stderr.String()) } @@ -283,32 +310,32 @@ func TestListRename(t *testing.T) { } func TestListDelete(t *testing.T) { - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "bye"}) + m := newFakeListManager(&domain.MailingList{Name: "bye"}) srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) defer srv.Close() var stdout, stderr bytes.Buffer - code := Run([]string{"--server", srv.URL, "list", "delete", "--id", "1"}, &stdout, &stderr) + code := Run([]string{"--server", srv.URL, "list", "delete", "--name", "bye"}, &stdout, &stderr) if code != 0 { t.Fatalf("expected 0, got %d: %s", code, stderr.String()) } - if _, exists := m.lists[1]; exists { + if _, exists := m.lists["bye"]; exists { t.Error("list should have been deleted") } } func TestListUsers(t *testing.T) { now := time.Now() - m := newFakeListManager(&domain.MailingList{ID: 1, Name: "weekly"}) + m := newFakeListManager(&domain.MailingList{Name: "weekly"}) m.users = []*domain.User{ - {ID: 1, MailingListID: 1, Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, - {ID: 2, MailingListID: 1, Name: "Bob", Email: "b@test.com"}, + {ID: 1, MailingListName: "weekly", Name: "Alice", Email: "a@test.com", ConfirmedAt: &now}, + {ID: 2, MailingListName: "weekly", Name: "Bob", Email: "b@test.com"}, } srv := startTestServer(t, m, &fakeMailDispatcher{}, nil) defer srv.Close() var stdout, stderr bytes.Buffer - code := Run([]string{"--server", srv.URL, "list", "users", "--id", "1"}, &stdout, &stderr) + code := Run([]string{"--server", srv.URL, "list", "users", "--name", "weekly"}, &stdout, &stderr) if code != 0 { t.Fatalf("expected 0, got %d: %s", code, stderr.String()) } diff --git a/config/config.go b/config/config.go index 1d39bc1..ddbf28d 100644 --- a/config/config.go +++ b/config/config.go @@ -46,9 +46,9 @@ type Config struct { Paths struct { Config string `env:"CONFIG_PATH" env-default:"config.yml"` - Template string `env:"TEMPLATE_PATH" env-default:"./template.html" yaml:"template"` - Theme string `env:"THEME_PATH" env-default:"https://raw.githubusercontent.com/5000K/5000blogs/refs/heads/stable/template/theme.base.css" yaml:"theme"` - ConfirmMail string `env:"CONFIRM_MAIL_PATH" env-default:"./confirm.md" yaml:"confirm-mail"` + Template string `env:"TEMPLATE_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/template.html" yaml:"template"` + Theme string `env:"THEME_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/theme.example.css" yaml:"theme"` + ConfirmMail string `env:"CONFIRM_MAIL_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/confirm.md" yaml:"confirm-mail"` } `yaml:"paths"` } diff --git a/db/model.go b/db/model.go index 549ee3e..ade313a 100644 --- a/db/model.go +++ b/db/model.go @@ -8,9 +8,8 @@ import ( ) type MailingList struct { - gorm.Model - Name string `gorm:"not null;uniqueIndex"` - Users []User `gorm:"foreignKey:MailingListID"` + Name string `gorm:"primaryKey"` + Users []User `gorm:"foreignKey:MailingListName"` } type User struct { @@ -18,7 +17,7 @@ type User struct { Name string `gorm:"not null"` Email string `gorm:"not null;uniqueIndex:idx_user_email_list"` ConfirmedAt *time.Time - MailingListID uint `gorm:"not null;uniqueIndex:idx_user_email_list"` + MailingListName string `gorm:"not null;uniqueIndex:idx_user_email_list"` UnsubscribeToken string `gorm:"not null;uniqueIndex"` } @@ -27,7 +26,7 @@ func ToGORMUser(u *domain.User) *User { Name: u.Name, Email: u.Email, ConfirmedAt: u.ConfirmedAt, - MailingListID: u.MailingListID, + MailingListName: u.MailingListName, UnsubscribeToken: u.UnsubscribeToken, } } @@ -38,7 +37,7 @@ func ToDomainUser(u *User) *domain.User { Name: u.Name, Email: u.Email, ConfirmedAt: u.ConfirmedAt, - MailingListID: u.MailingListID, + MailingListName: u.MailingListName, UnsubscribeToken: u.UnsubscribeToken, } } @@ -51,15 +50,8 @@ func ToDomainUsers(users []User) []domain.User { return result } -func ToGORMList(l *domain.MailingList) *MailingList { - return &MailingList{ - Name: l.Name, - } -} - func ToDomainList(l *MailingList) *domain.MailingList { return &domain.MailingList{ - ID: l.ID, Name: l.Name, } } diff --git a/db/repository.list.go b/db/repository.list.go index 0d916a6..682d8e8 100644 --- a/db/repository.list.go +++ b/db/repository.list.go @@ -8,6 +8,20 @@ import ( "github.com/5000K/5000mails/domain" ) +func (r *MailingListRepository) GetAllLists(ctx context.Context) ([]domain.MailingList, error) { + var lists []MailingList + result := r.db.WithContext(ctx).Find(&lists) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to get all mailing lists", slog.Any("error", result.Error)) + return nil, fmt.Errorf("get all mailing lists: %w", result.Error) + } + out := make([]domain.MailingList, len(lists)) + for i, l := range lists { + out[i] = *ToDomainList(&l) + } + return out, nil +} + func (r *MailingListRepository) CreateList(ctx context.Context, name string) (*domain.MailingList, error) { list := &MailingList{Name: name} @@ -20,20 +34,17 @@ func (r *MailingListRepository) CreateList(ctx context.Context, name string) (*d return nil, fmt.Errorf("create mailing list: %w", result.Error) } - r.logger.InfoContext(ctx, "created mailing list", - slog.String("name", name), - slog.Uint64("id", uint64(list.ID)), - ) + r.logger.InfoContext(ctx, "created mailing list", slog.String("name", name)) return ToDomainList(list), nil } -func (r *MailingListRepository) GetList(ctx context.Context, id uint) (*domain.MailingList, error) { +func (r *MailingListRepository) GetListByName(ctx context.Context, name string) (*domain.MailingList, error) { var list MailingList - result := r.db.WithContext(ctx).First(&list, id) + result := r.db.WithContext(ctx).First(&list, "name = ?", name) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to get mailing list", - slog.Uint64("id", uint64(id)), + slog.String("name", name), slog.Any("error", result.Error), ) return nil, fmt.Errorf("get mailing list: %w", result.Error) @@ -42,63 +53,43 @@ func (r *MailingListRepository) GetList(ctx context.Context, id uint) (*domain.M return ToDomainList(&list), nil } -func (r *MailingListRepository) GetListByName(ctx context.Context, name string) (*domain.MailingList, error) { - var list MailingList +func (r *MailingListRepository) RenameList(ctx context.Context, name, newName string) (*domain.MailingList, error) { + result := r.db.WithContext(ctx). + Model(&MailingList{}). + Where("name = ?", name). + Update("name", newName) - result := r.db.WithContext(ctx).Where("name = ?", name).First(&list) if result.Error != nil { - r.logger.ErrorContext(ctx, "failed to get mailing list by name", + r.logger.ErrorContext(ctx, "failed to rename mailing list", slog.String("name", name), + slog.String("new_name", newName), slog.Any("error", result.Error), ) - return nil, fmt.Errorf("get mailing list by name: %w", result.Error) - } - - return ToDomainList(&list), nil -} - -func (r *MailingListRepository) UpdateList(ctx context.Context, id uint, name string) (*domain.MailingList, error) { - var list MailingList - result := r.db.WithContext(ctx).First(&list, id) - if result.Error != nil { - r.logger.ErrorContext(ctx, "failed to find mailing list for update", - slog.Uint64("id", uint64(id)), - slog.Any("error", result.Error), - ) - return nil, fmt.Errorf("update mailing list: %w", result.Error) + return nil, fmt.Errorf("rename mailing list: %w", result.Error) } - - list.Name = name - result = r.db.WithContext(ctx).Save(&list) - if result.Error != nil { - r.logger.ErrorContext(ctx, "failed to update mailing list", - slog.Uint64("id", uint64(id)), - slog.Any("error", result.Error), - ) - return nil, fmt.Errorf("update mailing list: %w", result.Error) + if result.RowsAffected == 0 { + return nil, fmt.Errorf("rename mailing list: list %q not found", name) } - r.logger.InfoContext(ctx, "updated mailing list", - slog.Uint64("id", uint64(id)), + r.logger.InfoContext(ctx, "renamed mailing list", slog.String("name", name), + slog.String("new_name", newName), ) - return ToDomainList(&list), nil + return &domain.MailingList{Name: newName}, nil } -func (r *MailingListRepository) DeleteList(ctx context.Context, id uint) error { - result := r.db.WithContext(ctx).Delete(&MailingList{}, id) +func (r *MailingListRepository) DeleteList(ctx context.Context, name string) error { + result := r.db.WithContext(ctx).Delete(&MailingList{Name: name}) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to delete mailing list", - slog.Uint64("id", uint64(id)), + slog.String("name", name), slog.Any("error", result.Error), ) return fmt.Errorf("delete mailing list: %w", result.Error) } if result.RowsAffected == 0 { - return fmt.Errorf("delete mailing list: list %d not found", id) + return fmt.Errorf("delete mailing list: list %q not found", name) } - r.logger.InfoContext(ctx, "deleted mailing list", - slog.Uint64("id", uint64(id)), - ) + r.logger.InfoContext(ctx, "deleted mailing list", slog.String("name", name)) return nil } diff --git a/db/repository.user.go b/db/repository.user.go index ccda66c..f2575bb 100644 --- a/db/repository.user.go +++ b/db/repository.user.go @@ -9,18 +9,18 @@ import ( "github.com/5000K/5000mails/domain" ) -func (r *MailingListRepository) AddUser(ctx context.Context, mailingListID uint, name, email, unsubscribeToken string) (*domain.User, error) { +func (r *MailingListRepository) AddUser(ctx context.Context, mailingListName string, name, email, unsubscribeToken string) (*domain.User, error) { user := &User{ Name: name, Email: email, - MailingListID: mailingListID, + MailingListName: mailingListName, UnsubscribeToken: unsubscribeToken, } result := r.db.WithContext(ctx).Create(user) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to add user to mailing list", - slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("mailing_list_name", mailingListName), slog.String("email", email), slog.Any("error", result.Error), ) @@ -28,7 +28,7 @@ func (r *MailingListRepository) AddUser(ctx context.Context, mailingListID uint, } r.logger.InfoContext(ctx, "added user to mailing list", - slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("mailing_list_name", mailingListName), slog.Uint64("user_id", uint64(user.ID)), slog.String("email", email), ) @@ -79,23 +79,23 @@ func (r *MailingListRepository) GetUserByUnsubscribeToken(ctx context.Context, t return ToDomainUser(&user), nil } -func (r *MailingListRepository) GetConfirmedUsers(ctx context.Context, mailingListID uint) ([]domain.User, error) { +func (r *MailingListRepository) GetConfirmedUsers(ctx context.Context, mailingListName string) ([]domain.User, error) { var users []User result := r.db.WithContext(ctx). - Where("mailing_list_id = ? AND confirmed_at IS NOT NULL", mailingListID). + Where("mailing_list_name = ? AND confirmed_at IS NOT NULL", mailingListName). Find(&users) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to get confirmed users", - slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("mailing_list_name", mailingListName), slog.Any("error", result.Error), ) return nil, fmt.Errorf("get confirmed users: %w", result.Error) } r.logger.InfoContext(ctx, "fetched confirmed users", - slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("mailing_list_name", mailingListName), slog.Int("count", len(users)), ) return ToDomainUsers(users), nil @@ -121,20 +121,20 @@ func (r *MailingListRepository) RemoveUser(ctx context.Context, userID uint) err return nil } -func (r *MailingListRepository) GetUsers(ctx context.Context, mailingListID uint) ([]domain.User, error) { +func (r *MailingListRepository) GetUsers(ctx context.Context, mailingListName string) ([]domain.User, error) { var users []User - result := r.db.WithContext(ctx).Where("mailing_list_id = ?", mailingListID).Find(&users) + result := r.db.WithContext(ctx).Where("mailing_list_name = ?", mailingListName).Find(&users) if result.Error != nil { r.logger.ErrorContext(ctx, "failed to get users", - slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("mailing_list_name", mailingListName), slog.Any("error", result.Error), ) return nil, fmt.Errorf("get users: %w", result.Error) } r.logger.InfoContext(ctx, "fetched users", - slog.Uint64("mailing_list_id", uint64(mailingListID)), + slog.String("mailing_list_name", mailingListName), slog.Int("count", len(users)), ) return ToDomainUsers(users), nil diff --git a/db/repository_test.go b/db/repository_test.go index 97e57fc..2becaa9 100644 --- a/db/repository_test.go +++ b/db/repository_test.go @@ -32,9 +32,6 @@ func TestCreateList(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if list.ID == 0 { - t.Error("expected non-zero ID") - } if list.Name != "weekly" { t.Errorf("expected name %q, got %q", "weekly", list.Name) } @@ -51,27 +48,6 @@ func TestCreateList_DuplicateNameErrors(t *testing.T) { } } -func TestGetList(t *testing.T) { - repo := newTestRepo(t) - created, _ := repo.CreateList(context.Background(), "monthly") - - got, err := repo.GetList(context.Background(), created.ID) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got.ID != created.ID || got.Name != created.Name { - t.Errorf("got %+v, want %+v", got, created) - } -} - -func TestGetList_NotFound(t *testing.T) { - repo := newTestRepo(t) - _, err := repo.GetList(context.Background(), 9999) - if err == nil { - t.Fatal("expected error for unknown ID, got nil") - } -} - func TestGetListByName(t *testing.T) { repo := newTestRepo(t) repo.CreateList(context.Background(), "daily") @@ -93,22 +69,36 @@ func TestGetListByName_NotFound(t *testing.T) { } } +func TestGetAllLists(t *testing.T) { + repo := newTestRepo(t) + repo.CreateList(context.Background(), "list-a") + repo.CreateList(context.Background(), "list-b") + + lists, err := repo.GetAllLists(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(lists) != 2 { + t.Errorf("expected 2 lists, got %d", len(lists)) + } +} + // ---------- User ---------- -func seedList(t *testing.T, repo *MailingListRepository, name string) uint { +func seedList(t *testing.T, repo *MailingListRepository, name string) string { t.Helper() list, err := repo.CreateList(context.Background(), name) if err != nil { t.Fatalf("seed list %q: %v", name, err) } - return list.ID + return list.Name } func TestAddUser(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") + listName := seedList(t, repo, "weekly") - user, err := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + user, err := repo.AddUser(context.Background(), listName, "Alice", "alice@example.com", "tok-alice") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -128,10 +118,10 @@ func TestAddUser(t *testing.T) { func TestAddUser_DuplicateEmailErrors(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") - repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + listName := seedList(t, repo, "weekly") + repo.AddUser(context.Background(), listName, "Alice", "alice@example.com", "tok-alice") - _, err := repo.AddUser(context.Background(), listID, "Alice2", "alice@example.com", "tok-alice-2") + _, err := repo.AddUser(context.Background(), listName, "Alice2", "alice@example.com", "tok-alice-2") if err == nil { t.Fatal("expected error for duplicate email on same list, got nil") } @@ -139,8 +129,8 @@ func TestAddUser_DuplicateEmailErrors(t *testing.T) { func TestConfirmUser(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") - user, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + listName := seedList(t, repo, "weekly") + user, _ := repo.AddUser(context.Background(), listName, "Alice", "alice@example.com", "tok-alice") if err := repo.ConfirmUser(context.Background(), user.ID); err != nil { t.Fatalf("unexpected error: %v", err) @@ -154,8 +144,8 @@ func TestConfirmUser(t *testing.T) { func TestConfirmUser_AlreadyConfirmedErrors(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") - user, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + listName := seedList(t, repo, "weekly") + user, _ := repo.AddUser(context.Background(), listName, "Alice", "alice@example.com", "tok-alice") repo.ConfirmUser(context.Background(), user.ID) err := repo.ConfirmUser(context.Background(), user.ID) @@ -174,8 +164,8 @@ func TestConfirmUser_NotFoundErrors(t *testing.T) { func TestGetUserByUnsubscribeToken(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") - repo.AddUser(context.Background(), listID, "Bob", "bob@example.com", "tok-bob") + listName := seedList(t, repo, "weekly") + repo.AddUser(context.Background(), listName, "Bob", "bob@example.com", "tok-bob") got, err := repo.GetUserByUnsubscribeToken(context.Background(), "tok-bob") if err != nil { @@ -199,13 +189,13 @@ func TestGetUserByUnsubscribeToken_NotFound(t *testing.T) { func TestGetConfirmedUsers(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") + listName := seedList(t, repo, "weekly") - confirmed, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") - repo.AddUser(context.Background(), listID, "Bob", "bob@example.com", "tok-bob") + confirmed, _ := repo.AddUser(context.Background(), listName, "Alice", "alice@example.com", "tok-alice") + repo.AddUser(context.Background(), listName, "Bob", "bob@example.com", "tok-bob") repo.ConfirmUser(context.Background(), confirmed.ID) - users, err := repo.GetConfirmedUsers(context.Background(), listID) + users, err := repo.GetConfirmedUsers(context.Background(), listName) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -238,8 +228,8 @@ func TestGetConfirmedUsers_ExcludesOtherLists(t *testing.T) { func TestRemoveUser(t *testing.T) { repo := newTestRepo(t) - listID := seedList(t, repo, "weekly") - user, _ := repo.AddUser(context.Background(), listID, "Alice", "alice@example.com", "tok-alice") + listName := seedList(t, repo, "weekly") + user, _ := repo.AddUser(context.Background(), listName, "Alice", "alice@example.com", "tok-alice") if err := repo.RemoveUser(context.Background(), user.ID); err != nil { t.Fatalf("unexpected error: %v", err) @@ -261,9 +251,9 @@ func TestRemoveUser_NotFound(t *testing.T) { func TestUpdateList(t *testing.T) { repo := newTestRepo(t) - list, _ := repo.CreateList(context.Background(), "original") + repo.CreateList(context.Background(), "original") - updated, err := repo.UpdateList(context.Background(), list.ID, "renamed") + updated, err := repo.RenameList(context.Background(), "original", "renamed") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -274,7 +264,7 @@ func TestUpdateList(t *testing.T) { func TestUpdateList_NotFound(t *testing.T) { repo := newTestRepo(t) - _, err := repo.UpdateList(context.Background(), 9999, "nope") + _, err := repo.RenameList(context.Background(), "ghost", "nope") if err == nil { t.Fatal("expected error for unknown list, got nil") } @@ -282,13 +272,13 @@ func TestUpdateList_NotFound(t *testing.T) { func TestDeleteList(t *testing.T) { repo := newTestRepo(t) - list, _ := repo.CreateList(context.Background(), "doomed") + repo.CreateList(context.Background(), "doomed") - if err := repo.DeleteList(context.Background(), list.ID); err != nil { + if err := repo.DeleteList(context.Background(), "doomed"); err != nil { t.Fatalf("unexpected error: %v", err) } - _, err := repo.GetList(context.Background(), list.ID) + _, err := repo.GetListByName(context.Background(), "doomed") if err == nil { t.Fatal("expected error after deleting list, got nil") } @@ -296,7 +286,7 @@ func TestDeleteList(t *testing.T) { func TestDeleteList_NotFound(t *testing.T) { repo := newTestRepo(t) - err := repo.DeleteList(context.Background(), 9999) + err := repo.DeleteList(context.Background(), "ghost") if err == nil { t.Fatal("expected error for unknown list, got nil") } @@ -305,10 +295,10 @@ func TestDeleteList_NotFound(t *testing.T) { func TestGetUsers(t *testing.T) { repo := newTestRepo(t) list, _ := repo.CreateList(context.Background(), "weekly") - repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") - repo.AddUser(context.Background(), list.ID, "Bob", "b@test.com", "tok-b") + repo.AddUser(context.Background(), list.Name, "Alice", "a@test.com", "tok-a") + repo.AddUser(context.Background(), list.Name, "Bob", "b@test.com", "tok-b") - users, err := repo.GetUsers(context.Background(), list.ID) + users, err := repo.GetUsers(context.Background(), list.Name) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -320,7 +310,7 @@ func TestGetUsers(t *testing.T) { func TestCreateConfirmation(t *testing.T) { repo := newTestRepo(t) list, _ := repo.CreateList(context.Background(), "weekly") - user, _ := repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + user, _ := repo.AddUser(context.Background(), list.Name, "Alice", "a@test.com", "tok-a") conf, err := repo.CreateConfirmation(context.Background(), user.ID, "confirm-tok") if err != nil { @@ -334,7 +324,7 @@ func TestCreateConfirmation(t *testing.T) { func TestGetConfirmationByToken(t *testing.T) { repo := newTestRepo(t) list, _ := repo.CreateList(context.Background(), "weekly") - user, _ := repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + user, _ := repo.AddUser(context.Background(), list.Name, "Alice", "a@test.com", "tok-a") repo.CreateConfirmation(context.Background(), user.ID, "find-me") conf, err := repo.GetConfirmationByToken(context.Background(), "find-me") @@ -357,7 +347,7 @@ func TestGetConfirmationByToken_NotFound(t *testing.T) { func TestDeleteConfirmation(t *testing.T) { repo := newTestRepo(t) list, _ := repo.CreateList(context.Background(), "weekly") - user, _ := repo.AddUser(context.Background(), list.ID, "Alice", "a@test.com", "tok-a") + user, _ := repo.AddUser(context.Background(), list.Name, "Alice", "a@test.com", "tok-a") conf, _ := repo.CreateConfirmation(context.Background(), user.ID, "del-me") if err := repo.DeleteConfirmation(context.Background(), conf.ID); err != nil { diff --git a/domain/model.go b/domain/model.go index 8cfd5f2..e720fbd 100644 --- a/domain/model.go +++ b/domain/model.go @@ -4,7 +4,6 @@ import "time" // MailingList represents a named list that users can subscribe to. type MailingList struct { - ID uint Name string } @@ -14,7 +13,7 @@ type User struct { Name string Email string ConfirmedAt *time.Time - MailingListID uint + MailingListName string UnsubscribeToken string } diff --git a/domain/ports.go b/domain/ports.go index f578edc..83f243a 100644 --- a/domain/ports.go +++ b/domain/ports.go @@ -4,18 +4,18 @@ import "context" type MailingListRepository interface { CreateList(ctx context.Context, name string) (*MailingList, error) - GetList(ctx context.Context, id uint) (*MailingList, error) + GetAllLists(ctx context.Context) ([]MailingList, error) GetListByName(ctx context.Context, name string) (*MailingList, error) - UpdateList(ctx context.Context, id uint, name string) (*MailingList, error) - DeleteList(ctx context.Context, id uint) error + RenameList(ctx context.Context, name, newName string) (*MailingList, error) + DeleteList(ctx context.Context, name string) error } type UserRepository interface { - AddUser(ctx context.Context, mailingListID uint, name, email, unsubscribeToken string) (*User, error) + AddUser(ctx context.Context, mailingListName string, name, email, unsubscribeToken string) (*User, error) ConfirmUser(ctx context.Context, userID uint) error GetUserByUnsubscribeToken(ctx context.Context, token string) (*User, error) - GetUsers(ctx context.Context, mailingListID uint) ([]User, error) - GetConfirmedUsers(ctx context.Context, mailingListID uint) ([]User, error) + GetUsers(ctx context.Context, mailingListName string) ([]User, error) + GetConfirmedUsers(ctx context.Context, mailingListName string) ([]User, error) RemoveUser(ctx context.Context, userID uint) error } diff --git a/service/fakes_test.go b/service/fakes_test.go index de6f945..0f19a5d 100644 --- a/service/fakes_test.go +++ b/service/fakes_test.go @@ -10,45 +10,40 @@ import ( // fakeListRepo is an in-memory MailingListRepository. type fakeListRepo struct { - lists map[uint]*domain.MailingList - nextID uint + lists map[string]*domain.MailingList createErr error - getErr error + getAllErr error getByNameErr error updateErr error deleteErr error } func newFakeListRepo(seed ...*domain.MailingList) *fakeListRepo { - r := &fakeListRepo{lists: make(map[uint]*domain.MailingList), nextID: 1} + r := &fakeListRepo{lists: make(map[string]*domain.MailingList)} for _, l := range seed { - r.lists[l.ID] = l - if l.ID >= r.nextID { - r.nextID = l.ID + 1 - } + r.lists[l.Name] = l } return r } -func (r *fakeListRepo) CreateList(_ context.Context, name string) (*domain.MailingList, error) { - if r.createErr != nil { - return nil, r.createErr +func (r *fakeListRepo) GetAllLists(_ context.Context) ([]domain.MailingList, error) { + if r.getAllErr != nil { + return nil, r.getAllErr } - l := &domain.MailingList{ID: r.nextID, Name: name} - r.nextID++ - r.lists[l.ID] = l - return l, nil + out := make([]domain.MailingList, 0, len(r.lists)) + for _, l := range r.lists { + out = append(out, *l) + } + return out, nil } -func (r *fakeListRepo) GetList(_ context.Context, id uint) (*domain.MailingList, error) { - if r.getErr != nil { - return nil, r.getErr - } - l, ok := r.lists[id] - if !ok { - return nil, fmt.Errorf("list %d not found", id) +func (r *fakeListRepo) CreateList(_ context.Context, name string) (*domain.MailingList, error) { + if r.createErr != nil { + return nil, r.createErr } + l := &domain.MailingList{Name: name} + r.lists[name] = l return l, nil } @@ -56,34 +51,35 @@ func (r *fakeListRepo) GetListByName(_ context.Context, name string) (*domain.Ma if r.getByNameErr != nil { return nil, r.getByNameErr } - for _, l := range r.lists { - if l.Name == name { - return l, nil - } + l, ok := r.lists[name] + if !ok { + return nil, fmt.Errorf("list %q not found", name) } - return nil, fmt.Errorf("list %q not found", name) + return l, nil } -func (r *fakeListRepo) UpdateList(_ context.Context, id uint, name string) (*domain.MailingList, error) { +func (r *fakeListRepo) RenameList(_ context.Context, name, newName string) (*domain.MailingList, error) { if r.updateErr != nil { return nil, r.updateErr } - l, ok := r.lists[id] + l, ok := r.lists[name] if !ok { - return nil, fmt.Errorf("list %d not found", id) + return nil, fmt.Errorf("list %q not found", name) } - l.Name = name + delete(r.lists, name) + l.Name = newName + r.lists[newName] = l return l, nil } -func (r *fakeListRepo) DeleteList(_ context.Context, id uint) error { +func (r *fakeListRepo) DeleteList(_ context.Context, name string) error { if r.deleteErr != nil { return r.deleteErr } - if _, ok := r.lists[id]; !ok { - return fmt.Errorf("list %d not found", id) + if _, ok := r.lists[name]; !ok { + return fmt.Errorf("list %q not found", name) } - delete(r.lists, id) + delete(r.lists, name) return nil } @@ -111,11 +107,11 @@ func newFakeUserRepo(seed ...*domain.User) *fakeUserRepo { return r } -func (r *fakeUserRepo) AddUser(_ context.Context, mailingListID uint, name, email, unsubscribeToken string) (*domain.User, error) { +func (r *fakeUserRepo) AddUser(_ context.Context, mailingListName string, name, email, unsubscribeToken string) (*domain.User, error) { if r.addErr != nil { return nil, r.addErr } - u := &domain.User{ID: r.nextID, Name: name, Email: email, MailingListID: mailingListID, UnsubscribeToken: unsubscribeToken} + u := &domain.User{ID: r.nextID, Name: name, Email: email, MailingListName: mailingListName, UnsubscribeToken: unsubscribeToken} r.nextID++ r.users[u.ID] = u return u, nil @@ -146,26 +142,26 @@ func (r *fakeUserRepo) GetUserByUnsubscribeToken(_ context.Context, token string return nil, fmt.Errorf("user with unsubscribe token %q not found", token) } -func (r *fakeUserRepo) GetUsers(_ context.Context, mailingListID uint) ([]domain.User, error) { +func (r *fakeUserRepo) GetUsers(_ context.Context, mailingListName string) ([]domain.User, error) { if r.getUsersErr != nil { return nil, r.getUsersErr } var out []domain.User for _, u := range r.users { - if u.MailingListID == mailingListID { + if u.MailingListName == mailingListName { out = append(out, *u) } } return out, nil } -func (r *fakeUserRepo) GetConfirmedUsers(_ context.Context, mailingListID uint) ([]domain.User, error) { +func (r *fakeUserRepo) GetConfirmedUsers(_ context.Context, mailingListName string) ([]domain.User, error) { if r.getConfirmedErr != nil { return nil, r.getConfirmedErr } var out []domain.User for _, u := range r.users { - if u.MailingListID == mailingListID && u.ConfirmedAt != nil { + if u.MailingListName == mailingListName && u.ConfirmedAt != nil { out = append(out, *u) } } @@ -259,10 +255,10 @@ func (s *fakeSender) SendMail(_ context.Context, metadata domain.MailMetadata, b // fakeRenderer returns configurable metadata / body. type fakeRenderer struct { - metadata domain.MailMetadata - body string - err error - lastData map[string]any + metadata domain.MailMetadata + body string + err error + lastData map[string]any } func (r *fakeRenderer) Render(_ *string, data map[string]any) (domain.MailMetadata, string, error) { diff --git a/service/list.go b/service/list.go index a3f53a7..c19cc2e 100644 --- a/service/list.go +++ b/service/list.go @@ -18,26 +18,26 @@ func NewListService(lists domain.MailingListRepository, users domain.UserReposit return &ListService{lists: lists, users: users} } -// Create creates a new mailing list with the given name. -func (s *ListService) Create(ctx context.Context, name string) (*domain.MailingList, error) { - list, err := s.lists.CreateList(ctx, name) +// All returns all mailing lists. +func (s *ListService) All(ctx context.Context) ([]domain.MailingList, error) { + lists, err := s.lists.GetAllLists(ctx) if err != nil { - return nil, fmt.Errorf("creating list %q: %w", name, err) + return nil, fmt.Errorf("listing all lists: %w", err) } - return list, nil + return lists, nil } -// Get returns a mailing list by its ID. -func (s *ListService) Get(ctx context.Context, id uint) (*domain.MailingList, error) { - list, err := s.lists.GetList(ctx, id) +// Create creates a new mailing list with the given name. +func (s *ListService) Create(ctx context.Context, name string) (*domain.MailingList, error) { + list, err := s.lists.CreateList(ctx, name) if err != nil { - return nil, fmt.Errorf("getting list %d: %w", id, err) + return nil, fmt.Errorf("creating list %q: %w", name, err) } return list, nil } -// GetByName returns a mailing list by its name. -func (s *ListService) GetByName(ctx context.Context, name string) (*domain.MailingList, error) { +// Get returns a mailing list by name. +func (s *ListService) Get(ctx context.Context, name string) (*domain.MailingList, error) { list, err := s.lists.GetListByName(ctx, name) if err != nil { return nil, fmt.Errorf("getting list %q: %w", name, err) @@ -46,32 +46,32 @@ func (s *ListService) GetByName(ctx context.Context, name string) (*domain.Maili } // Rename renames a mailing list. -func (s *ListService) Rename(ctx context.Context, id uint, newName string) (*domain.MailingList, error) { - list, err := s.lists.UpdateList(ctx, id, newName) +func (s *ListService) Rename(ctx context.Context, name, newName string) (*domain.MailingList, error) { + list, err := s.lists.RenameList(ctx, name, newName) if err != nil { - return nil, fmt.Errorf("renaming list %d: %w", id, err) + return nil, fmt.Errorf("renaming list %q: %w", name, err) } return list, nil } -// Delete deletes a mailing list by its ID. -func (s *ListService) Delete(ctx context.Context, id uint) error { - if err := s.lists.DeleteList(ctx, id); err != nil { - return fmt.Errorf("deleting list %d: %w", id, err) +// Delete deletes a mailing list by name. +func (s *ListService) Delete(ctx context.Context, name string) error { + if err := s.lists.DeleteList(ctx, name); err != nil { + return fmt.Errorf("deleting list %q: %w", name, err) } return nil } // CountUsers returns the total and confirmed subscriber counts for a mailing list. -func (s *ListService) CountUsers(ctx context.Context, listID uint) (domain.UserCounts, error) { - all, err := s.users.GetUsers(ctx, listID) +func (s *ListService) CountUsers(ctx context.Context, listName string) (domain.UserCounts, error) { + all, err := s.users.GetUsers(ctx, listName) if err != nil { - return domain.UserCounts{}, fmt.Errorf("getting users for list %d: %w", listID, err) + return domain.UserCounts{}, fmt.Errorf("getting users for list %q: %w", listName, err) } - confirmed, err := s.users.GetConfirmedUsers(ctx, listID) + confirmed, err := s.users.GetConfirmedUsers(ctx, listName) if err != nil { - return domain.UserCounts{}, fmt.Errorf("getting confirmed users for list %d: %w", listID, err) + return domain.UserCounts{}, fmt.Errorf("getting confirmed users for list %q: %w", listName, err) } return domain.UserCounts{ @@ -81,10 +81,10 @@ func (s *ListService) CountUsers(ctx context.Context, listID uint) (domain.UserC } // Users returns all subscribers for a mailing list, confirmed or not. -func (s *ListService) Users(ctx context.Context, listID uint) ([]domain.User, error) { - users, err := s.users.GetUsers(ctx, listID) +func (s *ListService) Users(ctx context.Context, listName string) ([]domain.User, error) { + users, err := s.users.GetUsers(ctx, listName) if err != nil { - return nil, fmt.Errorf("getting users for list %d: %w", listID, err) + return nil, fmt.Errorf("getting users for list %q: %w", listName, err) } return users, nil } diff --git a/service/list_test.go b/service/list_test.go index 24ff895..c032ecb 100644 --- a/service/list_test.go +++ b/service/list_test.go @@ -36,39 +36,39 @@ func TestListService_Create(t *testing.T) { } func TestListService_Get(t *testing.T) { - list := &domain.MailingList{ID: 1, Name: "weekly"} + list := &domain.MailingList{Name: "weekly"} - t.Run("returns list by ID", func(t *testing.T) { + t.Run("returns list by name", func(t *testing.T) { svc := NewListService(newFakeListRepo(list), newFakeUserRepo()) - got, err := svc.Get(context.Background(), 1) + got, err := svc.Get(context.Background(), "weekly") if err != nil { t.Fatalf("unexpected error: %v", err) } - if got.ID != list.ID || got.Name != list.Name { + if got.Name != list.Name { t.Errorf("got %+v, want %+v", got, list) } }) t.Run("wraps repo error", func(t *testing.T) { repo := newFakeListRepo() - repo.getErr = errors.New("not found") + repo.getByNameErr = errors.New("not found") svc := NewListService(repo, newFakeUserRepo()) - _, err := svc.Get(context.Background(), 99) + _, err := svc.Get(context.Background(), "ghost") if err == nil { t.Fatal("expected error, got nil") } - if !errors.Is(err, repo.getErr) { + if !errors.Is(err, repo.getByNameErr) { t.Errorf("expected wrapped repo error, got: %v", err) } }) } func TestListService_GetByName(t *testing.T) { - list := &domain.MailingList{ID: 2, Name: "monthly"} + list := &domain.MailingList{Name: "monthly"} t.Run("returns list by name", func(t *testing.T) { svc := NewListService(newFakeListRepo(list), newFakeUserRepo()) - got, err := svc.GetByName(context.Background(), "monthly") + got, err := svc.Get(context.Background(), "monthly") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -81,7 +81,7 @@ func TestListService_GetByName(t *testing.T) { repo := newFakeListRepo() repo.getByNameErr = errors.New("not found") svc := NewListService(repo, newFakeUserRepo()) - _, err := svc.GetByName(context.Background(), "ghost") + _, err := svc.Get(context.Background(), "ghost") if err == nil { t.Fatal("expected error, got nil") } @@ -92,11 +92,11 @@ func TestListService_GetByName(t *testing.T) { } func TestListService_Rename(t *testing.T) { - list := &domain.MailingList{ID: 3, Name: "old-name"} + list := &domain.MailingList{Name: "old-name"} t.Run("updates list name", func(t *testing.T) { svc := NewListService(newFakeListRepo(list), newFakeUserRepo()) - got, err := svc.Rename(context.Background(), 3, "new-name") + got, err := svc.Rename(context.Background(), "old-name", "new-name") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -109,7 +109,7 @@ func TestListService_Rename(t *testing.T) { repo := newFakeListRepo() repo.updateErr = errors.New("update failed") svc := NewListService(repo, newFakeUserRepo()) - _, err := svc.Rename(context.Background(), 3, "new-name") + _, err := svc.Rename(context.Background(), "old-name", "new-name") if !errors.Is(err, repo.updateErr) { t.Errorf("expected wrapped repo error, got: %v", err) } @@ -117,15 +117,15 @@ func TestListService_Rename(t *testing.T) { } func TestListService_Delete(t *testing.T) { - list := &domain.MailingList{ID: 4, Name: "doomed"} + list := &domain.MailingList{Name: "doomed"} t.Run("deletes list", func(t *testing.T) { repo := newFakeListRepo(list) svc := NewListService(repo, newFakeUserRepo()) - if err := svc.Delete(context.Background(), 4); err != nil { + if err := svc.Delete(context.Background(), "doomed"); err != nil { t.Fatalf("unexpected error: %v", err) } - if _, exists := repo.lists[4]; exists { + if _, exists := repo.lists["doomed"]; exists { t.Error("expected list to be deleted") } }) @@ -134,7 +134,7 @@ func TestListService_Delete(t *testing.T) { repo := newFakeListRepo() repo.deleteErr = errors.New("delete failed") svc := NewListService(repo, newFakeUserRepo()) - err := svc.Delete(context.Background(), 4) + err := svc.Delete(context.Background(), "doomed") if !errors.Is(err, repo.deleteErr) { t.Errorf("expected wrapped repo error, got: %v", err) } @@ -144,14 +144,14 @@ func TestListService_Delete(t *testing.T) { func TestListService_CountUsers(t *testing.T) { now := time.Now() users := []*domain.User{ - {ID: 1, MailingListID: 10, Email: "a@example.com", ConfirmedAt: &now}, - {ID: 2, MailingListID: 10, Email: "b@example.com", ConfirmedAt: nil}, - {ID: 3, MailingListID: 10, Email: "c@example.com", ConfirmedAt: &now}, + {ID: 1, MailingListName: "weekly", Email: "a@example.com", ConfirmedAt: &now}, + {ID: 2, MailingListName: "weekly", Email: "b@example.com", ConfirmedAt: nil}, + {ID: 3, MailingListName: "weekly", Email: "c@example.com", ConfirmedAt: &now}, } t.Run("counts total and confirmed users", func(t *testing.T) { svc := NewListService(newFakeListRepo(), newFakeUserRepo(users...)) - counts, err := svc.CountUsers(context.Background(), 10) + counts, err := svc.CountUsers(context.Background(), "weekly") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -167,7 +167,7 @@ func TestListService_CountUsers(t *testing.T) { repo := newFakeUserRepo() repo.getUsersErr = errors.New("db down") svc := NewListService(newFakeListRepo(), repo) - _, err := svc.CountUsers(context.Background(), 10) + _, err := svc.CountUsers(context.Background(), "weekly") if !errors.Is(err, repo.getUsersErr) { t.Errorf("expected wrapped error, got: %v", err) } @@ -177,9 +177,49 @@ func TestListService_CountUsers(t *testing.T) { repo := newFakeUserRepo(users...) repo.getConfirmedErr = errors.New("confirmed query failed") svc := NewListService(newFakeListRepo(), repo) - _, err := svc.CountUsers(context.Background(), 10) + _, err := svc.CountUsers(context.Background(), "weekly") if !errors.Is(err, repo.getConfirmedErr) { t.Errorf("expected wrapped error, got: %v", err) } }) } + +func TestListService_All(t *testing.T) { + list1 := &domain.MailingList{Name: "weekly"} + list2 := &domain.MailingList{Name: "monthly"} + + t.Run("returns all lists", func(t *testing.T) { + svc := NewListService(newFakeListRepo(list1, list2), newFakeUserRepo()) + got, err := svc.All(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2 lists, got %d", len(got)) + } + }) + + t.Run("returns empty slice when no lists exist", func(t *testing.T) { + svc := NewListService(newFakeListRepo(), newFakeUserRepo()) + got, err := svc.All(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %d elements", len(got)) + } + }) + + t.Run("wraps repo error", func(t *testing.T) { + repo := newFakeListRepo() + repo.getAllErr = errors.New("db failure") + svc := NewListService(repo, newFakeUserRepo()) + _, err := svc.All(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, repo.getAllErr) { + t.Errorf("expected wrapped repo error, got: %v", err) + } + }) +} diff --git a/service/mail.go b/service/mail.go index dee6df7..fa9759c 100644 --- a/service/mail.go +++ b/service/mail.go @@ -35,7 +35,7 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin return fmt.Errorf("looking up list %q: %w", listName, err) } - recipients, err := s.users.GetConfirmedUsers(ctx, list.ID) + recipients, err := s.users.GetConfirmedUsers(ctx, list.Name) if err != nil { return fmt.Errorf("getting confirmed users for list %q: %w", listName, err) } diff --git a/service/mail_test.go b/service/mail_test.go index d2e92d2..806a3ed 100644 --- a/service/mail_test.go +++ b/service/mail_test.go @@ -9,14 +9,14 @@ import ( "github.com/5000K/5000mails/domain" ) -func confirmedUser(id uint, listID uint, email string) *domain.User { +func confirmedUser(id uint, listName string, email string) *domain.User { now := time.Now() - return &domain.User{ID: id, MailingListID: listID, Email: email, Name: "Test", ConfirmedAt: &now} + return &domain.User{ID: id, MailingListName: listName, Email: email, Name: "Test", ConfirmedAt: &now} } func TestMailService_SendToList(t *testing.T) { metadata := domain.MailMetadata{Subject: "Hello", SenderName: "Bot"} - list := &domain.MailingList{ID: 5, Name: "weekly"} + list := &domain.MailingList{Name: "weekly"} t.Run("skips send when no confirmed recipients", func(t *testing.T) { sender := &fakeSender{} @@ -36,8 +36,8 @@ func TestMailService_SendToList(t *testing.T) { t.Run("renders and sends to confirmed recipients", func(t *testing.T) { users := newFakeUserRepo( - confirmedUser(1, 5, "alice@example.com"), - confirmedUser(2, 5, "bob@example.com"), + confirmedUser(1, "weekly", "alice@example.com"), + confirmedUser(2, "weekly", "bob@example.com"), ) sender := &fakeSender{} svc := NewMailService(newFakeListRepo(list), users, &fakeRenderer{metadata: metadata, body: "rendered"}, sender) @@ -64,7 +64,7 @@ func TestMailService_SendToList(t *testing.T) { }) t.Run("injects Recipient into render data per recipient", func(t *testing.T) { - user := confirmedUser(1, 5, "alice@example.com") + user := confirmedUser(1, "weekly", "alice@example.com") renderer := &fakeRenderer{metadata: metadata, body: "body"} svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), renderer, &fakeSender{}) @@ -104,7 +104,7 @@ func TestMailService_SendToList(t *testing.T) { renderErr := errors.New("template broken") svc := NewMailService( newFakeListRepo(list), - newFakeUserRepo(confirmedUser(1, 5, "a@example.com")), + newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), &fakeRenderer{err: renderErr}, &fakeSender{}, ) @@ -118,7 +118,7 @@ func TestMailService_SendToList(t *testing.T) { sendErr := errors.New("smtp refused") svc := NewMailService( newFakeListRepo(list), - newFakeUserRepo(confirmedUser(1, 5, "a@example.com")), + newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, ) diff --git a/service/subscription.go b/service/subscription.go index 5b80512..057d39b 100644 --- a/service/subscription.go +++ b/service/subscription.go @@ -55,7 +55,7 @@ func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, return nil, fmt.Errorf("generating unsubscribe token: %w", err) } - user, err := s.users.AddUser(ctx, list.ID, userName, email, unsubToken) + user, err := s.users.AddUser(ctx, list.Name, userName, email, unsubToken) if err != nil { return nil, fmt.Errorf("adding user to list %q: %w", listName, err) } diff --git a/service/subscription_test.go b/service/subscription_test.go index fc75fa0..5b06600 100644 --- a/service/subscription_test.go +++ b/service/subscription_test.go @@ -20,7 +20,7 @@ func newSubscriptionSvc( func TestSubscriptionService_Subscribe(t *testing.T) { metadata := domain.MailMetadata{Subject: "Confirm", SenderName: "Bot"} - list := &domain.MailingList{ID: 1, Name: "weekly"} + list := &domain.MailingList{Name: "weekly"} t.Run("adds user, creates confirmation, sends mail", func(t *testing.T) { users := newFakeUserRepo() @@ -135,7 +135,7 @@ func TestSubscriptionService_Subscribe(t *testing.T) { func TestSubscriptionService_Confirm(t *testing.T) { t.Run("confirms user and deletes confirmation", func(t *testing.T) { - users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListID: 1}) + users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListName: "weekly"}) confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "abc123"}) svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) @@ -172,7 +172,7 @@ func TestSubscriptionService_Confirm(t *testing.T) { }) t.Run("returns error when DeleteConfirmation fails", func(t *testing.T) { - users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListID: 1}) + users := newFakeUserRepo(&domain.User{ID: 1, Email: "alice@example.com", MailingListName: "weekly"}) confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "tok"}) confs.deleteErr = errors.New("delete failed") svc := newSubscriptionSvc(newFakeListRepo(), users, confs, &fakeRenderer{}, &fakeSender{}) @@ -184,7 +184,7 @@ func TestSubscriptionService_Confirm(t *testing.T) { } func TestSubscriptionService_Unsubscribe(t *testing.T) { - u := &domain.User{ID: 1, Email: "alice@example.com", MailingListID: 1, UnsubscribeToken: "tok-alice"} + u := &domain.User{ID: 1, Email: "alice@example.com", MailingListName: "weekly", UnsubscribeToken: "tok-alice"} t.Run("removes user by unsubscribe token", func(t *testing.T) { users := newFakeUserRepo(u)