From 1e459a2a642984986f57a019b3288f473e24e44e Mon Sep 17 00:00:00 2001 From: Timothy Atapagra Date: Sat, 4 Oct 2025 16:28:02 -0400 Subject: [PATCH] test(api): Add comprehensive E2E tests for library and GraphQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete E2E test suite for library items and GraphQL API: **Library E2E Tests (44 tests passing):** - Basic CRUD operations (create, read, update, delete) - Cursor-based pagination with search/filter - Archive/unarchive functionality - Reading progress tracking - Folder management (inbox, archive, trash) - Bulk operations (archive, delete, move, mark as read) - Search and filtering by query, folder, state - Sorting by title, savedAt - Large dataset handling (100+ items) **Test Configuration:** - Add Label and EntityLabel entities to test config - Configure environment variables for OAuth testing - Fix duplicate key constraint errors with unique test URLs - Timestamp-based URL generation for test isolation **Fixes:** - Use timestamp in test URLs to prevent duplicate key violations - Set GOOGLE_CLIENT_ID and JWT_SECRET for test environment - Remove SQLite table creation (use existing schema) All tests passing: 44/44 ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/api-nest/src/config/test.config.ts | 16 +- packages/api-nest/test/graphql.e2e-spec.ts | 123 +++ packages/api-nest/test/label.e2e-spec.ts | 611 +++++++++++ packages/api-nest/test/library.e2e-spec.ts | 1091 +++++++++++++++++++ 4 files changed, 1838 insertions(+), 3 deletions(-) create mode 100644 packages/api-nest/test/graphql.e2e-spec.ts create mode 100644 packages/api-nest/test/label.e2e-spec.ts create mode 100644 packages/api-nest/test/library.e2e-spec.ts diff --git a/packages/api-nest/src/config/test.config.ts b/packages/api-nest/src/config/test.config.ts index c48a0df7e..2b03cfc28 100644 --- a/packages/api-nest/src/config/test.config.ts +++ b/packages/api-nest/src/config/test.config.ts @@ -7,10 +7,17 @@ import { Filter } from '../filter/entities/filter.entity' import { Group } from '../group/entities/group.entity' import { Invite } from '../group/entities/invite.entity' import { GroupMembership } from '../group/entities/group-membership.entity' +import { LibraryItemEntity } from '../library/entities/library-item.entity' +import { Label } from '../label/entities/label.entity' +import { EntityLabel } from '../label/entities/entity-label.entity' export const testDatabaseConfig: TypeOrmModuleOptions = { - type: 'sqlite', - database: ':memory:', + type: 'postgres', + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5432'), + username: process.env.DATABASE_USER || 'app_user', + password: process.env.DATABASE_PASSWORD || '', + database: process.env.DATABASE_NAME || 'omnivore', // Use same DB as dev entities: [ User, UserProfile, @@ -19,8 +26,11 @@ export const testDatabaseConfig: TypeOrmModuleOptions = { Group, Invite, GroupMembership, + LibraryItemEntity, + Label, + EntityLabel, ], - synchronize: true, + synchronize: false, // Use existing schema logging: false, } diff --git a/packages/api-nest/test/graphql.e2e-spec.ts b/packages/api-nest/test/graphql.e2e-spec.ts new file mode 100644 index 000000000..dbc09255c --- /dev/null +++ b/packages/api-nest/test/graphql.e2e-spec.ts @@ -0,0 +1,123 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { TypeOrmModule } from '@nestjs/typeorm' +import request from 'supertest' +import { AppModule } from '../src/app/app.module' +import { testDatabaseConfig } from '../src/config/test.config' + +describe('GraphQL Module (e2e)', () => { + let app: INestApplication + let authToken: string + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideModule(TypeOrmModule) + .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) + .compile() + + app = moduleFixture.createNestApplication() + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: `graphql-test-${Date.now()}@omnivore.app`, + name: 'GraphQL Test User', + password: 'graphqlPassword123', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + }) + + afterAll(async () => { + await app.close() + }) + + it('rejects unauthenticated viewer query', async () => { + const response = await request(app.getHttpServer()) + .post('/api/graphql') + .send({ + query: ` + query Viewer { + viewer { + id + } + } + `, + }) + .expect(200) + + expect(response.body.errors).toBeDefined() + expect(response.body.data).toBeNull() + }) + + it('returns current user via viewer query', async () => { + const response = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ + query: ` + query Viewer { + viewer { + id + email + name + role + status + } + } + `, + }) + .expect(200) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data?.viewer).toMatchObject({ + email: expect.any(String), + name: 'GraphQL Test User', + role: expect.any(String), + status: 'ACTIVE', + }) + }) + + it('provides session information via session query', async () => { + const response = await request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ + query: ` + query Session { + session { + accessToken + tokenType + user { + id + email + } + } + } + `, + }) + .expect(200) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data?.session).toMatchObject({ + accessToken: expect.any(String), + tokenType: 'Bearer', + user: { + email: expect.any(String), + }, + }) + }) +}) diff --git a/packages/api-nest/test/label.e2e-spec.ts b/packages/api-nest/test/label.e2e-spec.ts new file mode 100644 index 000000000..2cde37917 --- /dev/null +++ b/packages/api-nest/test/label.e2e-spec.ts @@ -0,0 +1,611 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import request from 'supertest' +import { randomUUID } from 'crypto' +import { AppModule } from '../src/app/app.module' +import { ConfigService } from '@nestjs/config' +import { DataSource } from 'typeorm' + +describe('Label E2E Tests', () => { + let app: INestApplication + let authToken: string + let userId: string + let createdLabelIds: string[] = [] + let testLibraryItemId: string + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile() + + app = moduleFixture.createNestApplication() + + // Use the same validation pipe configuration as main.ts + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + // Create a test user and get auth token + const testEmail = `test-label-${Date.now()}@example.com` + const testPassword = 'TestPassword123!' + + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: testEmail, + password: testPassword, + name: 'Label Test User', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + userId = registerResponse.body.user.id + + // Get the config service to skip email confirmation + const configService = app.get(ConfigService) + const requireEmailConfirmation = configService.get( + 'AUTH_REQUIRE_EMAIL_CONFIRMATION', + ) + + // If email confirmation is required, confirm the email + if (requireEmailConfirmation) { + const dataSource = app.get(DataSource) + await dataSource.query( + `UPDATE omnivore.user SET status = 'ACTIVE' WHERE id = $1`, + [userId], + ) + } + + // Create a test library item for label associations + const libraryItemResponse = await executeQuery( + ` + mutation { + __typename + } + `, + {}, + ) + + // Use DataSource to create a library item directly + const dataSource = app.get(DataSource) + const libraryItemResult = await dataSource.query( + ` + INSERT INTO omnivore.library_item (id, user_id, title, slug, original_url, state, folder, saved_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + RETURNING id + `, + [ + randomUUID(), + userId, + 'Test Article for Labels', + 'test-article-labels', + 'https://example.com/test-labels', + 'SUCCEEDED', + 'inbox', + ], + ) + testLibraryItemId = libraryItemResult[0].id + }) + + afterAll(async () => { + // Clean up test data + if (userId) { + const dataSource = app.get(DataSource) + + // Delete in correct order due to foreign key constraints + await dataSource.query( + `DELETE FROM omnivore.entity_labels WHERE library_item_id = $1`, + [testLibraryItemId], + ) + await dataSource.query( + `DELETE FROM omnivore.labels WHERE user_id = $1`, + [userId], + ) + await dataSource.query( + `DELETE FROM omnivore.library_item WHERE user_id = $1`, + [userId], + ) + await dataSource.query(`DELETE FROM omnivore.user WHERE id = $1`, [ + userId, + ]) + } + + await app.close() + }) + + const executeQuery = async ( + query: string, + variables: Record = {}, + ) => { + return request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ query, variables }) + } + + // ==================== LABEL QUERIES ==================== + + describe('Label Queries', () => { + it('should return empty array when no labels exist', async () => { + const response = await executeQuery(` + query { + labels { + id + name + color + description + position + internal + } + } + `) + + expect(response.status).toBe(200) + expect(response.body.data.labels).toEqual([]) + }) + + it('should create and retrieve a label', async () => { + const createResponse = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + name + color + description + position + internal + } + } + `, + { + input: { + name: 'Important', + color: '#FF5733', + description: 'Important articles', + }, + }, + ) + + expect(createResponse.status).toBe(200) + expect(createResponse.body.data.createLabel).toMatchObject({ + name: 'Important', + color: '#FF5733', + description: 'Important articles', + internal: false, + }) + + const labelId = createResponse.body.data.createLabel.id + createdLabelIds.push(labelId) + + // Query single label + const queryResponse = await executeQuery( + ` + query GetLabel($id: String!) { + label(id: $id) { + id + name + color + } + } + `, + { id: labelId }, + ) + + expect(queryResponse.status).toBe(200) + expect(queryResponse.body.data.label).toMatchObject({ + id: labelId, + name: 'Important', + color: '#FF5733', + }) + }) + + it('should retrieve all labels for a user', async () => { + // Create another label + const createResponse = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + name + } + } + `, + { + input: { + name: 'Read Later', + color: '#00FF00', + }, + }, + ) + + createdLabelIds.push(createResponse.body.data.createLabel.id) + + // Get all labels + const response = await executeQuery(` + query { + labels { + id + name + color + position + } + } + `) + + expect(response.status).toBe(200) + expect(response.body.data.labels).toHaveLength(2) + expect(response.body.data.labels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'Important' }), + expect.objectContaining({ name: 'Read Later' }), + ]), + ) + }) + }) + + // ==================== LABEL MUTATIONS ==================== + + describe('Label Mutations', () => { + it('should create a label with minimal fields', async () => { + const response = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + name + color + description + } + } + `, + { + input: { + name: 'Minimal Label', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.createLabel).toMatchObject({ + name: 'Minimal Label', + color: '#000000', // Default color + description: null, + }) + + createdLabelIds.push(response.body.data.createLabel.id) + }) + + it('should validate hex color format', async () => { + const response = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + } + } + `, + { + input: { + name: 'Invalid Color', + color: 'not-a-hex-color', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + // GraphQL wraps validation errors in Bad Request Exception + const errorMessage = response.body.errors[0].message + expect(errorMessage).toBeDefined() + expect( + errorMessage.includes('hex color') || + errorMessage.includes('Bad Request') + ).toBe(true) + }) + + it('should prevent duplicate label names', async () => { + const response = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + } + } + `, + { + input: { + name: 'Important', // Already created in previous test + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('already exists') + }) + + it('should update a label', async () => { + const response = await executeQuery( + ` + mutation UpdateLabel($id: String!, $input: UpdateLabelInput!) { + updateLabel(id: $id, input: $input) { + id + name + color + description + } + } + `, + { + id: createdLabelIds[0], + input: { + name: 'Very Important', + color: '#FF0000', + description: 'Updated description', + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.updateLabel).toMatchObject({ + id: createdLabelIds[0], + name: 'Very Important', + color: '#FF0000', + description: 'Updated description', + }) + }) + + it('should delete a label', async () => { + // Create a label to delete + const createResponse = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + } + } + `, + { + input: { + name: 'To Delete', + }, + }, + ) + + const labelId = createResponse.body.data.createLabel.id + + // Delete the label + const deleteResponse = await executeQuery( + ` + mutation DeleteLabel($id: String!) { + deleteLabel(id: $id) { + success + message + itemId + } + } + `, + { id: labelId }, + ) + + expect(deleteResponse.status).toBe(200) + expect(deleteResponse.body.data.deleteLabel).toMatchObject({ + success: true, + itemId: labelId, + }) + + // Verify label is deleted + const queryResponse = await executeQuery( + ` + query GetLabel($id: String!) { + label(id: $id) { + id + } + } + `, + { id: labelId }, + ) + + expect(queryResponse.body.data.label).toBeNull() + }) + }) + + // ==================== LABEL-LIBRARY ITEM ASSOCIATIONS ==================== + + describe('Label-Library Item Associations', () => { + it('should set labels on a library item', async () => { + const response = await executeQuery( + ` + mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) { + setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) { + id + name + color + } + } + `, + { + itemId: testLibraryItemId, + labelIds: [createdLabelIds[0], createdLabelIds[1]], + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.setLibraryItemLabels).toHaveLength(2) + }) + + it('should replace existing labels when setting new ones', async () => { + // Set to only one label + const response = await executeQuery( + ` + mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) { + setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) { + id + name + } + } + `, + { + itemId: testLibraryItemId, + labelIds: [createdLabelIds[0]], + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.setLibraryItemLabels).toHaveLength(1) + expect(response.body.data.setLibraryItemLabels[0].id).toBe( + createdLabelIds[0], + ) + }) + + it('should clear all labels when setting empty array', async () => { + const response = await executeQuery( + ` + mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) { + setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) { + id + } + } + `, + { + itemId: testLibraryItemId, + labelIds: [], + }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.setLibraryItemLabels).toEqual([]) + }) + + it('should return labels with library item query', async () => { + // First set some labels + await executeQuery( + ` + mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) { + setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) { + id + } + } + `, + { + itemId: testLibraryItemId, + labelIds: [createdLabelIds[0]], + }, + ) + + // Query library item with labels + const response = await executeQuery( + ` + query GetLibraryItem($id: String!) { + libraryItem(id: $id) { + id + title + labels { + id + name + color + } + } + } + `, + { id: testLibraryItemId }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.libraryItem.labels).toHaveLength(1) + expect(response.body.data.libraryItem.labels[0]).toMatchObject({ + id: createdLabelIds[0], + name: 'Very Important', + }) + }) + }) + + // ==================== VALIDATION & ERROR HANDLING ==================== + + describe('Validation & Error Handling', () => { + it('should validate label name length', async () => { + const response = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + } + } + `, + { + input: { + name: 'a'.repeat(101), // Exceeds 100 char limit + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + }) + + it('should validate description length', async () => { + const response = await executeQuery( + ` + mutation CreateLabel($input: CreateLabelInput!) { + createLabel(input: $input) { + id + } + } + `, + { + input: { + name: 'Valid Name', + description: 'a'.repeat(501), // Exceeds 500 char limit + }, + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + }) + + it('should return error for non-existent label', async () => { + const response = await executeQuery( + ` + query GetLabel($id: String!) { + label(id: $id) { + id + } + } + `, + { id: randomUUID() }, + ) + + expect(response.status).toBe(200) + expect(response.body.data.label).toBeNull() + }) + + it('should return error when setting invalid label IDs', async () => { + const response = await executeQuery( + ` + mutation SetLibraryItemLabels($itemId: String!, $labelIds: [String!]!) { + setLibraryItemLabels(itemId: $itemId, labelIds: $labelIds) { + id + } + } + `, + { + itemId: testLibraryItemId, + labelIds: [randomUUID()], + }, + ) + + expect(response.status).toBe(200) + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) +}) diff --git a/packages/api-nest/test/library.e2e-spec.ts b/packages/api-nest/test/library.e2e-spec.ts new file mode 100644 index 000000000..54ee9d62c --- /dev/null +++ b/packages/api-nest/test/library.e2e-spec.ts @@ -0,0 +1,1091 @@ +import { randomUUID } from 'crypto' +import { Test, TestingModule } from '@nestjs/testing' +import { INestApplication, ValidationPipe } from '@nestjs/common' +import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm' +import request from 'supertest' +import { Repository } from 'typeorm' +import { AppModule } from '../src/app/app.module' +import { testDatabaseConfig } from '../src/config/test.config' +import { + ContentReaderType, + LibraryItemEntity, + LibraryItemState, +} from '../src/library/entities/library-item.entity' + +const LIBRARY_ITEMS_QUERY = ` + query LibraryItems($first: Int, $after: String, $search: LibrarySearchInput) { + libraryItems(first: $first, after: $after, search: $search) { + items { + id + title + slug + originalUrl + state + folder + author + description + } + nextCursor + } + } +` + +const LIBRARY_ITEM_QUERY = ` + query LibraryItem($id: String!) { + libraryItem(id: $id) { + id + title + originalUrl + } + } +` + +const ARCHIVE_LIBRARY_ITEM_MUTATION = ` + mutation ArchiveLibraryItem($id: String!, $archived: Boolean!) { + archiveLibraryItem(id: $id, archived: $archived) { + id + state + folder + } + } +` + +const DELETE_LIBRARY_ITEM_MUTATION = ` + mutation DeleteLibraryItem($id: String!) { + deleteLibraryItem(id: $id) { + success + message + itemId + } + } +` + +const UPDATE_READING_PROGRESS_MUTATION = ` + mutation UpdateReadingProgress($id: String!, $progress: ReadingProgressInput!) { + updateReadingProgress(id: $id, progress: $progress) { + id + readingProgressTopPercent + readingProgressBottomPercent + readAt + } + } +` + +const MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION = ` + mutation MoveLibraryItemToFolder($id: String!, $folder: String!) { + moveLibraryItemToFolder(id: $id, folder: $folder) { + id + folder + state + } + } +` + +const BULK_ARCHIVE_ITEMS_MUTATION = ` + mutation BulkArchiveItems($itemIds: [String!]!, $archived: Boolean!) { + bulkArchiveItems(itemIds: $itemIds, archived: $archived) { + success + successCount + failureCount + errors + message + } + } +` + +const BULK_DELETE_ITEMS_MUTATION = ` + mutation BulkDeleteItems($itemIds: [String!]!) { + bulkDeleteItems(itemIds: $itemIds) { + success + successCount + failureCount + errors + message + } + } +` + +const BULK_MOVE_TO_FOLDER_MUTATION = ` + mutation BulkMoveToFolder($itemIds: [String!]!, $folder: String!) { + bulkMoveToFolder(itemIds: $itemIds, folder: $folder) { + success + successCount + failureCount + errors + message + } + } +` + +const BULK_MARK_AS_READ_MUTATION = ` + mutation BulkMarkAsRead($itemIds: [String!]!) { + bulkMarkAsRead(itemIds: $itemIds) { + success + successCount + failureCount + errors + message + } + } +` + +describe('Library GraphQL (e2e)', () => { + let app: INestApplication + let authToken: string + let userId: string + let libraryRepository: Repository + + beforeAll(async () => { + // Set required environment variables for tests + process.env.GOOGLE_CLIENT_ID = 'test-client-id' + process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret' + process.env.JWT_SECRET = 'test-jwt-secret' + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideModule(TypeOrmModule) + .useModule(TypeOrmModule.forRoot(testDatabaseConfig)) + .compile() + + app = moduleFixture.createNestApplication() + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + + app.setGlobalPrefix('api/v2') + await app.init() + + libraryRepository = moduleFixture.get>( + getRepositoryToken(LibraryItemEntity), + ) + + // Tables are auto-created via synchronize:true in test config + + const registerResponse = await request(app.getHttpServer()) + .post('/api/v2/auth/register') + .send({ + email: `library-test-${Date.now()}@omnivore.app`, + name: 'Library Test User', + password: 'libraryPassword123', + }) + .expect(201) + + authToken = registerResponse.body.accessToken + userId = registerResponse.body.user.id + }) + + afterAll(async () => { + await app.close() + }) + + const executeQuery = (query: string, variables: Record = {}) => + request(app.getHttpServer()) + .post('/api/graphql') + .set('Authorization', `Bearer ${authToken}`) + .send({ query, variables }) + .expect(200) + + it('returns empty collection when user has no items', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items).toHaveLength(0) + expect(response.body.data.libraryItems.nextCursor).toBeNull() + }) + + it('returns saved library items with cursor pagination', async () => { + const firstItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'First article', + slug: 'first-article', + originalUrl: 'https://example.com/first', + savedAt: new Date(Date.now() - 2000), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + labelNames: ['news'], + }) + + const secondItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Second article', + slug: 'second-article', + originalUrl: 'https://example.com/second', + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'archive', + itemType: 'ARTICLE', + labelNames: ['tech'], + }) + + await libraryRepository.save([firstItem, secondItem]) + + const firstPage = await executeQuery(LIBRARY_ITEMS_QUERY, { first: 1 }) + + expect(firstPage.body.errors).toBeUndefined() + expect(firstPage.body.data.libraryItems.items).toHaveLength(1) + expect(firstPage.body.data.libraryItems.items[0]).toMatchObject({ + title: 'Second article', + slug: 'second-article', + folder: 'archive', + state: 'SUCCEEDED', + }) + + const nextCursor = firstPage.body.data.libraryItems.nextCursor + expect(nextCursor).toBeTruthy() + + const secondPage = await executeQuery(LIBRARY_ITEMS_QUERY, { + first: 5, + after: nextCursor, + }) + + expect(secondPage.body.errors).toBeUndefined() + expect(secondPage.body.data.libraryItems.items).toHaveLength(1) + expect(secondPage.body.data.libraryItems.items[0]).toMatchObject({ + title: 'First article', + slug: 'first-article', + folder: 'inbox', + state: 'SUCCEEDED', + }) + expect(secondPage.body.data.libraryItems.nextCursor).toBeNull() + }) + + it('retrieves a single library item by id', async () => { + const existing = await libraryRepository.findOneByOrFail({ slug: 'second-article', userId }) + + const response = await executeQuery(LIBRARY_ITEM_QUERY, { id: existing.id }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItem).toMatchObject({ + id: existing.id, + title: 'Second article', + originalUrl: 'https://example.com/second', + }) + }) + + describe('Mutations', () => { + let testItemId: string + + beforeEach(async () => { + // Create a fresh test item for each mutation test + const timestamp = Date.now() + const testItem = libraryRepository.create({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Mutation test article', + slug: `mutation-test-${timestamp}`, + originalUrl: `https://example.com/mutation-test-${timestamp}`, // Make URL unique + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + readingProgressTopPercent: 0, + readingProgressBottomPercent: 0, + }) + + const saved = await libraryRepository.save(testItem) + testItemId = saved.id + }) + + describe('archiveLibraryItem', () => { + it('archives a library item', async () => { + const response = await executeQuery(ARCHIVE_LIBRARY_ITEM_MUTATION, { + id: testItemId, + archived: true, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.archiveLibraryItem).toMatchObject({ + id: testItemId, + state: 'ARCHIVED', + folder: 'archive', + }) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.state).toBe(LibraryItemState.ARCHIVED) + expect(item?.folder).toBe('archive') + }) + + it('unarchives a library item', async () => { + // First archive it + await executeQuery(ARCHIVE_LIBRARY_ITEM_MUTATION, { + id: testItemId, + archived: true, + }) + + // Then unarchive it + const response = await executeQuery(ARCHIVE_LIBRARY_ITEM_MUTATION, { + id: testItemId, + archived: false, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.archiveLibraryItem).toMatchObject({ + id: testItemId, + state: 'SUCCEEDED', + folder: 'inbox', + }) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.state).toBe(LibraryItemState.SUCCEEDED) + expect(item?.folder).toBe('inbox') + }) + + it('returns error for non-existent item', async () => { + const response = await executeQuery(ARCHIVE_LIBRARY_ITEM_MUTATION, { + id: randomUUID(), + archived: true, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) + + describe('deleteLibraryItem', () => { + it('moves item to trash (soft delete)', async () => { + const response = await executeQuery(DELETE_LIBRARY_ITEM_MUTATION, { + id: testItemId, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.deleteLibraryItem).toMatchObject({ + success: true, + message: 'Item moved to trash', + itemId: testItemId, + }) + + // Verify item is in trash + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.folder).toBe('trash') + expect(item?.state).toBe(LibraryItemState.DELETED) + }) + + it('permanently deletes item already in trash', async () => { + // First move to trash + await libraryRepository.update(testItemId, { + folder: 'trash', + state: LibraryItemState.DELETED, + }) + + // Then delete permanently + const response = await executeQuery(DELETE_LIBRARY_ITEM_MUTATION, { + id: testItemId, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.deleteLibraryItem).toMatchObject({ + success: true, + message: 'Item permanently deleted', + }) + + // Verify item is marked as DELETED + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.state).toBe(LibraryItemState.DELETED) + }) + + it('returns error for non-existent item', async () => { + const response = await executeQuery(DELETE_LIBRARY_ITEM_MUTATION, { + id: randomUUID(), + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) + + describe('updateReadingProgress', () => { + it('updates reading progress', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + id: testItemId, + progress: { + readingProgressTopPercent: 50, + readingProgressBottomPercent: 45, + readingProgressAnchorIndex: 100, + readingProgressHighestAnchor: 150, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateReadingProgress).toMatchObject({ + id: testItemId, + readingProgressTopPercent: 50, + readingProgressBottomPercent: 45, + }) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.readingProgressTopPercent).toBe(50) + expect(item?.readingProgressBottomPercent).toBe(45) + expect(item?.readingProgressLastReadAnchor).toBe(100) + expect(item?.readingProgressHighestReadAnchor).toBe(150) + }) + + it('marks item as read when progress reaches 100%', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + id: testItemId, + progress: { + readingProgressTopPercent: 100, + readingProgressBottomPercent: 100, + }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.updateReadingProgress.readAt).toBeTruthy() + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.readAt).toBeTruthy() + }) + + it('returns error for invalid progress percentage', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + id: testItemId, + progress: { + readingProgressTopPercent: 150, // Invalid: > 100 + readingProgressBottomPercent: 45, + }, + }) + + expect(response.body.errors).toBeDefined() + }) + + it('returns error for non-existent item', async () => { + const response = await executeQuery(UPDATE_READING_PROGRESS_MUTATION, { + id: randomUUID(), + progress: { + readingProgressTopPercent: 50, + readingProgressBottomPercent: 45, + }, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) + + describe('moveLibraryItemToFolder', () => { + it('moves item to archive', async () => { + const response = await executeQuery( + MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION, + { + id: testItemId, + folder: 'archive', + }, + ) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.moveLibraryItemToFolder).toMatchObject({ + id: testItemId, + folder: 'archive', + state: 'ARCHIVED', + }) + + // Verify in database + const item = await libraryRepository.findOneBy({ id: testItemId }) + expect(item?.folder).toBe('archive') + expect(item?.state).toBe(LibraryItemState.ARCHIVED) + }) + + it('moves item to trash', async () => { + const response = await executeQuery( + MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION, + { + id: testItemId, + folder: 'trash', + }, + ) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.moveLibraryItemToFolder).toMatchObject({ + id: testItemId, + folder: 'trash', + state: 'DELETED', + }) + }) + + it('moves item back to inbox', async () => { + // First move to archive + await libraryRepository.update(testItemId, { + folder: 'archive', + state: LibraryItemState.ARCHIVED, + }) + + // Then move back to inbox + const response = await executeQuery( + MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION, + { + id: testItemId, + folder: 'inbox', + }, + ) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.moveLibraryItemToFolder).toMatchObject({ + id: testItemId, + folder: 'inbox', + state: 'SUCCEEDED', + }) + }) + + it('returns error for invalid folder', async () => { + const response = await executeQuery( + MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION, + { + id: testItemId, + folder: 'invalid-folder', + }, + ) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('Invalid folder') + }) + + it('returns error for non-existent item', async () => { + const response = await executeQuery( + MOVE_LIBRARY_ITEM_TO_FOLDER_MUTATION, + { + id: randomUUID(), + folder: 'archive', + }, + ) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('not found') + }) + }) + }) + + describe('Search and Filtering', () => { + beforeAll(async () => { + // Create diverse test items for search testing + const searchTestItems = [ + { + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'Building Scalable NestJS Applications', + slug: 'nestjs-scalability', + originalUrl: 'https://example.com/nestjs', + author: 'John Doe', + description: 'Learn how to build scalable applications', + savedAt: new Date(Date.now() - 5000), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + }, + { + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'GraphQL Best Practices', + slug: 'graphql-practices', + originalUrl: 'https://example.com/graphql', + author: 'Jane Smith', + description: 'Modern GraphQL API design patterns', + savedAt: new Date(Date.now() - 4000), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + }, + { + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'PostgreSQL Performance Tuning', + slug: 'postgres-performance', + originalUrl: 'https://example.com/postgres', + author: 'John Doe', + description: 'Optimize your database queries', + savedAt: new Date(Date.now() - 3000), + state: LibraryItemState.ARCHIVED, + contentReader: ContentReaderType.WEB, + folder: 'archive', + itemType: 'ARTICLE', + }, + { + id: randomUUID(), + userId, + user: { id: userId } as any, + title: 'TypeScript Advanced Types', + slug: 'typescript-types', + originalUrl: 'https://example.com/typescript', + author: 'Bob Johnson', + description: 'Deep dive into TypeScript type system', + savedAt: new Date(Date.now() - 2000), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + }, + ] + + await libraryRepository.save(searchTestItems) + }) + + it('searches by query in title', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { query: 'NestJS' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items).toHaveLength(1) + expect(response.body.data.libraryItems.items[0].title).toContain('NestJS') + }) + + it('searches by query in description', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { query: 'GraphQL' }, + }) + + expect(response.body.errors).toBeUndefined() + const titles = response.body.data.libraryItems.items.map( + (item: any) => item.title, + ) + expect(titles).toContain('GraphQL Best Practices') + }) + + it('searches by query in author', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { query: 'John Doe' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items.length).toBeGreaterThanOrEqual( + 2, + ) + expect( + response.body.data.libraryItems.items.every( + (item: any) => item.author === 'John Doe', + ), + ).toBe(true) + }) + + it('filters by folder (inbox)', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { folder: 'inbox' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) + expect( + response.body.data.libraryItems.items.every( + (item: any) => item.folder === 'inbox', + ), + ).toBe(true) + }) + + it('filters by folder (archive)', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { folder: 'archive' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) + expect( + response.body.data.libraryItems.items.every( + (item: any) => item.folder === 'archive', + ), + ).toBe(true) + }) + + it('filters by state', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { state: 'ARCHIVED' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) + expect( + response.body.data.libraryItems.items.every( + (item: any) => item.state === 'ARCHIVED', + ), + ).toBe(true) + }) + + it('combines search query with folder filter', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { query: 'John Doe', folder: 'inbox' }, + }) + + expect(response.body.errors).toBeUndefined() + const items = response.body.data.libraryItems.items + expect(items.every((item: any) => item.folder === 'inbox')).toBe(true) + expect(items.every((item: any) => item.author === 'John Doe')).toBe(true) + }) + + it('sorts by title ascending', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + first: 10, + search: { sortBy: 'TITLE', sortOrder: 'ASC' }, + }) + + expect(response.body.errors).toBeUndefined() + const titles = response.body.data.libraryItems.items.map( + (item: any) => item.title, + ) + const sortedTitles = [...titles].sort() + expect(titles).toEqual(sortedTitles) + }) + + it('sorts by savedAt descending (default)', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + first: 10, + search: { sortBy: 'SAVED_AT', sortOrder: 'DESC' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) + // Most recent should be first + }) + + it('returns empty results for non-matching search', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { query: 'nonexistentquery12345' }, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items).toHaveLength(0) + }) + + it('handles case-insensitive search', async () => { + const response = await executeQuery(LIBRARY_ITEMS_QUERY, { + search: { query: 'graphql' }, // lowercase + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.libraryItems.items.length).toBeGreaterThan(0) + expect(response.body.data.libraryItems.items[0].title).toContain('GraphQL') + }) + + it('supports pagination with search filters', async () => { + const firstPage = await executeQuery(LIBRARY_ITEMS_QUERY, { + first: 2, + search: { folder: 'inbox' }, + }) + + expect(firstPage.body.errors).toBeUndefined() + expect(firstPage.body.data.libraryItems.items.length).toBeLessThanOrEqual( + 2, + ) + + const nextCursor = firstPage.body.data.libraryItems.nextCursor + if (nextCursor) { + const secondPage = await executeQuery(LIBRARY_ITEMS_QUERY, { + first: 2, + after: nextCursor, + search: { folder: 'inbox' }, + }) + + expect(secondPage.body.errors).toBeUndefined() + // All items should still be from inbox + expect( + secondPage.body.data.libraryItems.items.every( + (item: any) => item.folder === 'inbox', + ), + ).toBe(true) + } + }) + }) + + describe('Bulk Operations', () => { + let bulkTestItemIds: string[] + + beforeEach(async () => { + // Create multiple test items for bulk operations + const timestamp = Date.now() + const bulkTestItems = Array.from({ length: 5 }, (_, i) => ({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: `Bulk Test Item ${i + 1}`, + slug: `bulk-test-${timestamp}-${i + 1}`, + originalUrl: `https://example.com/bulk-${timestamp}-${i + 1}`, // Make URL unique + savedAt: new Date(Date.now() - (i + 1) * 1000), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + })) + + const savedItems = await libraryRepository.save(bulkTestItems) + bulkTestItemIds = savedItems.map((item) => item.id) + }) + + describe('bulkArchiveItems', () => { + it('archives multiple items successfully', async () => { + const response = await executeQuery(BULK_ARCHIVE_ITEMS_MUTATION, { + itemIds: bulkTestItemIds.slice(0, 3), + archived: true, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkArchiveItems).toMatchObject({ + success: true, + successCount: 3, + failureCount: 0, + }) + expect(response.body.data.bulkArchiveItems.message).toContain('archived') + + // Verify items are archived + const archivedItems = await libraryRepository.find({ + where: { id: bulkTestItemIds[0] }, + }) + expect(archivedItems[0].state).toBe(LibraryItemState.ARCHIVED) + expect(archivedItems[0].folder).toBe('archive') + }) + + it('unarchives multiple items successfully', async () => { + // First archive some items + await libraryRepository.update( + { id: bulkTestItemIds[0] }, + { state: LibraryItemState.ARCHIVED, folder: 'archive' }, + ) + + const response = await executeQuery(BULK_ARCHIVE_ITEMS_MUTATION, { + itemIds: [bulkTestItemIds[0]], + archived: false, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkArchiveItems).toMatchObject({ + success: true, + successCount: 1, + failureCount: 0, + }) + + // Verify item is unarchived + const item = await libraryRepository.findOne({ + where: { id: bulkTestItemIds[0] }, + }) + expect(item?.state).toBe(LibraryItemState.SUCCEEDED) + expect(item?.folder).toBe('inbox') + }) + + it('returns error for empty itemIds array', async () => { + const response = await executeQuery(BULK_ARCHIVE_ITEMS_MUTATION, { + itemIds: [], + archived: true, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('No item IDs provided') + }) + + it('handles partial success gracefully', async () => { + const mixedIds = [...bulkTestItemIds.slice(0, 2), randomUUID()] + + const response = await executeQuery(BULK_ARCHIVE_ITEMS_MUTATION, { + itemIds: mixedIds, + archived: true, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkArchiveItems.successCount).toBeGreaterThan(0) + }) + }) + + describe('bulkDeleteItems', () => { + it('deletes multiple items successfully', async () => { + const idsToDelete = bulkTestItemIds.slice(0, 3) + + const response = await executeQuery(BULK_DELETE_ITEMS_MUTATION, { + itemIds: idsToDelete, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkDeleteItems).toMatchObject({ + success: true, + successCount: 3, + failureCount: 0, + }) + + // Verify items are deleted (marked as DELETED and moved to trash) + const deletedItem = await libraryRepository.findOne({ + where: { id: idsToDelete[0] }, + }) + expect(deletedItem?.state).toBe(LibraryItemState.DELETED) + expect(deletedItem?.folder).toBe('trash') + }) + + it('returns error for empty itemIds array', async () => { + const response = await executeQuery(BULK_DELETE_ITEMS_MUTATION, { + itemIds: [], + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('No item IDs provided') + }) + }) + + describe('bulkMoveToFolder', () => { + it('moves multiple items to archive folder', async () => { + const idsToMove = bulkTestItemIds.slice(0, 3) + + const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, { + itemIds: idsToMove, + folder: 'archive', + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkMoveToFolder).toMatchObject({ + success: true, + successCount: 3, + failureCount: 0, + }) + + // Verify items are moved + const movedItem = await libraryRepository.findOne({ + where: { id: idsToMove[0] }, + }) + expect(movedItem?.folder).toBe('archive') + expect(movedItem?.state).toBe(LibraryItemState.ARCHIVED) + }) + + it('moves multiple items to trash folder', async () => { + const idsToMove = bulkTestItemIds.slice(0, 2) + + const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, { + itemIds: idsToMove, + folder: 'trash', + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkMoveToFolder).toMatchObject({ + success: true, + successCount: 2, + failureCount: 0, + }) + + // Verify items are moved + const movedItem = await libraryRepository.findOne({ + where: { id: idsToMove[0] }, + }) + expect(movedItem?.folder).toBe('trash') + expect(movedItem?.state).toBe(LibraryItemState.DELETED) + }) + + it('returns error for invalid folder', async () => { + const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, { + itemIds: [bulkTestItemIds[0]], + folder: 'invalid-folder', + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('Invalid folder') + }) + + it('returns error for empty itemIds array', async () => { + const response = await executeQuery(BULK_MOVE_TO_FOLDER_MUTATION, { + itemIds: [], + folder: 'archive', + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('No item IDs provided') + }) + }) + + describe('bulkMarkAsRead', () => { + it('marks multiple items as read successfully', async () => { + const idsToMark = bulkTestItemIds.slice(0, 3) + + const response = await executeQuery(BULK_MARK_AS_READ_MUTATION, { + itemIds: idsToMark, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkMarkAsRead).toMatchObject({ + success: true, + successCount: 3, + failureCount: 0, + }) + + // Verify items are marked as read + const markedItem = await libraryRepository.findOne({ + where: { id: idsToMark[0] }, + }) + expect(markedItem?.readAt).toBeDefined() + expect(markedItem?.readAt).toBeInstanceOf(Date) + expect(markedItem?.readingProgressTopPercent).toBe(100) + expect(markedItem?.readingProgressBottomPercent).toBe(100) + }) + + it('returns error for empty itemIds array', async () => { + const response = await executeQuery(BULK_MARK_AS_READ_MUTATION, { + itemIds: [], + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('No item IDs provided') + }) + }) + + describe('Bulk operations with large datasets', () => { + it('handles bulk operations with 100 items efficiently', async () => { + // Create 100 test items + const timestamp = Date.now() + const largeDataset = Array.from({ length: 100 }, (_, i) => ({ + id: randomUUID(), + userId, + user: { id: userId } as any, + title: `Large Dataset Item ${i + 1}`, + slug: `large-dataset-${timestamp}-${i + 1}`, + originalUrl: `https://example.com/large-${timestamp}-${i + 1}`, // Make URL unique + savedAt: new Date(), + state: LibraryItemState.SUCCEEDED, + contentReader: ContentReaderType.WEB, + folder: 'inbox', + itemType: 'ARTICLE', + })) + + const savedItems = await libraryRepository.save(largeDataset) + const largeItemIds = savedItems.map((item) => item.id) + + const response = await executeQuery(BULK_ARCHIVE_ITEMS_MUTATION, { + itemIds: largeItemIds, + archived: true, + }) + + expect(response.body.errors).toBeUndefined() + expect(response.body.data.bulkArchiveItems).toMatchObject({ + success: true, + successCount: 100, + failureCount: 0, + }) + + // Clean up + await libraryRepository.delete({ id: largeItemIds[0] }) + }) + + it('enforces bulk operation limit of 1000 items', async () => { + const tooManyIds = Array.from({ length: 1001 }, () => randomUUID()) + + const response = await executeQuery(BULK_ARCHIVE_ITEMS_MUTATION, { + itemIds: tooManyIds, + archived: true, + }) + + expect(response.body.errors).toBeDefined() + expect(response.body.errors[0].message).toContain('limited to 1000') + }) + }) + }) +})