Skip to content

Commit 1e0fe38

Browse files
committed
Add test for account-tag and voice modes with extra colon
This perfectly finds the regression caused by 2fea6c4 which intended to fix issues with the extra ":". Broken: 1.10.1 Valid: 1.10.0 and latest HEAD because of gotmode() using parse_irc now
1 parent 5a06ebf commit 1e0fe38

5 files changed

Lines changed: 197 additions & 18 deletions

File tree

tests/README.md

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,34 @@ drive_registration(mock_ircd, isupport_tokens=[
173173
])
174174
```
175175

176+
To influence which IRCv3 caps the bot negotiates, override the
177+
`mock_ircd` fixture for the test and construct the IRCd with the cap
178+
list. The bot sends `CAP LS 302` the moment TCP connects, before the
179+
test body runs, so the cap list has to be set at construction time:
180+
181+
```python
182+
@pytest.fixture
183+
def mock_ircd():
184+
ircd = MockIrcd(advertised_caps=["account-tag"]).start()
185+
try:
186+
yield ircd
187+
finally:
188+
with contextlib.suppress(Exception):
189+
ircd.stop()
190+
191+
def test_account_tag_negotiated(eggdrop_proc, mock_ircd, tcl_bridge):
192+
drive_registration(mock_ircd)
193+
assert "account-tag" in tcl_bridge.eval_ok("cap enabled").split()
194+
```
195+
196+
The bot only `REQ`s caps it has enabled in config (e.g. `account-tag`
197+
is opt-in via `set account-tag 1` in the rendered eggdrop.conf — pass
198+
`extra_tcl="set account-tag 1\n"` to `eggdrop_config.render`).
199+
176200
After this returns, Eggdrop has processed 005 and is about to JOIN
177201
configured channels.
178202

179-
### `drive_join_with_names(mock_ircd, members_with_prefix, nick="TestBot", server="mock.test") -> str`
203+
### `drive_join_with_names(mock_ircd, members_with_prefix, nick="TestBot", server="mock.test", member_accounts=None) -> str`
180204

181205
Mimics a real IRCd's full post-JOIN dance for the bot:
182206

@@ -187,19 +211,32 @@ Mimics a real IRCd's full post-JOIN dance for the bot:
187211
like `"@TestBot ~bigboss +regular"`) and `366` end-of-NAMES.
188212
4. Drains the post-join queries Eggdrop fires off:
189213
- `MODE +b/+e/+I` → empty `368/349/347` end-of-list replies
190-
- `WHO #chan` → one `352` per member (prefix symbols passed through to
191-
the WHO flags field, so `opchars`-based op detection picks them up)
192-
followed by `315` end-of-WHO
214+
- `WHO #chan ...` → if the bot sent a WHOX-style request (the
215+
`c%chnufat,222` form, used when `WHOX` ISUPPORT is on), reply with
216+
one `354` per member carrying the per-member account from
217+
`member_accounts` (default `*` = not logged in). Otherwise reply
218+
with one `352` per member (prefix symbols passed through to the WHO
219+
flags field, so `opchars`-based op detection picks them up). Either
220+
form ends with `315`.
193221
5. Leaves `MODE #chan` (no list flag) **unanswered** so individual tests
194222
can send their own `324` mode reply if they need to.
195223

224+
`member_accounts` is a `dict[str, str]` mapping member nick → account
225+
name. Only consulted on the WHOX path; ignored for plain WHO.
226+
196227
Returns the channel name. Quiesces when no new lines arrive for ~300 ms
197228
(or after a 5 s hard cap).
198229

199230
```python
200231
chan = drive_join_with_names(mock_ircd, "@TestBot alice +bob")
201232
# bot is now fully joined to chan; alice is a plain member, bob is voiced
202233
mock_ircd.send(f":mock.test 324 TestBot {chan} +ntk secret") # custom 324
234+
235+
# WHOX flavour (bot is on a network that advertised WHOX in 005):
236+
chan = drive_join_with_names(
237+
mock_ircd, "@op alice", member_accounts={"op": "op"}
238+
)
239+
# op's account is now "op" via 354 → got354 → setaccount
203240
```
204241

205242
### `wait_for_isupport(bridge, key, expected, timeout=5.0)`
@@ -233,6 +270,13 @@ The fixtures wire to each other: pulling in `tcl_bridge` is enough — it
233270
depends on `eggdrop_proc` which depends on `eggdrop_config` and
234271
`mock_ircd`, all of which depend on `tmp_eggdir`.
235272

273+
### Markers
274+
275+
| Marker | Effect |
276+
| --- | --- |
277+
| `@pytest.mark.partyline` | `eggdrop_proc` spawns with `-nt`; HQ partyline available on stdin |
278+
| `@pytest.mark.slow` | Tag for end-to-end / reconnect / timeout-driven tests |
279+
236280
## Customising a test's eggdrop.conf
237281

238282
Render with overrides before the proc starts:

tests/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ markers = [
2020
"slow: end-to-end tests that exercise reconnect/timeouts",
2121
"partyline: spawns eggdrop with -nt so stdin is the HQ partyline",
2222
]
23-
# Keep tmpdir only when a test fails; pytest sweeps it on session exit.
24-
tmp_path_retention_policy = "failed"
23+
# pytest sweeps it on session exit.
24+
tmp_path_retention_policy = "all"
2525
tmp_path_retention_count = 3
2626

2727
[tool.ruff]

tests/support/irc_helpers.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ def drive_registration(
4747
Waits for the TCP connect, drains the bot's NICK + USER, then sends the
4848
welcome sequence (001-004 + optional 005 with `isupport_tokens` + 376).
4949
Returns once the welcome is on the wire.
50+
51+
To influence which IRCv3 caps the bot negotiates, construct the IRCd with
52+
`MockIrcd(advertised_caps=[...])` (typically via a local `mock_ircd`
53+
fixture override). The cap list has to be set at construction time
54+
because the bot sends `CAP LS 302` immediately on TCP connect — before
55+
this helper runs.
5056
"""
5157
mock_ircd.wait_for_connect(timeout=10.0)
5258
for _ in range(2): # NICK and USER
@@ -59,6 +65,7 @@ def drive_join_with_names(
5965
members_with_prefix: str,
6066
nick: str = "TestBot",
6167
server: str = "mock.test",
68+
member_accounts: dict[str, str] | None = None,
6269
) -> str:
6370
"""Drive a realistic post-registration channel JOIN to completion.
6471
@@ -68,10 +75,16 @@ def drive_join_with_names(
6875
3. send NAMES (353) with `members_with_prefix` + end (366)
6976
4. drain the bot's post-join queries:
7077
* `MODE #chan +b/+e/+I` → empty 368/349/347 end-of-list replies
71-
* `WHO #chan` → one 352 per member (prefix symbols passed through to
72-
the flags field so opchars-based op detection works) + 315
78+
* `WHO #chan ...` → if the bot sent a WHOX-style request (the
79+
`c%chnufat,222` form, used when WHOX ISUPPORT is on), reply with
80+
one 354 per member carrying the per-member account from
81+
`member_accounts` (default "*" = not logged in). Otherwise reply
82+
with one 352 per member. Either form ends with 315.
7383
5. leave `MODE #chan` (no list flag) unanswered so tests can send
7484
their own 324 reply
85+
86+
`member_accounts` maps member nick → account name; nicks not in the dict
87+
get "*". Only consulted on the WHOX path; ignored for plain WHO.
7588
Returns the channel name. Quiesces when no new lines arrive for ~300 ms
7689
or after a 5 s hard cap.
7790
"""
@@ -86,16 +99,26 @@ def drive_join_with_names(
8699
members: list[tuple[str, str]] = [
87100
split_member_prefix(t) for t in members_with_prefix.split() if t
88101
]
102+
accounts = member_accounts or {}
89103

90-
def reply_who() -> None:
104+
def reply_who(whox: bool) -> None:
91105
for member_nick, prefix_syms in members:
92106
ident = "u"
93107
host = "h.example.com"
94108
flags = "H" + prefix_syms # H = here (not away)
95-
mock_ircd.send(
96-
f":{server} 352 {nick} {chan} {ident} {host} {server} "
97-
f"{member_nick} {flags} :0 {member_nick}"
98-
)
109+
if whox:
110+
# 354 format from chan.c:got354:
111+
# ":<srv> 354 <botnick> 222 <chan> <user> <host> <nick> <flags> <account>"
112+
acct = accounts.get(member_nick, "*")
113+
mock_ircd.send(
114+
f":{server} 354 {nick} 222 {chan} {ident} {host} "
115+
f"{member_nick} {flags} {acct}"
116+
)
117+
else:
118+
mock_ircd.send(
119+
f":{server} 352 {nick} {chan} {ident} {host} {server} "
120+
f"{member_nick} {flags} :0 {member_nick}"
121+
)
99122
mock_ircd.send(f":{server} 315 {nick} {chan} :End of /WHO list.")
100123

101124
deadline = time.monotonic() + 5.0
@@ -116,7 +139,9 @@ def reply_who() -> None:
116139
f":{server} 347 {nick} {chan} :End of Channel Invite List"
117140
)
118141
elif line.startswith(f"WHO {chan}"):
119-
reply_who()
142+
# WHOX form is `WHO #chan c%chnufat,222`; eggdrop emits this when
143+
# use_354 (WHOX ISUPPORT) is on and parses replies from got354.
144+
reply_who(whox=",222" in line)
120145
# MODE #chan (no list flag) — left for the test to answer with 324.
121146
# Anything else is silently drained.
122147
return chan

tests/support/mock_ircd.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,25 @@ def __init__(
4848
allow_reconnect: bool = False,
4949
auto_cap: bool = True,
5050
server_name: str = "mock.test",
51+
advertised_caps: Iterable[str] | None = None,
5152
) -> None:
5253
"""Configure (but do not start) a mock IRCd.
5354
5455
`allow_reconnect`: if False, a second client connection during the
5556
test is treated as a hard error at `stop()` time.
56-
`auto_cap`: auto-respond to `CAP LS`/`REQ`/`LIST` with empty caps.
57+
`auto_cap`: auto-respond to `CAP LS`/`REQ`/`LIST`. `CAP REQ` is
58+
always ACKed for whatever the bot asks for.
5759
`server_name`: source prefix for synthetic numerics (`:server 001 ...`).
60+
`advertised_caps`: caps offered in `CAP LS` replies. Default empty.
61+
Tests that need specific caps construct their own MockIrcd
62+
(typically via a local `mock_ircd` fixture override) — by the
63+
time the test body runs the bot has already sent `CAP LS 302`,
64+
so the cap list must be set at construction time.
5865
"""
5966
self._allow_reconnect = allow_reconnect
6067
self._auto_cap = auto_cap
6168
self._server_name = server_name
69+
self._advertised_caps = " ".join(advertised_caps or [])
6270
self._loop = asyncio.new_event_loop()
6371
self._thread = threading.Thread(
6472
target=self._loop.run_forever, name="MockIrcd", daemon=True
@@ -158,15 +166,16 @@ async def _on_client(
158166
def _handle_cap(self, text: str, writer: asyncio.StreamWriter) -> None:
159167
"""Auto-respond to client CAP commands so tests don't have to.
160168
161-
Strategy: advertise no capabilities. Eggdrop sees nothing it wants,
162-
sends `CAP END`, and proceeds with NICK/USER.
169+
Replies to `CAP LS` with the caps the IRCd was constructed with
170+
(default empty), and ACKs whatever the bot then asks for in
171+
`CAP REQ`.
163172
"""
164173
parts = text.split(maxsplit=2)
165174
sub = parts[1].upper() if len(parts) >= 2 else ""
166175
srv = self._server_name
167176
# Use "*" as the unregistered nick placeholder per RFC.
168177
if sub == "LS":
169-
writer.write(f":{srv} CAP * LS :\r\n".encode())
178+
writer.write(f":{srv} CAP * LS :{self._advertised_caps}\r\n".encode())
170179
elif sub == "REQ":
171180
cap = parts[2] if len(parts) >= 3 else ":"
172181
if cap.startswith(":"):
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Account-tag CAP + WHOX + tagged MODE: regression for the full account-aware
2+
path through CAP negotiation, WHOX-driven account learning, and an IRCv3-tagged
3+
mode change.
4+
5+
End-to-end shape:
6+
- CAP LS advertises `account-tag`; bot REQ/server ACK; bot enables it.
7+
- 005 carries `WHOX` so eggdrop sets `use_354` (chan.c:3053) and emits
8+
`WHO #chan c%chnufat,222` after JOIN; the helper replies with 354s
9+
carrying op's account name "op".
10+
- The IRCd then sends a MODE +vv with an `@account=op` IRCv3 tag. Eggdrop
11+
parses the MODE (via standard mode handling) and the tag (via
12+
chan.c:gotrawt → setaccount).
13+
- Result: test1 and test2 are voiced; op's account is "op".
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import contextlib
19+
from collections.abc import Iterator
20+
21+
import pytest
22+
23+
from support.bridge_client import BridgeClient
24+
from support.eggdrop_proc import EggdropProc
25+
from support.irc_helpers import (
26+
drive_join_with_names,
27+
drive_registration,
28+
wait_for_isupport,
29+
)
30+
from support.mock_ircd import MockIrcd
31+
from support.waiters import wait_for
32+
33+
34+
@pytest.fixture
35+
def mock_ircd() -> Iterator[MockIrcd]:
36+
"""Override the default fixture to advertise `account-tag` in CAP LS.
37+
38+
The cap list is fixed at construction time because the bot sends
39+
`CAP LS 302` immediately on TCP connect, before the test body runs.
40+
"""
41+
ircd = MockIrcd(advertised_caps=["account-tag"]).start()
42+
try:
43+
yield ircd
44+
finally:
45+
with contextlib.suppress(Exception):
46+
ircd.stop()
47+
48+
49+
def test_isvoice_after_tagged_mode_with_whox_and_account_tag(
50+
eggdrop_config,
51+
request: pytest.FixtureRequest,
52+
) -> None:
53+
# account-tag is disabled by default in server.mod (servmsg.c:44); the
54+
# bot only adds it to the CAP REQ list when the Tcl var is 1. Set it via
55+
# extra_tcl so the assignment runs after server.mod's loadmodule.
56+
eggdrop_config.render(extra_tcl="set account-tag 1\n")
57+
mock_ircd: MockIrcd = request.getfixturevalue("mock_ircd")
58+
eggdrop_proc: EggdropProc = request.getfixturevalue("eggdrop_proc") # noqa: F841 — proc must spawn before bridge
59+
tcl_bridge: BridgeClient = request.getfixturevalue("tcl_bridge")
60+
61+
drive_registration(mock_ircd, isupport_tokens=["WHOX"])
62+
wait_for_isupport(tcl_bridge, "WHOX", "") # bare token: value stored as ""
63+
64+
# CAP negotiation must have completed (welcome only fires after CAP END);
65+
# account-tag must be in the enabled list.
66+
enabled = tcl_bridge.eval_ok("cap enabled").split()
67+
assert "account-tag" in enabled, f"expected account-tag enabled, got {enabled}"
68+
69+
chan = drive_join_with_names(
70+
mock_ircd,
71+
"@op test1 test2 @TestBot",
72+
member_accounts={"op": "op"},
73+
)
74+
75+
# WHOX populated op's account via 354 (chan.c:got354 → got352or4 → setaccount).
76+
wait_for(
77+
lambda: tcl_bridge.eval_ok(f'getaccount op "{chan}"') == "op",
78+
timeout=5.0,
79+
description="WHOX 354 to set op's account to 'op'",
80+
)
81+
82+
# Pre-MODE sanity: nobody has voice yet.
83+
assert tcl_bridge.eval_ok(f'onchan test1 "{chan}"') == "1"
84+
assert tcl_bridge.eval_ok(f'onchan test2 "{chan}"') == "1"
85+
assert tcl_bridge.eval_ok(f'isvoice test1 "{chan}"') == "0"
86+
assert tcl_bridge.eval_ok(f'isvoice test2 "{chan}"') == "0"
87+
88+
mock_ircd.send(f"@account=op :op!op@127.0.0.1 MODE {chan} +vv test1 :test2")
89+
90+
wait_for(
91+
lambda: (
92+
tcl_bridge.eval_ok(f'isvoice test1 "{chan}"') == "1"
93+
and tcl_bridge.eval_ok(f'isvoice test2 "{chan}"') == "1"
94+
),
95+
timeout=5.0,
96+
description="MODE +vv test1 test2 to voice both members",
97+
)
98+
99+
# And op's account survived the tag-driven setaccount call (no-op since
100+
# it was already "op", but we want to be sure gotrawt didn't clobber it).
101+
assert tcl_bridge.eval_ok(f'getaccount op "{chan}"') == "op"

0 commit comments

Comments
 (0)