diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..564ba57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: 1.27.x + - name: Install cargo-make + run: cargo install --locked cargo-make --version 0.37.24 + - name: Check + run: makers check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..262f9e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Release + +on: + push: + tags: + - v* + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version: 1.27.x + - uses: goreleaser/goreleaser-action@v7 + with: + version: v2.18.2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7e8fed0..36d8ade 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,7 @@ vendor/ # Output of the go coverage tool, specifically when used with LiteIDE *.out +dist/ + # vscode .vscode/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..c627ded --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,27 @@ +version: 2 +project_name: synr + +builds: + - main: ./cmd/synr + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + - formats: + - tar.gz + +checksum: + algorithm: sha256 + +changelog: + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..a015672 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.27.1" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d367f49 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: local + hooks: + - id: makers-fmt-check + name: makers fmt-check + entry: makers fmt-check + language: system + pass_filenames: false + - id: makers-check + name: makers check + entry: makers check + language: system + pass_filenames: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f4752fe..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: go - -go: -- '1.12.x' - -script: -- go test - -deploy: -- provider: script - skip_cleanup: true - script: curl -sL http://git.io/goreleaser | bash - on: - tags: true \ No newline at end of file diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..551003c --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,81 @@ +[tasks.fmt] +command = "go" +args = ["fmt", "./..."] + +[tasks.fmt-check] +script = ''' +files="$(gofmt -l $(git ls-files '*.go'))" +test -z "$files" || { echo "$files"; exit 1; } +''' + +[tasks.vet] +command = "go" +args = ["vet", "./..."] + +[tasks.test] +command = "go" +args = ["test", "-race", "./..."] + +[tasks.test-config] +command = "go" +args = ["test", "./internal/adapter/config", "-v"] + +[tasks.test-chatwork] +command = "go" +args = ["test", "./internal/adapter/chatwork", "-v"] + +[tasks.test-zulip] +command = "go" +args = ["test", "./internal/adapter/zulip", "-v"] + +[tasks.test-slack] +command = "go" +args = ["test", "./internal/adapter/slack", "-v"] + +[tasks.test-cli] +command = "go" +args = ["test", "./internal/presentation/cli", "-v"] + +[tasks.cli-help] +command = "go" +args = ["run", "./cmd/synr", "--help"] + +[tasks.tidy] +command = "go" +args = ["mod", "tidy"] + +[tasks.modules] +command = "go" +args = ["list", "-m", "all"] + +[tasks.clean-testcache] +command = "go" +args = ["clean", "-testcache"] + +[tasks.test-preview-repeat] +command = "go" +args = ["test", "./internal/presentation/cli", "./internal/usecase", "-run", "Preview|DoesNotLeave|ListFails", "-count=20"] + +[tasks.build] +command = "go" +args = ["build", "./..."] + +[tasks.vuln] +command = "go" +args = ["run", "golang.org/x/vuln/cmd/govulncheck@v1.8.0", "./..."] + +[tasks.goreleaser-check] +script = ''' +git_common_dir="$(git rev-parse --git-common-dir)" +docker run --rm -v "$PWD:/src" -v "$git_common_dir:$git_common_dir:ro" -w /src goreleaser/goreleaser:v2.18.2 check +''' + +[tasks.goreleaser-snapshot] +script = ''' +git_common_dir="$(git rev-parse --git-common-dir)" +docker run --rm -v "$PWD:/src" -v "$git_common_dir:$git_common_dir:ro" -w /src goreleaser/goreleaser:v2.18.2 release --snapshot --clean +''' + +[tasks.check] +dependencies = ["fmt-check", "vet", "test", "build", "vuln"] +command = "true" diff --git a/README.md b/README.md index 188cde4..4b14a76 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,97 @@ -# synr: A script that leaving from your unnecessary chat rooms -[![Build Status](https://travis-ci.com/chck/synr.svg?branch=master)](https://travis-ci.com/chck/synr) -[![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://github.com/chck/synr/blob/master/LICENSE) +# synr + +`synr` previews, and with explicit approval leaves, inactive conversations in +Slack, Chatwork, and Zulip. ## Requirements + +Install the published command with Go: + ```bash -go==1.12.* +go install github.com/chck/synr/cmd/synr@latest ``` -## Installation +Source builds require Go 1.27. The repository checks are available through +`makers check`. + +## Configuration + +Credentials are read only from environment variables. Set the variables for the +one service you intend to scan: + ```bash -% go get -u github.com/chck/synr +# Slack +export SYNR_SLACK_TOKEN=... + +# Chatwork +export SYNR_CHATWORK_TOKEN=... + +# Zulip +export SYNR_ZULIP_URL=https://zulip.example +export SYNR_ZULIP_EMAIL=user@example.com +export SYNR_ZULIP_API_KEY=... ``` -## Setup -```bash -# Set tokens in config/secrets.yaml -# slack: https://api.slack.com/docs/oauth-test-tokens -# chatwork: http://developer.chatwork.com/ja/authenticate.html -vi config/secrets.yaml ---- -tokens: - chatwork: YOUR_CHARWORK_TOKEN - slack: YOUR_SLACK_TOKEN +Optional, non-secret protected conversation IDs are read from +`${XDG_CONFIG_HOME:-$HOME/.config}/synr/config.yaml`. Its schema is exactly: + +```yaml +services: + slack: + protected_channels: + - C0123456789 + chatwork: + protected_channels: + - "123456789" + zulip: + protected_channels: + - "42" ``` +Unknown keys and malformed values are configuration errors. Conversation IDs +remain strings even when a service uses numeric IDs. The configuration file is +optional; `synr` never writes secrets to disk. + +## Safety rules + +`synr` is preview-only unless `--apply` is provided. A preview lists every +conversation with its service, name, stable ID, last activity, decision, and +reason. Only conversations with known activity strictly older than the cutoff +can be eligible. + +- Slack: activity includes messages and thread replies across all accessible + history pages, and the workspace general channel is never left. The token + needs the applicable `channels:history` and `groups:history` scopes; + unavailable history stops the scan before any leave. +- Chatwork: sticky rooms, direct chats, and My Chat are never left. Only group + rooms can be eligible; unknown room types stop the scan before any leave. +- Zulip: channels pinned by the current user are never unsubscribed. +- Every service also preserves IDs listed in `protected_channels`. + ## Usage -```bash -# Show usage with options -% synr help -# Dry-run -% synr -c slack -d +Preview Slack conversations inactive for more than three months: -# Run -% synr -c slack +```bash +synr scan --service slack --before-months 3 +``` -# Run with selected months elapsed from last update (default: 1) -% synr -c slack -m 3 +After reviewing that preview, explicitly apply its eligible results: -# Supported chats are slack and chatwork -% synr -c chatwork -m 3 +```bash +synr scan --service slack --before-months 3 --apply ``` + +`--service` accepts `slack`, `chatwork`, or `zulip`. `--before-months` defaults +to `1` and must be a positive integer. + +## Manual checklist + +1. Set credentials only for the intended service and add every conversation + that must be retained to `protected_channels`. +2. Run a preview without `--apply` and review every eligible row, including its + stable ID and inactivity reason. +3. Deliberately select the live apply scope only after that review; if needed, + protect all other conversations before continuing. +4. Run the matching command once with `--apply` and verify the resulting + service state. diff --git a/chatwork/chatwork.go b/chatwork/chatwork.go deleted file mode 100644 index ff66b57..0000000 --- a/chatwork/chatwork.go +++ /dev/null @@ -1,105 +0,0 @@ -package chatwork - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "strconv" - "time" -) - -const ( - defaultBaseURL = "https://api.chatwork.com/v2/" -) - -type Client struct { - config struct { - token string - } - - BaseURL *url.URL -} - -func New(token string) *Client { - baseURL, _ := url.Parse(defaultBaseURL) - - c := &Client{BaseURL: baseURL} - c.config.token = token - return c -} - -func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) { - rel, err := url.Parse(urlStr) - if err != nil { - return nil, err - } - - u := c.BaseURL.ResolveReference(rel) - - var buf io.ReadWriter - if body != nil { - buf = new(bytes.Buffer) - err := json.NewEncoder(buf).Encode(body) - if err != nil { - return nil, err - } - } - - req, err := http.NewRequest(method, u.String(), buf) - if err != nil { - return nil, err - } - - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - return req, nil -} - -func MayBeLeaveRoom(canDryRun bool, beforeMonth int, client *Client, room *Room) { - canLeave := canLeave(room, beforeMonth) - if canLeave { - log.Println(room.Name, room.LastUpdateTime, canLeave, room.Type) - if !canDryRun { - client.LeaveRooms(strconv.Itoa(room.RoomID)) - } - } -} - -func canLeave(room *Room, beforeMonth int) bool { - switch { - case room == nil: - return false - case room.Sticky: - return false - case room.Type == "direct": - return false - case beforeLastRead(room, beforeMonth): - return true - default: - return false - } -} - -func rawLastRead(lastUpdateTime JSONTime) string { - return fmt.Sprintf("%d", lastUpdateTime) -} - -func beforeLastRead(room *Room, beforeMonth int) bool { - if 0 >= beforeMonth { - beforeMonth = 1 - } - return lastRead(room).Before(time.Now().AddDate(0, beforeMonth*-1, 0)) -} - -func lastRead(room *Room) time.Time { - if room != nil { - return room.LastUpdateTime.Time() - } - return time.Now() -} diff --git a/chatwork/rooms.go b/chatwork/rooms.go deleted file mode 100644 index 1e1c2c2..0000000 --- a/chatwork/rooms.go +++ /dev/null @@ -1,73 +0,0 @@ -package chatwork - -import ( - "encoding/json" - "net/http" - "net/url" - "strings" -) - -type Room struct { - RoomID int `json:"room_id"` - Name string `json:"name"` - Type string `json:"type"` - Role string `json:"role"` - Sticky bool `json:"sticky"` - UnreadNum int `json:"unread_num"` - MentionNum int `json:"mention_num"` - MytaskNum int `json:"mytask_num"` - MessageNum int `json:"message_num"` - FileNum int `json:"file_num"` - TaskNum int `json:"task_num"` - IconPath string `json:"icon_path"` - LastUpdateTime JSONTime `json:"last_update_time"` -} - -type RoomRequest struct { - Description *string `json:"description,omitempty"` -} - -func (api *Client) GetRooms() ([]Room, error) { - endpoint := defaultBaseURL + "rooms" - - req, err := http.NewRequest("GET", endpoint, nil) - req.Header.Set("X-ChatWorkToken", api.config.token) - if err != nil { - return nil, err - } - - client := &http.Client{} - res, err := client.Do(req) - if err != nil { - return nil, err - } - - var rooms []Room - err = json.NewDecoder(res.Body).Decode(&rooms) - if err != nil { - return nil, err - } - defer res.Body.Close() - - return rooms, nil -} - -func (api *Client) LeaveRooms(roomID string) (bool, error) { - endpoint := defaultBaseURL + "rooms/" + roomID - values := url.Values{ - "action_type": {"leave"}, - } - req, err := http.NewRequest("DELETE", endpoint, strings.NewReader(values.Encode())) - req.Header.Set("X-ChatWorkToken", api.config.token) - if err != nil { - return false, err - } - - client := &http.Client{} - _, err = client.Do(req) - if err != nil { - return false, err - } - - return true, nil -} diff --git a/chatwork/util.go b/chatwork/util.go deleted file mode 100644 index 5072b7e..0000000 --- a/chatwork/util.go +++ /dev/null @@ -1,17 +0,0 @@ -package chatwork - -import ( - "fmt" - "time" -) - -type JSONTime int64 - -func (t JSONTime) String() string { - tm := t.Time() - return fmt.Sprintf("\"%s\"", tm.Format("Mon Jan _2")) -} - -func (t JSONTime) Time() time.Time { - return time.Unix(int64(t), 0) -} diff --git a/cmd/synr/main.go b/cmd/synr/main.go new file mode 100644 index 0000000..4264746 --- /dev/null +++ b/cmd/synr/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "context" + "os" + + "github.com/chck/synr/internal/presentation/cli" +) + +func main() { + os.Exit(cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr, cli.DefaultDependencies())) +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index b25a39e..0000000 --- a/config/config.go +++ /dev/null @@ -1,32 +0,0 @@ -package config - -import ( - "io/ioutil" - "log" - - "gopkg.in/yaml.v2" -) - -const ( - configPath = "./config/secrets.yaml" -) - -type Config struct { - Tokens struct { - Chatwork string - Slack string - } -} - -func Load() Config { - - file, err := ioutil.ReadFile(configPath) - - if err != nil { - log.Fatalf("failed to read config file, please set \"%v\" alike secrets.yaml.copy", configPath) - } - - config := &Config{} - _ = yaml.Unmarshal([]byte(file), config) - return *config -} diff --git a/config/secrets.yaml.copy b/config/secrets.yaml.copy deleted file mode 100644 index 842a76e..0000000 --- a/config/secrets.yaml.copy +++ /dev/null @@ -1,3 +0,0 @@ -tokens: - chatwork: YOUR_CHARWORK_TOKEN - slack: YOUR_SLACK_TOKEN diff --git a/docs/superpowers/plans/2026-09-18-modernize-and-add-zulip.md b/docs/superpowers/plans/2026-09-18-modernize-and-add-zulip.md new file mode 100644 index 0000000..fcdab3c --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-modernize-and-add-zulip.md @@ -0,0 +1,740 @@ +# Modernize synr and Add Zulip Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the unsafe Go 1.12 implementation with a fail-closed Go 1.27 CLI that previews Slack, Chatwork, and Zulip cleanup decisions and mutates only with `--apply`. + +**Architecture:** Immutable domain values make invalid or unknown activity explicit, a use case evaluates and applies decisions through a small provider interface, and service/config adapters isolate external APIs. The presentation layer parses `synr scan`, renders deterministic output, and maps typed failures to nonzero exit statuses. + +**Tech Stack:** Go 1.27.1, standard `flag`/`testing`/`httptest`, `github.com/slack-go/slack` v0.29.0, `go.yaml.in/yaml/v3` v3.0.5, cargo-make, GitHub Actions, GoReleaser v2.18.2. + +**Spec:** `docs/superpowers/specs/2026-09-18-modernize-and-add-zulip-design.md` + +## Global Constraints + +- Go version is `1.27.1`; `go.mod` declares `go 1.27` and CI selects `1.27.x`. +- Preview is the default. Only `--apply` authorizes external mutation. +- `--service` is required; `--before-months` defaults to `1` and must be greater than zero. +- Secrets come only from the documented `SYNR_*` environment variables and are never written by synr. +- Missing, malformed, incomplete, or failed upstream data is never eligible for departure. +- Finish all listing and decision work before the first leave request; stop on the first leave failure. +- Slack general channels, Chatwork sticky/direct rooms, Zulip pinned channels, and configured protected IDs are ineligible. +- Do not add Discord code; Discord remains tracked by GitHub Issue #11. +- Do not add automatic retries, concurrent leaves, legacy CLI/config fallbacks, or live-account CI tests. +- All repository commands run through `makers`; use `mise exec go@1.27.1 -- makers ` where Go is not already active. +- Every commit includes `Co-Authored-By: Codex `. + +--- + +## File Structure + +- `cmd/synr/main.go`: process bootstrap only. +- `internal/domain/service.go`: supported-service enum and parser. +- `internal/domain/conversation.go`: validated IDs, activity, protection, and immutable conversation values. +- `internal/domain/decision.go`: cutoff/protection eligibility rules. +- `internal/usecase/scan.go`: list, evaluate, sort, preview, and sequential apply orchestration. +- `internal/adapter/config/config.go`: strict XDG YAML and per-service environment credentials. +- `internal/adapter/chatwork/client.go`: Chatwork v2 HTTP provider. +- `internal/adapter/zulip/client.go`: Zulip subscription/message HTTP provider. +- `internal/adapter/slack/client.go`: maintained Slack SDK provider. +- `internal/presentation/cli/run.go`: `scan` parsing, provider selection, output, and exit mapping. +- `Makefile.toml`, `.mise.toml`, `.pre-commit-config.yaml`: reproducible local commands and hooks. +- `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `.goreleaser.yaml`: CI and releases. +- `README.md`: current installation, configuration, and manual verification. + +--- + +### Task 1: Reproducible Toolchain and Domain Model + +**Files:** +- Create: `.mise.toml` +- Create: `Makefile.toml` +- Create: `internal/domain/service.go` +- Create: `internal/domain/conversation.go` +- Create: `internal/domain/decision.go` +- Create: `internal/domain/decision_test.go` +- Modify: `go.mod:1-14` +- Delete: `slack_test.go` +- Delete: `synr_suite_test.go` + +**Interfaces:** +- Produces: `domain.ParseService(string) (domain.Service, error)`. +- Produces: `domain.NewConversation(Service, string, string, Activity, Protection) (Conversation, error)`. +- Produces: `domain.KnownActivity(time.Time) (Activity, error)` and `domain.UnknownActivity() Activity`. +- Produces: `domain.Evaluate(Conversation, time.Time, map[ConversationID]struct{}) Decision`. + +- [ ] **Step 1: Add the pinned toolchain and command launcher** + +Create `.mise.toml`: + +```toml +[tools] +go = "1.27.1" +``` + +Create `Makefile.toml` with `fmt`, `fmt-check`, `vet`, `test`, `build`, `vuln`, and aggregate `check` tasks. Use these exact commands: + +```toml +[tasks.fmt] +command = "go" +args = ["fmt", "./..."] + +[tasks.fmt-check] +script = ''' +files="$(gofmt -l $(git ls-files '*.go'))" +test -z "$files" || { echo "$files"; exit 1; } +''' + +[tasks.vet] +command = "go" +args = ["vet", "./..."] + +[tasks.test] +command = "go" +args = ["test", "-race", "./..."] + +[tasks.build] +command = "go" +args = ["build", "./..."] + +[tasks.vuln] +command = "go" +args = ["run", "golang.org/x/vuln/cmd/govulncheck@v1.1.4", "./..."] + +[tasks.check] +dependencies = ["fmt-check", "vet", "test", "build", "vuln"] +``` + +Change `go.mod` to `go 1.27` without changing dependency requirements yet. + +- [ ] **Step 2: Replace the live Slack suite with failing domain tests** + +Delete the credential-dependent Ginkgo files and add table tests covering exact outcomes: + +```go +func TestEvaluate(t *testing.T) { + now := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + cutoff := now.AddDate(0, -1, 0) + old, _ := KnownActivity(cutoff.Add(-time.Second)) + recent, _ := KnownActivity(cutoff) + tests := []struct { + name string + activity Activity + protection Protection + configured bool + eligible bool + reason DecisionReason + }{ + {name: "old", activity: old, eligible: true, reason: ReasonInactive}, + {name: "cutoff is not old", activity: recent, reason: ReasonActive}, + {name: "unknown", activity: UnknownActivity(), reason: ReasonUnknownActivity}, + {name: "service protected", activity: old, protection: ProtectionGeneral, reason: ReasonServiceProtected}, + {name: "configured", activity: old, configured: true, reason: ReasonConfiguredProtected}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conversation, err := NewConversation(ServiceSlack, "C123", "general", tt.activity, tt.protection) + if err != nil { + t.Fatal(err) + } + protected := map[ConversationID]struct{}{} + if tt.configured { + protected[conversation.ID()] = struct{}{} + } + decision := Evaluate(conversation, cutoff, protected) + if decision.Eligible() != tt.eligible || decision.Reason() != tt.reason { + t.Fatalf("got eligible=%v reason=%q", decision.Eligible(), decision.Reason()) + } + }) + } +} +``` + +Also assert that empty IDs, empty names, zero known timestamps, and unsupported service strings return errors. + +- [ ] **Step 3: Run the domain test to verify it fails** + +Run: `mise exec go@1.27.1 -- makers test` + +Expected: FAIL because the `internal/domain` types do not exist; the prior clean-checkout secret-file failure is gone. + +- [ ] **Step 4: Implement validated immutable domain values** + +Use unexported fields with getters. Define these exact enums: + +```go +type Service string +const ( + ServiceSlack Service = "slack" + ServiceChatwork Service = "chatwork" + ServiceZulip Service = "zulip" +) + +type Protection string +const ( + ProtectionNone Protection = "" + ProtectionGeneral Protection = "general" + ProtectionSticky Protection = "sticky" + ProtectionDirect Protection = "direct" + ProtectionPinned Protection = "pinned" +) + +type DecisionReason string +const ( + ReasonInactive DecisionReason = "inactive" + ReasonActive DecisionReason = "active" + ReasonUnknownActivity DecisionReason = "unknown activity" + ReasonServiceProtected DecisionReason = "service protected" + ReasonConfiguredProtected DecisionReason = "configured protected" +) +``` + +`Evaluate` must check configured protection, service protection, known activity, then strict cutoff ordering. Return a `Decision` containing the original conversation, eligibility, and reason. + +- [ ] **Step 5: Format and verify the domain** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- makers test` + +Expected: PASS with no external API calls. + +- [ ] **Step 6: Commit** + +```bash +git add .mise.toml Makefile.toml go.mod internal/domain slack_test.go synr_suite_test.go +git commit -m "refactor: add validated cleanup domain + +Co-Authored-By: Codex " +``` + +--- + +### Task 2: Fail-closed Scan and Apply Use Case + +**Files:** +- Create: `internal/usecase/scan.go` +- Create: `internal/usecase/scan_test.go` + +**Interfaces:** +- Consumes: `domain.Conversation`, `domain.Decision`, and `domain.Evaluate` from Task 1. +- Produces: `usecase.Provider` with `List(context.Context) ([]domain.Conversation, error)` and `Leave(context.Context, domain.Conversation) error`. +- Produces: `usecase.Scan(context.Context, Provider, Request, Clock) (Result, error)`. +- `Request` fields: `BeforeMonths int`, `Apply bool`, `ProtectedIDs map[domain.ConversationID]struct{}`. +- `Result` fields: `Decisions []domain.Decision`, `Left []domain.Conversation`, `Failed *domain.Conversation`, `NotAttempted []domain.Conversation`. + +- [ ] **Step 1: Write failing orchestration tests with fakes** + +Implement a recording fake and fixed clock, then cover: + +```go +func TestScanDoesNotLeaveInPreview(t *testing.T) +func TestScanDoesNotLeaveWhenListFails(t *testing.T) +func TestScanSortsDecisionsByNameThenID(t *testing.T) +func TestScanAppliesOnlyEligibleConversations(t *testing.T) +func TestScanStopsAfterFirstLeaveFailure(t *testing.T) +func TestScanRejectsNonPositiveMonths(t *testing.T) +``` + +For stop-on-failure, provide eligible conversations `alpha`, `beta`, `gamma`, fail `beta`, and assert calls are exactly `alpha`, `beta`; `Result.Left` is `alpha`, `Result.Failed` is `beta`, and `Result.NotAttempted` is `gamma`. + +- [ ] **Step 2: Run tests to verify failure** + +Run: `mise exec go@1.27.1 -- makers test` + +Expected: FAIL because `usecase.Scan` and its types are undefined. + +- [ ] **Step 3: Implement two-phase orchestration** + +Define: + +```go +type Clock interface { Now() time.Time } +type Provider interface { + List(context.Context) ([]domain.Conversation, error) + Leave(context.Context, domain.Conversation) error +} +``` + +`Scan` validates the month count, calls `List` once, evaluates all items using one clock value, sorts decisions by `Name()` then `ID()`, returns immediately in preview mode, and sequentially leaves only eligible items in apply mode. Wrap list failures as `list conversations: %w` and leave failures as `leave conversation : %w` while returning the populated partial result. + +- [ ] **Step 4: Verify use-case behavior** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- makers test` + +Expected: PASS, including exact call-order assertions. + +- [ ] **Step 5: Commit** + +```bash +git add internal/usecase +git commit -m "refactor: add fail-closed cleanup workflow + +Co-Authored-By: Codex " +``` + +--- + +### Task 3: Strict XDG Configuration and Environment Credentials + +**Files:** +- Create: `internal/adapter/config/config.go` +- Create: `internal/adapter/config/config_test.go` +- Modify: `go.mod` +- Modify: `go.sum` + +**Interfaces:** +- Produces: `config.Load(path string) (Config, error)`; an empty `path` resolves the XDG/default path. +- Produces: `Config.ProtectedIDs(domain.Service) map[domain.ConversationID]struct{}`. +- Produces: `config.CredentialsFor(domain.Service, func(string) string) (Credentials, error)`. +- `Credentials` fields: `Token`, `URL`, `Email`, `APIKey`; only the fields required by the selected service are populated. + +- [ ] **Step 1: Add failing configuration tests** + +Cover absent optional files, strict decoding, ID validation, XDG resolution, and per-service credentials: + +```go +func TestLoadMissingFileReturnsEmptyConfig(t *testing.T) +func TestLoadRejectsUnknownYAMLKey(t *testing.T) +func TestLoadReturnsProtectedIDsByService(t *testing.T) +func TestDefaultPathUsesXDGConfigHome(t *testing.T) +func TestCredentialsForLoadsOnlySelectedService(t *testing.T) +func TestCredentialsForReportsMissingVariable(t *testing.T) +``` + +Use `t.TempDir()` and `t.Setenv`; never create a real token file. Assert that a Slack lookup does not require any Chatwork or Zulip variable. + +- [ ] **Step 2: Run the package test to verify failure** + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/config -v` + +Expected: FAIL because the config adapter is undefined. + +- [ ] **Step 3: Implement strict config decoding** + +Add `go.yaml.in/yaml/v3 v3.0.5`. Model explicit `slack`, `chatwork`, and `zulip` service keys, each with `protected_channels []string`. Decode with `yaml.Decoder.KnownFields(true)`, reject blank IDs, and ensure a second YAML document is not present. Treat `os.ErrNotExist` as an empty config; propagate all other read/decode errors with the path. + +Resolve the default path as `$XDG_CONFIG_HOME/synr/config.yaml`, otherwise `os.UserConfigDir()/synr/config.yaml`. Read these exact variables only for the selected service: + +```text +SYNR_SLACK_TOKEN +SYNR_CHATWORK_TOKEN +SYNR_ZULIP_URL +SYNR_ZULIP_EMAIL +SYNR_ZULIP_API_KEY +``` + +Trim surrounding whitespace for validation but do not log values. + +- [ ] **Step 4: Verify configuration behavior** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/config -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/adapter/config +git commit -m "feat: load strict non-secret service configuration + +Co-Authored-By: Codex " +``` + +--- + +### Task 4: Safe Chatwork Adapter + +**Files:** +- Create: `internal/adapter/chatwork/client.go` +- Create: `internal/adapter/chatwork/client_test.go` + +**Interfaces:** +- Consumes: `domain.NewConversation` and `usecase.Provider`. +- Produces: `chatwork.New(token string, baseURL *url.URL, httpClient *http.Client) (*Client, error)`. +- `Client` implements `usecase.Provider`. + +- [ ] **Step 1: Write failing HTTP contract tests** + +Use `httptest.Server` to cover: + +```go +func TestListBuildsConversationsAndProtectsStickyAndDirectRooms(t *testing.T) +func TestListRejectsNonSuccessStatus(t *testing.T) +func TestListRejectsMalformedJSON(t *testing.T) +func TestListTreatsZeroTimestampAsUnknown(t *testing.T) +func TestLeaveSendsFormEncodedAction(t *testing.T) +func TestLeaveRejectsUnexpectedStatus(t *testing.T) +``` + +In the success fixture, return one old group, one sticky group, one direct room, +and one zero-timestamp group. Assert `X-ChatWorkToken`, request paths, protection values, and `action_type=leave`. Expect HTTP 204 for leave. + +- [ ] **Step 2: Run the adapter test to verify failure** + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/chatwork -v` + +Expected: FAIL because `chatwork.Client` is undefined. + +- [ ] **Step 3: Implement the Chatwork v2 client** + +Reuse the injected client and base URL. `List` calls `GET rooms`; `Leave` calls `DELETE rooms/{escaped-id}` with `application/x-www-form-urlencoded`. Check request-construction errors before accessing the request, close every response body immediately after a successful round trip, cap diagnostic error-body reads, and accept only HTTP 200 for list and HTTP 204 for leave. + +Map `room_id` to a decimal string, use `last_update_time` seconds for known activity, and map `sticky`/`type == "direct"` to the matching domain protection. + +- [ ] **Step 4: Verify Chatwork behavior** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/chatwork -v` + +Expected: PASS and no network access beyond `httptest.Server`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/adapter/chatwork +git commit -m "refactor: replace Chatwork client with safe HTTP adapter + +Co-Authored-By: Codex " +``` + +--- + +### Task 5: Zulip Subscription Adapter + +**Files:** +- Create: `internal/adapter/zulip/client.go` +- Create: `internal/adapter/zulip/client_test.go` + +**Interfaces:** +- Consumes: `domain.NewConversation`, activity/protection values, and `usecase.Provider`. +- Produces: `zulip.New(baseURL *url.URL, email, apiKey string, httpClient *http.Client) (*Client, error)`. +- `Client` implements `usecase.Provider`; `Leave` uses `Conversation.Name()` because Zulip's unsubscribe endpoint requires channel names. + +- [ ] **Step 1: Write failing Zulip HTTP tests** + +Cover: + +```go +func TestListFetchesNewestMessageForEverySubscription(t *testing.T) +func TestListProtectsPinnedSubscription(t *testing.T) +func TestListTreatsChannelWithoutMessagesAsUnknown(t *testing.T) +func TestListFailsIfAnyActivityRequestFails(t *testing.T) +func TestLeaveSendsEncodedChannelName(t *testing.T) +func TestRequestsUseBasicAuthentication(t *testing.T) +``` + +The fixture must validate `GET /api/v1/users/me/subscriptions`, then one `GET /api/v1/messages` per channel with `anchor=newest`, `num_before=1`, `num_after=0`, and example JSON narrow `[{"operator":"channel","operand":"Denmark"}]`. The leave fixture validates `DELETE /api/v1/users/me/subscriptions` and form value `subscriptions=["Denmark"]`. + +- [ ] **Step 2: Run the Zulip test to verify failure** + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/zulip -v` + +Expected: FAIL because `zulip.Client` is undefined. + +- [ ] **Step 3: Implement sequential, fail-closed Zulip reads** + +Validate an absolute HTTP(S) base URL and nonblank email/API key. Decode subscription fields `stream_id`, `name`, and `pin_to_top`. Query newest messages sequentially in the returned subscription order; any transport, status, or decode failure aborts `List` without returning partial data. A missing message yields unknown activity; a positive UNIX timestamp yields known activity. Accept Zulip responses only when HTTP status is 2xx and JSON `result` is `success`. + +For unsubscribe, send Basic auth and the exact JSON-array form value derived from `Conversation.Name()`; validate the `removed`/`not_removed` success response so an ignored name is observable. + +- [ ] **Step 4: Verify Zulip behavior** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/zulip -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/adapter/zulip +git commit -m "feat: add Zulip channel cleanup adapter + +Co-Authored-By: Codex " +``` + +--- + +### Task 6: Maintained Slack Conversations Adapter + +**Files:** +- Create: `internal/adapter/slack/client.go` +- Create: `internal/adapter/slack/client_test.go` +- Modify: `go.mod` +- Modify: `go.sum` + +**Interfaces:** +- Consumes: `domain.NewConversation`, activity/protection values, and `usecase.Provider`. +- Produces: `slack.New(token string, httpClient *http.Client, apiURL string) (*Client, error)`. +- `Client` wraps `github.com/slack-go/slack.Client` and implements `usecase.Provider`. + +- [ ] **Step 1: Add failing paginated Slack contract tests** + +Use the SDK's `OptionAPIURL` with `httptest.Server` and cover: + +```go +func TestListPaginatesMemberConversationsAndFetchesInfo(t *testing.T) +func TestListProtectsGeneralChannel(t *testing.T) +func TestListTreatsMissingLastReadAsUnknown(t *testing.T) +func TestListFailsOnPaginationError(t *testing.T) +func TestLeaveUsesConversationsLeave(t *testing.T) +func TestLeaveReportsSlackAPIError(t *testing.T) +``` + +Return two `conversations.list` pages with cursors, include a non-member channel to assert it is skipped, and validate one `conversations.info` call per member. Use Slack timestamps such as `1789718400.000000`; invalid or empty timestamps must become unknown activity without epoch fallback. + +- [ ] **Step 2: Run the Slack package test to verify failure** + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/slack -v` + +Expected: FAIL because the new adapter is undefined. + +- [ ] **Step 3: Implement the maintained Slack adapter** + +Add `github.com/slack-go/slack v0.29.0`. Build the SDK with `OptionHTTPClient` and `OptionAPIURL`; do not enable its retry options. Iterate `GetConversationsContext` with `Limit: 200`, `ExcludeArchived: true`, and types `public_channel`, `private_channel`; stop only on an empty next cursor. Fetch each member conversation with `GetConversationInfoContext`. + +Parse the integer seconds before the decimal point with `strconv.ParseInt`. An empty or invalid `last_read` yields unknown activity. Map `IsGeneral` to `ProtectionGeneral`. `Leave` calls `LeaveConversationContext` and treats an SDK error as failure; `not_in_channel` is successful idempotent completion. + +- [ ] **Step 4: Verify Slack behavior** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- go test ./internal/adapter/slack -v` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add go.mod go.sum internal/adapter/slack +git commit -m "refactor: migrate Slack cleanup to Conversations API + +Co-Authored-By: Codex " +``` + +--- + +### Task 7: CLI Presentation, Bootstrap, and Legacy Removal + +**Files:** +- Create: `internal/presentation/cli/run.go` +- Create: `internal/presentation/cli/run_test.go` +- Create: `cmd/synr/main.go` +- Delete: `main.go` +- Delete: `chatwork/chatwork.go` +- Delete: `chatwork/rooms.go` +- Delete: `chatwork/util.go` +- Delete: `config/config.go` +- Delete: `config/secrets.yaml.copy` +- Delete: `slack/slack.go` +- Modify: `go.mod` +- Modify: `go.sum` + +**Interfaces:** +- Consumes: config credentials/protected IDs, all three provider constructors, and `usecase.Scan`. +- Produces: `cli.Run(ctx context.Context, args []string, stdout, stderr io.Writer, dependencies Dependencies) int`. +- `Dependencies` contains `Getenv func(string) string`, `ConfigPath string`, `HTTPClient *http.Client`, `Clock usecase.Clock`, and `NewProvider ProviderFactory` so tests never contact live APIs. +- Produces: `cli.DefaultDependencies() Dependencies` with `os.Getenv`, the default config path, a 30-second HTTP client, the real clock, and production adapter constructors. +- `cmd/synr/main.go` calls `os.Exit(cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr, cli.DefaultDependencies()))`. + +- [ ] **Step 1: Write failing CLI tests** + +Use injected writers, environment lookup, a temporary XDG directory, and an injectable provider factory. Cover: + +```go +func TestRunShowsHelpWithoutCredentials(t *testing.T) +func TestRunRequiresScanSubcommand(t *testing.T) +func TestRunRequiresSupportedService(t *testing.T) +func TestRunRejectsNonPositiveBeforeMonths(t *testing.T) +func TestRunPreviewNeverLeaves(t *testing.T) +func TestRunApplyPrintsPartialFailureSummary(t *testing.T) +func TestRunLoadsOnlySelectedServiceCredentials(t *testing.T) +``` + +Assert deterministic tabular lines containing service, name, ID, RFC3339 activity or `unknown`, decision, and reason. Assert exit `0` for help/success, `2` for usage/configuration errors, and `1` for API/apply failures. + +- [ ] **Step 2: Run CLI tests to verify failure** + +Run: `mise exec go@1.27.1 -- go test ./internal/presentation/cli -v` + +Expected: FAIL because `cli.Run` is undefined. + +- [ ] **Step 3: Implement `scan` parsing and provider construction** + +Use a dedicated `flag.FlagSet` with output directed to the injected stderr. Parse only: + +```text +scan --service --before-months --apply +``` + +Parse and validate before loading configuration or credentials. Use a 30-second `http.Client` and pass it to the selected adapter. Load protected IDs only after service validation. Render all decisions before rendering apply results; never print credential values. + +- [ ] **Step 4: Replace the bootstrap and remove legacy packages** + +Add `cmd/synr/main.go`, delete the old entry point/client/config files, then run: + +Run: `mise exec go@1.27.1 -- go mod tidy` + +Expected: `go.mod` retains only direct requirements `github.com/slack-go/slack v0.29.0` and `go.yaml.in/yaml/v3 v3.0.5`; Ginkgo, Gomega, go-flags, nlopes/slack, and yaml.v2 are absent. + +- [ ] **Step 5: Verify the integrated CLI** + +Run: `mise exec go@1.27.1 -- makers fmt` + +Run: `mise exec go@1.27.1 -- makers test` + +Run: `mise exec go@1.27.1 -- makers build` + +Run: `mise exec go@1.27.1 -- go run ./cmd/synr --help` + +Expected: all checks PASS; help exits without credentials; no test contacts a live service. + +- [ ] **Step 6: Commit** + +```bash +git add cmd internal/presentation go.mod go.sum main.go chatwork config slack +git commit -m "feat: expose safe multi-service scan command + +Co-Authored-By: Codex " +``` + +--- + +### Task 8: Hooks, CI, Releases, and Documentation + +**Files:** +- Create: `.pre-commit-config.yaml` +- Create: `.github/workflows/ci.yml` +- Create: `.github/workflows/release.yml` +- Create: `.goreleaser.yaml` +- Modify: `README.md` +- Delete: `.travis.yml` + +**Interfaces:** +- Consumes: `makers fmt-check`, `makers vet`, `makers test`, `makers build`, and `makers vuln`. +- Produces: pull-request/push CI and `v*` tag release artifacts for `./cmd/synr`. + +- [ ] **Step 1: Add local hooks and install them** + +Create local pre-commit hooks with `language: system`, `pass_filenames: false`, and these entries: + +```yaml +repos: + - repo: local + hooks: + - id: makers-fmt-check + name: makers fmt-check + entry: makers fmt-check + language: system + pass_filenames: false + - id: makers-check + name: makers check + entry: makers check + language: system + pass_filenames: false +``` + +Run: `pre-commit install` + +Expected: the worktree's `.git/hooks/pre-commit` is installed. + +- [ ] **Step 2: Add CI and release workflows** + +CI triggers on pull requests and pushes to `master`, uses `ubuntu-latest`, `actions/checkout@v7`, `actions/setup-go@v7` with `go-version: 1.27.x`, installs cargo-make 0.37.24 with `cargo install --locked cargo-make --version 0.37.24`, and runs `makers check`. + +Release triggers on pushed tags `v*`, uses the same checkout/setup actions, and runs `goreleaser/goreleaser-action@v7` with `version: v2.18.2`, `args: release --clean`, and `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. + +Configure `.goreleaser.yaml` with schema `version: 2`, project name `synr`, main package `./cmd/synr`, `CGO_ENABLED=0`, targets `linux`/`darwin` for `amd64`/`arm64`, tar.gz archives, SHA-256 checksums, and changelog filters excluding `docs:`, `test:`, and `chore:` commits. + +- [ ] **Step 3: Rewrite README against the implemented CLI** + +Document: + +```bash +go install github.com/chck/synr/cmd/synr@latest +synr scan --service slack --before-months 3 +synr scan --service slack --before-months 3 --apply +``` + +Include all five `SYNR_*` variables, the exact XDG YAML schema from the spec, service-specific protection rules, preview/apply safety, Go 1.27 requirement for source builds, and a manual checklist that requires preview review before a deliberately selected live apply. Remove Travis, OAuth-test-token, `go get -u`, and repository-relative secret-file instructions. + +- [ ] **Step 4: Run the complete local gate** + +Run: `mise exec go@1.27.1 -- makers check` + +Run: `pre-commit run --all-files` + +Run: `git diff --check` + +Expected: PASS with no formatting output, race failures, vet findings, build errors, or reachable known vulnerabilities. + +- [ ] **Step 5: Validate release configuration** + +Run: `docker run --rm -v "$PWD:/src" -w /src goreleaser/goreleaser:v2.18.2 check` + +Run: `docker run --rm -v "$PWD:/src" -w /src goreleaser/goreleaser:v2.18.2 release --snapshot --clean` + +Expected: configuration check PASS and snapshot archives/checksums appear under ignored `dist/` without publishing. + +- [ ] **Step 6: Commit** + +```bash +git add .pre-commit-config.yaml .github .goreleaser.yaml README.md .travis.yml +git commit -m "ci: modernize verification and release automation + +Co-Authored-By: Codex " +``` + +--- + +### Task 9: Final Compatibility and Safety Verification + +**Files:** +- Modify only files required by failures found in this task. + +**Interfaces:** +- Verifies every interface and global constraint from Tasks 1-8. + +- [ ] **Step 1: Audit dependencies and forbidden legacy paths** + +Run: `mise exec go@1.27.1 -- go list -m all` + +Run: `rg -n 'nlopes/slack|jessevdk/go-flags|onsi/ginkgo|onsi/gomega|gopkg.in/yaml.v2|config/secrets.yaml|GetChannelInfo|GetChannels' --glob '!docs/superpowers/**' .` + +Expected: only the intended current modules are direct dependencies and the repository search returns no legacy code/documentation references. + +- [ ] **Step 2: Prove preview cannot mutate** + +Run: `mise exec go@1.27.1 -- go test ./internal/presentation/cli ./internal/usecase -run 'Preview|DoesNotLeave|ListFails' -count=20` + +Expected: PASS on all repetitions with fake leave-call counts at zero. + +- [ ] **Step 3: Run the full verification suite fresh** + +Run: `mise exec go@1.27.1 -- go clean -testcache` + +Run: `mise exec go@1.27.1 -- makers check` + +Run: `git diff --check` + +Run: `git status --short` + +Expected: all checks PASS; status contains only intentional final-task fixes, or is clean if none were needed. + +- [ ] **Step 4: Commit only if verification required fixes** + +```bash +git add cmd internal go.mod go.sum Makefile.toml .mise.toml .pre-commit-config.yaml .github .goreleaser.yaml README.md +git commit -m "fix: resolve final modernization verification failures + +Co-Authored-By: Codex " +``` + +If no files changed, do not create an empty commit. diff --git a/docs/superpowers/specs/2026-09-18-modernize-and-add-zulip-design.md b/docs/superpowers/specs/2026-09-18-modernize-and-add-zulip-design.md new file mode 100644 index 0000000..20c835b --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-modernize-and-add-zulip-design.md @@ -0,0 +1,237 @@ +# Modernize synr and Add Zulip Support + +## Purpose + +Modernize the unsupported toolchain and dependencies, make destructive behavior +fail closed, and add Zulip as a supported service. Preserve Slack and Chatwork +support while replacing obsolete APIs and nondeterministic tests. + +Discord support is outside this change. It is tracked in GitHub Issue #11 +because its OAuth2 and explicit-selection workflow differs from inactivity-based +channel cleanup. + +## User-facing behavior + +The CLI has one operation: + +```text +synr scan --service [--before-months N] [--apply] +``` + +- `--service` is required. +- `--before-months` defaults to `1` and accepts integers greater than zero. +- Without `--apply`, the command only previews decisions. +- `--apply` is the explicit authorization to leave every eligible conversation + shown by the preview. There is no additional interactive prompt, so the same + command works in a terminal and in non-interactive automation. +- Output identifies each conversation by service, display name, stable ID, last + activity time, decision, and reason. +- Help and validation run before credentials are loaded. +- Unknown services, invalid arguments, missing credentials, incomplete API data, + and API failures result in a nonzero exit status and an actionable message. + +## Configuration + +Secrets are read only from environment variables: + +| Service | Variables | +| --- | --- | +| Slack | `SYNR_SLACK_TOKEN` | +| Chatwork | `SYNR_CHATWORK_TOKEN` | +| Zulip | `SYNR_ZULIP_URL`, `SYNR_ZULIP_EMAIL`, `SYNR_ZULIP_API_KEY` | + +Non-secret configuration is read from +`${XDG_CONFIG_HOME:-$HOME/.config}/synr/config.yaml`. The file is optional. Its +schema is: + +```yaml +services: + slack: + protected_channels: + - C0123456789 + chatwork: + protected_channels: + - "123456789" + zulip: + protected_channels: + - "42" +``` + +Conversation identifiers are strings in the domain even when an upstream API +uses numeric IDs. Unknown keys and malformed values are configuration errors. +The legacy repository-relative `config/secrets.yaml` format is removed without +a compatibility fallback. Secrets are never written to disk by synr. + +## Architecture + +The implementation follows the repository's layered architecture guidance: + +```text +cmd/synr/ +internal/ +├── presentation/cli/ +├── usecase/ +├── domain/ +└── adapter/ + ├── config/ + ├── slack/ + ├── chatwork/ + └── zulip/ +``` + +The domain owns immutable values for `Service`, `Conversation`, `Activity`, and +`Decision`. Constructors validate identifiers, names, and timestamps. A +conversation with missing or invalid activity cannot be represented as eligible +for departure. + +Each service adapter implements a small provider interface: + +```go +type Provider interface { + List(context.Context) ([]domain.Conversation, error) + Leave(context.Context, domain.Conversation) error +} +``` + +`List` returns all information needed for a decision. It never silently drops a +page, malformed item, unsupported state, or API error. `Leave` performs one +idempotent-as-supported upstream operation using the provider-specific stable +identifier or name and reports the conversation ID in its error context. + +The use case evaluates every returned conversation against one cutoff time and +the protected-ID set. Presentation code parses arguments, selects an adapter, +renders decisions, and maps typed errors to exit statuses. Adapters contain no +CLI output or process exits. + +## Eligibility rules + +A conversation is eligible only when all of these conditions are true: + +1. Its last activity timestamp is known and valid. +2. Its last activity is strictly before `now.AddDate(0, -beforeMonths, 0)`. +3. Its stable ID is not in the configured protected set. +4. No service-specific protection rule applies. + +Service-specific protection rules are: + +- Slack: never leave the workspace's general channel. Slack stars are not used, + because Slack no longer adds newly saved items to `stars.list`. +- Chatwork: never leave sticky rooms or direct chats. +- Zulip: never unsubscribe from channels pinned by the current user. + +Empty channels or rooms without a trustworthy timestamp are ineligible and are +shown with an `unknown activity` reason. Eligibility logic receives a clock so +tests do not depend on wall-clock time. + +## Service adapters + +### Slack + +Use `github.com/slack-go/slack` v0.29.0 and the Conversations API. Paginate until +the cursor is exhausted, request channel metadata needed for the decision, and +use `conversations.leave` for mutation. The adapter must distinguish Slack API +errors from transport and decoding errors. + +### Chatwork + +Use a focused standard-library HTTP client for Chatwork API v2. Send the API +token only in the documented request header. Reuse one injected `http.Client`, +resolve endpoints from an injectable base URL, close every response body, and +accept only documented success statuses. Leaving a room uses the documented +form-encoded `action_type=leave` request. + +### Zulip + +Use a focused standard-library HTTP client with HTTP Basic authentication. List +the current user's subscriptions, then request the newest accessible message +for each channel using a channel narrow with `anchor=newest`. The message +timestamp is the channel activity time. Unsubscribe through +`DELETE /api/v1/users/me/subscriptions`. + +All adapters use request contexts and a finite shared HTTP timeout. Rate limits, +server failures, authentication failures, malformed responses, and incomplete +pagination are returned as errors; this change does not add automatic retries. + +## Apply sequencing and failure behavior + +The command completes listing and decision-making before performing any +mutation. If any listing or metadata request fails, it prints no success claim +and performs no leaves. + +With `--apply`, eligible conversations are processed in the deterministic order +shown in the preview. The first leave failure stops further mutations. The +summary distinguishes: + +- successfully left conversations; +- the conversation whose leave failed; +- conversations not attempted after the failure. + +The command exits nonzero after any partial failure. Preview mode never calls a +mutation endpoint. + +## Dependencies and toolchain + +- Go 1.27.x. +- `github.com/slack-go/slack` v0.29.0. +- `go.yaml.in/yaml/v3` v3.0.5. +- Go standard `flag`, `testing`, and `net/http/httptest` packages. + +Remove `github.com/nlopes/slack`, `github.com/jessevdk/go-flags`, Ginkgo, +Gomega, and `gopkg.in/yaml.v2`. Transitive dependencies are generated by +`go mod tidy`; checksums are not edited manually. + +## Testing + +- Domain table tests cover every eligibility rule, cutoff boundaries, invalid + values, and service-specific protections with a fixed clock. +- Use-case tests cover preview, apply, no-mutation-on-list-failure, deterministic + ordering, and stop-on-first-leave-failure behavior with fake providers. +- Adapter tests use `httptest.Server` fixtures for authentication, pagination, + encoding, status validation, malformed payloads, and successful leave calls. +- CLI tests cover help, argument validation, missing environment variables, + config decoding, output, and exit statuses without real credentials. +- No default test contacts Slack, Chatwork, or Zulip. +- Manual end-to-end instructions document one preview and one intentionally + selected apply verification per service. These remain human-owned because + they require real accounts and destructive external actions. + +## Automation and releases + +Add `Makefile.toml` tasks invoked as `makers fmt`, `makers check`, `makers test`, +and `makers build`. `check` includes formatting verification, `go vet`, and +`govulncheck` v1.1.4. Add pre-commit hooks that run the repository's formatting +and check tasks, and install them in the development worktree. + +Replace Travis CI with GitHub Actions using `actions/checkout@v7` and +`actions/setup-go@v7`. Pull requests and branch pushes run formatting, vet, +tests, build, and vulnerability scanning. Tag pushes use +`goreleaser/goreleaser-action@v7` with GoReleaser v2.18.2 and a +repository-owned GoReleaser configuration to create GitHub release artifacts. +CI and release workflows use Go 1.27.x. + +Update the README with installation via `go install`, environment variables, +the non-secret YAML schema, preview/apply examples, supported services, and the +manual end-to-end checklist. + +## Migration and rollback + +This is an intentional breaking CLI and configuration migration. The forward +path is to move tokens from `config/secrets.yaml` into `SYNR_*` environment +variables and move protected IDs into the XDG config file. The previous command +shape and secret file do not remain as fallbacks, so an old invocation cannot +accidentally execute with new behavior. + +Rollback is source-level: reinstall the previous release and restore its former +invocation/configuration separately. No repository migration mutates user data, +and preview is the default after upgrade. Actual service departures cannot be +rolled back by synr; this is why unknown state fails closed and `--apply` is +required. + +## Out of scope + +- Discord support (GitHub Issue #11). +- Concurrent leave operations. +- Automatic retry or backoff policy. +- Secure credential-store integration. +- A compatibility shim for the legacy CLI or secret file. +- Automated live-account end-to-end tests. diff --git a/go.mod b/go.mod index b92e2d4..41f3910 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,10 @@ module github.com/chck/synr -go 1.12 +go 1.27 require ( - github.com/jessevdk/go-flags v0.0.0-20170926144705-f88afde2fa19 - github.com/nlopes/slack v0.0.0-20170725121730-5cde21b8b96a - github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c - github.com/onsi/gomega v0.0.0-20171012021143-283ed655c6b8 - golang.org/x/net v0.0.0-20171027103834-c73622c77280 - golang.org/x/sys v0.0.0-20171027071755-0649f9fe46d9 - golang.org/x/text v0.0.0-20171024115504-6eab0e8f74e8 - gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7 + github.com/slack-go/slack v0.29.0 + go.yaml.in/yaml/v3 v3.0.5 ) + +require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/go.sum b/go.sum index 6c522fe..bf08ae7 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,16 @@ -github.com/jessevdk/go-flags v0.0.0-20170926144705-f88afde2fa19 h1:k9/LaykApavRKKlaWkunBd48Um+vMxnUNNsIjS7OJn8= -github.com/jessevdk/go-flags v0.0.0-20170926144705-f88afde2fa19/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/nlopes/slack v0.0.0-20170725121730-5cde21b8b96a h1:ln3NXCJJdiiEF8Jmy1HWmeUUeglYlKCeoCLck5OpD08= -github.com/nlopes/slack v0.0.0-20170725121730-5cde21b8b96a/go.mod h1:jVI4BBK3lSktibKahxBF74txcK2vyvkza1z/+rRnVAM= -github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v0.0.0-20171012021143-283ed655c6b8/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -golang.org/x/net v0.0.0-20171027103834-c73622c77280 h1:TFSo8RGq2v9crRl/RW0EH71y1kdSjqeCxljzuDsD+oA= -golang.org/x/net v0.0.0-20171027103834-c73622c77280/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/sys v0.0.0-20171027071755-0649f9fe46d9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.0.0-20171024115504-6eab0e8f74e8/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7 h1:+t9dhfO+GNOIGJof6kPOAenx7YgrZMTdRPV+EsnPabk= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI= +github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/adapter/chatwork/client.go b/internal/adapter/chatwork/client.go new file mode 100644 index 0000000..570dbaf --- /dev/null +++ b/internal/adapter/chatwork/client.go @@ -0,0 +1,206 @@ +package chatwork + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/chck/synr/internal/domain" +) + +const diagnosticBodyLimit int64 = 4096 + +type Client struct { + token string + baseURL *url.URL + httpClient *http.Client +} + +type room struct { + ID *int64 `json:"room_id"` + Name *string `json:"name"` + Type *string `json:"type"` + Sticky *bool `json:"sticky"` + LastUpdateTime *int64 `json:"last_update_time"` +} + +func New(token string, baseURL *url.URL, httpClient *http.Client) (*Client, error) { + if token == "" { + return nil, fmt.Errorf("Chatwork token must not be empty") + } + if baseURL == nil { + return nil, fmt.Errorf("Chatwork base URL must not be nil") + } + if baseURL.Scheme == "" || baseURL.Host == "" { + return nil, fmt.Errorf("Chatwork base URL must be absolute") + } + if httpClient == nil { + return nil, fmt.Errorf("Chatwork HTTP client must not be nil") + } + + baseURLCopy := *baseURL + if !strings.HasSuffix(baseURLCopy.Path, "/") { + baseURLCopy.Path += "/" + } + if baseURLCopy.RawPath != "" && !strings.HasSuffix(baseURLCopy.RawPath, "/") { + baseURLCopy.RawPath += "/" + } + return &Client{token: token, baseURL: &baseURLCopy, httpClient: httpClient}, nil +} + +func (client *Client) List(ctx context.Context) ([]domain.Conversation, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.endpoint("rooms").String(), nil) + if err != nil { + return nil, fmt.Errorf("create Chatwork rooms request: %w", err) + } + request.Header.Set("X-ChatWorkToken", client.token) + + response, err := client.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("request Chatwork rooms: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, unexpectedStatus("list Chatwork rooms", response) + } + + decoder := json.NewDecoder(response.Body) + var rooms []room + if err := decoder.Decode(&rooms); err != nil { + return nil, fmt.Errorf("decode Chatwork rooms: %w", err) + } + if rooms == nil { + return nil, fmt.Errorf("decode Chatwork rooms: response must be an array") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("decode Chatwork rooms: unexpected additional JSON value") + } + return nil, fmt.Errorf("decode trailing Chatwork rooms data: %w", err) + } + + conversations := make([]domain.Conversation, 0, len(rooms)) + for _, room := range rooms { + conversation, err := room.conversation() + if err != nil { + return nil, err + } + conversations = append(conversations, conversation) + } + return conversations, nil +} + +func (client *Client) Leave(ctx context.Context, conversation domain.Conversation) error { + if conversation.Service() != domain.ServiceChatwork { + return fmt.Errorf("cannot leave non-Chatwork conversation") + } + + values := url.Values{"action_type": {"leave"}} + request, err := http.NewRequestWithContext( + ctx, + http.MethodDelete, + client.roomEndpoint(conversation.ID().Value()).String(), + strings.NewReader(values.Encode()), + ) + if err != nil { + return fmt.Errorf("create Chatwork leave request: %w", err) + } + request.Header.Set("X-ChatWorkToken", client.token) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + response, err := client.httpClient.Do(request) + if err != nil { + return fmt.Errorf("request Chatwork leave: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + return unexpectedStatus("leave Chatwork room", response) + } + return nil +} + +func (client *Client) endpoint(path string) *url.URL { + return client.baseURL.ResolveReference(&url.URL{Path: path}) +} + +func (client *Client) roomEndpoint(id string) *url.URL { + endpoint := client.endpoint("rooms/") + escapedPath := endpoint.EscapedPath() + escapedID := url.PathEscape(id) + endpoint.Path += id + endpoint.RawPath = escapedPath + escapedID + return endpoint +} + +func (room room) conversation() (domain.Conversation, error) { + if room.ID == nil { + return domain.Conversation{}, fmt.Errorf("Chatwork room is missing room_id") + } + if room.Name == nil { + return domain.Conversation{}, fmt.Errorf("Chatwork room %d is missing name", *room.ID) + } + if room.Type == nil { + return domain.Conversation{}, fmt.Errorf("Chatwork room %d is missing type", *room.ID) + } + if room.Sticky == nil { + return domain.Conversation{}, fmt.Errorf("Chatwork room %d is missing sticky", *room.ID) + } + if room.LastUpdateTime == nil { + return domain.Conversation{}, fmt.Errorf("Chatwork room %d is missing last_update_time", *room.ID) + } + + activity := domain.UnknownActivity() + if *room.LastUpdateTime > 0 { + var err error + activity, err = domain.KnownActivity(time.Unix(*room.LastUpdateTime, 0)) + if err != nil { + return domain.Conversation{}, fmt.Errorf("create Chatwork room %d activity: %w", *room.ID, err) + } + } + + protection := domain.ProtectionNone + switch *room.Type { + case "direct": + protection = domain.ProtectionDirect + case "my": + protection = domain.ProtectionMy + case "group": + if *room.Sticky { + protection = domain.ProtectionSticky + } + default: + return domain.Conversation{}, fmt.Errorf("Chatwork room %d has unsupported type %q; only group rooms can be left", *room.ID, *room.Type) + } + + conversation, err := domain.NewConversation( + domain.ServiceChatwork, + strconv.FormatInt(*room.ID, 10), + *room.Name, + activity, + protection, + ) + if err != nil { + return domain.Conversation{}, fmt.Errorf("create Chatwork room %d: %w", *room.ID, err) + } + return conversation, nil +} + +func unexpectedStatus(operation string, response *http.Response) error { + body, err := io.ReadAll(io.LimitReader(response.Body, diagnosticBodyLimit)) + if err != nil { + return fmt.Errorf("%s: unexpected HTTP status %d (read response body: %w)", operation, response.StatusCode, err) + } + if diagnostic := strings.TrimSpace(string(body)); diagnostic != "" { + return fmt.Errorf("%s: unexpected HTTP status %d: %s", operation, response.StatusCode, diagnostic) + } + return fmt.Errorf("%s: unexpected HTTP status %d", operation, response.StatusCode) +} diff --git a/internal/adapter/chatwork/client_test.go b/internal/adapter/chatwork/client_test.go new file mode 100644 index 0000000..13e581c --- /dev/null +++ b/internal/adapter/chatwork/client_test.go @@ -0,0 +1,338 @@ +package chatwork + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/chck/synr/internal/domain" + "github.com/chck/synr/internal/usecase" +) + +var _ usecase.Provider = (*Client)(nil) + +func TestApplyLeavesOnlyGroupRooms(t *testing.T) { + var left []string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodDelete { + left = append(left, request.URL.Path) + writer.WriteHeader(http.StatusNoContent) + return + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `[ + {"room_id":101,"name":"group","type":"group","sticky":false,"last_update_time":1710000000}, + {"room_id":102,"name":"my chat","type":"my","sticky":false,"last_update_time":1710000000}, + {"room_id":103,"name":"direct","type":"direct","sticky":false,"last_update_time":1710000000} + ]`) + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err != nil { + t.Fatal(err) + } + if want := []string{"/rooms/101"}; !reflect.DeepEqual(left, want) { + t.Errorf("left = %v, want %v", left, want) + } + for _, decision := range result.Decisions { + if decision.Conversation().ID().Value() == "102" && decision.Reason() != domain.ReasonServiceProtected { + t.Errorf("My Chat reason = %q, want service protected", decision.Reason()) + } + } +} + +func TestApplyRejectsUnknownRoomTypeBeforeAnyLeave(t *testing.T) { + for _, roomType := range []string{"unknown", ""} { + t.Run(roomType, func(t *testing.T) { + leaves := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodDelete { + leaves++ + writer.WriteHeader(http.StatusNoContent) + return + } + writer.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(writer, `[ + {"room_id":101,"name":"group","type":"group","sticky":false,"last_update_time":1710000000}, + {"room_id":102,"name":"unknown","type":%q,"sticky":false,"last_update_time":1710000000} + ]`, roomType) + })) + defer server.Close() + _, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err == nil || !strings.Contains(err.Error(), "type") { + t.Errorf("Scan error = %v, want invalid room type error", err) + } + if leaves != 0 { + t.Errorf("leave count = %d, want 0", leaves) + } + }) + } +} + +type fixedClock struct{} + +func (fixedClock) Now() time.Time { + return time.Date(2026, time.September, 18, 0, 0, 0, 0, time.UTC) +} + +func TestListBuildsConversationsAndProtectsStickyAndDirectRooms(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + t.Errorf("method = %s, want GET", request.Method) + } + if request.URL.Path != "/rooms" { + t.Errorf("path = %s, want /rooms", request.URL.Path) + } + if request.Header.Get("X-ChatWorkToken") != "test-token" { + t.Errorf("X-ChatWorkToken = %q, want test-token", request.Header.Get("X-ChatWorkToken")) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `[ + {"room_id": 101, "name": "old group", "type": "group", "sticky": false, "last_update_time": 1710000000}, + {"room_id": 102, "name": "sticky group", "type": "group", "sticky": true, "last_update_time": 1710000001}, + {"room_id": 103, "name": "direct room", "type": "direct", "sticky": false, "last_update_time": 1710000002}, + {"room_id": 104, "name": "unknown group", "type": "group", "sticky": false, "last_update_time": 0} + ]`) + })) + defer server.Close() + + client := newTestClient(t, server) + conversations, err := client.List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if got, want := conversationIDs(conversations), []string{"101", "102", "103", "104"}; !reflect.DeepEqual(got, want) { + t.Fatalf("conversation IDs = %v, want %v", got, want) + } + if got, want := conversationProtections(conversations), []domain.Protection{ + domain.ProtectionNone, + domain.ProtectionSticky, + domain.ProtectionDirect, + domain.ProtectionNone, + }; !reflect.DeepEqual(got, want) { + t.Errorf("protections = %v, want %v", got, want) + } + if got, want := conversations[0].Activity().At(), time.Unix(1710000000, 0); !conversations[0].Activity().Known() || !got.Equal(want) { + t.Errorf("old group activity = (%v, %v), want known %v", conversations[0].Activity().Known(), got, want) + } + if conversations[3].Activity().Known() { + t.Error("unknown group activity is known, want unknown") + } +} + +func TestListRejectsNonSuccessStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Error(writer, "not authorized", http.StatusUnauthorized) + })) + defer server.Close() + + _, err := newTestClient(t, server).List(context.Background()) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("List() error = %v, want HTTP status error", err) + } +} + +func TestListRejectsMalformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{`) + })) + defer server.Close() + + _, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want malformed JSON error") + } +} + +func TestListTreatsZeroTimestampAsUnknown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `[{"room_id": 105, "name": "empty", "type": "group", "sticky": false, "last_update_time": 0}]`) + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(conversations) != 1 { + t.Fatalf("conversation count = %d, want 1", len(conversations)) + } + if conversations[0].Activity().Known() { + t.Error("activity is known, want unknown") + } +} + +func TestListTreatsNegativeTimestampAsUnknown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `[{"room_id":106,"name":"invalid timestamp","type":"group","sticky":false,"last_update_time":-1}]`) + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(conversations) != 1 { + t.Fatalf("conversation count = %d, want 1", len(conversations)) + } + if conversations[0].Activity().Known() { + t.Error("negative timestamp activity is known, want unknown") + } +} + +func TestListRejectsIncompleteRoomData(t *testing.T) { + for _, test := range []struct { + name string + body string + }{ + {name: "room ID", body: `[{"name":"room","type":"group","sticky":false,"last_update_time":0}]`}, + {name: "name", body: `[{"room_id":106,"type":"group","sticky":false,"last_update_time":0}]`}, + {name: "type", body: `[{"room_id":106,"name":"room","sticky":false,"last_update_time":0}]`}, + {name: "sticky", body: `[{"room_id":106,"name":"room","type":"group","last_update_time":0}]`}, + {name: "last update time", body: `[{"room_id":106,"name":"room","type":"group","sticky":false}]`}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, test.body) + })) + defer server.Close() + + _, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want incomplete room data error") + } + }) + } +} + +func TestListRejectsTrailingJSON(t *testing.T) { + for _, test := range []struct { + name string + body string + }{ + {name: "malformed", body: `[] {`}, + {name: "additional value", body: `[] []`}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, test.body) + })) + defer server.Close() + + _, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want trailing JSON error") + } + }) + } +} + +func TestListRejectsNullJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `null`) + })) + defer server.Close() + + _, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want null payload error") + } +} + +func TestLeaveSendsFormEncodedAction(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodDelete { + t.Errorf("method = %s, want DELETE", request.Method) + } + if request.URL.EscapedPath() != "/rooms/room%2F123" { + t.Errorf("escaped path = %s, want /rooms/room%%2F123", request.URL.EscapedPath()) + } + if request.Header.Get("X-ChatWorkToken") != "test-token" { + t.Errorf("X-ChatWorkToken = %q, want test-token", request.Header.Get("X-ChatWorkToken")) + } + if contentType := request.Header.Get("Content-Type"); contentType != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", contentType) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + if action := form.Get("action_type"); action != "leave" { + t.Errorf("action_type = %q, want leave", action) + } + writer.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + if err := newTestClient(t, server).Leave(context.Background(), testConversation(t, "room/123")); err != nil { + t.Fatal(err) + } +} + +func TestLeaveRejectsUnexpectedStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + http.Error(writer, "cannot leave", http.StatusOK) + })) + defer server.Close() + + err := newTestClient(t, server).Leave(context.Background(), testConversation(t, "123")) + if err == nil || !strings.Contains(err.Error(), "200") { + t.Fatalf("Leave() error = %v, want HTTP status error", err) + } +} + +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client, err := New("test-token", baseURL, server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} + +func testConversation(t *testing.T, id string) domain.Conversation { + t.Helper() + conversation, err := domain.NewConversation(domain.ServiceChatwork, id, "test room", domain.UnknownActivity(), domain.ProtectionNone) + if err != nil { + t.Fatal(err) + } + return conversation +} + +func conversationIDs(conversations []domain.Conversation) []string { + ids := make([]string, 0, len(conversations)) + for _, conversation := range conversations { + ids = append(ids, conversation.ID().Value()) + } + return ids +} + +func conversationProtections(conversations []domain.Conversation) []domain.Protection { + protections := make([]domain.Protection, 0, len(conversations)) + for _, conversation := range conversations { + protections = append(protections, conversation.Protection()) + } + return protections +} diff --git a/internal/adapter/config/config.go b/internal/adapter/config/config.go new file mode 100644 index 0000000..b9ab20e --- /dev/null +++ b/internal/adapter/config/config.go @@ -0,0 +1,195 @@ +package config + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/chck/synr/internal/domain" + "go.yaml.in/yaml/v3" +) + +const applicationName = "synr" + +type Credentials struct { + Token string + URL string + Email string + APIKey string +} + +type Config struct { + protectedIDs map[domain.Service]map[domain.ConversationID]struct{} +} + +type fileConfig struct { + Services *servicesConfig `yaml:"services"` +} + +type servicesConfig struct { + Slack serviceConfig `yaml:"slack"` + Chatwork serviceConfig `yaml:"chatwork"` + Zulip serviceConfig `yaml:"zulip"` +} + +type serviceConfig struct { + ProtectedChannels yaml.Node `yaml:"protected_channels"` +} + +func Load(path string) (Config, error) { + if path == "" { + var err error + path, err = defaultPath() + if err != nil { + return Config{}, err + } + } + + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return emptyConfig(), nil + } + if err != nil { + return Config{}, fmt.Errorf("open configuration %q: %w", path, err) + } + defer file.Close() + + decoder := yaml.NewDecoder(file) + decoder.KnownFields(true) + var decoded fileConfig + if err := decoder.Decode(&decoded); err != nil { + return Config{}, fmt.Errorf("decode configuration %q: %w", path, err) + } + if err := rejectAdditionalDocument(decoder); err != nil { + return Config{}, fmt.Errorf("decode configuration %q: %w", path, err) + } + if decoded.Services == nil { + return Config{}, fmt.Errorf("configuration %q requires a services mapping; put service settings under services", path) + } + return newConfig(decoded) +} + +func (config Config) ProtectedIDs(service domain.Service) map[domain.ConversationID]struct{} { + protected := config.protectedIDs[service] + copy := make(map[domain.ConversationID]struct{}, len(protected)) + for id := range protected { + copy[id] = struct{}{} + } + return copy +} + +func CredentialsFor(service domain.Service, lookup func(string) string) (Credentials, error) { + if lookup == nil { + return Credentials{}, fmt.Errorf("environment variable lookup must not be nil") + } + + switch service { + case domain.ServiceSlack: + token, err := requiredCredential("SYNR_SLACK_TOKEN", lookup) + if err != nil { + return Credentials{}, err + } + return Credentials{Token: token}, nil + case domain.ServiceChatwork: + token, err := requiredCredential("SYNR_CHATWORK_TOKEN", lookup) + if err != nil { + return Credentials{}, err + } + return Credentials{Token: token}, nil + case domain.ServiceZulip: + url, err := requiredCredential("SYNR_ZULIP_URL", lookup) + if err != nil { + return Credentials{}, err + } + email, err := requiredCredential("SYNR_ZULIP_EMAIL", lookup) + if err != nil { + return Credentials{}, err + } + apiKey, err := requiredCredential("SYNR_ZULIP_API_KEY", lookup) + if err != nil { + return Credentials{}, err + } + return Credentials{URL: url, Email: email, APIKey: apiKey}, nil + default: + return Credentials{}, fmt.Errorf("unsupported service %q", service) + } +} + +func defaultPath() (string, error) { + if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" { + return filepath.Join(configHome, applicationName, "config.yaml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve user home directory for configuration: %w", err) + } + return filepath.Join(home, ".config", applicationName, "config.yaml"), nil +} + +func rejectAdditionalDocument(decoder *yaml.Decoder) error { + var additionalDocument any + err := decoder.Decode(&additionalDocument) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + return fmt.Errorf("multiple YAML documents are not supported") +} + +func emptyConfig() Config { + return Config{protectedIDs: make(map[domain.Service]map[domain.ConversationID]struct{})} +} + +func newConfig(file fileConfig) (Config, error) { + config := emptyConfig() + for _, service := range []struct { + name domain.Service + configured serviceConfig + }{ + {name: domain.ServiceSlack, configured: file.Services.Slack}, + {name: domain.ServiceChatwork, configured: file.Services.Chatwork}, + {name: domain.ServiceZulip, configured: file.Services.Zulip}, + } { + protected, err := protectedIDs(service.name, service.configured.ProtectedChannels) + if err != nil { + return Config{}, err + } + config.protectedIDs[service.name] = protected + } + return config, nil +} + +func protectedIDs(service domain.Service, values yaml.Node) (map[domain.ConversationID]struct{}, error) { + if values.Kind != 0 && values.Kind != yaml.SequenceNode { + return nil, fmt.Errorf("protected_channels for %q must be a list of non-empty strings", service) + } + protected := make(map[domain.ConversationID]struct{}, len(values.Content)) + for _, value := range values.Content { + if value.Kind != yaml.ScalarNode || value.ShortTag() != "!!str" { + return nil, fmt.Errorf("protected channel ID for %q must be a non-empty string; quote numeric IDs", service) + } + id := strings.TrimSpace(value.Value) + if id == "" { + return nil, fmt.Errorf("protected channel ID for %q must not be blank", service) + } + conversation, err := domain.NewConversation(service, id, "configured", domain.UnknownActivity(), domain.ProtectionNone) + if err != nil { + return nil, fmt.Errorf("create protected channel ID for %q: %w", service, err) + } + protected[conversation.ID()] = struct{}{} + } + return protected, nil +} + +func requiredCredential(name string, lookup func(string) string) (string, error) { + value := strings.TrimSpace(lookup(name)) + if value == "" { + return "", fmt.Errorf("required environment variable %s is not set", name) + } + return value, nil +} diff --git a/internal/adapter/config/config_test.go b/internal/adapter/config/config_test.go new file mode 100644 index 0000000..1f15ed9 --- /dev/null +++ b/internal/adapter/config/config_test.go @@ -0,0 +1,257 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/chck/synr/internal/domain" +) + +func TestLoadMissingFileReturnsEmptyConfig(t *testing.T) { + config, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got := config.ProtectedIDs(domain.ServiceSlack); len(got) != 0 { + t.Errorf("ProtectedIDs(slack) = %v, want empty", got) + } +} + +func TestLoadRequiresServicesMapping(t *testing.T) { + for _, test := range []struct { + name string + content string + }{ + {name: "empty document mapping", content: "{}\n"}, + {name: "null document", content: "null\n"}, + {name: "null services", content: "services: null\n"}, + {name: "sequence services", content: "services: []\n"}, + {name: "scalar services", content: "services: value\n"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := Load(writeConfig(t, test.content)); err == nil { + t.Fatal("Load accepted a document without a services mapping") + } + }) + } +} + +func TestLoadAcceptsEmptyServicesMapping(t *testing.T) { + configuration, err := Load(writeConfig(t, "services: {}\n")) + if err != nil { + t.Fatal(err) + } + if got := configuration.ProtectedIDs(domain.ServiceSlack); len(got) != 0 { + t.Errorf("ProtectedIDs(slack) = %v, want empty", got) + } +} + +func TestLoadRejectsUnknownYAMLKey(t *testing.T) { + path := writeConfig(t, "unknown: true\n") + + _, err := Load(path) + if err == nil { + t.Fatal("Load() error = nil, want unknown YAML key error") + } + if !strings.Contains(err.Error(), "unknown") { + t.Errorf("Load() error = %q, want unknown key context", err) + } +} + +func TestLoadRejectsAdditionalYAMLDocument(t *testing.T) { + path := writeConfig(t, "services:\n slack: {}\n---\nservices:\n chatwork: {}\n") + + _, err := Load(path) + if err == nil { + t.Fatal("Load() error = nil, want multiple YAML documents error") + } + if !strings.Contains(err.Error(), "multiple") { + t.Errorf("Load() error = %q, want multiple document context", err) + } +} + +func TestLoadReturnsProtectedIDsByService(t *testing.T) { + path := writeConfig(t, "services:\n slack:\n protected_channels:\n - C123\n chatwork:\n protected_channels:\n - room-456\n zulip:\n protected_channels:\n - stream-789\n") + + config, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + for _, test := range []struct { + service domain.Service + id string + }{ + {service: domain.ServiceSlack, id: "C123"}, + {service: domain.ServiceChatwork, id: "room-456"}, + {service: domain.ServiceZulip, id: "stream-789"}, + } { + t.Run(string(test.service), func(t *testing.T) { + if _, ok := config.ProtectedIDs(test.service)[conversationID(t, test.service, test.id)]; !ok { + t.Errorf("ProtectedIDs(%q) does not include %q", test.service, test.id) + } + }) + } +} + +func TestLoadRejectsBlankProtectedID(t *testing.T) { + path := writeConfig(t, "services:\n slack:\n protected_channels:\n - ' '\n") + + _, err := Load(path) + if err == nil { + t.Fatal("Load() error = nil, want blank protected ID error") + } + if !strings.Contains(err.Error(), "protected") { + t.Errorf("Load() error = %q, want protected ID context", err) + } +} + +func TestLoadRejectsNonStringProtectedIDs(t *testing.T) { + for _, test := range []struct { + name string + value string + }{ + {name: "null", value: "[null]"}, + {name: "empty list item", value: "\n -"}, + {name: "boolean", value: "[true]"}, + {name: "number", value: "[123]"}, + {name: "null list", value: "null"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := Load(writeConfig(t, "services:\n chatwork:\n protected_channels: "+test.value+"\n")) + if err == nil || !strings.Contains(err.Error(), "protected") { + t.Fatalf("Load() error = %v, want invalid protected ID error", err) + } + }) + } +} + +func TestLoadPreservesQuotedProtectedIDs(t *testing.T) { + configuration, err := Load(writeConfig(t, "services:\n chatwork:\n protected_channels: ['00123', 'true', 'null']\n")) + if err != nil { + t.Fatal(err) + } + want := map[domain.ConversationID]struct{}{} + for _, id := range []string{"00123", "true", "null"} { + want[conversationID(t, domain.ServiceChatwork, id)] = struct{}{} + } + if got := configuration.ProtectedIDs(domain.ServiceChatwork); !reflect.DeepEqual(got, want) { + t.Fatalf("ProtectedIDs() = %v, want %v", got, want) + } +} + +func TestDefaultPathUsesXDGConfigHome(t *testing.T) { + xdgConfigHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + path := filepath.Join(xdgConfigHome, "synr", "config.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("services:\n slack:\n protected_channels:\n - C123\n"), 0o600); err != nil { + t.Fatal(err) + } + + config, err := Load("") + if err != nil { + t.Fatalf("Load(\"\") error = %v", err) + } + if _, ok := config.ProtectedIDs(domain.ServiceSlack)[conversationID(t, domain.ServiceSlack, "C123")]; !ok { + t.Error("Load(\"\") did not read the XDG configuration path") + } +} + +func TestDefaultPathUsesHomeDotConfigWithoutXDG(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("APPDATA", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", "") + path := filepath.Join(home, ".config", "synr", "config.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("services:\n slack:\n protected_channels: [C123]\n"), 0o600); err != nil { + t.Fatal(err) + } + + configuration, err := Load("") + if err != nil { + t.Fatal(err) + } + if _, ok := configuration.ProtectedIDs(domain.ServiceSlack)[conversationID(t, domain.ServiceSlack, "C123")]; !ok { + t.Fatal("Load did not read $HOME/.config/synr/config.yaml with XDG_CONFIG_HOME unset") + } +} + +func TestCredentialsForLoadsOnlySelectedService(t *testing.T) { + t.Setenv("SYNR_SLACK_TOKEN", "test-slack-token") + lookups := make([]string, 0, 1) + + credentials, err := CredentialsFor(domain.ServiceSlack, func(name string) string { + lookups = append(lookups, name) + return os.Getenv(name) + }) + if err != nil { + t.Fatalf("CredentialsFor() error = %v", err) + } + + want := Credentials{Token: "test-slack-token"} + if !reflect.DeepEqual(credentials, want) { + t.Errorf("CredentialsFor() = %#v, want %#v", credentials, want) + } + if !reflect.DeepEqual(lookups, []string{"SYNR_SLACK_TOKEN"}) { + t.Errorf("credential lookups = %v, want only SYNR_SLACK_TOKEN", lookups) + } +} + +func TestLoadRejectsServicesOutsideWrapper(t *testing.T) { + for _, service := range []string{"slack", "chatwork", "zulip"} { + t.Run(service, func(t *testing.T) { + _, err := Load(writeConfig(t, service+": {}\n")) + if err == nil { + t.Fatal("Load accepted a service outside the services wrapper") + } + }) + } +} + +func TestLoadRejectsUnknownNestedKeys(t *testing.T) { + for _, content := range []string{"services:\n discord: {}\n", "services:\n slack:\n unknown: true\n"} { + _, err := Load(writeConfig(t, content)) + if err == nil { + t.Fatalf("Load accepted unknown nested key: %s", content) + } + } +} + +func TestCredentialsForReportsMissingVariable(t *testing.T) { + _, err := CredentialsFor(domain.ServiceZulip, func(string) string { return " " }) + if err == nil { + t.Fatal("CredentialsFor() error = nil, want missing variable error") + } + if !strings.Contains(err.Error(), "SYNR_ZULIP_URL") { + t.Errorf("CredentialsFor() error = %q, want missing variable name", err) + } +} + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func conversationID(t *testing.T, service domain.Service, value string) domain.ConversationID { + t.Helper() + conversation, err := domain.NewConversation(service, value, "test", domain.UnknownActivity(), domain.ProtectionNone) + if err != nil { + t.Fatal(err) + } + return conversation.ID() +} diff --git a/internal/adapter/slack/client.go b/internal/adapter/slack/client.go new file mode 100644 index 0000000..67f3f34 --- /dev/null +++ b/internal/adapter/slack/client.go @@ -0,0 +1,196 @@ +package slack + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + slackapi "github.com/slack-go/slack" + + "github.com/chck/synr/internal/domain" +) + +type Client struct { + api *slackapi.Client +} + +var slackTimestampPattern = regexp.MustCompile(`^[0-9]+\.[0-9]{6}$`) + +func New(token string, httpClient *http.Client, apiURL string) (*Client, error) { + if strings.TrimSpace(token) == "" { + return nil, fmt.Errorf("Slack token must not be blank") + } + if httpClient == nil { + return nil, fmt.Errorf("Slack HTTP client must not be nil") + } + if strings.TrimSpace(apiURL) == "" { + return nil, fmt.Errorf("Slack API URL must not be blank") + } + + return &Client{api: slackapi.New(token, slackapi.OptionHTTPClient(httpClient), slackapi.OptionAPIURL(apiURL))}, nil +} + +func (client *Client) List(ctx context.Context) ([]domain.Conversation, error) { + var conversations []domain.Conversation + var cursor string + for { + channels, nextCursor, err := client.api.GetConversationsContext(ctx, &slackapi.GetConversationsParameters{ + Cursor: cursor, + ExcludeArchived: true, + Limit: 200, + Types: []string{"public_channel", "private_channel"}, + }) + if err != nil { + return nil, fmt.Errorf("list Slack conversations: %w", err) + } + if channels == nil { + return nil, fmt.Errorf("list Slack conversations: response is missing a channels array") + } + + for _, channel := range channels { + if !channel.IsMember { + continue + } + + info, err := client.api.GetConversationInfoContext(ctx, &slackapi.GetConversationInfoInput{ChannelID: channel.ID}) + if err != nil { + return nil, fmt.Errorf("get Slack conversation %q info: %w", channel.ID, err) + } + + activity, err := client.latestActivity(ctx, channel.ID) + if err != nil { + return nil, fmt.Errorf("get Slack conversation %q history: %w", channel.ID, err) + } + + conversation, err := conversationFromChannel(*info, activity) + if err != nil { + return nil, err + } + conversations = append(conversations, conversation) + } + + if nextCursor == "" { + return conversations, nil + } + cursor = nextCursor + } +} + +func (client *Client) latestActivity(ctx context.Context, channelID string) (domain.Activity, error) { + activity := domain.UnknownActivity() + var cursor string + seenCursors := make(map[string]struct{}) + for { + history, err := client.api.GetConversationHistoryContext(ctx, &slackapi.GetConversationHistoryParameters{ + ChannelID: channelID, + Cursor: cursor, + Limit: 200, + }) + if err != nil { + return domain.Activity{}, err + } + if history.Messages == nil { + return domain.Activity{}, fmt.Errorf("response is missing a messages array") + } + for _, message := range history.Messages { + candidate, err := activityFromMessage(message) + if err != nil { + return domain.Activity{}, err + } + if !activity.Known() || candidate.At().After(activity.At()) { + activity = candidate + } + } + cursor = history.ResponseMetaData.NextCursor + if cursor == "" { + if history.HasMore { + return domain.Activity{}, fmt.Errorf("incomplete history: has_more is true without a next cursor") + } + return activity, nil + } + if _, seen := seenCursors[cursor]; seen { + return domain.Activity{}, fmt.Errorf("incomplete history: repeated pagination cursor") + } + seenCursors[cursor] = struct{}{} + } +} + +func (client *Client) Leave(ctx context.Context, conversation domain.Conversation) error { + if conversation.Service() != domain.ServiceSlack { + return fmt.Errorf("cannot leave non-Slack conversation") + } + + _, err := client.api.LeaveConversationContext(ctx, conversation.ID().Value()) + if err == nil { + return nil + } + + var slackError slackapi.SlackErrorResponse + if errors.As(err, &slackError) && slackError.Err == "not_in_channel" { + return nil + } + return fmt.Errorf("leave Slack conversation %q: %w", conversation.ID().Value(), err) +} + +func conversationFromChannel(channel slackapi.Channel, activity domain.Activity) (domain.Conversation, error) { + protection := domain.ProtectionNone + if channel.IsGeneral { + protection = domain.ProtectionGeneral + } + + conversation, err := domain.NewConversation(domain.ServiceSlack, channel.ID, channel.Name, activity, protection) + if err != nil { + return domain.Conversation{}, fmt.Errorf("create Slack conversation %q: %w", channel.ID, err) + } + return conversation, nil +} + +func activityFromMessage(message slackapi.Message) (domain.Activity, error) { + activity, err := activityFromTimestamp(message.Timestamp) + if err != nil { + return domain.Activity{}, fmt.Errorf("message ts: %w", err) + } + if message.ReplyCount < 0 { + return domain.Activity{}, fmt.Errorf("message reply_count must not be negative") + } + if message.LatestReply == "" { + if message.ReplyCount > 0 { + return domain.Activity{}, fmt.Errorf("message with replies is missing latest_reply") + } + return activity, nil + } + latestReply, err := activityFromTimestamp(message.LatestReply) + if err != nil { + return domain.Activity{}, fmt.Errorf("message latest_reply: %w", err) + } + if latestReply.At().After(activity.At()) { + return latestReply, nil + } + return activity, nil +} + +func activityFromTimestamp(timestamp string) (domain.Activity, error) { + if !slackTimestampPattern.MatchString(timestamp) { + return domain.Activity{}, fmt.Errorf("invalid timestamp %q", timestamp) + } + + parts := strings.SplitN(timestamp, ".", 2) + seconds, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return domain.Activity{}, fmt.Errorf("invalid timestamp seconds: %w", err) + } + if seconds <= 0 { + return domain.Activity{}, fmt.Errorf("timestamp seconds must be positive") + } + microseconds, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return domain.Activity{}, fmt.Errorf("invalid timestamp fraction: %w", err) + } + + return domain.KnownActivity(time.Unix(seconds, microseconds*1000)) +} diff --git a/internal/adapter/slack/client_test.go b/internal/adapter/slack/client_test.go new file mode 100644 index 0000000..a417718 --- /dev/null +++ b/internal/adapter/slack/client_test.go @@ -0,0 +1,607 @@ +package slack + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/chck/synr/internal/domain" + "github.com/chck/synr/internal/usecase" +) + +var _ usecase.Provider = (*Client)(nil) + +type fixedClock struct{} + +func (fixedClock) Now() time.Time { + return time.Date(2026, time.September, 18, 0, 0, 0, 0, time.UTC) +} + +func TestApplyUsesLatestMessageInsteadOfLastRead(t *testing.T) { + leaves := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true}]}`) + case "/conversations.info": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"active","is_general":false,"last_read":"1710000000.000000"}}`) + case "/conversations.history": + if got := formValue(t, request, "channel"); got != "C1" { + t.Errorf("history channel = %q, want C1", got) + } + if got := formValue(t, request, "limit"); got != "200" { + t.Errorf("history limit = %q, want 200", got) + } + if formValue(t, request, "oldest") != "" || formValue(t, request, "latest") != "" { + t.Error("history must request the newest message without time bounds") + } + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1789689599.000000"}],"has_more":false}`) + case "/conversations.leave": + leaves++ + writeJSON(t, writer, `{"ok":true}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err != nil { + t.Fatal(err) + } + if leaves != 0 { + t.Errorf("leave count = %d, want 0 for recent message with old last_read", leaves) + } + if len(result.Decisions) != 1 || result.Decisions[0].Reason() != domain.ReasonActive { + t.Fatalf("decisions = %v, want one active conversation", result.Decisions) + } + if got := result.Decisions[0].Conversation().Activity().At(); !got.Equal(time.Unix(1789689599, 0)) { + t.Errorf("activity = %v, want latest message timestamp", got) + } +} + +func TestApplyPreservesChannelsWithRecentThreadReplies(t *testing.T) { + for _, test := range []struct { + name string + pages []string + }{ + { + name: "newest parent reply", + pages: []string{`{"ok":true,"messages":[{"ts":"1710000000.000000","reply_count":1,"latest_reply":"1789689599.123456"}]}`}, + }, + { + name: "older parent reply on same page", + pages: []string{`{"ok":true,"messages":[{"ts":"1710000000.000000"},{"ts":"1700000000.000000","reply_count":1,"latest_reply":"1789689599.123456"}]}`}, + }, + { + name: "older parent reply on next page", + pages: []string{ + `{"ok":true,"messages":[{"ts":"1710000000.000000"}],"has_more":true,"response_metadata":{"next_cursor":"older"}}`, + `{"ok":true,"messages":[{"ts":"1700000000.000000","reply_count":1,"latest_reply":"1789689599.123456"}],"has_more":false}`, + }, + }, + { + name: "cursor continues after active page with false has_more", + pages: []string{ + `{"ok":true,"messages":[{"ts":"1710000000.000000","reply_count":1,"latest_reply":"1789689599.123455"}],"has_more":false,"response_metadata":{"next_cursor":"older"}}`, + `{"ok":true,"messages":[{"ts":"1700000000.000000","reply_count":1,"latest_reply":"1789689599.123456"}],"has_more":false}`, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + leaves := 0 + var cursors []string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true}]}`) + case "/conversations.info": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"thread active","is_general":false,"last_read":"1710000000.000000"}}`) + case "/conversations.history": + cursor := formValue(t, request, "cursor") + cursors = append(cursors, cursor) + if len(cursors) > len(test.pages) { + t.Error("history requested more pages than available") + writer.WriteHeader(http.StatusBadRequest) + return + } + writeJSON(t, writer, test.pages[len(cursors)-1]) + case "/conversations.leave": + leaves++ + writeJSON(t, writer, `{"ok":true}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err != nil { + t.Fatal(err) + } + if leaves != 0 { + t.Errorf("leave count = %d, want 0 for recent thread reply", leaves) + } + if len(result.Decisions) != 1 || result.Decisions[0].Reason() != domain.ReasonActive { + t.Fatalf("decisions = %v, want one active conversation", result.Decisions) + } + if got := result.Decisions[0].Conversation().Activity().At(); !got.Equal(time.Unix(1789689599, 123456000)) { + t.Errorf("activity = %v, want latest reply including its fractional timestamp", got) + } + wantCursors := []string{""} + if len(test.pages) > 1 { + wantCursors = append(wantCursors, "older") + } + if !reflect.DeepEqual(cursors, wantCursors) { + t.Errorf("history cursors = %v, want %v", cursors, wantCursors) + } + }) + } +} + +func TestNewRejectsInvalidConfiguration(t *testing.T) { + for _, input := range []struct { + name string + token string + httpClient *http.Client + apiURL string + }{ + {name: "blank token", token: " ", httpClient: http.DefaultClient, apiURL: "https://slack.example/"}, + {name: "nil HTTP client", token: "token", apiURL: "https://slack.example/"}, + {name: "blank API URL", token: "token", httpClient: http.DefaultClient, apiURL: " "}, + } { + t.Run(input.name, func(t *testing.T) { + client, err := New(input.token, input.httpClient, input.apiURL) + if err == nil { + t.Errorf("New() = %v, nil, want configuration error", client) + } + }) + } +} + +func TestListPaginatesMemberConversationsAndFetchesInfo(t *testing.T) { + var infoCalls []string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + if got, want := formValue(t, request, "limit"), "200"; got != want { + t.Errorf("limit = %q, want %q", got, want) + } + if got, want := formValue(t, request, "exclude_archived"), "true"; got != want { + t.Errorf("exclude_archived = %q, want %q", got, want) + } + if got, want := formValue(t, request, "types"), "public_channel,private_channel"; got != want { + t.Errorf("types = %q, want %q", got, want) + } + switch formValue(t, request, "cursor") { + case "": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true},{"id":"C2","is_member":false}],"response_metadata":{"next_cursor":"second-page"}}`) + case "second-page": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C3","is_member":true}],"response_metadata":{"next_cursor":""}}`) + default: + t.Errorf("cursor = %q, want a known page cursor", formValue(t, request, "cursor")) + writer.WriteHeader(http.StatusBadRequest) + } + case "/conversations.info": + id := formValue(t, request, "channel") + infoCalls = append(infoCalls, id) + switch id { + case "C1": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"first","is_general":false,"last_read":"1789718400.000000"}}`) + case "C3": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C3","name":"third","is_general":false,"last_read":"1789718401.000000"}}`) + default: + t.Errorf("conversation info requested for %q, want a member conversation", id) + writer.WriteHeader(http.StatusBadRequest) + } + case "/conversations.history": + switch formValue(t, request, "channel") { + case "C1": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1789718400.000000"}]}`) + case "C3": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1789718401.000000"}]}`) + default: + t.Error("history requested for a non-member conversation") + writer.WriteHeader(http.StatusBadRequest) + } + default: + t.Errorf("path = %q, want a Slack Conversations endpoint", request.URL.Path) + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if got, want := infoCalls, []string{"C1", "C3"}; !reflect.DeepEqual(got, want) { + t.Errorf("conversation info calls = %v, want %v", got, want) + } + if got, want := conversationIDs(conversations), []string{"C1", "C3"}; !reflect.DeepEqual(got, want) { + t.Errorf("conversation IDs = %v, want %v", got, want) + } + if got, want := conversationNames(conversations), []string{"first", "third"}; !reflect.DeepEqual(got, want) { + t.Errorf("conversation names = %v, want %v", got, want) + } + for index, timestamp := range []int64{1789718400, 1789718401} { + activity := conversations[index].Activity() + if !activity.Known() || !activity.At().Equal(time.Unix(timestamp, 0)) { + t.Errorf("conversation %d activity = (%v, %v), want known %v", index, activity.Known(), activity.At(), time.Unix(timestamp, 0)) + } + } +} + +func TestListProtectsGeneralChannel(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true}],"response_metadata":{"next_cursor":""}}`) + case "/conversations.info": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"general","is_general":true,"last_read":"1789718400.000000"}}`) + case "/conversations.history": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1710000000.000000"}]}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + if got, want := conversations[0].Protection(), domain.ProtectionGeneral; got != want { + t.Errorf("protection = %q, want %q", got, want) + } + if decision := domain.Evaluate(conversations[0], fixedClock{}.Now(), nil); decision.Eligible() { + t.Error("general channel must remain protected with old messages") + } +} + +func TestListRejectsMissingOrMalformedMessageTimestamp(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true},{"id":"C2","is_member":true},{"id":"C3","is_member":true},{"id":"C4","is_member":true}],"response_metadata":{"next_cursor":""}}`) + case "/conversations.info": + id := formValue(t, request, "channel") + writeJSON(t, writer, fmt.Sprintf(`{"ok":true,"channel":{"id":%q,"name":%q,"is_general":false,"last_read":"1710000000.000000"}}`, id, id)) + case "/conversations.history": + switch formValue(t, request, "channel") { + case "C1": + writeJSON(t, writer, `{"ok":true,"messages":[{}]}`) + case "C2": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"not-a-timestamp"}]}`) + case "C3": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1789718400.not-a-fraction"}]}`) + case "C4": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1789718400."}]}`) + } + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err == nil || conversations != nil { + t.Fatalf("List() = %v, %v, want no partial data and a timestamp error", conversations, err) + } +} + +func TestApplyRejectsIncompleteHistoryPagesBeforeAnyLeave(t *testing.T) { + for _, test := range []struct { + name string + body string + status int + wantError string + }{ + {name: "missing messages", body: `{"ok":true}`, status: http.StatusOK, wantError: "messages"}, + {name: "null messages", body: `{"ok":true,"messages":null}`, status: http.StatusOK, wantError: "messages"}, + {name: "missing timestamp", body: `{"ok":true,"messages":[{}]}`, status: http.StatusOK, wantError: "timestamp"}, + {name: "malformed timestamp", body: `{"ok":true,"messages":[{"ts":"invalid"}]}`, status: http.StatusOK, wantError: "timestamp"}, + {name: "timestamp fraction", body: `{"ok":true,"messages":[{"ts":"1700000000.invalid"}]}`, status: http.StatusOK, wantError: "timestamp"}, + {name: "timestamp empty fraction", body: `{"ok":true,"messages":[{"ts":"1700000000."}]}`, status: http.StatusOK, wantError: "timestamp"}, + {name: "invalid latest reply", body: `{"ok":true,"messages":[{"ts":"1700000000.000000","reply_count":1,"latest_reply":"invalid"}]}`, status: http.StatusOK, wantError: "timestamp"}, + {name: "missing latest reply", body: `{"ok":true,"messages":[{"ts":"1700000000.000000","reply_count":1}]}`, status: http.StatusOK, wantError: "latest_reply"}, + {name: "negative reply count", body: `{"ok":true,"messages":[{"ts":"1700000000.000000","reply_count":-1}]}`, status: http.StatusOK, wantError: "reply_count"}, + {name: "missing next cursor", body: `{"ok":true,"messages":[{"ts":"1700000000.000000"}],"has_more":true}`, status: http.StatusOK, wantError: "cursor"}, + {name: "repeated cursor", body: `{"ok":true,"messages":[{"ts":"1700000000.000000"}],"has_more":true,"response_metadata":{"next_cursor":"older"}}`, status: http.StatusOK, wantError: "cursor"}, + {name: "API error", body: `{"ok":false,"error":"missing_scope"}`, status: http.StatusOK, wantError: "missing_scope"}, + {name: "HTTP error", body: `{"ok":false,"error":"ratelimited"}`, status: http.StatusTooManyRequests, wantError: "429"}, + {name: "invalid JSON", body: `{`, status: http.StatusOK, wantError: "EOF"}, + } { + t.Run(test.name, func(t *testing.T) { + leaves := 0 + var cursors []string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true}]}`) + case "/conversations.info": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"history incomplete","is_general":false}}`) + case "/conversations.history": + cursors = append(cursors, formValue(t, request, "cursor")) + switch len(cursors) { + case 1: + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1710000000.000000"}],"has_more":true,"response_metadata":{"next_cursor":"older"}}`) + case 2: + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(test.status) + _, _ = io.WriteString(writer, test.body) + default: + writer.WriteHeader(http.StatusBadRequest) + } + case "/conversations.leave": + leaves++ + writeJSON(t, writer, `{"ok":true}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err == nil || !strings.Contains(err.Error(), "C1") || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("Scan error = %v, want history error for C1 containing %q", err, test.wantError) + } + if leaves != 0 || len(result.Decisions) != 0 { + t.Errorf("leave count = %d, decisions = %v, want no leaves or partial decisions", leaves, result.Decisions) + } + if want := []string{"", "older"}; !reflect.DeepEqual(cursors, want) { + t.Errorf("history cursors = %v, want %v", cursors, want) + } + }) + } +} + +func TestListFailsOnPaginationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/conversations.list" { + writer.WriteHeader(http.StatusNotFound) + return + } + writeJSON(t, writer, `{"ok":false,"error":"ratelimited"}`) + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want Slack API error") + } + if conversations != nil { + t.Errorf("List() conversations = %v, want no partial data", conversations) + } +} + +func TestApplyRejectsIncompleteSecondPageBeforeAnyLeave(t *testing.T) { + for _, test := range []struct { + name string + body string + }{ + {name: "missing channels", body: `{"ok":true}`}, + {name: "null channels", body: `{"ok":true,"channels":null}`}, + } { + t.Run(test.name, func(t *testing.T) { + leaves := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + if formValue(t, request, "cursor") == "" { + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true}],"response_metadata":{"next_cursor":"second"}}`) + } else { + writeJSON(t, writer, test.body) + } + case "/conversations.info": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"old","is_general":false,"last_read":"1710000000.000000"}}`) + case "/conversations.history": + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1710000000.000000"}]}`) + case "/conversations.leave": + leaves++ + writeJSON(t, writer, `{"ok":true}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err == nil || !strings.Contains(err.Error(), "channels") { + t.Errorf("Scan error = %v, want incomplete channels error", err) + } + if leaves != 0 || len(result.Decisions) != 0 { + t.Errorf("leave count = %d, decisions = %v, want no leaves or partial decisions", leaves, result.Decisions) + } + }) + } +} + +func TestApplyRejectsUnavailableHistoryBeforeAnyLeave(t *testing.T) { + for _, test := range []struct { + name string + status int + body string + }{ + {name: "missing messages", status: http.StatusOK, body: `{"ok":true}`}, + {name: "null messages", status: http.StatusOK, body: `{"ok":true,"messages":null}`}, + {name: "Slack API error", status: http.StatusOK, body: `{"ok":false,"error":"missing_scope"}`}, + {name: "HTTP error", status: http.StatusTooManyRequests, body: `{"ok":false,"error":"ratelimited"}`}, + {name: "malformed JSON", status: http.StatusOK, body: `{`}, + } { + t.Run(test.name, func(t *testing.T) { + leaves := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true},{"id":"C2","is_member":true}]}`) + case "/conversations.info": + id := formValue(t, request, "channel") + writeJSON(t, writer, fmt.Sprintf(`{"ok":true,"channel":{"id":%q,"name":%q,"is_general":false,"last_read":"1710000000.000000"}}`, id, id)) + case "/conversations.history": + if formValue(t, request, "channel") == "C1" { + writeJSON(t, writer, `{"ok":true,"messages":[{"ts":"1710000000.000000"}]}`) + } else { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(test.status) + _, _ = io.WriteString(writer, test.body) + } + case "/conversations.leave": + leaves++ + writeJSON(t, writer, `{"ok":true}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err == nil || !strings.Contains(err.Error(), "history") || !strings.Contains(err.Error(), "C2") { + t.Errorf("Scan error = %v, want history error for C2", err) + } + if leaves != 0 || len(result.Decisions) != 0 { + t.Errorf("leave count = %d, decisions = %v, want no leaves or partial decisions", leaves, result.Decisions) + } + }) + } +} + +func TestApplyTreatsEmptyHistoryAsUnknown(t *testing.T) { + leaves := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/conversations.list": + writeJSON(t, writer, `{"ok":true,"channels":[{"id":"C1","is_member":true}]}`) + case "/conversations.info": + writeJSON(t, writer, `{"ok":true,"channel":{"id":"C1","name":"empty","is_general":false,"last_read":"1710000000.000000"}}`) + case "/conversations.history": + writeJSON(t, writer, `{"ok":true,"messages":[]}`) + case "/conversations.leave": + leaves++ + writeJSON(t, writer, `{"ok":true}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + result, err := usecase.Scan(context.Background(), newTestClient(t, server), usecase.Request{BeforeMonths: 1, Apply: true}, fixedClock{}) + if err != nil { + t.Fatal(err) + } + if leaves != 0 { + t.Errorf("leave count = %d, want 0", leaves) + } + if len(result.Decisions) != 1 || result.Decisions[0].Reason() != domain.ReasonUnknownActivity { + t.Errorf("decisions = %v, want one unknown activity decision", result.Decisions) + } +} + +func TestListAcceptsEmptyChannelsArray(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writeJSON(t, writer, `{"ok":true,"channels":[]}`) + })) + defer server.Close() + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil || len(conversations) != 0 { + t.Fatalf("List() = %v, %v, want successful empty list", conversations, err) + } +} + +func TestLeaveUsesConversationsLeave(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if got, want := request.URL.Path, "/conversations.leave"; got != want { + t.Errorf("path = %q, want %q", got, want) + writer.WriteHeader(http.StatusNotFound) + return + } + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + if got, want := request.Form.Get("channel"), "C1"; got != want { + t.Errorf("channel = %q, want %q", got, want) + } + writeJSON(t, writer, `{"ok":true}`) + })) + defer server.Close() + + if err := newTestClient(t, server).Leave(context.Background(), testConversation(t, "C1")); err != nil { + t.Fatal(err) + } +} + +func TestLeaveReportsSlackAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writeJSON(t, writer, `{"ok":false,"error":"cant_leave"}`) + })) + defer server.Close() + + if err := newTestClient(t, server).Leave(context.Background(), testConversation(t, "C1")); err == nil { + t.Fatal("Leave() error = nil, want Slack API error") + } +} + +func TestLeaveTreatsNotInChannelAsSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writeJSON(t, writer, `{"ok":false,"error":"not_in_channel"}`) + })) + defer server.Close() + + if err := newTestClient(t, server).Leave(context.Background(), testConversation(t, "C1")); err != nil { + t.Fatalf("Leave() error = %v, want nil", err) + } +} + +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + + client, err := New("test-token", server.Client(), server.URL+"/") + if err != nil { + t.Fatal(err) + } + return client +} + +func testConversation(t *testing.T, id string) domain.Conversation { + t.Helper() + + conversation, err := domain.NewConversation(domain.ServiceSlack, id, "test channel", domain.UnknownActivity(), domain.ProtectionNone) + if err != nil { + t.Fatal(err) + } + return conversation +} + +func conversationIDs(conversations []domain.Conversation) []string { + ids := make([]string, len(conversations)) + for index, conversation := range conversations { + ids[index] = conversation.ID().Value() + } + return ids +} + +func conversationNames(conversations []domain.Conversation) []string { + names := make([]string, len(conversations)) + for index, conversation := range conversations { + names[index] = conversation.Name() + } + return names +} + +func writeJSON(t *testing.T, writer http.ResponseWriter, body string) { + t.Helper() + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(json.RawMessage(body)); err != nil { + t.Fatal(err) + } +} + +func formValue(t *testing.T, request *http.Request, key string) string { + t.Helper() + if err := request.ParseForm(); err != nil { + t.Fatal(err) + } + return request.Form.Get(key) +} diff --git a/internal/adapter/zulip/client.go b/internal/adapter/zulip/client.go new file mode 100644 index 0000000..3bf05d4 --- /dev/null +++ b/internal/adapter/zulip/client.go @@ -0,0 +1,301 @@ +package zulip + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/chck/synr/internal/domain" +) + +const diagnosticBodyLimit int64 = 4096 + +type Client struct { + baseURL *url.URL + email string + apiKey string + httpClient *http.Client +} + +type subscription struct { + StreamID *int64 `json:"stream_id"` + Name *string `json:"name"` + PinToTop *bool `json:"pin_to_top"` +} + +type message struct { + Timestamp *int64 `json:"timestamp"` +} + +type subscriptionsResponse struct { + Result *string `json:"result"` + Subscriptions *[]subscription `json:"subscriptions"` +} + +type messagesResponse struct { + Result *string `json:"result"` + Messages *[]message `json:"messages"` +} + +type unsubscribeResponse struct { + Result *string `json:"result"` + Removed *[]string `json:"removed"` + NotRemoved *[]string `json:"not_removed"` +} + +func New(baseURL *url.URL, email string, apiKey string, httpClient *http.Client) (*Client, error) { + if baseURL == nil { + return nil, fmt.Errorf("Zulip base URL must not be nil") + } + if baseURL.Host == "" || (baseURL.Scheme != "http" && baseURL.Scheme != "https") { + return nil, fmt.Errorf("Zulip base URL must be an absolute HTTP(S) URL") + } + if strings.TrimSpace(email) == "" { + return nil, fmt.Errorf("Zulip email must not be blank") + } + if strings.TrimSpace(apiKey) == "" { + return nil, fmt.Errorf("Zulip API key must not be blank") + } + if httpClient == nil { + return nil, fmt.Errorf("Zulip HTTP client must not be nil") + } + + baseURLCopy := *baseURL + if !strings.HasSuffix(baseURLCopy.Path, "/") { + baseURLCopy.Path += "/" + } + if baseURLCopy.RawPath != "" && !strings.HasSuffix(baseURLCopy.RawPath, "/") { + baseURLCopy.RawPath += "/" + } + return &Client{baseURL: &baseURLCopy, email: email, apiKey: apiKey, httpClient: httpClient}, nil +} + +func (client *Client) List(ctx context.Context) ([]domain.Conversation, error) { + request, err := client.newRequest(ctx, http.MethodGet, "api/v1/users/me/subscriptions", nil, nil) + if err != nil { + return nil, fmt.Errorf("create Zulip subscriptions request: %w", err) + } + + var response subscriptionsResponse + if err := client.doJSON(request, &response, "list Zulip subscriptions"); err != nil { + return nil, err + } + if err := validateSuccess(response.Result, "list Zulip subscriptions"); err != nil { + return nil, err + } + if response.Subscriptions == nil { + return nil, fmt.Errorf("list Zulip subscriptions: response is missing subscriptions") + } + + conversations := make([]domain.Conversation, 0, len(*response.Subscriptions)) + for _, subscription := range *response.Subscriptions { + if err := subscription.validate(); err != nil { + return nil, err + } + activity, err := client.newestActivity(ctx, *subscription.Name) + if err != nil { + return nil, err + } + + protection := domain.ProtectionNone + if *subscription.PinToTop { + protection = domain.ProtectionPinned + } + conversation, err := domain.NewConversation( + domain.ServiceZulip, + strconv.FormatInt(*subscription.StreamID, 10), + *subscription.Name, + activity, + protection, + ) + if err != nil { + return nil, fmt.Errorf("create Zulip subscription %q: %w", *subscription.Name, err) + } + conversations = append(conversations, conversation) + } + return conversations, nil +} + +func (client *Client) Leave(ctx context.Context, conversation domain.Conversation) error { + if conversation.Service() != domain.ServiceZulip { + return fmt.Errorf("cannot leave non-Zulip conversation") + } + + subscriptions, err := json.Marshal([]string{conversation.Name()}) + if err != nil { + return fmt.Errorf("encode Zulip subscriptions: %w", err) + } + values := url.Values{"subscriptions": {string(subscriptions)}} + request, err := client.newRequest( + ctx, + http.MethodDelete, + "api/v1/users/me/subscriptions", + nil, + strings.NewReader(values.Encode()), + ) + if err != nil { + return fmt.Errorf("create Zulip unsubscribe request: %w", err) + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var response unsubscribeResponse + if err := client.doJSON(request, &response, "leave Zulip subscription"); err != nil { + return err + } + if err := validateSuccess(response.Result, "leave Zulip subscription"); err != nil { + return err + } + if response.Removed == nil || response.NotRemoved == nil { + return fmt.Errorf("leave Zulip subscription: response is missing removed or not_removed") + } + if contains(*response.Removed, conversation.Name()) { + return nil + } + if contains(*response.NotRemoved, conversation.Name()) { + return fmt.Errorf("leave Zulip subscription %q: not removed", conversation.Name()) + } + return fmt.Errorf("leave Zulip subscription %q: response did not report the subscription", conversation.Name()) +} + +func (client *Client) newestActivity(ctx context.Context, channel string) (domain.Activity, error) { + narrow, err := json.Marshal([]struct { + Operator string `json:"operator"` + Operand string `json:"operand"` + }{{Operator: "channel", Operand: channel}}) + if err != nil { + return domain.Activity{}, fmt.Errorf("encode Zulip channel narrow: %w", err) + } + query := url.Values{ + "anchor": {"newest"}, + "num_before": {"1"}, + "num_after": {"0"}, + "narrow": {string(narrow)}, + } + request, err := client.newRequest(ctx, http.MethodGet, "api/v1/messages", query, nil) + if err != nil { + return domain.Activity{}, fmt.Errorf("create Zulip messages request: %w", err) + } + + var response messagesResponse + if err := client.doJSON(request, &response, "list Zulip messages"); err != nil { + return domain.Activity{}, err + } + if err := validateSuccess(response.Result, "list Zulip messages"); err != nil { + return domain.Activity{}, err + } + if response.Messages == nil { + return domain.Activity{}, fmt.Errorf("list Zulip messages: response is missing messages") + } + if len(*response.Messages) == 0 { + return domain.UnknownActivity(), nil + } + if (*response.Messages)[0].Timestamp == nil { + return domain.Activity{}, fmt.Errorf("list Zulip messages: newest message is missing timestamp") + } + if *(*response.Messages)[0].Timestamp <= 0 { + return domain.Activity{}, fmt.Errorf("list Zulip messages: newest message timestamp must be positive") + } + activity, err := domain.KnownActivity(time.Unix(*(*response.Messages)[0].Timestamp, 0)) + if err != nil { + return domain.Activity{}, fmt.Errorf("create Zulip message activity: %w", err) + } + return activity, nil +} + +func (client *Client) newRequest(ctx context.Context, method string, path string, query url.Values, body io.Reader) (*http.Request, error) { + endpoint := client.endpoint(path) + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, err + } + request.SetBasicAuth(client.email, client.apiKey) + return request, nil +} + +func (client *Client) endpoint(path string) *url.URL { + return client.baseURL.ResolveReference(&url.URL{Path: path}) +} + +func (client *Client) doJSON(request *http.Request, target any, operation string) error { + response, err := client.httpClient.Do(request) + if err != nil { + return fmt.Errorf("%s: %w", operation, err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return unexpectedStatus(operation, response) + } + if err := decodeJSON(response.Body, target); err != nil { + return fmt.Errorf("%s: %w", operation, err) + } + return nil +} + +func (subscription subscription) validate() error { + if subscription.StreamID == nil { + return fmt.Errorf("Zulip subscription is missing stream_id") + } + if *subscription.StreamID <= 0 { + return fmt.Errorf("Zulip subscription stream_id must be positive") + } + if subscription.Name == nil || *subscription.Name == "" { + return fmt.Errorf("Zulip subscription %d is missing name", *subscription.StreamID) + } + if subscription.PinToTop == nil { + return fmt.Errorf("Zulip subscription %q is missing pin_to_top", *subscription.Name) + } + return nil +} + +func validateSuccess(result *string, operation string) error { + if result == nil { + return fmt.Errorf("%s: response is missing result", operation) + } + if *result != "success" { + return fmt.Errorf("%s: Zulip result is %q, want success", operation, *result) + } + return nil +} + +func decodeJSON(body io.Reader, target any) error { + decoder := json.NewDecoder(body) + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("decode JSON: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("decode JSON: unexpected additional JSON value") + } + return fmt.Errorf("decode trailing JSON data: %w", err) + } + return nil +} + +func unexpectedStatus(operation string, response *http.Response) error { + body, err := io.ReadAll(io.LimitReader(response.Body, diagnosticBodyLimit)) + if err != nil { + return fmt.Errorf("%s: unexpected HTTP status %d (read response body: %w)", operation, response.StatusCode, err) + } + if diagnostic := strings.TrimSpace(string(body)); diagnostic != "" { + return fmt.Errorf("%s: unexpected HTTP status %d: %s", operation, response.StatusCode, diagnostic) + } + return fmt.Errorf("%s: unexpected HTTP status %d", operation, response.StatusCode) +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/internal/adapter/zulip/client_test.go b/internal/adapter/zulip/client_test.go new file mode 100644 index 0000000..74a7397 --- /dev/null +++ b/internal/adapter/zulip/client_test.go @@ -0,0 +1,364 @@ +package zulip + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" + + "github.com/chck/synr/internal/domain" + "github.com/chck/synr/internal/usecase" +) + +var _ usecase.Provider = (*Client)(nil) + +func TestListFetchesNewestMessageForEverySubscription(t *testing.T) { + var requestedChannels []string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + if request.Method != http.MethodGet { + t.Errorf("subscriptions method = %s, want GET", request.Method) + } + writeJSON(t, writer, `{"result":"success","subscriptions":[{"stream_id":1,"name":"Denmark","pin_to_top":false},{"stream_id":2,"name":"Norway","pin_to_top":false}]}`) + case "/api/v1/messages": + channel := requestedChannel(t, request) + requestedChannels = append(requestedChannels, channel) + timestamps := map[string]int64{"Denmark": 1710000000, "Norway": 1710000001} + writeJSON(t, writer, fmt.Sprintf(`{"result":"success","messages":[{"timestamp":%d}]}`, timestamps[channel])) + default: + t.Errorf("path = %s, want Zulip endpoint", request.URL.Path) + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + + if got, want := requestedChannels, []string{"Denmark", "Norway"}; !reflect.DeepEqual(got, want) { + t.Errorf("message channels = %v, want %v", got, want) + } + if got, want := conversationIDs(conversations), []string{"1", "2"}; !reflect.DeepEqual(got, want) { + t.Errorf("conversation IDs = %v, want %v", got, want) + } + if got, want := conversationNames(conversations), []string{"Denmark", "Norway"}; !reflect.DeepEqual(got, want) { + t.Errorf("conversation names = %v, want %v", got, want) + } + for index, timestamp := range []int64{1710000000, 1710000001} { + activity := conversations[index].Activity() + if !activity.Known() || !activity.At().Equal(time.Unix(timestamp, 0)) { + t.Errorf("conversation %d activity = (%v, %v), want known %v", index, activity.Known(), activity.At(), time.Unix(timestamp, 0)) + } + } +} + +func TestListProtectsPinnedSubscription(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + writeJSON(t, writer, `{"result":"success","subscriptions":[{"stream_id":42,"name":"Pinned","pin_to_top":true}]}`) + case "/api/v1/messages": + requestedChannel(t, request) + writeJSON(t, writer, `{"result":"success","messages":[{"timestamp":1710000000}]}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(conversations) != 1 { + t.Fatalf("conversation count = %d, want 1", len(conversations)) + } + if got := conversations[0].Protection(); got != domain.ProtectionPinned { + t.Errorf("protection = %q, want %q", got, domain.ProtectionPinned) + } +} + +func TestListTreatsChannelWithoutMessagesAsUnknown(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + writeJSON(t, writer, `{"result":"success","subscriptions":[{"stream_id":7,"name":"Empty","pin_to_top":false}]}`) + case "/api/v1/messages": + requestedChannel(t, request) + writeJSON(t, writer, `{"result":"success","messages":[]}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(conversations) != 1 { + t.Fatalf("conversation count = %d, want 1", len(conversations)) + } + if conversations[0].Activity().Known() { + t.Error("activity is known, want unknown") + } +} + +func TestListFailsIfAnyActivityRequestFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + writeJSON(t, writer, `{"result":"success","subscriptions":[{"stream_id":1,"name":"First","pin_to_top":false},{"stream_id":2,"name":"Second","pin_to_top":false}]}`) + case "/api/v1/messages": + if requestedChannel(t, request) == "First" { + writeJSON(t, writer, `{"result":"success","messages":[{"timestamp":1710000000}]}`) + return + } + http.Error(writer, "unavailable", http.StatusServiceUnavailable) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want activity request error") + } + if conversations != nil { + t.Errorf("List() conversations = %v, want no partial data", conversations) + } +} + +func TestListFailsClosedForIncompleteActivityResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + writeJSON(t, writer, `{"result":"success","subscriptions":[{"stream_id":1,"name":"Missing messages","pin_to_top":false}]}`) + case "/api/v1/messages": + requestedChannel(t, request) + writeJSON(t, writer, `{"result":"success"}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want incomplete activity response error") + } + if conversations != nil { + t.Errorf("List() conversations = %v, want no partial data", conversations) + } +} + +func TestListRejectsNonPositiveStreamID(t *testing.T) { + for _, streamID := range []int64{0, -1} { + t.Run(fmt.Sprintf("stream ID %d", streamID), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + writeJSON(t, writer, fmt.Sprintf(`{"result":"success","subscriptions":[{"stream_id":%d,"name":"Invalid","pin_to_top":false}]}`, streamID)) + case "/api/v1/messages": + requestedChannel(t, request) + writeJSON(t, writer, `{"result":"success","messages":[{"timestamp":1710000000}]}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + conversations, err := newTestClient(t, server).List(context.Background()) + if err == nil { + t.Fatal("List() error = nil, want invalid stream ID error") + } + if conversations != nil { + t.Errorf("List() conversations = %v, want no partial data", conversations) + } + }) + } +} + +func TestLeaveSendsEncodedChannelName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodDelete { + t.Errorf("method = %s, want DELETE", request.Method) + } + if request.URL.Path != "/api/v1/users/me/subscriptions" { + t.Errorf("path = %s, want /api/v1/users/me/subscriptions", request.URL.Path) + } + if contentType := request.Header.Get("Content-Type"); contentType != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", contentType) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + if got := form.Get("subscriptions"); got != `["Denmark"]` { + t.Errorf("subscriptions = %q, want [\\\"Denmark\\\"]", got) + } + writeJSON(t, writer, `{"result":"success","removed":["Denmark"],"not_removed":[]}`) + })) + defer server.Close() + + conversation := testConversation(t, "12", "Denmark") + if err := newTestClient(t, server).Leave(context.Background(), conversation); err != nil { + t.Fatal(err) + } +} + +func TestRequestsUseBasicAuthentication(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + email, apiKey, ok := request.BasicAuth() + if !ok || email != "test@example.com" || apiKey != "test-api-key" { + t.Errorf("BasicAuth() = (%q, %q, %v), want test credentials", email, apiKey, ok) + } + switch request.URL.Path { + case "/api/v1/users/me/subscriptions": + writeJSON(t, writer, `{"result":"success","subscriptions":[{"stream_id":1,"name":"Authenticated","pin_to_top":false}]}`) + case "/api/v1/messages": + requestedChannel(t, request) + writeJSON(t, writer, `{"result":"success","messages":[]}`) + default: + writer.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + if _, err := newTestClient(t, server).List(context.Background()); err != nil { + t.Fatal(err) + } +} + +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + client, err := New(baseURL, "test@example.com", "test-api-key", server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} + +func requestedChannel(t *testing.T, request *http.Request) string { + t.Helper() + if request.Method != http.MethodGet { + t.Errorf("messages method = %s, want GET", request.Method) + } + query := request.URL.Query() + if got := query.Get("anchor"); got != "newest" { + t.Errorf("anchor = %q, want newest", got) + } + if got := query.Get("num_before"); got != "1" { + t.Errorf("num_before = %q, want 1", got) + } + if got := query.Get("num_after"); got != "0" { + t.Errorf("num_after = %q, want 0", got) + } + var narrow []struct { + Operator string `json:"operator"` + Operand string `json:"operand"` + } + if err := json.Unmarshal([]byte(query.Get("narrow")), &narrow); err != nil { + t.Fatalf("decode narrow = %v", err) + } + if len(narrow) != 1 || narrow[0].Operator != "channel" || narrow[0].Operand == "" { + t.Fatalf("narrow = %v, want a channel predicate", narrow) + } + return narrow[0].Operand +} + +func writeJSON(t *testing.T, writer http.ResponseWriter, body string) { + t.Helper() + writer.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(writer, body); err != nil { + t.Fatal(err) + } +} + +func testConversation(t *testing.T, id string, name string) domain.Conversation { + t.Helper() + conversation, err := domain.NewConversation(domain.ServiceZulip, id, name, domain.UnknownActivity(), domain.ProtectionNone) + if err != nil { + t.Fatal(err) + } + return conversation +} + +func conversationIDs(conversations []domain.Conversation) []string { + ids := make([]string, 0, len(conversations)) + for _, conversation := range conversations { + ids = append(ids, conversation.ID().Value()) + } + return ids +} + +func conversationNames(conversations []domain.Conversation) []string { + names := make([]string, 0, len(conversations)) + for _, conversation := range conversations { + names = append(names, conversation.Name()) + } + return names +} + +func TestNewRejectsInvalidConfiguration(t *testing.T) { + validURL, err := url.Parse("https://zulip.example.com") + if err != nil { + t.Fatal(err) + } + relativeURL, err := url.Parse("/api") + if err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + baseURL *url.URL + email string + apiKey string + httpClient *http.Client + }{ + {name: "nil URL", email: "a@example.com", apiKey: "key", httpClient: http.DefaultClient}, + {name: "relative URL", baseURL: relativeURL, email: "a@example.com", apiKey: "key", httpClient: http.DefaultClient}, + {name: "unsupported scheme", baseURL: &url.URL{Scheme: "ftp", Host: "zulip.example.com"}, email: "a@example.com", apiKey: "key", httpClient: http.DefaultClient}, + {name: "blank email", baseURL: validURL, apiKey: "key", httpClient: http.DefaultClient}, + {name: "blank API key", baseURL: validURL, email: "a@example.com", httpClient: http.DefaultClient}, + {name: "nil HTTP client", baseURL: validURL, email: "a@example.com", apiKey: "key"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := New(test.baseURL, test.email, test.apiKey, test.httpClient); err == nil { + t.Fatal("New() error = nil, want configuration error") + } + }) + } +} + +func TestLeaveReportsNotRemovedSubscription(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writeJSON(t, writer, `{"result":"success","removed":[],"not_removed":["Denmark"]}`) + })) + defer server.Close() + + err := newTestClient(t, server).Leave(context.Background(), testConversation(t, "12", "Denmark")) + if err == nil || !strings.Contains(err.Error(), "not removed") { + t.Fatalf("Leave() error = %v, want not removed error", err) + } +} diff --git a/internal/domain/conversation.go b/internal/domain/conversation.go new file mode 100644 index 0000000..cb6b9d7 --- /dev/null +++ b/internal/domain/conversation.go @@ -0,0 +1,113 @@ +package domain + +import ( + "fmt" + "time" +) + +type Protection string + +const ( + ProtectionNone Protection = "" + ProtectionGeneral Protection = "general" + ProtectionSticky Protection = "sticky" + ProtectionDirect Protection = "direct" + ProtectionMy Protection = "my" + ProtectionPinned Protection = "pinned" +) + +type Activity struct { + known bool + at time.Time +} + +func KnownActivity(at time.Time) (Activity, error) { + if at.IsZero() { + return Activity{}, fmt.Errorf("known activity timestamp must not be zero") + } + return Activity{known: true, at: at}, nil +} + +func UnknownActivity() Activity { + return Activity{} +} + +func (activity Activity) Known() bool { + return activity.known +} + +func (activity Activity) At() time.Time { + return activity.at +} + +type ConversationID struct { + service Service + value string +} + +func (id ConversationID) Service() Service { + return id.service +} + +func (id ConversationID) Value() string { + return id.value +} + +type Conversation struct { + service Service + id ConversationID + name string + activity Activity + protection Protection +} + +func NewConversation(service Service, id string, name string, activity Activity, protection Protection) (Conversation, error) { + if !isSupportedService(service) { + return Conversation{}, fmt.Errorf("unsupported service %q", service) + } + if id == "" { + return Conversation{}, fmt.Errorf("conversation ID must not be empty") + } + if name == "" { + return Conversation{}, fmt.Errorf("conversation name must not be empty") + } + if !isSupportedProtection(protection) { + return Conversation{}, fmt.Errorf("unsupported protection %q", protection) + } + return Conversation{ + service: service, + id: ConversationID{service: service, value: id}, + name: name, + activity: activity, + protection: protection, + }, nil +} + +func (conversation Conversation) Service() Service { + return conversation.service +} + +func (conversation Conversation) ID() ConversationID { + return conversation.id +} + +func (conversation Conversation) Name() string { + return conversation.name +} + +func (conversation Conversation) Activity() Activity { + return conversation.activity +} + +func (conversation Conversation) Protection() Protection { + return conversation.protection +} + +func isSupportedProtection(protection Protection) bool { + switch protection { + case ProtectionNone, ProtectionGeneral, ProtectionSticky, ProtectionDirect, ProtectionMy, ProtectionPinned: + return true + default: + return false + } +} diff --git a/internal/domain/decision.go b/internal/domain/decision.go new file mode 100644 index 0000000..c1336ef --- /dev/null +++ b/internal/domain/decision.go @@ -0,0 +1,52 @@ +package domain + +import "time" + +type DecisionReason string + +const ( + ReasonInactive DecisionReason = "inactive" + ReasonActive DecisionReason = "active" + ReasonUnknownActivity DecisionReason = "unknown activity" + ReasonServiceProtected DecisionReason = "service protected" + ReasonConfiguredProtected DecisionReason = "configured protected" +) + +type Decision struct { + conversation Conversation + eligible bool + reason DecisionReason +} + +func Evaluate(conversation Conversation, cutoff time.Time, protected map[ConversationID]struct{}) Decision { + if _, configured := protected[conversation.ID()]; configured { + return ineligibleDecision(conversation, ReasonConfiguredProtected) + } + if conversation.Protection() != ProtectionNone { + return ineligibleDecision(conversation, ReasonServiceProtected) + } + activity := conversation.Activity() + if !activity.Known() { + return ineligibleDecision(conversation, ReasonUnknownActivity) + } + if activity.At().Before(cutoff) { + return Decision{conversation: conversation, eligible: true, reason: ReasonInactive} + } + return ineligibleDecision(conversation, ReasonActive) +} + +func (decision Decision) Conversation() Conversation { + return decision.conversation +} + +func (decision Decision) Eligible() bool { + return decision.eligible +} + +func (decision Decision) Reason() DecisionReason { + return decision.reason +} + +func ineligibleDecision(conversation Conversation, reason DecisionReason) Decision { + return Decision{conversation: conversation, reason: reason} +} diff --git a/internal/domain/decision_test.go b/internal/domain/decision_test.go new file mode 100644 index 0000000..28594ad --- /dev/null +++ b/internal/domain/decision_test.go @@ -0,0 +1,138 @@ +package domain + +import ( + "testing" + "time" +) + +func TestEvaluate(t *testing.T) { + now := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + cutoff := now.AddDate(0, -1, 0) + old, _ := KnownActivity(cutoff.Add(-time.Second)) + recent, _ := KnownActivity(cutoff) + tests := []struct { + name string + activity Activity + protection Protection + configured bool + eligible bool + reason DecisionReason + }{ + {name: "old", activity: old, eligible: true, reason: ReasonInactive}, + {name: "cutoff is not old", activity: recent, reason: ReasonActive}, + {name: "unknown", activity: UnknownActivity(), reason: ReasonUnknownActivity}, + {name: "service protected", activity: old, protection: ProtectionGeneral, reason: ReasonServiceProtected}, + {name: "configured", activity: old, configured: true, reason: ReasonConfiguredProtected}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conversation, err := NewConversation(ServiceSlack, "C123", "general", tt.activity, tt.protection) + if err != nil { + t.Fatal(err) + } + protected := map[ConversationID]struct{}{} + if tt.configured { + protected[conversation.ID()] = struct{}{} + } + decision := Evaluate(conversation, cutoff, protected) + if decision.Eligible() != tt.eligible || decision.Reason() != tt.reason { + t.Fatalf("got eligible=%v reason=%q", decision.Eligible(), decision.Reason()) + } + }) + } +} + +func TestNewConversationRejectsEmptyID(t *testing.T) { + activity, err := KnownActivity(time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if _, err := NewConversation(ServiceSlack, "", "general", activity, ProtectionNone); err == nil { + t.Fatal("expected an empty ID to be rejected") + } +} + +func TestNewConversationRejectsEmptyName(t *testing.T) { + activity, err := KnownActivity(time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if _, err := NewConversation(ServiceSlack, "C123", "", activity, ProtectionNone); err == nil { + t.Fatal("expected an empty name to be rejected") + } +} + +func TestKnownActivityRejectsZeroTimestamp(t *testing.T) { + if _, err := KnownActivity(time.Time{}); err == nil { + t.Fatal("expected a zero timestamp to be rejected") + } +} + +func TestParseServiceRejectsUnsupportedValue(t *testing.T) { + if _, err := ParseService("discord"); err == nil { + t.Fatal("expected an unsupported service to be rejected") + } +} + +func TestParseServiceAcceptsSupportedValues(t *testing.T) { + tests := []struct { + input string + want Service + }{ + {input: "slack", want: ServiceSlack}, + {input: "chatwork", want: ServiceChatwork}, + {input: "zulip", want: ServiceZulip}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + service, err := ParseService(tt.input) + if err != nil { + t.Fatal(err) + } + if service != tt.want { + t.Fatalf("got %q, want %q", service, tt.want) + } + }) + } +} + +func TestNewConversationPreservesValues(t *testing.T) { + activityAt := time.Date(2026, time.September, 17, 12, 0, 0, 0, time.UTC) + activity, err := KnownActivity(activityAt) + if err != nil { + t.Fatal(err) + } + conversation, err := NewConversation(ServiceZulip, "stream-42", "general", activity, ProtectionPinned) + if err != nil { + t.Fatal(err) + } + if conversation.Service() != ServiceZulip || conversation.Name() != "general" || conversation.Protection() != ProtectionPinned { + t.Fatal("conversation did not preserve its values") + } + if conversation.ID().Service() != ServiceZulip || conversation.ID().Value() != "stream-42" { + t.Fatal("conversation ID did not preserve its service and value") + } + if !conversation.Activity().Known() || !conversation.Activity().At().Equal(activityAt) { + t.Fatal("conversation did not preserve its activity") + } +} + +func TestNewConversationRejectsUnsupportedProtection(t *testing.T) { + activity, err := KnownActivity(time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if _, err := NewConversation(ServiceSlack, "C123", "general", activity, Protection("custom")); err == nil { + t.Fatal("expected an unsupported protection to be rejected") + } +} + +func TestNewConversationRejectsUnsupportedService(t *testing.T) { + activity, err := KnownActivity(time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if _, err := NewConversation(Service("discord"), "C123", "general", activity, ProtectionNone); err == nil { + t.Fatal("expected an unsupported service to be rejected") + } +} diff --git a/internal/domain/service.go b/internal/domain/service.go new file mode 100644 index 0000000..5193f01 --- /dev/null +++ b/internal/domain/service.go @@ -0,0 +1,28 @@ +package domain + +import "fmt" + +type Service string + +const ( + ServiceSlack Service = "slack" + ServiceChatwork Service = "chatwork" + ServiceZulip Service = "zulip" +) + +func ParseService(value string) (Service, error) { + service := Service(value) + if !isSupportedService(service) { + return "", fmt.Errorf("unsupported service %q", value) + } + return service, nil +} + +func isSupportedService(service Service) bool { + switch service { + case ServiceSlack, ServiceChatwork, ServiceZulip: + return true + default: + return false + } +} diff --git a/internal/presentation/cli/run.go b/internal/presentation/cli/run.go new file mode 100644 index 0000000..be3ed27 --- /dev/null +++ b/internal/presentation/cli/run.go @@ -0,0 +1,195 @@ +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/chck/synr/internal/adapter/chatwork" + "github.com/chck/synr/internal/adapter/config" + "github.com/chck/synr/internal/adapter/slack" + "github.com/chck/synr/internal/adapter/zulip" + "github.com/chck/synr/internal/domain" + "github.com/chck/synr/internal/usecase" +) + +const help = `Usage: synr scan --service [--before-months N] [--apply] + +Preview inactive conversations. Only --apply leaves eligible conversations. + --service Required service: slack, chatwork, or zulip + --before-months Positive number of months (default 1) + --apply Leave eligible conversations; stop at the first failure + --help Show this help without loading credentials +` + +type ProviderFactory func(domain.Service, config.Credentials, *http.Client) (usecase.Provider, error) + +type Dependencies struct { + Getenv func(string) string + ConfigPath string + HTTPClient *http.Client + Clock usecase.Clock + NewProvider ProviderFactory +} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } + +func DefaultDependencies() Dependencies { + return Dependencies{ + Getenv: os.Getenv, + ConfigPath: "", // config.Load resolves the XDG/default path at use time. + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Clock: realClock{}, + NewProvider: newProvider, + } +} + +func Run(ctx context.Context, args []string, stdout, stderr io.Writer, dependencies Dependencies) int { + if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { + return writeOutput(stdout, stderr, help) + } + if len(args) == 0 || args[0] != "scan" { + fmt.Fprintln(stderr, "expected scan subcommand; run synr --help for usage") + return 2 + } + + flags := flag.NewFlagSet("scan", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.Usage = func() {} // Run writes help to the injected stdout on ErrHelp. + serviceName := flags.String("service", "", "service to scan") + beforeMonths := flags.Int("before-months", 1, "positive number of months") + apply := flags.Bool("apply", false, "leave eligible conversations") + if err := flags.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return writeOutput(stdout, stderr, help) + } + fmt.Fprintln(stderr, "run synr scan --help for usage") + return 2 + } + if flags.NArg() != 0 { + fmt.Fprintln(stderr, "unexpected positional arguments; run synr scan --help for usage") + return 2 + } + service, err := domain.ParseService(*serviceName) + if err != nil { + fmt.Fprintln(stderr, "--service must be slack, chatwork, or zulip; run synr scan --help for usage") + return 2 + } + if *beforeMonths <= 0 { + fmt.Fprintln(stderr, "--before-months must be a positive integer") + return 2 + } + + configuration, err := config.Load(dependencies.ConfigPath) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + credentials, err := config.CredentialsFor(service, dependencies.Getenv) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + provider, err := dependencies.NewProvider(service, credentials, dependencies.HTTPClient) + if err != nil { + fmt.Fprintln(stderr, redact(err.Error(), credentials)) + return 2 + } + + result, scanErr := usecase.Scan(ctx, provider, usecase.Request{ + BeforeMonths: *beforeMonths, + Apply: *apply, + ProtectedIDs: configuration.ProtectedIDs(service), + }, dependencies.Clock) + if result.Decisions != nil { + if code := writeOutput(stdout, stderr, render(result, *apply)); code != 0 { + return code + } + } + if scanErr != nil { + fmt.Fprintln(stderr, redact(scanErr.Error(), credentials)) + return 1 + } + return 0 +} + +func newProvider(service domain.Service, credentials config.Credentials, httpClient *http.Client) (usecase.Provider, error) { + switch service { + case domain.ServiceSlack: + return slack.New(credentials.Token, httpClient, "https://slack.com/api/") + case domain.ServiceChatwork: + return chatwork.New(credentials.Token, &url.URL{Scheme: "https", Host: "api.chatwork.com", Path: "/v2/"}, httpClient) + case domain.ServiceZulip: + baseURL, err := url.Parse(credentials.URL) + if err != nil { + return nil, fmt.Errorf("invalid SYNR_ZULIP_URL; set an absolute HTTP(S) URL") + } + return zulip.New(baseURL, credentials.Email, credentials.APIKey, httpClient) + default: + return nil, fmt.Errorf("unsupported service; select slack, chatwork, or zulip") + } +} + +func render(result usecase.Result, apply bool) string { + var output strings.Builder + output.WriteString("service\tname\tid\tactivity\tdecision\treason\n") + for _, decision := range result.Decisions { + conversation := decision.Conversation() + activity := "unknown" + if conversation.Activity().Known() { + activity = conversation.Activity().At().Format(time.RFC3339) + } + status := "skip" + if decision.Eligible() { + status = "eligible" + } + fmt.Fprintf(&output, "%s\t%s\t%s\t%s\t%s\t%s\n", conversation.Service(), cell(conversation.Name()), cell(conversation.ID().Value()), activity, status, decision.Reason()) + } + if apply { + output.WriteString("result\tservice\tname\tid\n") + for _, conversation := range result.Left { + renderApplied(&output, "left", conversation) + } + if result.Failed != nil { + renderApplied(&output, "failed", *result.Failed) + } + for _, conversation := range result.NotAttempted { + renderApplied(&output, "not attempted", conversation) + } + } + return output.String() +} + +func renderApplied(output *strings.Builder, status string, conversation domain.Conversation) { + fmt.Fprintf(output, "%s\t%s\t%s\t%s\n", status, conversation.Service(), cell(conversation.Name()), cell(conversation.ID().Value())) +} + +func cell(value string) string { + return strings.NewReplacer("\\", "\\\\", "\t", "\\t", "\n", "\\n", "\r", "\\r").Replace(value) +} + +func redact(message string, credentials config.Credentials) string { + for _, value := range []string{credentials.Token, credentials.APIKey, credentials.Email, credentials.URL} { + if value != "" { + message = strings.ReplaceAll(message, value, "[redacted]") + } + } + return message +} + +func writeOutput(stdout, stderr io.Writer, output string) int { + if _, err := io.WriteString(stdout, output); err != nil { + fmt.Fprintf(stderr, "write output: %v; check the output destination\n", err) + return 1 + } + return 0 +} diff --git a/internal/presentation/cli/run_test.go b/internal/presentation/cli/run_test.go new file mode 100644 index 0000000..215d86f --- /dev/null +++ b/internal/presentation/cli/run_test.go @@ -0,0 +1,338 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/chck/synr/internal/adapter/config" + "github.com/chck/synr/internal/domain" + "github.com/chck/synr/internal/usecase" +) + +type fixedClock struct{} + +func (fixedClock) Now() time.Time { + return time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) +} + +type recordingProvider struct { + conversations []domain.Conversation + left []string + listErr error + failID string +} + +func (provider *recordingProvider) List(context.Context) ([]domain.Conversation, error) { + return provider.conversations, provider.listErr +} + +func (provider *recordingProvider) Leave(_ context.Context, conversation domain.Conversation) error { + provider.left = append(provider.left, conversation.ID().Value()) + if conversation.ID().Value() == provider.failID { + return errors.New("permission denied") + } + return nil +} + +func testDependencies(t *testing.T, provider usecase.Provider) Dependencies { + t.Helper() + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + return Dependencies{ + Getenv: func(key string) string { + if key == "SYNR_SLACK_TOKEN" { + return "test-token" + } + return "" + }, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Clock: fixedClock{}, + NewProvider: func(domain.Service, config.Credentials, *http.Client) (usecase.Provider, error) { + return provider, nil + }, + } +} + +func conversation(t *testing.T, name string, id string, timestamp string) domain.Conversation { + t.Helper() + activity := domain.UnknownActivity() + if timestamp != "" { + at, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + t.Fatal(err) + } + activity, err = domain.KnownActivity(at) + if err != nil { + t.Fatal(err) + } + } + value, err := domain.NewConversation(domain.ServiceSlack, id, name, activity, domain.ProtectionNone) + if err != nil { + t.Fatal(err) + } + return value +} + +func TestRunShowsHelpWithoutCredentials(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"-h"}, {"scan", "--help"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), args, &stdout, &stderr, Dependencies{}); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + for _, want := range []string{"scan", "--service", "--before-months", "--apply"} { + if !strings.Contains(stdout.String(), want) { + t.Errorf("help missing %q: %s", want, &stdout) + } + } + if stderr.Len() != 0 { + t.Errorf("help stderr = %s", &stderr) + } + }) + } +} + +func assertUsageError(t *testing.T, args []string) { + t.Helper() + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), args, &stdout, &stderr, Dependencies{}); code != 2 { + t.Fatalf("exit = %d, want 2; stderr = %s", code, &stderr) + } + if stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatalf("stdout = %q, stderr = %q", &stdout, &stderr) + } +} + +func TestRunRequiresScanSubcommand(t *testing.T) { + for _, args := range [][]string{nil, {"slack"}, {"--service", "slack"}, {"scan", "extra", "--service", "slack"}, {"scan", "--service", "slack", "--unknown"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { assertUsageError(t, args) }) + } +} + +func TestRunRequiresSupportedService(t *testing.T) { + for _, args := range [][]string{{"scan"}, {"scan", "--service", "discord"}, {"scan", "--service"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { assertUsageError(t, args) }) + } +} + +func TestRunRejectsNonPositiveBeforeMonths(t *testing.T) { + for _, value := range []string{"0", "-1", "invalid"} { + t.Run(value, func(t *testing.T) { + assertUsageError(t, []string{"scan", "--service", "slack", "--before-months", value}) + }) + } +} + +func TestRunPreviewNeverLeaves(t *testing.T) { + provider := &recordingProvider{conversations: []domain.Conversation{ + conversation(t, "unknown", "C3", ""), + conversation(t, "same", "C2", "2026-08-18T00:00:00Z"), + conversation(t, "same", "C1", "2026-08-17T23:59:59Z"), + }} + dependencies := testDependencies(t, provider) + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), []string{"scan", "--service", "slack"}, &stdout, &stderr, dependencies); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + want := "service\tname\tid\tactivity\tdecision\treason\n" + + "slack\tsame\tC1\t2026-08-17T23:59:59Z\teligible\tinactive\n" + + "slack\tsame\tC2\t2026-08-18T00:00:00Z\tskip\tactive\n" + + "slack\tunknown\tC3\tunknown\tskip\tunknown activity\n" + if stdout.String() != want { + t.Errorf("output = %q, want %q", &stdout, want) + } + if len(provider.left) != 0 { + t.Errorf("preview left %v", provider.left) + } +} + +func TestRunApplyPrintsPartialFailureSummary(t *testing.T) { + provider := &recordingProvider{failID: "C2", conversations: []domain.Conversation{ + conversation(t, "third", "C3", "2026-01-01T00:00:00Z"), + conversation(t, "first", "C1", "2026-01-01T00:00:00Z"), + conversation(t, "second", "C2", "2026-01-01T00:00:00Z"), + }} + dependencies := testDependencies(t, provider) + var stdout, stderr bytes.Buffer + code := Run(context.Background(), []string{"scan", "--service", "slack", "--apply"}, &stdout, &stderr, dependencies) + if code != 1 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + wantSuffix := "result\tservice\tname\tid\nleft\tslack\tfirst\tC1\nfailed\tslack\tsecond\tC2\nnot attempted\tslack\tthird\tC3\n" + if !strings.HasSuffix(stdout.String(), wantSuffix) { + t.Errorf("missing partial summary: %s", &stdout) + } + if strings.Count(stdout.String(), "\teligible\tinactive\n") != 3 { + t.Errorf("missing decisions: %s", &stdout) + } + if !strings.Contains(stderr.String(), "permission denied") { + t.Errorf("missing failure: %s", &stderr) + } + if !reflect.DeepEqual(provider.left, []string{"C1", "C2"}) { + t.Errorf("leave attempts = %v", provider.left) + } +} + +func TestRunLoadsOnlySelectedServiceCredentials(t *testing.T) { + for _, service := range []domain.Service{domain.ServiceSlack, domain.ServiceChatwork, domain.ServiceZulip} { + t.Run(string(service), func(t *testing.T) { + provider := &recordingProvider{} + dependencies := testDependencies(t, provider) + values := map[string]string{} + wantCredentials := config.Credentials{Token: "selected-token"} + switch service { + case domain.ServiceSlack: + values["SYNR_SLACK_TOKEN"] = "selected-token" + case domain.ServiceChatwork: + values["SYNR_CHATWORK_TOKEN"] = "selected-token" + case domain.ServiceZulip: + values = map[string]string{"SYNR_ZULIP_URL": "https://zulip.example", "SYNR_ZULIP_EMAIL": "user@example.com", "SYNR_ZULIP_API_KEY": "selected-key"} + wantCredentials = config.Credentials{URL: "https://zulip.example", Email: "user@example.com", APIKey: "selected-key"} + } + dependencies.Getenv = func(key string) string { + value, ok := values[key] + if !ok { + t.Fatalf("read unselected credential %s", key) + } + return value + } + dependencies.NewProvider = func(got domain.Service, credentials config.Credentials, client *http.Client) (usecase.Provider, error) { + if got != service || credentials != wantCredentials || client != dependencies.HTTPClient { + t.Fatal("incorrect provider dependencies") + } + return provider, nil + } + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), []string{"scan", "--service", string(service)}, &stdout, &stderr, dependencies); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + }) + } +} + +func TestRunLoadsProtectedIDsFromDefaultConfig(t *testing.T) { + provider := &recordingProvider{conversations: []domain.Conversation{conversation(t, "protected", "C1", "2026-01-01T00:00:00Z")}} + dependencies := testDependencies(t, provider) + path := filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "synr", "config.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("services:\n slack:\n protected_channels: [C1]\n"), 0600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), []string{"scan", "--service", "slack", "--apply"}, &stdout, &stderr, dependencies); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + if !strings.Contains(stdout.String(), "\tskip\tconfigured protected\n") || len(provider.left) != 0 { + t.Fatalf("protected channel mishandled: %s; left %v", &stdout, provider.left) + } +} + +func TestRunConfigurationErrors(t *testing.T) { + for _, scenario := range []string{"credentials", "config", "provider"} { + t.Run(scenario, func(t *testing.T) { + dependencies := testDependencies(t, &recordingProvider{}) + switch scenario { + case "credentials": + dependencies.Getenv = func(string) string { return "" } + case "config": + dependencies.ConfigPath = filepath.Join(t.TempDir(), "invalid.yaml") + if err := os.WriteFile(dependencies.ConfigPath, []byte("unknown: value\n"), 0600); err != nil { + t.Fatal(err) + } + case "provider": + dependencies.NewProvider = func(domain.Service, config.Credentials, *http.Client) (usecase.Provider, error) { + return nil, errors.New("invalid provider settings") + } + } + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), []string{"scan", "--service", "slack"}, &stdout, &stderr, dependencies); code != 2 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + if stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatalf("stdout = %s, stderr = %s", &stdout, &stderr) + } + }) + } +} + +func TestRunListingFailureDoesNotClaimSuccessOrExposeCredentials(t *testing.T) { + provider := &recordingProvider{listErr: errors.New("request failed with test-token")} + dependencies := testDependencies(t, provider) + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), []string{"scan", "--service", "slack", "--apply"}, &stdout, &stderr, dependencies); code != 1 { + t.Fatalf("exit = %d", code) + } + if stdout.Len() != 0 || strings.Contains(stderr.String(), "test-token") || !strings.Contains(stderr.String(), "request failed") { + t.Fatalf("stdout = %q, stderr = %q", &stdout, &stderr) + } + if len(provider.left) != 0 { + t.Fatalf("left after listing failure: %v", provider.left) + } +} + +type fixtureTransport func(*http.Request) (*http.Response, error) + +func (transport fixtureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport(request) +} + +func TestDefaultDependenciesBuildSelectedAdapters(t *testing.T) { + for _, service := range []string{"slack", "chatwork", "zulip"} { + t.Run(service, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("SYNR_SLACK_TOKEN", "slack-token") + t.Setenv("SYNR_CHATWORK_TOKEN", "chatwork-token") + t.Setenv("SYNR_ZULIP_URL", "https://zulip.example") + t.Setenv("SYNR_ZULIP_EMAIL", "user@example.com") + t.Setenv("SYNR_ZULIP_API_KEY", "zulip-key") + dependencies := DefaultDependencies() + if dependencies.HTTPClient.Timeout != 30*time.Second { + t.Fatalf("timeout = %v", dependencies.HTTPClient.Timeout) + } + if delta := time.Since(dependencies.Clock.Now()); delta < -time.Second || delta > time.Second { + t.Fatalf("clock delta = %v", delta) + } + dependencies.HTTPClient.Transport = fixtureTransport(func(request *http.Request) (*http.Response, error) { + body := "" + switch service { + case "slack": + if err := request.ParseForm(); err != nil { + return nil, err + } + if request.URL.String() != "https://slack.com/api/conversations.list" || request.Form.Get("token") != "slack-token" { + return nil, fmt.Errorf("incorrect Slack request") + } + body = `{"ok":true,"channels":[],"response_metadata":{"next_cursor":""}}` + case "chatwork": + if request.URL.String() != "https://api.chatwork.com/v2/rooms" || request.Header.Get("X-ChatWorkToken") != "chatwork-token" { + return nil, fmt.Errorf("incorrect Chatwork request") + } + body = `[]` + case "zulip": + email, key, ok := request.BasicAuth() + if request.URL.String() != "https://zulip.example/api/v1/users/me/subscriptions" || !ok || email != "user@example.com" || key != "zulip-key" { + return nil, fmt.Errorf("incorrect Zulip request") + } + body = `{"result":"success","subscriptions":[]}` + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + }) + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), []string{"scan", "--service", service}, &stdout, &stderr, dependencies); code != 0 { + t.Fatalf("exit = %d, stderr = %s", code, &stderr) + } + }) + } +} diff --git a/internal/usecase/scan.go b/internal/usecase/scan.go new file mode 100644 index 0000000..8db21da --- /dev/null +++ b/internal/usecase/scan.go @@ -0,0 +1,90 @@ +package usecase + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/chck/synr/internal/domain" +) + +type Clock interface { + Now() time.Time +} + +type Provider interface { + List(context.Context) ([]domain.Conversation, error) + Leave(context.Context, domain.Conversation) error +} + +type Request struct { + BeforeMonths int + Apply bool + ProtectedIDs map[domain.ConversationID]struct{} +} + +type Result struct { + Decisions []domain.Decision + Left []domain.Conversation + Failed *domain.Conversation + NotAttempted []domain.Conversation +} + +func Scan(ctx context.Context, provider Provider, request Request, clock Clock) (Result, error) { + if request.BeforeMonths <= 0 { + return Result{}, fmt.Errorf("before months must be greater than zero") + } + + conversations, err := provider.List(ctx) + if err != nil { + return Result{}, fmt.Errorf("list conversations: %w", err) + } + + cutoff := clock.Now().AddDate(0, -request.BeforeMonths, 0) + decisions := make([]domain.Decision, 0, len(conversations)) + for _, conversation := range conversations { + decisions = append(decisions, domain.Evaluate(conversation, cutoff, request.ProtectedIDs)) + } + sort.Slice(decisions, func(left int, right int) bool { + leftConversation := decisions[left].Conversation() + rightConversation := decisions[right].Conversation() + if leftConversation.Name() != rightConversation.Name() { + return leftConversation.Name() < rightConversation.Name() + } + if leftConversation.ID().Value() != rightConversation.ID().Value() { + return leftConversation.ID().Value() < rightConversation.ID().Value() + } + return leftConversation.ID().Service() < rightConversation.ID().Service() + }) + + result := Result{Decisions: decisions} + if !request.Apply { + return result, nil + } + + for index, decision := range decisions { + if !decision.Eligible() { + continue + } + conversation := decision.Conversation() + if err := provider.Leave(ctx, conversation); err != nil { + result.Failed = &conversation + result.NotAttempted = eligibleConversations(decisions[index+1:]) + return result, fmt.Errorf("leave %s conversation %s: %w", conversation.Service(), conversation.ID().Value(), err) + } + result.Left = append(result.Left, conversation) + } + + return result, nil +} + +func eligibleConversations(decisions []domain.Decision) []domain.Conversation { + conversations := make([]domain.Conversation, 0, len(decisions)) + for _, decision := range decisions { + if decision.Eligible() { + conversations = append(conversations, decision.Conversation()) + } + } + return conversations +} diff --git a/internal/usecase/scan_test.go b/internal/usecase/scan_test.go new file mode 100644 index 0000000..e771069 --- /dev/null +++ b/internal/usecase/scan_test.go @@ -0,0 +1,225 @@ +package usecase + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/chck/synr/internal/domain" +) + +var scanNow = time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + +type fixedClock struct { + now time.Time +} + +func (clock fixedClock) Now() time.Time { + return clock.now +} + +type recordingProvider struct { + conversations []domain.Conversation + listErr error + leaveErrs map[domain.ConversationID]error + leaveCalls []domain.Conversation + listCalls int +} + +func (provider *recordingProvider) List(context.Context) ([]domain.Conversation, error) { + provider.listCalls++ + if provider.listErr != nil { + return nil, provider.listErr + } + return provider.conversations, nil +} + +func (provider *recordingProvider) Leave(_ context.Context, conversation domain.Conversation) error { + provider.leaveCalls = append(provider.leaveCalls, conversation) + return provider.leaveErrs[conversation.ID()] +} + +func TestScanDoesNotLeaveInPreview(t *testing.T) { + provider := &recordingProvider{conversations: []domain.Conversation{oldConversation(t, "alpha", "alpha")}} + + result, err := Scan(context.Background(), provider, Request{BeforeMonths: 1}, fixedClock{now: scanNow}) + + if err != nil { + t.Fatal(err) + } + if provider.listCalls != 1 { + t.Fatalf("got %d list calls, want 1", provider.listCalls) + } + if len(provider.leaveCalls) != 0 { + t.Fatalf("got leave calls %v, want none", conversationNames(provider.leaveCalls)) + } + if len(result.Decisions) != 1 || !result.Decisions[0].Eligible() { + t.Fatalf("got decisions %#v, want one eligible decision", result.Decisions) + } + if len(result.Left) != 0 || result.Failed != nil || len(result.NotAttempted) != 0 { + t.Fatalf("got preview result %#v, want no apply outcomes", result) + } +} + +func TestScanDoesNotLeaveWhenListFails(t *testing.T) { + listErr := errors.New("provider unavailable") + provider := &recordingProvider{listErr: listErr} + + result, err := Scan(context.Background(), provider, Request{BeforeMonths: 1, Apply: true}, fixedClock{now: scanNow}) + + if !errors.Is(err, listErr) { + t.Fatalf("got error %v, want wrapped %v", err, listErr) + } + if err == nil || err.Error() != "list conversations: provider unavailable" { + t.Fatalf("got error %v, want list context", err) + } + if provider.listCalls != 1 { + t.Fatalf("got %d list calls, want 1", provider.listCalls) + } + if len(provider.leaveCalls) != 0 { + t.Fatalf("got leave calls %v, want none", conversationNames(provider.leaveCalls)) + } + if len(result.Decisions) != 0 || len(result.Left) != 0 || result.Failed != nil || len(result.NotAttempted) != 0 { + t.Fatalf("got result %#v, want empty result", result) + } +} + +func TestScanSortsDecisionsByNameThenID(t *testing.T) { + provider := &recordingProvider{conversations: []domain.Conversation{ + oldConversation(t, "3", "beta"), + oldConversation(t, "2", "alpha"), + oldConversation(t, "1", "alpha"), + }} + + result, err := Scan(context.Background(), provider, Request{BeforeMonths: 1}, fixedClock{now: scanNow}) + + if err != nil { + t.Fatal(err) + } + if got, want := decisionIDs(result.Decisions), []string{"1", "2", "3"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got decision IDs %v, want %v", got, want) + } +} + +func TestScanAppliesOnlyEligibleConversations(t *testing.T) { + old := oldConversation(t, "old", "old") + active := conversation(t, "active", "active", scanNow, domain.ProtectionNone) + unknown := unknownConversation(t, "unknown", "unknown") + protected := oldConversation(t, "protected", "protected") + provider := &recordingProvider{conversations: []domain.Conversation{old, active, unknown, protected}} + + result, err := Scan(context.Background(), provider, Request{ + BeforeMonths: 1, + Apply: true, + ProtectedIDs: map[domain.ConversationID]struct{}{protected.ID(): {}}, + }, fixedClock{now: scanNow}) + + if err != nil { + t.Fatal(err) + } + if got, want := conversationNames(provider.leaveCalls), []string{"old"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got leave calls %v, want %v", got, want) + } + if got, want := conversationNames(result.Left), []string{"old"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got left %v, want %v", got, want) + } + if result.Failed != nil || len(result.NotAttempted) != 0 { + t.Fatalf("got apply result %#v, want no failure", result) + } +} + +func TestScanStopsAfterFirstLeaveFailure(t *testing.T) { + alpha := oldConversation(t, "alpha", "alpha") + beta := oldConversation(t, "beta", "beta") + gamma := oldConversation(t, "gamma", "gamma") + leaveErr := errors.New("permission denied") + provider := &recordingProvider{ + conversations: []domain.Conversation{gamma, beta, alpha}, + leaveErrs: map[domain.ConversationID]error{ + beta.ID(): leaveErr, + }, + } + + result, err := Scan(context.Background(), provider, Request{BeforeMonths: 1, Apply: true}, fixedClock{now: scanNow}) + + if !errors.Is(err, leaveErr) { + t.Fatalf("got error %v, want wrapped %v", err, leaveErr) + } + if err == nil || err.Error() != "leave slack conversation beta: permission denied" { + t.Fatalf("got error %v, want leave context", err) + } + if got, want := conversationNames(provider.leaveCalls), []string{"alpha", "beta"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got leave calls %v, want %v", got, want) + } + if got, want := conversationNames(result.Left), []string{"alpha"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got left %v, want %v", got, want) + } + if result.Failed == nil || result.Failed.Name() != "beta" { + t.Fatalf("got failed %#v, want beta", result.Failed) + } + if got, want := conversationNames(result.NotAttempted), []string{"gamma"}; !reflect.DeepEqual(got, want) { + t.Fatalf("got not attempted %v, want %v", got, want) + } +} + +func TestScanRejectsNonPositiveMonths(t *testing.T) { + for _, beforeMonths := range []int{0, -1} { + t.Run("before months", func(t *testing.T) { + provider := &recordingProvider{} + + _, err := Scan(context.Background(), provider, Request{BeforeMonths: beforeMonths}, fixedClock{now: scanNow}) + + if err == nil || err.Error() != "before months must be greater than zero" { + t.Fatalf("got error %v, want non-positive month validation", err) + } + if provider.listCalls != 0 { + t.Fatalf("got %d list calls, want 0", provider.listCalls) + } + }) + } +} + +func oldConversation(t *testing.T, id string, name string) domain.Conversation { + t.Helper() + return conversation(t, id, name, scanNow.AddDate(0, -2, 0), domain.ProtectionNone) +} + +func unknownConversation(t *testing.T, id string, name string) domain.Conversation { + t.Helper() + conversation, err := domain.NewConversation(domain.ServiceSlack, id, name, domain.UnknownActivity(), domain.ProtectionNone) + if err != nil { + t.Fatal(err) + } + return conversation +} + +func conversation(t *testing.T, id string, name string, at time.Time, protection domain.Protection) domain.Conversation { + t.Helper() + activity, err := domain.KnownActivity(at) + if err != nil { + t.Fatal(err) + } + conversation, err := domain.NewConversation(domain.ServiceSlack, id, name, activity, protection) + if err != nil { + t.Fatal(err) + } + return conversation +} + +func decisionIDs(decisions []domain.Decision) []string { + ids := make([]string, 0, len(decisions)) + for _, decision := range decisions { + ids = append(ids, decision.Conversation().ID().Value()) + } + return ids +} + +func conversationNames(conversations []domain.Conversation) []string { + names := make([]string, 0, len(conversations)) + for _, conversation := range conversations { + names = append(names, conversation.Name()) + } + return names +} diff --git a/main.go b/main.go deleted file mode 100644 index 550a895..0000000 --- a/main.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "log" - "os" - - "github.com/chck/synr/chatwork" - "github.com/chck/synr/slack" - - "github.com/chck/synr/config" - flags "github.com/jessevdk/go-flags" -) - -type options struct { - ChatName string `short:"c" long:"chatname" description:"A chat name you'd like to leave"` - DryRun bool `short:"d" long:"dry-run" description:"Pre-running to leave unnessesary chat rooms"` - BeforeMonth int `short:"m" long:"before-month" description:"Set X month elapsed when Last of talking date to leave: DEFAULT 1 MONTH AGO"` -} - -func cmdOpts() *options { - opts := &options{} - parser := flags.NewParser(opts, flags.PrintErrors) - parser.Name = "synr" - parser.Usage = "-c slack" - args, _ := parser.Parse() - - if len(args) != 0 || opts.ChatName == "" { - parser.WriteHelp(os.Stdout) - os.Exit(1) - } - - return opts -} - -func main() { - log.Println("Erase your past glory...") - log.Println("++++++++++++++++++++++++") - tokens := config.Load().Tokens - - opts := cmdOpts() - - switch opts.ChatName { - case "chatwork": - client := chatwork.New(tokens.Chatwork) - rooms, _ := client.GetRooms() - for _, room := range rooms { - chatwork.MayBeLeaveRoom(opts.DryRun, opts.BeforeMonth, client, &room) - } - case "slack": - client := slack.New(tokens.Slack) - channels, _ := client.GetChannels(false) - starredIDs := slack.StarredChannelIDs(client) - for _, channel := range channels { - slack.MayBeLeaveChannel(opts.DryRun, opts.BeforeMonth, client, channel, starredIDs) - } - } - log.Println("++++++++++++++++++++++++") - log.Println("Done!!") -} diff --git a/slack/slack.go b/slack/slack.go deleted file mode 100644 index 214e076..0000000 --- a/slack/slack.go +++ /dev/null @@ -1,86 +0,0 @@ -package slack - -import ( - "log" - "strconv" - "strings" - "time" - - "github.com/nlopes/slack" -) - -func New(token string) *slack.Client { - return slack.New(token) -} - -func StarredChannelIDs(client *slack.Client) []string { - var stars []string - //todo: equipment of the second return value for paging - items, _, _ := client.GetStarred(slack.NewStarsParameters()) - for _, star := range items { - switch star.Type { - case slack.TYPE_CHANNEL: - stars = append(stars, star.Channel) - } - } - return stars -} - -func MayBeLeaveChannel(canDryRun bool, beforeMonth int, client *slack.Client, channel slack.Channel, starredIDs []string) { - if channel.IsMember { - channelInfo, _ := client.GetChannelInfo(channel.ID) - canLeave := canLeave(channelInfo, starredIDs, beforeMonth) - if canLeave { - log.Println(channel.Name, lastRead(channelInfo), canLeave) - if !canDryRun { - client.LeaveChannel(channel.ID) - } - } - } -} - -// condition of leaving channel -// 1. last of talking date is more than X month elapsed -// 2. however this shall not apply when its channel is added star -func canLeave(channel *slack.Channel, starredIDs []string, beforeMonth int) bool { - switch { - case channel == nil: - return false - case includes(channel.ID, starredIDs): - return false - case beforeLastRead(channel, beforeMonth): - return true - default: - return false - } -} - -func includes(a string, list []string) bool { - for _, b := range list { - if b == a { - return true - } - } - return false -} - -func toTime(lastReadUnix string) (time.Time, error) { - unixStr := strings.Split(lastReadUnix, ".")[0] - unix, error := strconv.ParseInt(unixStr, 10, 64) - return time.Unix(unix, 0), error -} - -func beforeLastRead(channel *slack.Channel, beforeMonth int) bool { - if 0 >= beforeMonth { - beforeMonth = 1 - } - return lastRead(channel).Before(time.Now().AddDate(0, beforeMonth*-1, 0)) -} - -func lastRead(channel *slack.Channel) time.Time { - if channel != nil { - lastRead, _ := toTime(channel.LastRead) - return lastRead - } - return time.Now() -} diff --git a/slack_test.go b/slack_test.go deleted file mode 100644 index bba7358..0000000 --- a/slack_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package main_test - -import ( - "fmt" - - "github.com/chck/synr/config" - "github.com/nlopes/slack" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -var _ = Describe("Slack", func() { - var ( - client *slack.Client - ) - - BeforeEach(func() { - client = slack.New(config.Load().Tokens.Slack) - }) - - Describe("Getting stars", func() { - Context("by default parameters", func() { - It("should be present", func() { - stars, _, _ := client.ListStars(slack.NewStarsParameters()) - items, _, _ := client.GetStarred(slack.NewStarsParameters()) - for _, stars := range items { - fmt.Println(stars.Channel) - } - Expect(stars).NotTo(BeEmpty()) - }) - }) - }) -}) diff --git a/synr_suite_test.go b/synr_suite_test.go deleted file mode 100644 index 5f7dbed..0000000 --- a/synr_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main_test - -import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "testing" -) - -func TestSynr(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Synr Suite") -}