From d3fd0c72b0742c2da7935a8293c587fe48dc26bb Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 27 Oct 2025 12:33:33 +0100 Subject: [PATCH] tests: extra tests for Dicord bit and nullcomp. --- .../compression/nullcomp/nullcomp_test.go | 407 +++++++++++++++++ server/discordbot/discord_bot_test.go | 419 ++++++++++++++++++ 2 files changed, 826 insertions(+) create mode 100644 server/channelserver/compression/nullcomp/nullcomp_test.go create mode 100644 server/discordbot/discord_bot_test.go diff --git a/server/channelserver/compression/nullcomp/nullcomp_test.go b/server/channelserver/compression/nullcomp/nullcomp_test.go new file mode 100644 index 000000000..8b94049aa --- /dev/null +++ b/server/channelserver/compression/nullcomp/nullcomp_test.go @@ -0,0 +1,407 @@ +package nullcomp + +import ( + "bytes" + "testing" +) + +func TestDecompress_WithValidHeader(t *testing.T) { + tests := []struct { + name string + input []byte + expected []byte + }{ + { + name: "empty data after header", + input: []byte("cmp\x2020110113\x20\x20\x20\x00"), + expected: []byte{}, + }, + { + name: "single regular byte", + input: []byte("cmp\x2020110113\x20\x20\x20\x00\x42"), + expected: []byte{0x42}, + }, + { + name: "multiple regular bytes", + input: []byte("cmp\x2020110113\x20\x20\x20\x00\x48\x65\x6c\x6c\x6f"), + expected: []byte("Hello"), + }, + { + name: "single null byte compression", + input: []byte("cmp\x2020110113\x20\x20\x20\x00\x00\x05"), + expected: []byte{0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "multiple null bytes with max count", + input: []byte("cmp\x2020110113\x20\x20\x20\x00\x00\xFF"), + expected: make([]byte, 255), + }, + { + name: "mixed regular and null bytes", + input: append( + []byte("cmp\x2020110113\x20\x20\x20\x00\x48\x65\x6c\x6c\x6f"), + []byte{0x00, 0x03, 0x57, 0x6f, 0x72, 0x6c, 0x64}..., + ), + expected: []byte("Hello\x00\x00\x00World"), + }, + { + name: "multiple null compressions", + input: append( + []byte("cmp\x2020110113\x20\x20\x20\x00"), + []byte{0x41, 0x00, 0x02, 0x42, 0x00, 0x03, 0x43}..., + ), + expected: []byte{0x41, 0x00, 0x00, 0x42, 0x00, 0x00, 0x00, 0x43}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Decompress(tt.input) + if err != nil { + t.Fatalf("Decompress() error = %v", err) + } + if !bytes.Equal(result, tt.expected) { + t.Errorf("Decompress() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestDecompress_WithoutHeader(t *testing.T) { + tests := []struct { + name string + input []byte + expectError bool + expectOriginal bool // Expect original data returned + }{ + { + name: "plain data without header (16+ bytes)", + // Data must be at least 16 bytes to read header + input: []byte("Hello, World!!!!"), // Exactly 16 bytes + expectError: false, + expectOriginal: true, + }, + { + name: "binary data without header (16+ bytes)", + input: []byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + }, + expectError: false, + expectOriginal: true, + }, + { + name: "data shorter than 16 bytes", + // When data is shorter than 16 bytes, Read returns what it can with err=nil + // Then n != len(header) returns nil, nil (not an error) + input: []byte("Short"), + expectError: false, + expectOriginal: false, // Returns empty slice + }, + { + name: "empty data", + input: []byte{}, + expectError: true, // EOF on first read + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Decompress(tt.input) + if tt.expectError { + if err == nil { + t.Errorf("Decompress() expected error but got none") + } + return + } + if err != nil { + t.Fatalf("Decompress() error = %v", err) + } + if tt.expectOriginal && !bytes.Equal(result, tt.input) { + t.Errorf("Decompress() = %v, want %v (original data)", result, tt.input) + } + }) + } +} + +func TestDecompress_InvalidData(t *testing.T) { + tests := []struct { + name string + input []byte + expectErr bool + }{ + { + name: "incomplete header", + // Less than 16 bytes: Read returns what it can (no error), + // but n != len(header) returns nil, nil + input: []byte("cmp\x20201"), + expectErr: false, + }, + { + name: "header with missing null count", + input: []byte("cmp\x2020110113\x20\x20\x20\x00\x00"), + expectErr: false, // Valid header, EOF during decompression is handled + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Decompress(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("Decompress() expected error but got none, result = %v", result) + } + } else { + if err != nil { + t.Errorf("Decompress() unexpected error = %v", err) + } + } + }) + } +} + +func TestCompress_BasicData(t *testing.T) { + tests := []struct { + name string + input []byte + }{ + { + name: "empty data", + input: []byte{}, + }, + { + name: "regular bytes without nulls", + input: []byte("Hello, World!"), + }, + { + name: "single null byte", + input: []byte{0x00}, + }, + { + name: "multiple consecutive nulls", + input: []byte{0x00, 0x00, 0x00, 0x00, 0x00}, + }, + { + name: "mixed data with nulls", + input: []byte("Hello\x00\x00\x00World"), + }, + { + name: "data starting with nulls", + input: []byte{0x00, 0x00, 0x48, 0x65, 0x6c, 0x6c, 0x6f}, + }, + { + name: "data ending with nulls", + input: []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x00, 0x00}, + }, + { + name: "alternating nulls and bytes", + input: []byte{0x41, 0x00, 0x42, 0x00, 0x43}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compressed, err := Compress(tt.input) + if err != nil { + t.Fatalf("Compress() error = %v", err) + } + + // Verify it has the correct header + expectedHeader := []byte("cmp\x2020110113\x20\x20\x20\x00") + if !bytes.HasPrefix(compressed, expectedHeader) { + t.Errorf("Compress() result doesn't have correct header") + } + + // Verify round-trip + decompressed, err := Decompress(compressed) + if err != nil { + t.Fatalf("Decompress() error = %v", err) + } + if !bytes.Equal(decompressed, tt.input) { + t.Errorf("Round-trip failed: got %v, want %v", decompressed, tt.input) + } + }) + } +} + +func TestCompress_LargeNullSequences(t *testing.T) { + tests := []struct { + name string + nullCount int + }{ + { + name: "exactly 255 nulls", + nullCount: 255, + }, + { + name: "256 nulls (overflow case)", + nullCount: 256, + }, + { + name: "500 nulls", + nullCount: 500, + }, + { + name: "1000 nulls", + nullCount: 1000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := make([]byte, tt.nullCount) + compressed, err := Compress(input) + if err != nil { + t.Fatalf("Compress() error = %v", err) + } + + // Verify round-trip + decompressed, err := Decompress(compressed) + if err != nil { + t.Fatalf("Decompress() error = %v", err) + } + if !bytes.Equal(decompressed, input) { + t.Errorf("Round-trip failed: got len=%d, want len=%d", len(decompressed), len(input)) + } + }) + } +} + +func TestCompressDecompress_RoundTrip(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + { + name: "binary data with mixed nulls", + data: []byte{0x01, 0x02, 0x00, 0x00, 0x03, 0x04, 0x00, 0x05}, + }, + { + name: "large binary data", + data: append(append([]byte{0xFF, 0xFE, 0xFD}, make([]byte, 300)...), []byte{0x01, 0x02, 0x03}...), + }, + { + name: "text with embedded nulls", + data: []byte("Test\x00\x00Data\x00\x00\x00End"), + }, + { + name: "all non-null bytes", + data: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}, + }, + { + name: "only null bytes", + data: make([]byte, 100), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Compress + compressed, err := Compress(tt.data) + if err != nil { + t.Fatalf("Compress() error = %v", err) + } + + // Decompress + decompressed, err := Decompress(compressed) + if err != nil { + t.Fatalf("Decompress() error = %v", err) + } + + // Verify + if !bytes.Equal(decompressed, tt.data) { + t.Errorf("Round-trip failed:\ngot = %v\nwant = %v", decompressed, tt.data) + } + }) + } +} + +func TestCompress_CompressionEfficiency(t *testing.T) { + // Test that data with many nulls is actually compressed + input := make([]byte, 1000) + compressed, err := Compress(input) + if err != nil { + t.Fatalf("Compress() error = %v", err) + } + + // The compressed size should be much smaller than the original + // With 1000 nulls, we expect roughly 16 (header) + 4*3 (for 255*3 + 235) bytes + if len(compressed) >= len(input) { + t.Errorf("Compression failed: compressed size (%d) >= input size (%d)", len(compressed), len(input)) + } +} + +func TestDecompress_EdgeCases(t *testing.T) { + tests := []struct { + name string + input []byte + }{ + { + name: "only header", + input: []byte("cmp\x2020110113\x20\x20\x20\x00"), + }, + { + name: "null with count 1", + input: []byte("cmp\x2020110113\x20\x20\x20\x00\x00\x01"), + }, + { + name: "multiple sections of compressed nulls", + input: append([]byte("cmp\x2020110113\x20\x20\x20\x00"), []byte{0x00, 0x10, 0x41, 0x00, 0x20, 0x42}...), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Decompress(tt.input) + if err != nil { + t.Fatalf("Decompress() unexpected error = %v", err) + } + // Just ensure it doesn't crash and returns something + _ = result + }) + } +} + +func BenchmarkCompress(b *testing.B) { + data := make([]byte, 10000) + // Fill with some pattern (half nulls, half data) + for i := 0; i < len(data); i++ { + if i%2 == 0 { + data[i] = 0x00 + } else { + data[i] = byte(i % 256) + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := Compress(data) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkDecompress(b *testing.B) { + data := make([]byte, 10000) + for i := 0; i < len(data); i++ { + if i%2 == 0 { + data[i] = 0x00 + } else { + data[i] = byte(i % 256) + } + } + + compressed, err := Compress(data) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := Decompress(compressed) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/server/discordbot/discord_bot_test.go b/server/discordbot/discord_bot_test.go new file mode 100644 index 000000000..556146f6a --- /dev/null +++ b/server/discordbot/discord_bot_test.go @@ -0,0 +1,419 @@ +package discordbot + +import ( + "regexp" + "testing" +) + +func TestReplaceTextAll(t *testing.T) { + tests := []struct { + name string + text string + regex *regexp.Regexp + handler func(string) string + expected string + }{ + { + name: "replace single match", + text: "Hello @123456789012345678", + regex: regexp.MustCompile(`@(\d+)`), + handler: func(id string) string { + return "@user_" + id + }, + expected: "Hello @user_123456789012345678", + }, + { + name: "replace multiple matches", + text: "Users @111111111111111111 and @222222222222222222", + regex: regexp.MustCompile(`@(\d+)`), + handler: func(id string) string { + return "@user_" + id + }, + expected: "Users @user_111111111111111111 and @user_222222222222222222", + }, + { + name: "no matches", + text: "Hello World", + regex: regexp.MustCompile(`@(\d+)`), + handler: func(id string) string { + return "@user_" + id + }, + expected: "Hello World", + }, + { + name: "replace with empty string", + text: "Remove @123456789012345678 this", + regex: regexp.MustCompile(`@(\d+)`), + handler: func(id string) string { + return "" + }, + expected: "Remove this", + }, + { + name: "replace emoji syntax", + text: "Hello :smile: and :wave:", + regex: regexp.MustCompile(`:(\w+):`), + handler: func(emoji string) string { + return "[" + emoji + "]" + }, + expected: "Hello [smile] and [wave]", + }, + { + name: "complex replacement", + text: "Text with <@!123456789012345678> mention", + regex: regexp.MustCompile(`<@!?(\d+)>`), + handler: func(id string) string { + return "@user_" + id + }, + expected: "Text with @user_123456789012345678 mention", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ReplaceTextAll(tt.text, tt.regex, tt.handler) + if result != tt.expected { + t.Errorf("ReplaceTextAll() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestReplaceTextAll_UserMentionPattern(t *testing.T) { + // Test the actual user mention regex used in NormalizeDiscordMessage + userRegex := regexp.MustCompile(`<@!?(\d{17,19})>`) + + tests := []struct { + name string + text string + expected []string // Expected captured IDs + }{ + { + name: "standard mention", + text: "<@123456789012345678>", + expected: []string{"123456789012345678"}, + }, + { + name: "nickname mention", + text: "<@!123456789012345678>", + expected: []string{"123456789012345678"}, + }, + { + name: "multiple mentions", + text: "<@123456789012345678> and <@!987654321098765432>", + expected: []string{"123456789012345678", "987654321098765432"}, + }, + { + name: "17 digit ID", + text: "<@12345678901234567>", + expected: []string{"12345678901234567"}, + }, + { + name: "19 digit ID", + text: "<@1234567890123456789>", + expected: []string{"1234567890123456789"}, + }, + { + name: "invalid - too short", + text: "<@1234567890123456>", + expected: []string{}, + }, + { + name: "invalid - too long", + text: "<@12345678901234567890>", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := userRegex.FindAllStringSubmatch(tt.text, -1) + if len(matches) != len(tt.expected) { + t.Fatalf("Expected %d matches, got %d", len(tt.expected), len(matches)) + } + for i, match := range matches { + if len(match) < 2 { + t.Fatalf("Match %d: expected capture group", i) + } + if match[1] != tt.expected[i] { + t.Errorf("Match %d: got ID %q, want %q", i, match[1], tt.expected[i]) + } + } + }) + } +} + +func TestReplaceTextAll_EmojiPattern(t *testing.T) { + // Test the actual emoji regex used in NormalizeDiscordMessage + emojiRegex := regexp.MustCompile(`(?:)?`) + + tests := []struct { + name string + text string + expectedName []string // Expected emoji names + }{ + { + name: "simple emoji", + text: ":smile:", + expectedName: []string{"smile"}, + }, + { + name: "custom emoji", + text: "<:customemoji:123456789012345678>", + expectedName: []string{"customemoji"}, + }, + { + name: "animated emoji", + text: "", + expectedName: []string{"animated"}, + }, + { + name: "multiple emojis", + text: ":wave: <:custom:123456789012345678> :smile:", + expectedName: []string{"wave", "custom", "smile"}, + }, + { + name: "emoji with underscores", + text: ":thumbs_up:", + expectedName: []string{"thumbs_up"}, + }, + { + name: "emoji with numbers", + text: ":emoji123:", + expectedName: []string{"emoji123"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := emojiRegex.FindAllStringSubmatch(tt.text, -1) + if len(matches) != len(tt.expectedName) { + t.Fatalf("Expected %d matches, got %d", len(tt.expectedName), len(matches)) + } + for i, match := range matches { + if len(match) < 2 { + t.Fatalf("Match %d: expected capture group", i) + } + if match[1] != tt.expectedName[i] { + t.Errorf("Match %d: got name %q, want %q", i, match[1], tt.expectedName[i]) + } + } + }) + } +} + +func TestNormalizeDiscordMessage_Integration(t *testing.T) { + // Create a mock bot for testing the normalization logic + // Note: We can't fully test this without a real Discord session, + // but we can test the regex patterns and structure + tests := []struct { + name string + input string + contains []string // Strings that should be in the output + }{ + { + name: "plain text unchanged", + input: "Hello World", + contains: []string{"Hello World"}, + }, + { + name: "user mention format", + input: "Hello <@123456789012345678>", + // We can't test the actual replacement without a real Discord session + // but we can verify the pattern is matched + contains: []string{"Hello"}, + }, + { + name: "emoji format preserved", + input: "Hello :smile:", + contains: []string{"Hello", ":smile:"}, + }, + { + name: "mixed content", + input: "<@123456789012345678> sent :wave:", + contains: []string{"sent"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test that the message contains expected parts + for _, expected := range tt.contains { + if len(expected) > 0 && !contains(tt.input, expected) { + t.Errorf("Input %q should contain %q", tt.input, expected) + } + } + }) + } +} + +func TestCommands_Structure(t *testing.T) { + // Test that the Commands slice is properly structured + if len(Commands) == 0 { + t.Error("Commands slice should not be empty") + } + + expectedCommands := map[string]bool{ + "link": false, + "password": false, + } + + for _, cmd := range Commands { + if cmd.Name == "" { + t.Error("Command should have a name") + } + if cmd.Description == "" { + t.Errorf("Command %q should have a description", cmd.Name) + } + + if _, exists := expectedCommands[cmd.Name]; exists { + expectedCommands[cmd.Name] = true + } + } + + // Verify expected commands exist + for name, found := range expectedCommands { + if !found { + t.Errorf("Expected command %q not found in Commands", name) + } + } +} + +func TestCommands_LinkCommand(t *testing.T) { + var linkCmd *struct { + Name string + Description string + Options []struct { + Type int + Name string + Description string + Required bool + } + } + + // Find the link command + for _, cmd := range Commands { + if cmd.Name == "link" { + // Verify structure + if cmd.Description == "" { + t.Error("Link command should have a description") + } + if len(cmd.Options) == 0 { + t.Error("Link command should have options") + } + + // Verify token option + for _, opt := range cmd.Options { + if opt.Name == "token" { + if !opt.Required { + t.Error("Token option should be required") + } + if opt.Description == "" { + t.Error("Token option should have a description") + } + return + } + } + t.Error("Link command should have a 'token' option") + } + } + + if linkCmd == nil { + t.Error("Link command not found") + } +} + +func TestCommands_PasswordCommand(t *testing.T) { + // Find the password command + for _, cmd := range Commands { + if cmd.Name == "password" { + // Verify structure + if cmd.Description == "" { + t.Error("Password command should have a description") + } + if len(cmd.Options) == 0 { + t.Error("Password command should have options") + } + + // Verify password option + for _, opt := range cmd.Options { + if opt.Name == "password" { + if !opt.Required { + t.Error("Password option should be required") + } + if opt.Description == "" { + t.Error("Password option should have a description") + } + return + } + } + t.Error("Password command should have a 'password' option") + } + } + + t.Error("Password command not found") +} + +func TestDiscordBotStruct(t *testing.T) { + // Test that the DiscordBot struct can be initialized + bot := &DiscordBot{ + Session: nil, // Can't create real session in tests + MainGuild: nil, + RelayChannel: nil, + } + + if bot == nil { + t.Error("Failed to create DiscordBot struct") + } +} + +func TestOptionsStruct(t *testing.T) { + // Test that the Options struct can be initialized + opts := Options{ + Config: nil, + Logger: nil, + } + + // Just verify we can create the struct + _ = opts +} + +// Helper function +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func BenchmarkReplaceTextAll(b *testing.B) { + text := "Message with <@123456789012345678> and <@!987654321098765432> mentions and :smile: :wave: emojis" + userRegex := regexp.MustCompile(`<@!?(\d{17,19})>`) + handler := func(id string) string { + return "@user_" + id + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ReplaceTextAll(text, userRegex, handler) + } +} + +func BenchmarkReplaceTextAll_NoMatches(b *testing.B) { + text := "Message with no mentions or special syntax at all, just plain text" + userRegex := regexp.MustCompile(`<@!?(\d{17,19})>`) + handler := func(id string) string { + return "@user_" + id + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = ReplaceTextAll(text, userRegex, handler) + } +}