feat: MVP two-user chat via embedded SPA (#9)
All checks were successful
check / check (push) Successful in 1m51s
All checks were successful
check / check (push) Successful in 1m51s
Backend: - Session/client UUID model: sessions table (uuid, nick, signing_key), clients table (uuid, session_id, token) with per-client message queues - MOTD delivery as IRC numeric messages (375/372/376) on connect - EnqueueToSession fans out to all clients of a session - EnqueueToClient for targeted delivery (MOTD) - All queries updated for session/client model SPA client: - Long-poll loop (15s timeout) instead of setInterval - IRC message envelope parsing (command/from/to/body) - Display JOIN/PART/NICK/TOPIC/QUIT system messages - Nick change via /nick command - Topic display in header bar - Unread count badges on inactive tabs - Auto-rejoin channels on reconnect (localStorage) - Connection status indicator - Message deduplication by UUID - Channel history loaded on join - /topic command support Closes #9
This commit is contained in:
@@ -27,70 +27,91 @@ func setupTestDB(t *testing.T) *db.Database {
|
||||
return database
|
||||
}
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
func TestCreateSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
id, token, err := database.CreateUser(ctx, "alice")
|
||||
sessionID, _, token, err := database.CreateSession(
|
||||
ctx, "alice",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if id == 0 || token == "" {
|
||||
if sessionID == 0 || token == "" {
|
||||
t.Fatal("expected valid id and token")
|
||||
}
|
||||
|
||||
_, _, err = database.CreateUser(ctx, "alice")
|
||||
if err == nil {
|
||||
_, _, dupToken, dupErr := database.CreateSession(
|
||||
ctx, "alice",
|
||||
)
|
||||
if dupErr == nil {
|
||||
t.Fatal("expected error for duplicate nick")
|
||||
}
|
||||
|
||||
_ = dupToken
|
||||
}
|
||||
|
||||
func TestGetUserByToken(t *testing.T) {
|
||||
func TestGetSessionByToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, token, err := database.CreateUser(ctx, "bob")
|
||||
_, _, token, err := database.CreateSession(ctx, "bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, nick, err := database.GetUserByToken(ctx, token)
|
||||
sessionID, clientID, nick, err :=
|
||||
database.GetSessionByToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if nick != "bob" || id == 0 {
|
||||
if nick != "bob" || sessionID == 0 || clientID == 0 {
|
||||
t.Fatalf("expected bob, got %s", nick)
|
||||
}
|
||||
|
||||
_, _, err = database.GetUserByToken(ctx, "badtoken")
|
||||
if err == nil {
|
||||
badSID, badCID, badNick, badErr :=
|
||||
database.GetSessionByToken(ctx, "badtoken")
|
||||
if badErr == nil {
|
||||
t.Fatal("expected error for bad token")
|
||||
}
|
||||
|
||||
if badSID != 0 || badCID != 0 || badNick != "" {
|
||||
t.Fatal("expected zero values on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserByNick(t *testing.T) {
|
||||
func TestGetSessionByNick(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, _, err := database.CreateUser(ctx, "charlie")
|
||||
charlieID, charlieClientID, charlieToken, err :=
|
||||
database.CreateSession(ctx, "charlie")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
id, err := database.GetUserByNick(ctx, "charlie")
|
||||
if charlieID == 0 || charlieClientID == 0 {
|
||||
t.Fatal("expected valid session/client IDs")
|
||||
}
|
||||
|
||||
if charlieToken == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
|
||||
id, err := database.GetSessionByNick(ctx, "charlie")
|
||||
if err != nil || id == 0 {
|
||||
t.Fatal("expected to find charlie")
|
||||
}
|
||||
|
||||
_, err = database.GetUserByNick(ctx, "nobody")
|
||||
_, err = database.GetSessionByNick(ctx, "nobody")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown nick")
|
||||
}
|
||||
@@ -129,7 +150,7 @@ func TestJoinAndPart(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "user1")
|
||||
sid, _, _, err := database.CreateSession(ctx, "user1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,22 +160,22 @@ func TestJoinAndPart(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ids, err := database.GetChannelMemberIDs(ctx, chID)
|
||||
if err != nil || len(ids) != 1 || ids[0] != uid {
|
||||
t.Fatal("expected user in channel")
|
||||
if err != nil || len(ids) != 1 || ids[0] != sid {
|
||||
t.Fatal("expected session in channel")
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.PartChannel(ctx, chID, uid)
|
||||
err = database.PartChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -178,17 +199,17 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "temp")
|
||||
sid, _, _, err := database.CreateSession(ctx, "temp")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.PartChannel(ctx, chID, uid)
|
||||
err = database.PartChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -204,7 +225,7 @@ func TestDeleteChannelIfEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func createUserWithChannels(
|
||||
func createSessionWithChannels(
|
||||
t *testing.T,
|
||||
database *db.Database,
|
||||
nick, ch1Name, ch2Name string,
|
||||
@@ -213,7 +234,7 @@ func createUserWithChannels(
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, nick)
|
||||
sid, _, _, err := database.CreateSession(ctx, nick)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -232,29 +253,29 @@ func createUserWithChannels(
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, ch1, uid)
|
||||
err = database.JoinChannel(ctx, ch1, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, ch2, uid)
|
||||
err = database.JoinChannel(ctx, ch2, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return uid, ch1, ch2
|
||||
return sid, ch1, ch2
|
||||
}
|
||||
|
||||
func TestListChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
uid, _, _ := createUserWithChannels(
|
||||
sid, _, _ := createSessionWithChannels(
|
||||
t, database, "lister", "#a", "#b",
|
||||
)
|
||||
|
||||
channels, err := database.ListChannels(
|
||||
t.Context(), uid,
|
||||
t.Context(), sid,
|
||||
)
|
||||
if err != nil || len(channels) != 2 {
|
||||
t.Fatalf(
|
||||
@@ -295,17 +316,21 @@ func TestChangeNick(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, token, err := database.CreateUser(ctx, "old")
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "old",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.ChangeNick(ctx, uid, "new")
|
||||
err = database.ChangeNick(ctx, sid, "new")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, nick, err := database.GetUserByToken(ctx, token)
|
||||
_, _, nick, err := database.GetSessionByToken(
|
||||
ctx, token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -375,7 +400,16 @@ func TestPollMessages(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "poller")
|
||||
sid, _, token, err := database.CreateSession(
|
||||
ctx, "poller",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, clientID, _, err := database.GetSessionByToken(
|
||||
ctx, token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -389,7 +423,7 @@ func TestPollMessages(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.EnqueueMessage(ctx, uid, dbID)
|
||||
err = database.EnqueueToSession(ctx, sid, dbID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -397,7 +431,7 @@ func TestPollMessages(t *testing.T) {
|
||||
const batchSize = 10
|
||||
|
||||
msgs, lastQID, err := database.PollMessages(
|
||||
ctx, uid, 0, batchSize,
|
||||
ctx, clientID, 0, batchSize,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -420,7 +454,7 @@ func TestPollMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
msgs, _, _ = database.PollMessages(
|
||||
ctx, uid, lastQID, batchSize,
|
||||
ctx, clientID, lastQID, batchSize,
|
||||
)
|
||||
|
||||
if len(msgs) != 0 {
|
||||
@@ -467,13 +501,15 @@ func TestGetHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
func TestDeleteSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid, _, err := database.CreateUser(ctx, "deleteme")
|
||||
sid, _, _, err := database.CreateSession(
|
||||
ctx, "deleteme",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -485,19 +521,19 @@ func TestDeleteUser(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid)
|
||||
err = database.JoinChannel(ctx, chID, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.DeleteUser(ctx, uid)
|
||||
err = database.DeleteSession(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = database.GetUserByNick(ctx, "deleteme")
|
||||
_, err = database.GetSessionByNick(ctx, "deleteme")
|
||||
if err == nil {
|
||||
t.Fatal("user should be deleted")
|
||||
t.Fatal("session should be deleted")
|
||||
}
|
||||
|
||||
ids, _ := database.GetChannelMemberIDs(ctx, chID)
|
||||
@@ -512,12 +548,12 @@ func TestChannelMembers(t *testing.T) {
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
uid1, _, err := database.CreateUser(ctx, "m1")
|
||||
sid1, _, _, err := database.CreateSession(ctx, "m1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
uid2, _, err := database.CreateUser(ctx, "m2")
|
||||
sid2, _, _, err := database.CreateSession(ctx, "m2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -529,12 +565,12 @@ func TestChannelMembers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid1)
|
||||
err = database.JoinChannel(ctx, chID, sid1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.JoinChannel(ctx, chID, uid2)
|
||||
err = database.JoinChannel(ctx, chID, sid2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -548,17 +584,17 @@ func TestChannelMembers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllChannelMembershipsForUser(t *testing.T) {
|
||||
func TestGetSessionChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
uid, _, _ := createUserWithChannels(
|
||||
sid, _, _ := createSessionWithChannels(
|
||||
t, database, "multi", "#m1", "#m2",
|
||||
)
|
||||
|
||||
channels, err :=
|
||||
database.GetAllChannelMembershipsForUser(
|
||||
t.Context(), uid,
|
||||
database.GetSessionChannels(
|
||||
t.Context(), sid,
|
||||
)
|
||||
if err != nil || len(channels) != 2 {
|
||||
t.Fatalf(
|
||||
@@ -567,3 +603,51 @@ func TestGetAllChannelMembershipsForUser(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueToClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
database := setupTestDB(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, _, token, err := database.CreateSession(
|
||||
ctx, "enqclient",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, clientID, _, err := database.GetSessionByToken(
|
||||
ctx, token,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := json.RawMessage(`["test"]`)
|
||||
|
||||
dbID, _, err := database.InsertMessage(
|
||||
ctx, "PRIVMSG", "sender", "#ch", body, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = database.EnqueueToClient(ctx, clientID, dbID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const batchSize = 10
|
||||
|
||||
msgs, _, err := database.PollMessages(
|
||||
ctx, clientID, 0, batchSize,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user