Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

Select the types of activity you want to include in your feed.

add validation tests

Aly Raffauf (Jan 15, 2026, 1:08 AM EST) 6bca8bb0 c4bb0d3e

+541 -2
+6
.github/workflows/ci.yml
··· 18 18 run: just test 19 19 - name: Generate coverage report 20 20 run: just test-coverage 21 + - name: Display coverage summary 22 + run: | 23 + echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY 24 + echo '```' >> $GITHUB_STEP_SUMMARY 25 + go tool cover -func=coverage.out >> $GITHUB_STEP_SUMMARY 26 + echo '```' >> $GITHUB_STEP_SUMMARY 21 27 - name: Upload coverage report 22 28 uses: actions/upload-artifact@v4 23 29 with:
+2
README.md
··· 1 1 # Switchyard 2 2 3 + [![CI](https://github.com/alyraffauf/switchyard/actions/workflows/ci.yml/badge.svg)](https://github.com/alyraffauf/switchyard/actions/workflows/ci.yml) 4 + 3 5 **A rules-based URL router for Linux.** When you click a link, Switchyard automatically opens it in the right browser based on your rules, or shows a quick picker to let you choose. 4 6 5 7 <p align="center">
+2 -2
justfile
··· 52 52 # Run unit tests 53 53 test: 54 54 @echo "Running unit tests..." 55 - go test -v ./src/config_test.go ./src/config.go 55 + go test -v ./src/config_test.go ./src/validation_test.go ./src/config.go ./src/validation.go 56 56 57 57 # Run tests with coverage report 58 58 test-coverage: 59 59 @echo "Running tests with coverage..." 60 - go test -coverprofile=coverage.out ./src/config_test.go ./src/config.go 60 + go test -coverprofile=coverage.out ./src/config_test.go ./src/validation_test.go ./src/config.go ./src/validation.go 61 61 go tool cover -func=coverage.out 62 62 @echo "" 63 63 @echo "To view HTML coverage report, run: go tool cover -html=coverage.out"
+6
src/config_test.go
··· 208 208 pattern: "*.example.*", 209 209 want: true, 210 210 }, 211 + { 212 + name: "invalid pattern causes regex error", 213 + url: "https://example.com", 214 + pattern: "[invalid", 215 + want: false, 216 + }, 211 217 } 212 218 213 219 for _, tt := range tests {
+525
src/validation_test.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "testing" 7 + ) 8 + 9 + // TestValidateDomainPattern tests domain pattern validation 10 + func TestValidateDomainPattern(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + pattern string 14 + wantErr bool 15 + errMsg string 16 + }{ 17 + { 18 + name: "valid simple domain", 19 + pattern: "example.com", 20 + wantErr: false, 21 + }, 22 + { 23 + name: "valid subdomain", 24 + pattern: "api.example.com", 25 + wantErr: false, 26 + }, 27 + { 28 + name: "valid multiple subdomains", 29 + pattern: "deep.api.example.com", 30 + wantErr: false, 31 + }, 32 + { 33 + name: "valid domain with hyphen", 34 + pattern: "my-site.example.com", 35 + wantErr: false, 36 + }, 37 + { 38 + name: "valid domain with numbers", 39 + pattern: "site123.example.com", 40 + wantErr: false, 41 + }, 42 + { 43 + name: "valid domain with underscore", 44 + pattern: "my_site.example.com", 45 + wantErr: false, 46 + }, 47 + { 48 + name: "empty domain", 49 + pattern: "", 50 + wantErr: true, 51 + errMsg: "Domain cannot be empty", 52 + }, 53 + { 54 + name: "domain with wildcard asterisk", 55 + pattern: "*.example.com", 56 + wantErr: true, 57 + errMsg: "Wildcards not allowed in domain patterns (use Wildcard type instead)", 58 + }, 59 + { 60 + name: "domain with wildcard question mark", 61 + pattern: "example?.com", 62 + wantErr: true, 63 + errMsg: "Wildcards not allowed in domain patterns (use Wildcard type instead)", 64 + }, 65 + { 66 + name: "domain with space", 67 + pattern: "example .com", 68 + wantErr: true, 69 + errMsg: "Domain cannot contain spaces", 70 + }, 71 + { 72 + name: "domain starting with dot", 73 + pattern: ".example.com", 74 + wantErr: true, 75 + errMsg: "Domain cannot start or end with a dot", 76 + }, 77 + { 78 + name: "domain ending with dot", 79 + pattern: "example.com.", 80 + wantErr: true, 81 + errMsg: "Domain cannot start or end with a dot", 82 + }, 83 + { 84 + name: "domain starting with hyphen", 85 + pattern: "-example.com", 86 + wantErr: true, 87 + errMsg: "Domain cannot start or end with a hyphen", 88 + }, 89 + { 90 + name: "domain ending with hyphen", 91 + pattern: "example.com-", 92 + wantErr: true, 93 + errMsg: "Domain cannot start or end with a hyphen", 94 + }, 95 + { 96 + name: "domain with slash", 97 + pattern: "example.com/path", 98 + wantErr: true, 99 + errMsg: "Domain contains invalid character: /", 100 + }, 101 + { 102 + name: "domain with special character", 103 + pattern: "example@test.com", 104 + wantErr: true, 105 + errMsg: "Domain contains invalid character: @", 106 + }, 107 + } 108 + 109 + for _, tt := range tests { 110 + t.Run(tt.name, func(t *testing.T) { 111 + err := validateDomainPattern(tt.pattern) 112 + if tt.wantErr { 113 + if err == nil { 114 + t.Errorf("validateDomainPattern(%q) expected error but got nil", tt.pattern) 115 + } else if tt.errMsg != "" && err.Error() != tt.errMsg { 116 + t.Errorf("validateDomainPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errMsg) 117 + } 118 + } else { 119 + if err != nil { 120 + t.Errorf("validateDomainPattern(%q) unexpected error: %v", tt.pattern, err) 121 + } 122 + } 123 + }) 124 + } 125 + } 126 + 127 + // TestValidateGlobPattern tests wildcard pattern validation 128 + func TestValidateGlobPattern(t *testing.T) { 129 + tests := []struct { 130 + name string 131 + pattern string 132 + wantErr bool 133 + errMsg string 134 + }{ 135 + { 136 + name: "valid wildcard at start", 137 + pattern: "*.example.com", 138 + wantErr: false, 139 + }, 140 + { 141 + name: "valid wildcard at end", 142 + pattern: "example.*", 143 + wantErr: false, 144 + }, 145 + { 146 + name: "valid wildcard in middle", 147 + pattern: "api.*.example.com", 148 + wantErr: false, 149 + }, 150 + { 151 + name: "valid multiple wildcards", 152 + pattern: "*.*.example.com", 153 + wantErr: false, 154 + }, 155 + { 156 + name: "valid domain with hyphen and wildcard", 157 + pattern: "my-*.example.com", 158 + wantErr: false, 159 + }, 160 + { 161 + name: "valid domain with numbers and wildcard", 162 + pattern: "site*.example.com", 163 + wantErr: false, 164 + }, 165 + { 166 + name: "empty pattern", 167 + pattern: "", 168 + wantErr: true, 169 + errMsg: "Wildcard pattern cannot be empty", 170 + }, 171 + { 172 + name: "pattern with space", 173 + pattern: "* .example.com", 174 + wantErr: true, 175 + errMsg: "Wildcard pattern cannot contain spaces", 176 + }, 177 + { 178 + name: "pattern starting with dot (not wildcard)", 179 + pattern: ".example.com", 180 + wantErr: true, 181 + errMsg: "Wildcard pattern cannot start with a dot", 182 + }, 183 + { 184 + name: "pattern ending with dot (not wildcard)", 185 + pattern: "example.com.", 186 + wantErr: true, 187 + errMsg: "Wildcard pattern cannot end with a dot", 188 + }, 189 + { 190 + name: "pattern with special character", 191 + pattern: "example@*.com", 192 + wantErr: true, 193 + errMsg: "Wildcard pattern contains invalid character: @", 194 + }, 195 + { 196 + name: "pattern with slash", 197 + pattern: "example.com/*", 198 + wantErr: true, 199 + errMsg: "Wildcard pattern contains invalid character: /", 200 + }, 201 + } 202 + 203 + for _, tt := range tests { 204 + t.Run(tt.name, func(t *testing.T) { 205 + err := validateGlobPattern(tt.pattern) 206 + if tt.wantErr { 207 + if err == nil { 208 + t.Errorf("validateGlobPattern(%q) expected error but got nil", tt.pattern) 209 + } else if tt.errMsg != "" && err.Error() != tt.errMsg { 210 + t.Errorf("validateGlobPattern(%q) error = %q, want %q", tt.pattern, err.Error(), tt.errMsg) 211 + } 212 + } else { 213 + if err != nil { 214 + t.Errorf("validateGlobPattern(%q) unexpected error: %v", tt.pattern, err) 215 + } 216 + } 217 + }) 218 + } 219 + } 220 + 221 + // TestValidateConditionPattern tests the main condition pattern validator 222 + func TestValidateConditionPattern(t *testing.T) { 223 + tests := []struct { 224 + name string 225 + condType string 226 + pattern string 227 + wantErr bool 228 + }{ 229 + // Domain type 230 + { 231 + name: "valid domain type", 232 + condType: "domain", 233 + pattern: "example.com", 234 + wantErr: false, 235 + }, 236 + { 237 + name: "invalid domain type - wildcard", 238 + condType: "domain", 239 + pattern: "*.example.com", 240 + wantErr: true, 241 + }, 242 + // Keyword type 243 + { 244 + name: "valid keyword type", 245 + condType: "keyword", 246 + pattern: "github", 247 + wantErr: false, 248 + }, 249 + { 250 + name: "valid keyword with special chars", 251 + condType: "keyword", 252 + pattern: "/api/v2/", 253 + wantErr: false, 254 + }, 255 + { 256 + name: "empty keyword", 257 + condType: "keyword", 258 + pattern: "", 259 + wantErr: true, 260 + }, 261 + // Glob type 262 + { 263 + name: "valid glob type", 264 + condType: "glob", 265 + pattern: "*.example.com", 266 + wantErr: false, 267 + }, 268 + { 269 + name: "invalid glob type - spaces", 270 + condType: "glob", 271 + pattern: "* .example.com", 272 + wantErr: true, 273 + }, 274 + // Regex type 275 + { 276 + name: "valid regex type", 277 + condType: "regex", 278 + pattern: "^https://.*\\.example\\.com", 279 + wantErr: false, 280 + }, 281 + { 282 + name: "invalid regex type - bad syntax", 283 + condType: "regex", 284 + pattern: "[invalid", 285 + wantErr: true, 286 + }, 287 + { 288 + name: "empty regex", 289 + condType: "regex", 290 + pattern: "", 291 + wantErr: true, 292 + }, 293 + // Empty pattern universal check 294 + { 295 + name: "empty pattern for domain", 296 + condType: "domain", 297 + pattern: "", 298 + wantErr: true, 299 + }, 300 + { 301 + name: "empty pattern for glob", 302 + condType: "glob", 303 + pattern: "", 304 + wantErr: true, 305 + }, 306 + } 307 + 308 + for _, tt := range tests { 309 + t.Run(tt.name, func(t *testing.T) { 310 + err := validateConditionPattern(tt.condType, tt.pattern) 311 + if tt.wantErr && err == nil { 312 + t.Errorf("validateConditionPattern(%q, %q) expected error but got nil", tt.condType, tt.pattern) 313 + } 314 + if !tt.wantErr && err != nil { 315 + t.Errorf("validateConditionPattern(%q, %q) unexpected error: %v", tt.condType, tt.pattern, err) 316 + } 317 + }) 318 + } 319 + } 320 + 321 + // TestIsConditionValid tests single condition validation 322 + func TestIsConditionValid(t *testing.T) { 323 + tests := []struct { 324 + name string 325 + condition Condition 326 + want bool 327 + }{ 328 + { 329 + name: "valid domain condition", 330 + condition: Condition{ 331 + Type: "domain", 332 + Pattern: "example.com", 333 + }, 334 + want: true, 335 + }, 336 + { 337 + name: "valid keyword condition", 338 + condition: Condition{ 339 + Type: "keyword", 340 + Pattern: "github", 341 + }, 342 + want: true, 343 + }, 344 + { 345 + name: "valid glob condition", 346 + condition: Condition{ 347 + Type: "glob", 348 + Pattern: "*.example.com", 349 + }, 350 + want: true, 351 + }, 352 + { 353 + name: "valid regex condition", 354 + condition: Condition{ 355 + Type: "regex", 356 + Pattern: "^https://.*", 357 + }, 358 + want: true, 359 + }, 360 + { 361 + name: "invalid domain condition - wildcard", 362 + condition: Condition{ 363 + Type: "domain", 364 + Pattern: "*.example.com", 365 + }, 366 + want: false, 367 + }, 368 + { 369 + name: "invalid condition - empty pattern", 370 + condition: Condition{ 371 + Type: "domain", 372 + Pattern: "", 373 + }, 374 + want: false, 375 + }, 376 + { 377 + name: "invalid regex condition", 378 + condition: Condition{ 379 + Type: "regex", 380 + Pattern: "[invalid", 381 + }, 382 + want: false, 383 + }, 384 + } 385 + 386 + for _, tt := range tests { 387 + t.Run(tt.name, func(t *testing.T) { 388 + got := isConditionValid(tt.condition) 389 + if got != tt.want { 390 + t.Errorf("isConditionValid(%v) = %v, want %v", tt.condition, got, tt.want) 391 + } 392 + }) 393 + } 394 + } 395 + 396 + // TestAreAllConditionsValid tests validation of condition slices 397 + func TestAreAllConditionsValid(t *testing.T) { 398 + tests := []struct { 399 + name string 400 + conditions []Condition 401 + want bool 402 + }{ 403 + { 404 + name: "all valid conditions", 405 + conditions: []Condition{ 406 + {Type: "domain", Pattern: "example.com"}, 407 + {Type: "keyword", Pattern: "github"}, 408 + }, 409 + want: true, 410 + }, 411 + { 412 + name: "one invalid condition", 413 + conditions: []Condition{ 414 + {Type: "domain", Pattern: "example.com"}, 415 + {Type: "domain", Pattern: "*.invalid.com"}, 416 + }, 417 + want: false, 418 + }, 419 + { 420 + name: "empty pattern in list", 421 + conditions: []Condition{ 422 + {Type: "domain", Pattern: "example.com"}, 423 + {Type: "keyword", Pattern: ""}, 424 + }, 425 + want: false, 426 + }, 427 + { 428 + name: "empty conditions list", 429 + conditions: []Condition{}, 430 + want: false, 431 + }, 432 + { 433 + name: "nil conditions list", 434 + conditions: nil, 435 + want: false, 436 + }, 437 + { 438 + name: "single valid condition", 439 + conditions: []Condition{ 440 + {Type: "domain", Pattern: "example.com"}, 441 + }, 442 + want: true, 443 + }, 444 + { 445 + name: "invalid regex in list", 446 + conditions: []Condition{ 447 + {Type: "domain", Pattern: "example.com"}, 448 + {Type: "regex", Pattern: "[invalid"}, 449 + }, 450 + want: false, 451 + }, 452 + } 453 + 454 + for _, tt := range tests { 455 + t.Run(tt.name, func(t *testing.T) { 456 + got := areAllConditionsValid(tt.conditions) 457 + if got != tt.want { 458 + t.Errorf("areAllConditionsValid(%v) = %v, want %v", tt.conditions, got, tt.want) 459 + } 460 + }) 461 + } 462 + } 463 + 464 + // TestValidateConditions tests the main validateConditions function 465 + func TestValidateConditions(t *testing.T) { 466 + tests := []struct { 467 + name string 468 + conditions []Condition 469 + want bool 470 + }{ 471 + { 472 + name: "valid conditions with all types", 473 + conditions: []Condition{ 474 + {Type: "domain", Pattern: "example.com"}, 475 + {Type: "keyword", Pattern: "test"}, 476 + {Type: "glob", Pattern: "*.example.com"}, 477 + {Type: "regex", Pattern: "^https://.*"}, 478 + }, 479 + want: true, 480 + }, 481 + { 482 + name: "invalid type", 483 + conditions: []Condition{ 484 + {Type: "invalid_type", Pattern: "example.com"}, 485 + }, 486 + want: false, 487 + }, 488 + { 489 + name: "empty pattern", 490 + conditions: []Condition{ 491 + {Type: "domain", Pattern: ""}, 492 + }, 493 + want: false, 494 + }, 495 + { 496 + name: "invalid regex pattern", 497 + conditions: []Condition{ 498 + {Type: "regex", Pattern: "[unclosed"}, 499 + }, 500 + want: false, 501 + }, 502 + { 503 + name: "empty list", 504 + conditions: []Condition{}, 505 + want: true, 506 + }, 507 + { 508 + name: "mixed valid and invalid", 509 + conditions: []Condition{ 510 + {Type: "domain", Pattern: "valid.com"}, 511 + {Type: "domain", Pattern: ""}, 512 + }, 513 + want: false, 514 + }, 515 + } 516 + 517 + for _, tt := range tests { 518 + t.Run(tt.name, func(t *testing.T) { 519 + got := validateConditions(tt.conditions) 520 + if got != tt.want { 521 + t.Errorf("validateConditions(%v) = %v, want %v", tt.conditions, got, tt.want) 522 + } 523 + }) 524 + } 525 + }