A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
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 + }