Decode the API's actual fields in neoirc-cli (closes #146)
check / check (push) Waiting to run

The CLI read the poll cursor from `lastId` while the server sends
`last_id`, so the cursor stayed at 0 and every poll returned the
whole queue at once. It now reads `last_id`.

/names decodes the member objects the server sends and prints each
nick with `@` for an operator or `+` for a voiced member. /list
prints each channel's name and topic, since the server sends no
member count. StateResponse.Channels decodes channel objects.

/topic with no text prints a usage line and sends nothing, because
the HTTP API has no topic query and a TOPIC without text gets 461.
The help says "Set topic".

The client tests serve the server's real response shapes.

Model: opus-5-5
This commit is contained in:
2026-10-20 23:23:21 +00:00
parent bbdf3a70af
commit 748709afbf
4 changed files with 223 additions and 27 deletions
+2 -2
View File
@@ -250,7 +250,7 @@ func (client *Client) ListChannels() (
// GetMembers returns members of a channel.
func (client *Client) GetMembers(
channel string,
) ([]string, error) {
) ([]Member, error) {
name := strings.TrimPrefix(channel, "#")
data, err := client.do(
@@ -263,7 +263,7 @@ func (client *Client) GetMembers(
return nil, err
}
var members []string
var members []Member
err = json.Unmarshal(data, &members)
if err != nil {
+180 -3
View File
@@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"testing"
@@ -56,9 +58,14 @@ func newSessionServer(t *testing.T) *httptest.Server {
return
}
_, _ = io.WriteString(
writer, `{"id":1,"nick":"alice","channels":[]}`,
)
_, _ = io.WriteString(writer, `{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}`)
})
server := httptest.NewServer(mux)
@@ -67,6 +74,176 @@ func newSessionServer(t *testing.T) *httptest.Server {
return server
}
// newClientForPath starts a server that answers GET path with
// body, and returns a client for it.
func newClientForPath(t *testing.T, path, body string) *api.Client {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET "+path, func(
writer http.ResponseWriter, _ *http.Request,
) {
_, _ = io.WriteString(writer, body)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return api.NewClient(server.URL)
}
func TestGetStateDecodesChannels(t *testing.T) {
t.Parallel()
client := api.NewClient(newSessionServer(t).URL)
_, err := client.CreateSession("alice")
if err != nil {
t.Fatalf("create session: %v", err)
}
state, err := client.GetState()
if err != nil {
t.Fatalf("state: %v", err)
}
want := []api.Channel{
{Name: "#general", Topic: "Welcome!"},
{Name: "#dev", Topic: ""},
}
if !slices.Equal(state.Channels, want) {
t.Fatalf("channels: got %+v, want %+v", state.Channels, want)
}
}
func TestListChannelsDecodesNamesAndTopics(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels", `[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]`)
channels, err := client.ListChannels()
if err != nil {
t.Fatalf("list channels: %v", err)
}
want := []api.Channel{
{Name: "#general", Topic: "Welcome!"},
{Name: "#dev", Topic: "Development discussion"},
}
if !slices.Equal(channels, want) {
t.Fatalf("channels: got %+v, want %+v", channels, want)
}
}
func TestGetMembersDecodesMemberObjects(t *testing.T) {
t.Parallel()
client := newClientForPath(t, "/api/v1/channels/general/members", `[
{
"id": 1, "nick": "alice", "username": "alice",
"hostname": "host.example.com",
"isOperator": true, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 2, "nick": "bob", "username": "bob",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": true,
"lastSeen": "2026-02-10T20:00:00Z"
},
{
"id": 3, "nick": "Carol", "username": "carol",
"hostname": "host.example.com",
"isOperator": false, "isVoiced": false,
"lastSeen": "2026-02-10T20:00:00Z"
}
]`)
members, err := client.GetMembers("#general")
if err != nil {
t.Fatalf("get members: %v", err)
}
want := []api.Member{
{Nick: "alice", IsOperator: true, IsVoiced: false},
{Nick: "bob", IsOperator: false, IsVoiced: true},
{Nick: "Carol", IsOperator: false, IsVoiced: false},
}
if !slices.Equal(members, want) {
t.Fatalf("members: got %+v, want %+v", members, want)
}
}
// TestPollMessagesAdvancesCursor serves the queue as the server
// does: entries after the cursor, and last_id as the new cursor.
func TestPollMessagesAdvancesCursor(t *testing.T) {
t.Parallel()
const lastQueueID = 42
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/messages", func(
writer http.ResponseWriter, request *http.Request,
) {
after := request.URL.Query().Get("after")
if after == strconv.Itoa(lastQueueID) {
_, _ = io.WriteString(
writer, `{"messages": [], "last_id": 42}`,
)
return
}
_, _ = io.WriteString(writer, `{
"messages": [{
"id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:01.234567891Z",
"meta": {}
}],
"last_id": 42
}`)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
client := api.NewClient(server.URL)
first, err := client.PollMessages(0, 0)
if err != nil {
t.Fatalf("first poll: %v", err)
}
if first.LastID != lastQueueID || len(first.Messages) != 1 {
t.Fatalf(
"first poll: got last ID %d and %d messages, "+
"want %d and 1",
first.LastID, len(first.Messages), lastQueueID,
)
}
second, err := client.PollMessages(first.LastID, 0)
if err != nil {
t.Fatalf("second poll: %v", err)
}
if len(second.Messages) != 0 {
t.Fatalf(
"second poll: got %d messages again, want none",
len(second.Messages),
)
}
}
func TestClientKeepsSessionOverPlainHTTPOnLocalhost(t *testing.T) {
t.Parallel()
+16 -9
View File
@@ -16,9 +16,9 @@ type SessionResponse struct {
// StateResponse is the response from GET /api/v1/state.
type StateResponse struct {
ID int64 `json:"id"`
Nick string `json:"nick"`
Channels []string `json:"channels"`
ID int64 `json:"id"`
Nick string `json:"nick"`
Channels []Channel `json:"channels"`
}
// Message represents a neoirc message envelope.
@@ -53,12 +53,19 @@ func (m *Message) BodyLines() []string {
}
}
// Channel represents a channel in the list response.
// Channel is a channel as GET /api/v1/channels and
// GET /api/v1/state describe it.
type Channel struct {
Name string `json:"name"`
Topic string `json:"topic"`
Members int `json:"members"`
CreatedAt string `json:"createdAt"`
Name string `json:"name"`
Topic string `json:"topic"`
}
// Member is one entry of the response from
// GET /api/v1/channels/{name}/members.
type Member struct {
Nick string `json:"nick"`
IsOperator bool `json:"isOperator"`
IsVoiced bool `json:"isVoiced"`
}
// ServerInfo is the response from GET /api/v1/server.
@@ -72,7 +79,7 @@ type ServerInfo struct {
// MessagesResponse wraps polling results.
type MessagesResponse struct {
Messages []Message `json:"messages"`
LastID int64 `json:"lastId"`
LastID int64 `json:"last_id"` //nolint:tagliatelle
}
// PollResult wraps the poll response including the cursor.
+25 -13
View File
@@ -434,16 +434,9 @@ func (a *App) cmdTopic(args string) {
return
}
// The HTTP API has no topic query: a TOPIC needs text.
if args == "" {
err := a.client.SendMessage(&api.Message{ //nolint:exhaustruct
Command: irc.CmdTopic,
To: target,
})
if err != nil {
a.ui.AddStatus(fmt.Sprintf(
"[red]Topic query failed: %v", err,
))
}
a.ui.AddStatus("[red]Usage: /topic <text>")
return
}
@@ -487,12 +480,32 @@ func (a *App) cmdNames() {
return
}
nicks := make([]string, 0, len(members))
for _, member := range members {
nicks = append(nicks, memberPrefix(member)+member.Nick)
}
a.ui.AddLine(target, fmt.Sprintf(
"[cyan]*** Members of %s: %s",
target, strings.Join(members, " "),
target, strings.Join(nicks, " "),
))
}
// memberPrefix returns "@" for an operator, "+" for a voiced
// member, and "" for anyone else.
func memberPrefix(member api.Member) string {
if member.IsOperator {
return "@"
}
if member.IsVoiced {
return "+"
}
return ""
}
func (a *App) cmdList() {
a.mu.Lock()
connected := a.connected
@@ -517,8 +530,7 @@ func (a *App) cmdList() {
for _, ch := range channels {
a.ui.AddStatus(fmt.Sprintf(
" %s (%d members) %s",
ch.Name, ch.Members, ch.Topic,
" %s %s", ch.Name, ch.Topic,
))
}
@@ -676,7 +688,7 @@ func (a *App) cmdHelp() {
" /part [#chan] — Leave channel",
" /msg <nick> <text> — Send DM",
" /query <nick> — Open DM window",
" /topic [text] — View/set topic",
" /topic <text> — Set topic",
" /names — List channel members",
" /list — List channels",
" /who [#channel] — List users in channel",