| Ask for the backlog, and survive hanging up 6214c7e nandi 22h ago | 1 | ## What this client says back to a server, given what a server says to it. |
| 2 | ## |
| 3 | ## The reducer's answer to an incoming line was the largest untested thing in |
| 4 | ## the program: reachable only through a real socket, so nothing checked it. |
| 5 | ## That is where the missing CHATHISTORY request sat through the whole port — |
| 6 | ## every room came back with no history and the only comment about it was in |
| 7 | ## another module, describing behaviour that had never been ported. |
| 8 | ## |
| 9 | ## `conn.feed` puts a line in as though the server had sent it; `tryOutbound` |
| 10 | ## reads what went out. No socket at either end. |
| 11 | |
| Find out who somebody is 4b72ad1 nandi 21h ago | 12 | import std/[json, sequtils, strutils, tables, unittest] |
| Faces, fetched off the thread that draws them 60bbd35 nandi 21h ago | 13 | import frq/[cells, model, profile, reducer, rooms] |
| Ask for the backlog, and survive hanging up 6214c7e nandi 22h ago | 14 | import frq/conn as tr |
| 15 | |
| 16 | proc sent(): seq[string] = |
| 17 | ## Everything queued to go out, drained. |
| 18 | while true: |
| 19 | let (ok, line) = tr.tryOutbound() |
| 20 | if not ok: break |
| 21 | result.add line |
| 22 | |
| 23 | proc say(lines: varargs[string]) = |
| 24 | for l in lines: tr.feed(l) |
| 25 | drain() |
| 26 | |
| 27 | proc reset() = |
| 28 | app = initState() |
| 29 | app.formNick = "alice" |
| 30 | discard sent() |
| 31 | |
| 32 | proc joined(room: string) = |
| 33 | ## The server putting us in a room, which is how every one of them starts — |
| 34 | ## a JOIN we sent, or one freeq made for a signed-in account at |
| 35 | ## registration. A 366 for a room this client has never heard of is not |
| 36 | ## ours and is left alone. |
| 37 | say(":alice!a@h JOIN " & room) |
| 38 | discard sent() |
| 39 | |
| 40 | suite "asking for the backlog": |
| 41 | setup: reset() |
| 42 | |
| 43 | test "end of NAMES in an empty room asks for history": |
| 44 | joined("#freeq") |
| 45 | # freeq re-joins an authenticated user's channels at registration and |
| 46 | # leaves the backlog for the client to ask for. A room that arrives this |
| 47 | # way has no history coming unless we ask, which is why a signed-in |
| 48 | # connection showed new lines and nothing else. |
| 49 | say(":server 366 alice #freeq :End of /NAMES list") |
| 50 | check app.rooms.hasKey("#freeq") |
| 51 | check sent().anyIt(it.startsWith("CHATHISTORY LATEST #freeq * ")) |
| 52 | |
| 53 | test "and asks for a hundred lines of it": |
| 54 | joined("#freeq") |
| 55 | say(":server 366 alice #freeq :End of /NAMES list") |
| 56 | check "CHATHISTORY LATEST #freeq * 100" in sent() |
| 57 | |
| 58 | test "a room that already has lines is not asked twice": |
| 59 | # The replay comes back as ordinary PRIVMSGs; asking again is a second |
| 60 | # copy of the same history crossing the wire to be thrown away. |
| 61 | joined("#freeq") |
| 62 | say(":bob!b@h PRIVMSG #freeq :already here") |
| 63 | discard sent() |
| 64 | say(":server 366 alice #freeq :End of /NAMES list") |
| 65 | check not sent().anyIt(it.startsWith("CHATHISTORY")) |
| 66 | |
| 67 | test "a room this client was never put in is not asked about": |
| 68 | # Not a room of ours: 366 for it arrives before any JOIN, and answering |
| 69 | # it would ask a server for the history of somewhere we are not. |
| 70 | say(":server 366 alice #nowhere :End of /NAMES list") |
| 71 | check not app.rooms.hasKey("#nowhere") |
| 72 | check not sent().anyIt(it.startsWith("CHATHISTORY")) |
| 73 | |
| 74 | suite "the rest of the conversation": |
| 75 | setup: reset() |
| 76 | |
| 77 | test "a PING is answered with its own token": |
| 78 | say("PING :abc123") |
| 79 | check "PONG :abc123" in sent() |
| 80 | |
| 81 | test "our own JOIN marks the room joined; somebody else's adds a name": |
| 82 | say(":alice!a@h JOIN #freeq") |
| 83 | check app.rooms["#freeq"].joined |
| 84 | say(":bob!b@h JOIN #freeq") |
| 85 | check app.rooms["#freeq"].users.hasKey("bob") |
| 86 | |
| 87 | test "NAMES arrives over several lines and lands in one go": |
| 88 | joined("#freeq") |
| 89 | say(":server 353 alice = #freeq :alice @bob", |
| 90 | ":server 353 alice = #freeq :carol") |
| 91 | # Still pending: replacing the list per line empties the panel and |
| 92 | # refills it a name at a time. |
| 93 | check app.rooms["#freeq"].users.len == 0 |
| 94 | say(":server 366 alice #freeq :End of /NAMES list") |
| 95 | check app.rooms["#freeq"].users.len == 3 |
| 96 | |
| 97 | test "a replayed line lands in the room it names": |
| 98 | joined("#freeq") |
| 99 | say(":server 366 alice #freeq :End of /NAMES list", |
| 100 | ":bob!b@h PRIVMSG #freeq :an old line") |
| 101 | check app.rooms["#freeq"].messages.anyIt(it.text == "an old line") |
| 102 | |
| 103 | test "a message to us is filed under whoever sent it": |
| 104 | say(":bob!b@h PRIVMSG alice :a direct word") |
| 105 | check app.rooms.hasKey("bob") |
| 106 | check app.rooms["bob"].messages[^1].text == "a direct word" |
| Find out who somebody is 4b72ad1 nandi 21h ago | 107 | |
| 108 | suite "who is who": |
| 109 | setup: reset() |
| 110 | |
| 111 | test "WHO reports the whole DID where the hostmask has eight characters": |
| 112 | # `freeq/plc/ngokl2gn` is enough to tell two people apart and not enough |
| 113 | # to look either of them up. The realname field carries all of it. |
| 114 | say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " & |
| 115 | "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p") |
| 116 | check app.dids["nandi.uk"] == "did:plc:ngokl2gnmpbvuvrfckja3g7p" |
| 117 | |
| 118 | test "an agent signs with a key, and that is an identity too": |
| 119 | say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " & |
| 120 | "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4shCpSB") |
| 121 | check app.dids["cartographer"].startsWith("did:key:") |
| 122 | |
| 123 | test "a guest has none, and is not recorded as having one": |
| 124 | # freeq puts the literal "IRC User" there for an unauthenticated |
| 125 | # connection, which is not a DID and must not be stored as one. |
| 126 | say(":irc.freeq.at 352 alice #freeq ~u freeq/guest irc.freeq.at " & |
| 127 | "adam12 H :0 IRC User") |
| 128 | check not app.dids.hasKey("adam12") |
| 129 | |
| 130 | test "WHOIS answers for one nick the same way": |
| 131 | say(":irc.freeq.at 330 alice zapnap did:plc:k2n3e2vsabcdefghijklmnop " & |
| 132 | ":is authenticated as") |
| 133 | check app.dids["zapnap"] == "did:plc:k2n3e2vsabcdefghijklmnop" |
| 134 | |
| 135 | test "and the room is asked who is in it once it has arrived": |
| 136 | joined("#freeq") |
| 137 | say(":server 366 alice #freeq :End of /NAMES list") |
| 138 | check "WHO #freeq" in sent() |
| 139 | |
| 140 | suite "opening a profile": |
| 141 | setup: reset() |
| 142 | |
| 143 | test "uses what WHO reported for a nick that is not a handle": |
| 144 | say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/k2n3e2vs irc.freeq.at " & |
| 145 | "zapnap H :0 did:plc:k2n3e2vsabcdefghijklmnop") |
| 146 | dispatch(%*{"id": "profile.open:zapnap:"}) |
| 147 | check app.profileViewing.actor == "did:plc:k2n3e2vsabcdefghijklmnop" |
| 148 | |
| 149 | test "asks the server about somebody it has never seen": |
| 150 | dispatch(%*{"id": "profile.open:stranger:"}) |
| 151 | check "WHOIS stranger" in sent() |
| 152 | |
| 153 | test "and what the message itself knew still wins": |
| 154 | dispatch(%*{"id": "profile.open:bob:did:plc:fromtheaccounttag"}) |
| 155 | check app.profileViewing.actor == "did:plc:fromtheaccounttag" |
| Faces, fetched off the thread that draws them 60bbd35 nandi 21h ago | 156 | |
| 157 | suite "faces": |
| 158 | setup: |
| 159 | reset() |
| 160 | forgetProfiles() |
| 161 | |
| 162 | test "learning a DID starts the face on its way, without waiting for it": |
| 163 | # `want` is not `fetch`: a room of twelve is twelve HTTPS round trips, |
| 164 | # and this is the thread that answers every keystroke. |
| 165 | say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " & |
| 166 | "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p") |
| 167 | let (p, known) = entry("did:plc:ngokl2gnmpbvuvrfckja3g7p") |
| 168 | check known |
| 169 | check p.status == psLoading |
| 170 | |
| 171 | test "and nothing is painted for one that has not arrived": |
| 172 | say(":irc.freeq.at 352 alice #freeq ~u freeq/plc/ngokl2gn irc.freeq.at " & |
| 173 | "nandi.uk H :0 did:plc:ngokl2gnmpbvuvrfckja3g7p") |
| 174 | check avatarFor("did:plc:ngokl2gnmpbvuvrfckja3g7p") == "" |
| 175 | |
| 176 | test "an agent is never asked about": |
| 177 | # `did:key:` has no Bluesky profile, so a request for one can only 400. |
| 178 | say(":irc.freeq.at 352 alice #freeq ~u freeq/key/z6Mkp5we irc.freeq.at " & |
| 179 | "cartographer H :0 did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh") |
| 180 | check not entry("did:key:z6Mkp5wegrxZR62h54HwR329yz7TJ8Ccx4sh")[1] |