A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

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

add and fix more query tests

+799 -3
+6 -3
pkg/appview/db/queries.go
··· 701 701 var m Manifest 702 702 703 703 // Use sql.NullString for nullable annotation fields 704 - var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 704 + var title, description, sourceURL, documentationURL, licenses, iconURL, readmeURL sql.NullString 705 705 706 706 err := db.QueryRow(` 707 707 SELECT id, did, repository, digest, hold_endpoint, schema_version, 708 708 media_type, config_digest, config_size, created_at, 709 - title, description, source_url, documentation_url, licenses, icon_url 709 + title, description, source_url, documentation_url, licenses, icon_url, readme_url 710 710 FROM manifests 711 711 WHERE digest = ? 712 712 `, digest).Scan(&m.ID, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint, 713 713 &m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize, 714 714 &m.CreatedAt, 715 - &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL) 715 + &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL, &readmeURL) 716 716 717 717 if err != nil { 718 718 return nil, err ··· 736 736 } 737 737 if iconURL.Valid { 738 738 m.IconURL = iconURL.String 739 + } 740 + if readmeURL.Valid { 741 + m.ReadmeURL = readmeURL.String 739 742 } 740 743 741 744 return &m, nil
+793
pkg/appview/db/queries_test.go
··· 118 118 t.Error("Expected all empty strings for manifest with NULL metadata fields") 119 119 } 120 120 } 121 + 122 + func TestInsertManifest(t *testing.T) { 123 + // Create in-memory test database 124 + db, err := InitDB(":memory:") 125 + if err != nil { 126 + t.Fatalf("Failed to init database: %v", err) 127 + } 128 + defer db.Close() 129 + 130 + // Insert test user 131 + testUser := &User{ 132 + DID: "did:plc:test123", 133 + Handle: "testuser.bsky.social", 134 + PDSEndpoint: "https://test.pds.example.com", 135 + Avatar: "", 136 + LastSeen: time.Now(), 137 + } 138 + if err := UpsertUser(db, testUser); err != nil { 139 + t.Fatalf("Failed to insert user: %v", err) 140 + } 141 + 142 + // Test 1: Insert new manifest with all fields populated 143 + manifest1 := &Manifest{ 144 + DID: testUser.DID, 145 + Repository: "myapp", 146 + Digest: "sha256:abc123", 147 + HoldEndpoint: "did:web:hold.example.com", 148 + SchemaVersion: 2, 149 + MediaType: "application/vnd.oci.image.manifest.v1+json", 150 + ConfigDigest: "sha256:config123", 151 + ConfigSize: 1024, 152 + CreatedAt: time.Now(), 153 + Title: "My App", 154 + Description: "A cool application", 155 + SourceURL: "https://github.com/user/myapp", 156 + DocumentationURL: "https://docs.example.com", 157 + Licenses: "MIT", 158 + IconURL: "https://example.com/icon.png", 159 + ReadmeURL: "https://github.com/user/myapp/blob/main/README.md", 160 + } 161 + 162 + id1, err := InsertManifest(db, manifest1) 163 + if err != nil { 164 + t.Fatalf("Failed to insert manifest: %v", err) 165 + } 166 + if id1 == 0 { 167 + t.Error("Expected non-zero manifest ID") 168 + } 169 + 170 + // Verify the manifest was inserted correctly 171 + retrieved, err := GetManifest(db, manifest1.Digest) 172 + if err != nil { 173 + t.Fatalf("Failed to retrieve manifest: %v", err) 174 + } 175 + if retrieved.ID != id1 { 176 + t.Errorf("Expected ID %d, got %d", id1, retrieved.ID) 177 + } 178 + if retrieved.Title != "My App" { 179 + t.Errorf("Expected title 'My App', got '%s'", retrieved.Title) 180 + } 181 + if retrieved.ReadmeURL != "https://github.com/user/myapp/blob/main/README.md" { 182 + t.Errorf("Expected readme_url, got '%s'", retrieved.ReadmeURL) 183 + } 184 + 185 + // Test 2: Insert manifest with minimal fields (NULLs for annotations) 186 + manifest2 := &Manifest{ 187 + DID: testUser.DID, 188 + Repository: "minimal", 189 + Digest: "sha256:minimal123", 190 + HoldEndpoint: "did:web:hold.example.com", 191 + SchemaVersion: 2, 192 + MediaType: "application/vnd.oci.image.manifest.v1+json", 193 + CreatedAt: time.Now(), 194 + } 195 + 196 + id2, err := InsertManifest(db, manifest2) 197 + if err != nil { 198 + t.Fatalf("Failed to insert minimal manifest: %v", err) 199 + } 200 + if id2 == 0 { 201 + t.Error("Expected non-zero manifest ID for minimal manifest") 202 + } 203 + 204 + retrieved2, err := GetManifest(db, manifest2.Digest) 205 + if err != nil { 206 + t.Fatalf("Failed to retrieve minimal manifest: %v", err) 207 + } 208 + if retrieved2.Title != "" { 209 + t.Errorf("Expected empty title for minimal manifest, got '%s'", retrieved2.Title) 210 + } 211 + 212 + // Test 3: Upsert existing manifest (same DID+repo+digest) - verify UPDATE path 213 + manifest1Updated := &Manifest{ 214 + DID: testUser.DID, 215 + Repository: "myapp", 216 + Digest: "sha256:abc123", // Same digest - should trigger UPDATE 217 + HoldEndpoint: "did:web:hold2.example.com", 218 + SchemaVersion: 2, 219 + MediaType: "application/vnd.oci.image.manifest.v1+json", 220 + ConfigDigest: "sha256:newconfig", 221 + ConfigSize: 2048, 222 + CreatedAt: time.Now(), 223 + Title: "My App v2", 224 + Description: "An updated application", 225 + SourceURL: "https://github.com/user/myapp-v2", 226 + DocumentationURL: "https://v2.docs.example.com", 227 + Licenses: "Apache-2.0", 228 + IconURL: "https://example.com/icon-v2.png", 229 + ReadmeURL: "https://github.com/user/myapp/blob/v2/README.md", 230 + } 231 + 232 + id3, err := InsertManifest(db, manifest1Updated) 233 + if err != nil { 234 + t.Fatalf("Failed to upsert manifest: %v", err) 235 + } 236 + // ID should be the same as the original insert (UPDATE, not INSERT) 237 + if id3 != id1 { 238 + t.Errorf("Expected upsert to return same ID %d, got %d", id1, id3) 239 + } 240 + 241 + // Verify the manifest was updated 242 + retrievedUpdated, err := GetManifest(db, manifest1.Digest) 243 + if err != nil { 244 + t.Fatalf("Failed to retrieve updated manifest: %v", err) 245 + } 246 + if retrievedUpdated.Title != "My App v2" { 247 + t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdated.Title) 248 + } 249 + if retrievedUpdated.HoldEndpoint != "did:web:hold2.example.com" { 250 + t.Errorf("Expected updated hold_endpoint, got '%s'", retrievedUpdated.HoldEndpoint) 251 + } 252 + if retrievedUpdated.ReadmeURL != "https://github.com/user/myapp/blob/v2/README.md" { 253 + t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdated.ReadmeURL) 254 + } 255 + 256 + // Test 4: Verify count - should have 2 manifests (not 3, because one was upserted) 257 + digests, err := GetManifestDigestsForDID(db, testUser.DID) 258 + if err != nil { 259 + t.Fatalf("Failed to get manifest digests: %v", err) 260 + } 261 + if len(digests) != 2 { 262 + t.Errorf("Expected 2 manifests after upsert, got %d", len(digests)) 263 + } 264 + } 265 + 266 + func TestUserManagement(t *testing.T) { 267 + // Create in-memory test database 268 + db, err := InitDB(":memory:") 269 + if err != nil { 270 + t.Fatalf("Failed to init database: %v", err) 271 + } 272 + defer db.Close() 273 + 274 + // Test 1: Upsert new user 275 + user1 := &User{ 276 + DID: "did:plc:alice123", 277 + Handle: "alice.bsky.social", 278 + PDSEndpoint: "https://bsky.social", 279 + Avatar: "https://example.com/avatar.jpg", 280 + LastSeen: time.Now(), 281 + } 282 + 283 + err = UpsertUser(db, user1) 284 + if err != nil { 285 + t.Fatalf("Failed to upsert new user: %v", err) 286 + } 287 + 288 + // Test 2: GetUserByDID - found 289 + retrieved, err := GetUserByDID(db, user1.DID) 290 + if err != nil { 291 + t.Fatalf("Failed to get user by DID: %v", err) 292 + } 293 + if retrieved == nil { 294 + t.Fatal("Expected user to be found, got nil") 295 + } 296 + if retrieved.Handle != "alice.bsky.social" { 297 + t.Errorf("Expected handle 'alice.bsky.social', got '%s'", retrieved.Handle) 298 + } 299 + if retrieved.Avatar != "https://example.com/avatar.jpg" { 300 + t.Errorf("Expected avatar URL, got '%s'", retrieved.Avatar) 301 + } 302 + 303 + // Test 3: GetUserByHandle - found 304 + retrievedByHandle, err := GetUserByHandle(db, user1.Handle) 305 + if err != nil { 306 + t.Fatalf("Failed to get user by handle: %v", err) 307 + } 308 + if retrievedByHandle == nil { 309 + t.Fatal("Expected user to be found by handle, got nil") 310 + } 311 + if retrievedByHandle.DID != user1.DID { 312 + t.Errorf("Expected DID '%s', got '%s'", user1.DID, retrievedByHandle.DID) 313 + } 314 + 315 + // Test 4: GetUserByDID - not found 316 + notFound, err := GetUserByDID(db, "did:plc:nonexistent") 317 + if err != nil { 318 + t.Fatalf("Expected no error for nonexistent user, got: %v", err) 319 + } 320 + if notFound != nil { 321 + t.Error("Expected nil for nonexistent user") 322 + } 323 + 324 + // Test 5: GetUserByHandle - not found 325 + notFoundByHandle, err := GetUserByHandle(db, "nonexistent.bsky.social") 326 + if err != nil { 327 + t.Fatalf("Expected no error for nonexistent handle, got: %v", err) 328 + } 329 + if notFoundByHandle != nil { 330 + t.Error("Expected nil for nonexistent handle") 331 + } 332 + 333 + // Test 6: Upsert existing user (update) 334 + user1.Handle = "alice-new.bsky.social" // Change handle 335 + user1.Avatar = "" // Remove avatar 336 + user1.LastSeen = time.Now().Add(1 * time.Hour) 337 + 338 + err = UpsertUser(db, user1) 339 + if err != nil { 340 + t.Fatalf("Failed to upsert existing user: %v", err) 341 + } 342 + 343 + // Verify update 344 + updated, err := GetUserByDID(db, user1.DID) 345 + if err != nil { 346 + t.Fatalf("Failed to get updated user: %v", err) 347 + } 348 + if updated.Handle != "alice-new.bsky.social" { 349 + t.Errorf("Expected updated handle 'alice-new.bsky.social', got '%s'", updated.Handle) 350 + } 351 + if updated.Avatar != "" { 352 + t.Errorf("Expected empty avatar after update, got '%s'", updated.Avatar) 353 + } 354 + 355 + // Test 7: User with empty avatar (NULL) 356 + user2 := &User{ 357 + DID: "did:plc:bob456", 358 + Handle: "bob.bsky.social", 359 + PDSEndpoint: "https://bsky.social", 360 + Avatar: "", // Empty avatar 361 + LastSeen: time.Now(), 362 + } 363 + 364 + err = UpsertUser(db, user2) 365 + if err != nil { 366 + t.Fatalf("Failed to upsert user with empty avatar: %v", err) 367 + } 368 + 369 + retrieved2, err := GetUserByDID(db, user2.DID) 370 + if err != nil { 371 + t.Fatalf("Failed to get user with empty avatar: %v", err) 372 + } 373 + if retrieved2.Avatar != "" { 374 + t.Errorf("Expected empty avatar, got '%s'", retrieved2.Avatar) 375 + } 376 + } 377 + 378 + func TestManifestOperations(t *testing.T) { 379 + // Create in-memory test database 380 + db, err := InitDB(":memory:") 381 + if err != nil { 382 + t.Fatalf("Failed to init database: %v", err) 383 + } 384 + defer db.Close() 385 + 386 + // Setup: Create test user 387 + testUser := &User{ 388 + DID: "did:plc:test123", 389 + Handle: "test.bsky.social", 390 + PDSEndpoint: "https://test.pds.example.com", 391 + LastSeen: time.Now(), 392 + } 393 + if err := UpsertUser(db, testUser); err != nil { 394 + t.Fatalf("Failed to create test user: %v", err) 395 + } 396 + 397 + // Insert test manifests 398 + manifests := []*Manifest{ 399 + { 400 + DID: testUser.DID, 401 + Repository: "app1", 402 + Digest: "sha256:aaa", 403 + HoldEndpoint: "did:web:hold.example.com", 404 + SchemaVersion: 2, 405 + MediaType: "application/vnd.oci.image.manifest.v1+json", 406 + CreatedAt: time.Now(), 407 + Title: "App 1", 408 + }, 409 + { 410 + DID: testUser.DID, 411 + Repository: "app1", 412 + Digest: "sha256:bbb", 413 + HoldEndpoint: "did:web:hold.example.com", 414 + SchemaVersion: 2, 415 + MediaType: "application/vnd.oci.image.manifest.v1+json", 416 + CreatedAt: time.Now(), 417 + Title: "App 1 v2", 418 + }, 419 + { 420 + DID: testUser.DID, 421 + Repository: "app2", 422 + Digest: "sha256:ccc", 423 + HoldEndpoint: "did:web:hold.example.com", 424 + SchemaVersion: 2, 425 + MediaType: "application/vnd.oci.image.manifest.v1+json", 426 + CreatedAt: time.Now(), 427 + Title: "App 2", 428 + }, 429 + } 430 + 431 + for _, m := range manifests { 432 + _, err := InsertManifest(db, m) 433 + if err != nil { 434 + t.Fatalf("Failed to insert manifest: %v", err) 435 + } 436 + } 437 + 438 + // Test 1: GetManifest - found 439 + retrieved, err := GetManifest(db, "sha256:aaa") 440 + if err != nil { 441 + t.Fatalf("Failed to get manifest: %v", err) 442 + } 443 + if retrieved.Title != "App 1" { 444 + t.Errorf("Expected title 'App 1', got '%s'", retrieved.Title) 445 + } 446 + 447 + // Test 2: GetManifest - not found 448 + notFound, err := GetManifest(db, "sha256:nonexistent") 449 + if err == nil { 450 + t.Error("Expected error for nonexistent manifest") 451 + } 452 + if notFound != nil { 453 + t.Error("Expected nil for nonexistent manifest") 454 + } 455 + 456 + // Test 3: GetManifestDigestsForDID - multiple manifests 457 + digests, err := GetManifestDigestsForDID(db, testUser.DID) 458 + if err != nil { 459 + t.Fatalf("Failed to get manifest digests: %v", err) 460 + } 461 + if len(digests) != 3 { 462 + t.Errorf("Expected 3 manifests, got %d", len(digests)) 463 + } 464 + 465 + // Test 4: GetManifestDigestsForDID - no manifests 466 + noDigests, err := GetManifestDigestsForDID(db, "did:plc:nonexistent") 467 + if err != nil { 468 + t.Fatalf("Expected no error for user with no manifests, got: %v", err) 469 + } 470 + if len(noDigests) != 0 { 471 + t.Errorf("Expected 0 manifests for nonexistent user, got %d", len(noDigests)) 472 + } 473 + 474 + // Test 5: DeleteManifestsNotInList - keep some, delete others 475 + keepDigests := []string{"sha256:aaa", "sha256:ccc"} 476 + err = DeleteManifestsNotInList(db, testUser.DID, keepDigests) 477 + if err != nil { 478 + t.Fatalf("Failed to delete manifests not in list: %v", err) 479 + } 480 + 481 + // Verify only sha256:aaa and sha256:ccc remain 482 + remaining, err := GetManifestDigestsForDID(db, testUser.DID) 483 + if err != nil { 484 + t.Fatalf("Failed to get remaining manifests: %v", err) 485 + } 486 + if len(remaining) != 2 { 487 + t.Errorf("Expected 2 remaining manifests, got %d", len(remaining)) 488 + } 489 + 490 + // Verify sha256:bbb was deleted 491 + deleted, err := GetManifest(db, "sha256:bbb") 492 + if err == nil { 493 + t.Error("Expected error for deleted manifest") 494 + } 495 + if deleted != nil { 496 + t.Error("Expected nil for deleted manifest") 497 + } 498 + 499 + // Test 6: DeleteManifestsNotInList - empty list (delete all) 500 + err = DeleteManifestsNotInList(db, testUser.DID, []string{}) 501 + if err != nil { 502 + t.Fatalf("Failed to delete all manifests: %v", err) 503 + } 504 + 505 + allGone, err := GetManifestDigestsForDID(db, testUser.DID) 506 + if err != nil { 507 + t.Fatalf("Failed to get manifests after delete all: %v", err) 508 + } 509 + if len(allGone) != 0 { 510 + t.Errorf("Expected 0 manifests after delete all, got %d", len(allGone)) 511 + } 512 + 513 + // Test 7: DeleteManifest - specific deletion (re-insert for this test) 514 + manifest := &Manifest{ 515 + DID: testUser.DID, 516 + Repository: "app3", 517 + Digest: "sha256:ddd", 518 + HoldEndpoint: "did:web:hold.example.com", 519 + SchemaVersion: 2, 520 + MediaType: "application/vnd.oci.image.manifest.v1+json", 521 + CreatedAt: time.Now(), 522 + } 523 + _, err = InsertManifest(db, manifest) 524 + if err != nil { 525 + t.Fatalf("Failed to insert manifest for delete test: %v", err) 526 + } 527 + 528 + // Delete by DID+repo+digest 529 + err = DeleteManifest(db, testUser.DID, "app3", "sha256:ddd") 530 + if err != nil { 531 + t.Fatalf("Failed to delete manifest: %v", err) 532 + } 533 + 534 + // Verify deletion 535 + afterDelete, err := GetManifest(db, "sha256:ddd") 536 + if err == nil { 537 + t.Error("Expected error for deleted manifest") 538 + } 539 + if afterDelete != nil { 540 + t.Error("Expected nil after deletion") 541 + } 542 + } 543 + 544 + func TestIsManifestTagged(t *testing.T) { 545 + // Create in-memory test database 546 + db, err := InitDB(":memory:") 547 + if err != nil { 548 + t.Fatalf("Failed to init database: %v", err) 549 + } 550 + defer db.Close() 551 + 552 + // Setup: Create test user 553 + testUser := &User{ 554 + DID: "did:plc:test123", 555 + Handle: "test.bsky.social", 556 + PDSEndpoint: "https://test.pds.example.com", 557 + LastSeen: time.Now(), 558 + } 559 + if err := UpsertUser(db, testUser); err != nil { 560 + t.Fatalf("Failed to create test user: %v", err) 561 + } 562 + 563 + // Insert manifest 564 + manifest := &Manifest{ 565 + DID: testUser.DID, 566 + Repository: "myapp", 567 + Digest: "sha256:abc123", 568 + HoldEndpoint: "did:web:hold.example.com", 569 + SchemaVersion: 2, 570 + MediaType: "application/vnd.oci.image.manifest.v1+json", 571 + CreatedAt: time.Now(), 572 + } 573 + _, err = InsertManifest(db, manifest) 574 + if err != nil { 575 + t.Fatalf("Failed to insert manifest: %v", err) 576 + } 577 + 578 + // Test 1: Manifest without tags 579 + tagged, err := IsManifestTagged(db, testUser.DID, "myapp", "sha256:abc123") 580 + if err != nil { 581 + t.Fatalf("Failed to check if manifest is tagged: %v", err) 582 + } 583 + if tagged { 584 + t.Error("Expected manifest to not be tagged") 585 + } 586 + 587 + // Test 2: Add a tag 588 + tag := &Tag{ 589 + DID: testUser.DID, 590 + Repository: "myapp", 591 + Tag: "latest", 592 + Digest: "sha256:abc123", 593 + CreatedAt: time.Now(), 594 + } 595 + err = UpsertTag(db, tag) 596 + if err != nil { 597 + t.Fatalf("Failed to insert tag: %v", err) 598 + } 599 + 600 + // Test 3: Manifest with tag 601 + taggedNow, err := IsManifestTagged(db, testUser.DID, "myapp", "sha256:abc123") 602 + if err != nil { 603 + t.Fatalf("Failed to check if manifest is tagged: %v", err) 604 + } 605 + if !taggedNow { 606 + t.Error("Expected manifest to be tagged") 607 + } 608 + } 609 + 610 + func TestTagOperations(t *testing.T) { 611 + // Create in-memory test database 612 + db, err := InitDB(":memory:") 613 + if err != nil { 614 + t.Fatalf("Failed to init database: %v", err) 615 + } 616 + defer db.Close() 617 + 618 + // Setup: Create test user and manifests 619 + testUser := &User{ 620 + DID: "did:plc:test123", 621 + Handle: "test.bsky.social", 622 + PDSEndpoint: "https://test.pds.example.com", 623 + LastSeen: time.Now(), 624 + } 625 + if err := UpsertUser(db, testUser); err != nil { 626 + t.Fatalf("Failed to create test user: %v", err) 627 + } 628 + 629 + manifest := &Manifest{ 630 + DID: testUser.DID, 631 + Repository: "myapp", 632 + Digest: "sha256:abc123", 633 + HoldEndpoint: "did:web:hold.example.com", 634 + SchemaVersion: 2, 635 + MediaType: "application/vnd.oci.image.manifest.v1+json", 636 + CreatedAt: time.Now(), 637 + } 638 + _, err = InsertManifest(db, manifest) 639 + if err != nil { 640 + t.Fatalf("Failed to insert manifest: %v", err) 641 + } 642 + 643 + // Test 1: UpsertTag - insert new tag 644 + tag1 := &Tag{ 645 + DID: testUser.DID, 646 + Repository: "myapp", 647 + Tag: "latest", 648 + Digest: "sha256:abc123", 649 + CreatedAt: time.Now(), 650 + } 651 + err = UpsertTag(db, tag1) 652 + if err != nil { 653 + t.Fatalf("Failed to upsert tag: %v", err) 654 + } 655 + 656 + // Test 2: GetTagsForDID - should have 1 tag 657 + tags, err := GetTagsForDID(db, testUser.DID) 658 + if err != nil { 659 + t.Fatalf("Failed to get tags: %v", err) 660 + } 661 + if len(tags) != 1 { 662 + t.Errorf("Expected 1 tag, got %d", len(tags)) 663 + } 664 + if tags[0].Repository != "myapp" || tags[0].Tag != "latest" { 665 + t.Errorf("Expected myapp:latest, got %s:%s", tags[0].Repository, tags[0].Tag) 666 + } 667 + 668 + // Test 3: UpsertTag - update existing tag (point to new digest) 669 + tag1Updated := &Tag{ 670 + DID: testUser.DID, 671 + Repository: "myapp", 672 + Tag: "latest", // Same tag 673 + Digest: "sha256:new456", 674 + CreatedAt: time.Now(), 675 + } 676 + err = UpsertTag(db, tag1Updated) 677 + if err != nil { 678 + t.Fatalf("Failed to update tag: %v", err) 679 + } 680 + 681 + // Verify update - should still have 1 tag but with new digest 682 + tagsAfterUpdate, err := GetTagsForDID(db, testUser.DID) 683 + if err != nil { 684 + t.Fatalf("Failed to get tags after update: %v", err) 685 + } 686 + if len(tagsAfterUpdate) != 1 { 687 + t.Errorf("Expected 1 tag after update, got %d", len(tagsAfterUpdate)) 688 + } 689 + if tagsAfterUpdate[0].Tag != "latest" { 690 + t.Errorf("Expected tag 'latest', got '%s'", tagsAfterUpdate[0].Tag) 691 + } 692 + 693 + // Test 4: Add more tags 694 + tag2 := &Tag{ 695 + DID: testUser.DID, 696 + Repository: "myapp", 697 + Tag: "v1.0.0", 698 + Digest: "sha256:abc123", 699 + CreatedAt: time.Now(), 700 + } 701 + tag3 := &Tag{ 702 + DID: testUser.DID, 703 + Repository: "otherapp", 704 + Tag: "latest", 705 + Digest: "sha256:xyz789", 706 + CreatedAt: time.Now(), 707 + } 708 + err = UpsertTag(db, tag2) 709 + if err != nil { 710 + t.Fatalf("Failed to insert tag2: %v", err) 711 + } 712 + err = UpsertTag(db, tag3) 713 + if err != nil { 714 + t.Fatalf("Failed to insert tag3: %v", err) 715 + } 716 + 717 + // Verify count 718 + allTags, err := GetTagsForDID(db, testUser.DID) 719 + if err != nil { 720 + t.Fatalf("Failed to get all tags: %v", err) 721 + } 722 + if len(allTags) != 3 { 723 + t.Errorf("Expected 3 tags, got %d", len(allTags)) 724 + } 725 + 726 + // Test 5: DeleteTagsNotInList - keep some, delete others 727 + keepTags := []struct{ Repository, Tag string }{ 728 + {Repository: "myapp", Tag: "latest"}, 729 + {Repository: "otherapp", Tag: "latest"}, 730 + } 731 + err = DeleteTagsNotInList(db, testUser.DID, keepTags) 732 + if err != nil { 733 + t.Fatalf("Failed to delete tags not in list: %v", err) 734 + } 735 + 736 + // Verify v1.0.0 was deleted 737 + remaining, err := GetTagsForDID(db, testUser.DID) 738 + if err != nil { 739 + t.Fatalf("Failed to get remaining tags: %v", err) 740 + } 741 + if len(remaining) != 2 { 742 + t.Errorf("Expected 2 remaining tags, got %d", len(remaining)) 743 + } 744 + 745 + // Test 6: DeleteTag - specific deletion 746 + err = DeleteTag(db, testUser.DID, "myapp", "latest") 747 + if err != nil { 748 + t.Fatalf("Failed to delete tag: %v", err) 749 + } 750 + 751 + // Verify deletion 752 + afterDelete, err := GetTagsForDID(db, testUser.DID) 753 + if err != nil { 754 + t.Fatalf("Failed to get tags after delete: %v", err) 755 + } 756 + if len(afterDelete) != 1 { 757 + t.Errorf("Expected 1 tag after delete, got %d", len(afterDelete)) 758 + } 759 + if afterDelete[0].Repository != "otherapp" { 760 + t.Errorf("Wrong tag remained: %s:%s", afterDelete[0].Repository, afterDelete[0].Tag) 761 + } 762 + 763 + // Test 7: GetTagsForDID - no tags 764 + noTags, err := GetTagsForDID(db, "did:plc:nonexistent") 765 + if err != nil { 766 + t.Fatalf("Expected no error for user with no tags, got: %v", err) 767 + } 768 + if len(noTags) != 0 { 769 + t.Errorf("Expected 0 tags for nonexistent user, got %d", len(noTags)) 770 + } 771 + } 772 + 773 + func TestGetTagsWithPlatforms(t *testing.T) { 774 + // Create in-memory test database 775 + db, err := InitDB(":memory:") 776 + if err != nil { 777 + t.Fatalf("Failed to init database: %v", err) 778 + } 779 + defer db.Close() 780 + 781 + // Setup: Create test user 782 + testUser := &User{ 783 + DID: "did:plc:test123", 784 + Handle: "test.bsky.social", 785 + PDSEndpoint: "https://test.pds.example.com", 786 + LastSeen: time.Now(), 787 + } 788 + if err := UpsertUser(db, testUser); err != nil { 789 + t.Fatalf("Failed to create test user: %v", err) 790 + } 791 + 792 + // Test 1: Single-arch manifest (no platform info) 793 + singleArchManifest := &Manifest{ 794 + DID: testUser.DID, 795 + Repository: "myapp", 796 + Digest: "sha256:single", 797 + HoldEndpoint: "did:web:hold.example.com", 798 + SchemaVersion: 2, 799 + MediaType: "application/vnd.oci.image.manifest.v1+json", 800 + CreatedAt: time.Now(), 801 + } 802 + manifestID1, err := InsertManifest(db, singleArchManifest) 803 + if err != nil { 804 + t.Fatalf("Failed to insert single-arch manifest: %v", err) 805 + } 806 + 807 + singleTag := &Tag{ 808 + DID: testUser.DID, 809 + Repository: "myapp", 810 + Tag: "latest", 811 + Digest: "sha256:single", 812 + CreatedAt: time.Now(), 813 + } 814 + err = UpsertTag(db, singleTag) 815 + if err != nil { 816 + t.Fatalf("Failed to insert single-arch tag: %v", err) 817 + } 818 + 819 + tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp") 820 + if err != nil { 821 + t.Fatalf("Failed to get tags with platforms: %v", err) 822 + } 823 + if len(tagsWithPlatforms) != 1 { 824 + t.Fatalf("Expected 1 tag, got %d", len(tagsWithPlatforms)) 825 + } 826 + if tagsWithPlatforms[0].IsMultiArch { 827 + t.Error("Expected single-arch tag to not be multi-arch") 828 + } 829 + if len(tagsWithPlatforms[0].Platforms) != 0 { 830 + t.Errorf("Expected 0 platforms for single-arch, got %d", len(tagsWithPlatforms[0].Platforms)) 831 + } 832 + 833 + // Test 2: Multi-arch manifest (manifest list with platform info) 834 + multiArchManifest := &Manifest{ 835 + DID: testUser.DID, 836 + Repository: "multiapp", 837 + Digest: "sha256:multi", 838 + HoldEndpoint: "did:web:hold.example.com", 839 + SchemaVersion: 2, 840 + MediaType: "application/vnd.oci.image.index.v1+json", // Manifest list 841 + CreatedAt: time.Now(), 842 + } 843 + manifestID2, err := InsertManifest(db, multiArchManifest) 844 + if err != nil { 845 + t.Fatalf("Failed to insert multi-arch manifest: %v", err) 846 + } 847 + 848 + // Add manifest references with platform info 849 + ref1 := &ManifestReference{ 850 + ManifestID: manifestID2, 851 + Digest: "sha256:amd64", 852 + Size: 1000, 853 + MediaType: "application/vnd.oci.image.manifest.v1+json", 854 + PlatformOS: "linux", 855 + PlatformArchitecture: "amd64", 856 + ReferenceIndex: 0, 857 + } 858 + ref2 := &ManifestReference{ 859 + ManifestID: manifestID2, 860 + Digest: "sha256:arm64", 861 + Size: 1000, 862 + MediaType: "application/vnd.oci.image.manifest.v1+json", 863 + PlatformOS: "linux", 864 + PlatformArchitecture: "arm64", 865 + ReferenceIndex: 1, 866 + } 867 + err = InsertManifestReference(db, ref1) 868 + if err != nil { 869 + t.Fatalf("Failed to insert manifest reference 1: %v", err) 870 + } 871 + err = InsertManifestReference(db, ref2) 872 + if err != nil { 873 + t.Fatalf("Failed to insert manifest reference 2: %v", err) 874 + } 875 + 876 + multiTag := &Tag{ 877 + DID: testUser.DID, 878 + Repository: "multiapp", 879 + Tag: "latest", 880 + Digest: "sha256:multi", 881 + CreatedAt: time.Now(), 882 + } 883 + err = UpsertTag(db, multiTag) 884 + if err != nil { 885 + t.Fatalf("Failed to insert multi-arch tag: %v", err) 886 + } 887 + 888 + multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp") 889 + if err != nil { 890 + t.Fatalf("Failed to get multi-arch tags with platforms: %v", err) 891 + } 892 + if len(multiTagsWithPlatforms) != 1 { 893 + t.Fatalf("Expected 1 tag, got %d", len(multiTagsWithPlatforms)) 894 + } 895 + if !multiTagsWithPlatforms[0].IsMultiArch { 896 + t.Error("Expected multi-arch tag to be marked as multi-arch") 897 + } 898 + if len(multiTagsWithPlatforms[0].Platforms) != 2 { 899 + t.Errorf("Expected 2 platforms for multi-arch, got %d", len(multiTagsWithPlatforms[0].Platforms)) 900 + } 901 + 902 + // Verify platform details 903 + platforms := multiTagsWithPlatforms[0].Platforms 904 + if platforms[0].OS != "linux" || platforms[0].Architecture != "amd64" { 905 + t.Errorf("Expected linux/amd64, got %s/%s", platforms[0].OS, platforms[0].Architecture) 906 + } 907 + if platforms[1].OS != "linux" || platforms[1].Architecture != "arm64" { 908 + t.Errorf("Expected linux/arm64, got %s/%s", platforms[1].OS, platforms[1].Architecture) 909 + } 910 + 911 + // Don't use manifestID1 since it's not accessed after assignment 912 + _ = manifestID1 913 + }