feat(highlight): Implement highlight management in GraphQL API

- Introduced CreateHighlightInput and UpdateHighlightInput types for creating and updating highlights.
- Added Highlight type to represent highlights in the GraphQL schema.
- Implemented HighlightService for managing highlights, including methods for creating, updating, retrieving, and deleting highlights.
- Created HighlightResolver to handle GraphQL queries and mutations related to highlights.
- Updated LibraryItem entity to include note and noteUpdatedAt fields for better integration with highlights.
- Added comprehensive E2E tests for highlight functionality, ensuring robust validation and error handling.

This commit enhances the content management capabilities of the application by allowing users to create, update, and manage highlights effectively.
This commit is contained in:
Timothy Atapagra 2025-10-17 14:47:51 -04:00
parent ba8b4d5f4d
commit da6850a7f4
19 changed files with 2205 additions and 6 deletions

View file

@ -23,6 +23,35 @@ enum ContentReaderType {
WEB
}
input CreateHighlightInput {
"""User annotation/note on the highlight"""
annotation: String
"""Highlight color (yellow, red, green, blue)"""
color: String
"""Anchor index for position"""
highlightPositionAnchorIndex: Int = 0
"""Position in document as percentage (0-100)"""
highlightPositionPercent: Float = 0
"""HTML content of the highlight"""
html: String
"""Library item ID"""
libraryItemId: String!
"""Text before the quote (for context)"""
prefix: String
"""Quoted text from the document"""
quote: String!
"""Text after the quote (for context)"""
suffix: String
}
input CreateLabelInput {
color: String = "#000000"
description: String
@ -45,6 +74,32 @@ type DeleteResult {
success: Boolean!
}
type Highlight {
annotation: String
color: String
createdAt: DateTime!
highlightPositionAnchorIndex: Int!
highlightPositionPercent: Float!
highlightType: HighlightType!
html: String
id: ID!
libraryItemId: String!
patch: String
prefix: String
quote: String
representation: RepresentationType!
sharedAt: DateTime
shortId: String!
suffix: String
updatedAt: DateTime!
}
enum HighlightType {
HIGHLIGHT
NOTE
REDACTION
}
type Label {
color: String!
createdAt: DateTime!
@ -65,6 +120,8 @@ type LibraryItem {
folder: String!
id: ID!
labels: [Label!]
note: String
noteUpdatedAt: DateTime
originalUrl: String!
publishedAt: DateTime
readAt: DateTime
@ -162,9 +219,21 @@ type Mutation {
itemIds: [String!]!
): BulkActionResult!
"""Create a new highlight with optional color"""
createHighlight(
"""Highlight creation data"""
input: CreateHighlightInput!
): Highlight!
"""Create a new label"""
createLabel(input: CreateLabelInput!): Label!
"""Delete a highlight"""
deleteHighlight(
"""Highlight ID"""
id: String!
): DeleteResult!
"""Delete a label"""
deleteLabel(id: String!): DeleteResult!
@ -191,9 +260,27 @@ type Mutation {
"""Set labels for a library item (replaces existing labels)"""
setLibraryItemLabels(itemId: String!, labelIds: [String!]!): [Label!]!
"""Update a highlight (annotation and/or color)"""
updateHighlight(
"""Highlight ID"""
id: String!
"""Highlight update data"""
input: UpdateHighlightInput!
): Highlight!
"""Update an existing label"""
updateLabel(id: String!, input: UpdateLabelInput!): Label!
"""Update notebook content for a library item"""
updateNotebook(
"""Library item ID"""
id: String!
"""Notebook content"""
input: UpdateNotebookInput!
): LibraryItem!
"""Update reading progress for a library item"""
updateReadingProgress(
"""Library item ID"""
@ -205,6 +292,18 @@ type Mutation {
}
type Query {
"""Get a single highlight by ID"""
highlight(
"""Highlight ID"""
id: String!
): Highlight
"""Get all highlights for a library item"""
highlights(
"""Library item ID"""
libraryItemId: String!
): [Highlight!]!
"""Get a single label by ID"""
label(id: String!): Label
@ -238,6 +337,11 @@ enum RegistrationType {
TWITTER
}
enum RepresentationType {
CONTENT
FEED_CONTENT
}
input SaveUrlInput {
"""Folder to save the URL to (inbox, archive)"""
folder: String = "inbox"
@ -261,12 +365,25 @@ enum StatusType {
PENDING
}
input UpdateHighlightInput {
"""User annotation/note on the highlight"""
annotation: String
"""Highlight color (yellow, red, green, blue)"""
color: String
}
input UpdateLabelInput {
color: String
description: String
name: String
}
input UpdateNotebookInput {
"""Notebook content (supports markdown)"""
note: String!
}
type User {
createdAt: DateTime!
email: String

View file

@ -8,6 +8,7 @@ import { LoggingModule } from '../logging/logging.module'
import { GraphqlModule } from '../graphql/graphql.module'
import { LibraryModule } from '../library/library.module'
import { LabelModule } from '../label/label.module'
import { HighlightModule } from '../highlight/highlight.module'
import { QueueModule } from '../queue/queue.module'
import { AppController } from './app.controller'
import { AppService } from './app.service'
@ -44,6 +45,12 @@ import { configValidationSchema } from '../config/config.schema'
// Library / Reader
LibraryModule,
// Labels
LabelModule,
// Highlights
HighlightModule,
// Queue and Background Processing
QueueModule,

View file

@ -13,11 +13,11 @@ import { EntityLabel } from '../label/entities/entity-label.entity'
export const testDatabaseConfig: TypeOrmModuleOptions = {
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
host: process.env.TEST_DATABASE_HOST,
port: Number.parseInt(process.env.TEST_DATABASE_PORT || '5432'),
username: process.env.TEST_DATABASE_USER,
password: process.env.TEST_DATABASE_PASSWORD,
database: process.env.TEST_DATABASE_NAME,
entities: [
User,
UserProfile,
@ -30,7 +30,7 @@ export const testDatabaseConfig: TypeOrmModuleOptions = {
Label,
EntityLabel,
],
synchronize: false, // Use existing schema
synchronize: false,
logging: false,
}

View file

@ -0,0 +1,109 @@
import { InputType, Field, Float, Int } from '@nestjs/graphql'
import {
IsString,
IsOptional,
IsNumber,
Min,
Max,
IsInt,
IsIn,
} from 'class-validator'
/**
* Input type for creating a new highlight
*/
@InputType()
export class CreateHighlightInput {
@Field(() => String, { description: 'Library item ID' })
@IsString()
libraryItemId!: string
@Field(() => String, { description: 'Quoted text from the document' })
@IsString()
quote!: string
@Field(() => String, {
nullable: true,
description: 'Text before the quote (for context)',
})
@IsOptional()
@IsString()
prefix?: string
@Field(() => String, {
nullable: true,
description: 'Text after the quote (for context)',
})
@IsOptional()
@IsString()
suffix?: string
@Field(() => String, {
nullable: true,
description: 'User annotation/note on the highlight',
})
@IsOptional()
@IsString()
annotation?: string
@Field(() => Float, {
nullable: true,
defaultValue: 0,
description: 'Position in document as percentage (0-100)',
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
highlightPositionPercent?: number
@Field(() => Int, {
nullable: true,
defaultValue: 0,
description: 'Anchor index for position',
})
@IsOptional()
@IsInt()
@Min(0)
highlightPositionAnchorIndex?: number
@Field(() => String, {
nullable: true,
description: 'Highlight color (yellow, red, green, blue)',
})
@IsOptional()
@IsString()
@IsIn(['yellow', 'red', 'green', 'blue'])
color?: string
@Field(() => String, {
nullable: true,
description: 'HTML content of the highlight',
})
@IsOptional()
@IsString()
html?: string
}
/**
* Input type for updating an existing highlight
*/
@InputType()
export class UpdateHighlightInput {
@Field(() => String, {
nullable: true,
description: 'User annotation/note on the highlight',
})
@IsOptional()
@IsString()
annotation?: string
@Field(() => String, {
nullable: true,
description: 'Highlight color (yellow, red, green, blue)',
})
@IsOptional()
@IsString()
@IsIn(['yellow', 'red', 'green', 'blue'])
color?: string
}

View file

@ -0,0 +1,64 @@
import { Field, Float, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql'
import { HighlightType, RepresentationType } from '../entities/highlight.entity'
registerEnumType(HighlightType, {
name: 'HighlightType',
})
registerEnumType(RepresentationType, {
name: 'RepresentationType',
})
@ObjectType()
export class Highlight {
@Field(() => ID)
id!: string
@Field()
shortId!: string
@Field()
libraryItemId!: string
@Field({ nullable: true })
quote?: string | null
@Field({ nullable: true })
prefix?: string | null
@Field({ nullable: true })
suffix?: string | null
@Field({ nullable: true })
patch?: string | null
@Field({ nullable: true })
annotation?: string | null
@Field(() => Date)
createdAt!: Date
@Field(() => Date)
updatedAt!: Date
@Field(() => Date, { nullable: true })
sharedAt?: Date | null
@Field(() => Float)
highlightPositionPercent!: number
@Field(() => Int)
highlightPositionAnchorIndex!: number
@Field(() => HighlightType)
highlightType!: HighlightType
@Field({ nullable: true })
html?: string | null
@Field({ nullable: true })
color?: string | null
@Field(() => RepresentationType)
representation!: RepresentationType
}

View file

@ -0,0 +1,96 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../../user/entities/user.entity'
import { LibraryItemEntity } from '../../library/entities/library-item.entity'
export enum HighlightType {
HIGHLIGHT = 'HIGHLIGHT',
REDACTION = 'REDACTION',
NOTE = 'NOTE', // Legacy - being phased out in favor of library_item.note
}
export enum RepresentationType {
CONTENT = 'CONTENT',
FEED_CONTENT = 'FEED_CONTENT',
}
@Entity({ name: 'highlight', schema: 'omnivore' })
export class HighlightEntity {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ name: 'short_id', type: 'varchar', length: 14 })
shortId!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column({ name: 'user_id', type: 'uuid' })
userId!: string
@ManyToOne(() => LibraryItemEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'library_item_id' })
libraryItem!: LibraryItemEntity
@Column({ name: 'library_item_id', type: 'uuid' })
libraryItemId!: string
@Column({ type: 'text', nullable: true })
quote?: string | null
@Column({ type: 'varchar', length: 5000, nullable: true })
prefix?: string | null
@Column({ type: 'varchar', length: 5000, nullable: true })
suffix?: string | null
@Column({ type: 'text', nullable: true })
patch?: string | null
@Column({ type: 'text', nullable: true })
annotation?: string | null
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date
@Column({ name: 'shared_at', type: 'timestamptz', nullable: true })
sharedAt?: Date | null
@Column({ name: 'highlight_position_percent', type: 'real', default: 0 })
highlightPositionPercent!: number
@Column({ name: 'highlight_position_anchor_index', type: 'integer', default: 0 })
highlightPositionAnchorIndex!: number
@Column({
name: 'highlight_type',
type: 'enum',
enum: HighlightType,
default: HighlightType.HIGHLIGHT,
})
highlightType!: HighlightType
@Column({ type: 'text', nullable: true })
html?: string | null
@Column({ type: 'text', nullable: true })
color?: string | null
@Column({
type: 'enum',
enum: RepresentationType,
default: RepresentationType.CONTENT,
})
representation!: RepresentationType
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { HighlightEntity } from './entities/highlight.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { HighlightService } from './highlight.service'
import { HighlightResolver } from './highlight.resolver'
@Module({
imports: [TypeOrmModule.forFeature([HighlightEntity, LibraryItemEntity])],
providers: [HighlightService, HighlightResolver],
exports: [HighlightService],
})
export class HighlightModule {}

View file

@ -0,0 +1,126 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'
import { CurrentUser } from '../user/decorators/current-user.decorator'
import { User } from '../user/entities/user.entity'
import { HighlightService } from './highlight.service'
import { Highlight } from './dto/highlight.type'
import {
CreateHighlightInput,
UpdateHighlightInput,
} from './dto/highlight-inputs.type'
import { DeleteResult } from '../library/dto/library-inputs.type'
@Resolver(() => Highlight)
export class HighlightResolver {
constructor(private readonly highlightService: HighlightService) {}
// ==================== QUERIES ====================
@Query(() => [Highlight], {
description: 'Get all highlights for a library item',
})
@UseGuards(JwtAuthGuard)
async highlights(
@CurrentUser() user: User,
@Args('libraryItemId', {
type: () => String,
description: 'Library item ID',
})
libraryItemId: string,
): Promise<Highlight[]> {
const entities = await this.highlightService.findByLibraryItem(
user.id,
libraryItemId,
)
return entities.map(mapEntityToGraph)
}
@Query(() => Highlight, {
nullable: true,
description: 'Get a single highlight by ID',
})
@UseGuards(JwtAuthGuard)
async highlight(
@CurrentUser() user: User,
@Args('id', { type: () => String, description: 'Highlight ID' })
id: string,
): Promise<Highlight | null> {
const entity = await this.highlightService.findById(user.id, id)
return entity ? mapEntityToGraph(entity) : null
}
// ==================== MUTATIONS ====================
@Mutation(() => Highlight, {
description: 'Create a new highlight with optional color',
})
@UseGuards(JwtAuthGuard)
async createHighlight(
@CurrentUser() user: User,
@Args('input', {
type: () => CreateHighlightInput,
description: 'Highlight creation data',
})
input: CreateHighlightInput,
): Promise<Highlight> {
const entity = await this.highlightService.createHighlight(user.id, input)
return mapEntityToGraph(entity)
}
@Mutation(() => Highlight, {
description: 'Update a highlight (annotation and/or color)',
})
@UseGuards(JwtAuthGuard)
async updateHighlight(
@CurrentUser() user: User,
@Args('id', { type: () => String, description: 'Highlight ID' })
id: string,
@Args('input', {
type: () => UpdateHighlightInput,
description: 'Highlight update data',
})
input: UpdateHighlightInput,
): Promise<Highlight> {
const entity = await this.highlightService.updateHighlight(
user.id,
id,
input,
)
return mapEntityToGraph(entity)
}
@Mutation(() => DeleteResult, {
description: 'Delete a highlight',
})
@UseGuards(JwtAuthGuard)
async deleteHighlight(
@CurrentUser() user: User,
@Args('id', { type: () => String, description: 'Highlight ID' })
id: string,
): Promise<DeleteResult> {
return await this.highlightService.deleteHighlight(user.id, id)
}
}
function mapEntityToGraph(entity: any): Highlight {
return {
id: entity.id,
shortId: entity.shortId,
libraryItemId: entity.libraryItemId,
quote: entity.quote ?? null,
prefix: entity.prefix ?? null,
suffix: entity.suffix ?? null,
patch: entity.patch ?? null,
annotation: entity.annotation ?? null,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
sharedAt: entity.sharedAt ?? null,
highlightPositionPercent: entity.highlightPositionPercent ?? 0,
highlightPositionAnchorIndex: entity.highlightPositionAnchorIndex ?? 0,
highlightType: entity.highlightType,
html: entity.html ?? null,
color: entity.color ?? 'yellow',
representation: entity.representation,
}
}

View file

@ -0,0 +1,164 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { HighlightEntity, HighlightType } from './entities/highlight.entity'
import { LibraryItemEntity } from '../library/entities/library-item.entity'
import { CreateHighlightInput, UpdateHighlightInput } from './dto/highlight-inputs.type'
@Injectable()
export class HighlightService {
private readonly logger = new Logger(HighlightService.name)
constructor(
@InjectRepository(HighlightEntity)
private readonly highlightRepository: Repository<HighlightEntity>,
@InjectRepository(LibraryItemEntity)
private readonly libraryItemRepository: Repository<LibraryItemEntity>,
) {}
/**
* Get all highlights for a library item
*/
async findByLibraryItem(
userId: string,
libraryItemId: string,
): Promise<HighlightEntity[]> {
// Verify the library item belongs to the user
const libraryItem = await this.libraryItemRepository.findOne({
where: { id: libraryItemId, userId },
})
if (!libraryItem) {
throw new NotFoundException(
`Library item with ID ${libraryItemId} not found`,
)
}
return this.highlightRepository.find({
where: {
libraryItemId,
userId,
},
order: {
highlightPositionPercent: 'ASC',
},
})
}
/**
* Get a single highlight by ID
*/
async findById(userId: string, id: string): Promise<HighlightEntity | null> {
return this.highlightRepository.findOne({
where: {
id,
userId,
},
})
}
/**
* Create a new highlight
*/
async createHighlight(
userId: string,
input: CreateHighlightInput,
): Promise<HighlightEntity> {
// Verify the library item exists and belongs to the user
const libraryItem = await this.libraryItemRepository.findOne({
where: { id: input.libraryItemId, userId },
})
if (!libraryItem) {
throw new NotFoundException(
`Library item with ID ${input.libraryItemId} not found`,
)
}
// Generate a short ID (8 characters)
const shortId = this.generateShortId()
const highlight = this.highlightRepository.create({
userId,
libraryItemId: input.libraryItemId,
shortId,
quote: input.quote,
prefix: input.prefix,
suffix: input.suffix,
annotation: input.annotation,
highlightPositionPercent: input.highlightPositionPercent ?? 0,
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex ?? 0,
color: input.color ?? 'yellow',
html: input.html,
highlightType: HighlightType.HIGHLIGHT,
representation: 'CONTENT' as any,
})
return this.highlightRepository.save(highlight)
}
/**
* Update an existing highlight
*/
async updateHighlight(
userId: string,
id: string,
input: UpdateHighlightInput,
): Promise<HighlightEntity> {
const highlight = await this.findById(userId, id)
if (!highlight) {
throw new NotFoundException(`Highlight with ID ${id} not found`)
}
// Update only the fields that are provided
if (input.annotation !== undefined) {
highlight.annotation = input.annotation
}
if (input.color !== undefined) {
highlight.color = input.color
}
return this.highlightRepository.save(highlight)
}
/**
* Delete a highlight
*/
async deleteHighlight(
userId: string,
id: string,
): Promise<{ success: boolean; message?: string; itemId: string }> {
const highlight = await this.findById(userId, id)
if (!highlight) {
throw new NotFoundException(`Highlight with ID ${id} not found`)
}
await this.highlightRepository.remove(highlight)
return {
success: true,
message: 'Highlight deleted successfully',
itemId: id,
}
}
/**
* Generate a short ID for the highlight
*/
private generateShortId(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
}

View file

@ -205,3 +205,13 @@ export class SaveUrlInput {
@IsIn(['web', 'mobile', 'api', 'extension'])
source?: 'web' | 'mobile' | 'api' | 'extension'
}
/**
* Input type for updating notebook content
*/
@InputType()
export class UpdateNotebookInput {
@Field(() => String, { description: 'Notebook content (supports markdown)' })
@IsString()
note: string
}

View file

@ -65,6 +65,12 @@ export class LibraryItem {
@Field({ nullable: true })
content?: string | null
@Field({ nullable: true })
note?: string | null
@Field(() => Date, { nullable: true })
noteUpdatedAt?: Date | null
}
@ObjectType()

View file

@ -119,6 +119,12 @@ export class LibraryItemEntity {
@Column({ name: 'readable_content', type: 'text', default: '' })
readableContent!: string
@Column({ type: 'text', nullable: true })
note?: string | null
@Column({ name: 'note_updated_at', type: 'timestamptz', nullable: true })
noteUpdatedAt?: Date | null
@OneToMany(() => EntityLabel, (entityLabel) => entityLabel.libraryItem)
entityLabels!: EntityLabel[]
}

View file

@ -10,6 +10,7 @@ import {
DeleteResult,
LibrarySearchInput,
SaveUrlInput,
UpdateNotebookInput,
} from './dto/library-inputs.type'
import { LabelService } from '../label/label.service'
import { Label } from '../label/dto/label.type'
@ -122,6 +123,28 @@ export class LibraryResolver {
return mapEntityToGraph(entity)
}
@Mutation(() => LibraryItem, {
description: 'Update notebook content for a library item',
})
@UseGuards(JwtAuthGuard)
async updateNotebook(
@CurrentUser() user: User,
@Args('id', { type: () => String, description: 'Library item ID' })
id: string,
@Args('input', {
type: () => UpdateNotebookInput,
description: 'Notebook content',
})
input: UpdateNotebookInput,
): Promise<LibraryItem> {
const entity = await this.libraryService.updateNotebook(
user.id,
id,
input.note,
)
return mapEntityToGraph(entity)
}
@Mutation(() => LibraryItem, {
description: 'Move a library item to a different folder',
})
@ -247,6 +270,8 @@ function mapEntityToGraph(entity: any): LibraryItem {
contentReader: entity.contentReader,
folder: entity.folder,
content: entity.readableContent ?? null,
note: entity.note ?? null,
noteUpdatedAt: entity.noteUpdatedAt ?? null,
labels: null, // Labels will be resolved by the field resolver
}
}

View file

@ -705,6 +705,32 @@ export class LibraryService {
return savedItem
}
/**
* Update notebook content for a library item
* @param userId - User ID who owns the item
* @param itemId - Library item ID
* @param note - Notebook content (supports markdown)
* @returns Updated library item
*/
async updateNotebook(
userId: string,
itemId: string,
note: string,
): Promise<LibraryItemEntity> {
const item = await this.findById(userId, itemId)
if (!item) {
throw new NotFoundException(`Library item with ID ${itemId} not found`)
}
// Update note and timestamp
item.note = note
item.noteUpdatedAt = new Date()
await this.libraryRepository.save(item)
return item
}
/**
* Generate a slug from URL
*/

View file

@ -0,0 +1,166 @@
import { DataSource } from 'typeorm'
import { testDatabaseConfig } from '../../src/config/test.config'
/**
* Test Database Setup Utility
*
* Provides utilities for managing test database lifecycle:
* - Creating test database before tests
* - Cleaning up test database after tests
* - Ensuring database isolation
*/
/**
* Creates a PostgreSQL connection to the default 'postgres' database
* Used to create/drop test databases
*/
async function getAdminConnection(): Promise<DataSource> {
const adminConfig = {
...testDatabaseConfig,
database: 'postgres', // Connect to default postgres database
}
// Remove TypeORM-specific options that DataSource doesn't use
const { entities, synchronize, logging, ...connectionOptions } = adminConfig as any
const dataSource = new DataSource(connectionOptions as any)
await dataSource.initialize()
return dataSource
}
/**
* Creates the test database if it doesn't exist
* This should be run before test suite starts
*/
export async function createTestDatabase(): Promise<void> {
const testDbName = testDatabaseConfig.database as string
console.log(`\n🔧 Setting up test database: ${testDbName}`)
let adminConnection: DataSource | null = null
try {
adminConnection = await getAdminConnection()
// Check if database exists
const result = await adminConnection.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[testDbName]
)
if (result.length === 0) {
// Database doesn't exist, create it
await adminConnection.query(`CREATE DATABASE ${testDbName}`)
console.log(`✅ Created test database: ${testDbName}`)
} else {
console.log(` Test database already exists: ${testDbName}`)
}
} catch (error) {
console.error(`❌ Failed to create test database:`, error)
throw error
} finally {
if (adminConnection) {
await adminConnection.destroy()
}
}
}
/**
* Drops the test database completely
* WARNING: This will delete ALL test data
* This should be run after test suite completes
*/
export async function dropTestDatabase(): Promise<void> {
const testDbName = testDatabaseConfig.database as string
console.log(`\n🧹 Cleaning up test database: ${testDbName}`)
let adminConnection: DataSource | null = null
try {
adminConnection = await getAdminConnection()
// Terminate all active connections to the test database
await adminConnection.query(`
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = $1
AND pid <> pg_backend_pid()
`, [testDbName])
// Drop the database
await adminConnection.query(`DROP DATABASE IF EXISTS ${testDbName}`)
console.log(`✅ Dropped test database: ${testDbName}`)
} catch (error) {
console.error(`❌ Failed to drop test database:`, error)
// Don't throw - cleanup failures shouldn't fail tests
} finally {
if (adminConnection) {
await adminConnection.destroy()
}
}
}
/**
* Truncates all tables in the test database
* Faster than dropping/recreating for between-test cleanup
*/
export async function cleanTestDatabase(): Promise<void> {
const testDbName = testDatabaseConfig.database as string
let testConnection: DataSource | null = null
try {
const { entities, synchronize, logging, ...connectionOptions } = testDatabaseConfig as any
testConnection = new DataSource(connectionOptions as any)
await testConnection.initialize()
// Get all table names from omnivore schema
const tables = await testConnection.query(`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'omnivore'
`)
if (tables.length > 0) {
const tableNames = tables.map((t: any) => `omnivore.${t.tablename}`).join(', ')
// Truncate all tables with CASCADE to handle foreign keys
await testConnection.query(`TRUNCATE TABLE ${tableNames} RESTART IDENTITY CASCADE`)
console.log(`✅ Cleaned ${tables.length} tables in test database`)
}
} catch (error) {
console.error(`❌ Failed to clean test database:`, error)
throw error
} finally {
if (testConnection) {
await testConnection.destroy()
}
}
}
/**
* Ensures test database exists and runs migrations
* Call this in your test setup (e.g., jest globalSetup)
*/
export async function setupTestDatabase(): Promise<void> {
await createTestDatabase()
// Note: Migrations should be run separately using the migration script
// This is to ensure the test database schema matches production
console.log(`\n⚠ Remember to run migrations on test database:`)
console.log(` TEST_DATABASE_NAME=omnivore_test npm run migration:run\n`)
}
/**
* Tears down test database
* Call this in your test teardown (e.g., jest globalTeardown)
*/
export async function teardownTestDatabase(): Promise<void> {
// Option 1: Drop entire database (clean slate for next run)
// await dropTestDatabase()
// Option 2: Just clean tables (faster, keeps schema)
await cleanTestDatabase()
}

View file

@ -0,0 +1,758 @@
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'
import { HighlightEntity } from '../src/highlight/entities/highlight.entity'
const HIGHLIGHTS_QUERY = `
query Highlights($libraryItemId: String!) {
highlights(libraryItemId: $libraryItemId) {
id
shortId
quote
annotation
color
highlightPositionPercent
createdAt
updatedAt
}
}
`
const HIGHLIGHT_QUERY = `
query Highlight($id: String!) {
highlight(id: $id) {
id
quote
annotation
color
}
}
`
const CREATE_HIGHLIGHT_MUTATION = `
mutation CreateHighlight($input: CreateHighlightInput!) {
createHighlight(input: $input) {
id
shortId
quote
annotation
color
highlaryPositionPercent
createdAt
}
}
`
const UPDATE_HIGHLIGHT_MUTATION = `
mutation UpdateHighlight($id: String!, $input: UpdateHighlightInput!) {
updateHighlight(id: $id, input: $input) {
id
annotation
color
updatedAt
}
}
`
const DELETE_HIGHLIGHT_MUTATION = `
mutation DeleteHighlight($id: String!) {
deleteHighlight(id: $id) {
success
message
itemId
}
}
`
describe('Highlight GraphQL (e2e)', () => {
let app: INestApplication
let authToken: string
let userId: string
let libraryRepository: Repository<LibraryItemEntity>
let highlightRepository: Repository<HighlightEntity>
let testLibraryItemId: string
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<Repository<LibraryItemEntity>>(
getRepositoryToken(LibraryItemEntity),
)
highlightRepository = moduleFixture.get<Repository<HighlightEntity>>(
getRepositoryToken(HighlightEntity),
)
const registerResponse = await request(app.getHttpServer())
.post('/api/v2/auth/register')
.send({
email: `highlight-test-${Date.now()}@omnivore.app`,
name: 'Highlight Test User',
password: 'highlightPassword123',
})
.expect(201)
authToken = registerResponse.body.accessToken
userId = registerResponse.body.user.id
// Create a test library item for all highlight tests
const testItem = libraryRepository.create({
id: randomUUID(),
userId,
user: { id: userId } as any,
title: 'Test Article for Highlights',
slug: `highlight-article-${Date.now()}`,
originalUrl: `https://example.com/highlight-test-${Date.now()}`,
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
itemType: 'ARTICLE',
readableContent: 'This is the content of the article that can be highlighted.',
})
const saved = await libraryRepository.save(testItem)
testLibraryItemId = saved.id
})
afterAll(async () => {
await app.close()
}, 30000)
const executeQuery = (query: string, variables: Record<string, unknown> = {}) =>
request(app.getHttpServer())
.post('/api/graphql')
.set('Authorization', `Bearer ${authToken}`)
.send({ query, variables })
.expect(200)
describe('Query highlights', () => {
beforeAll(async () => {
// Create test highlights with different colors
const highlights = [
{
id: randomUUID(),
userId,
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test001',
quote: 'First important quote',
annotation: 'This is significant',
color: 'yellow',
highlightPositionPercent: 10,
highlightPositionAnchorIndex: 0,
highlightType: 'HIGHLIGHT' as any,
representation: 'CONTENT' as any,
},
{
id: randomUUID(),
userId,
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test002',
quote: 'Second important quote',
annotation: 'Very interesting',
color: 'green',
highlightPositionPercent: 25,
highlightPositionAnchorIndex: 0,
highlightType: 'HIGHLIGHT' as any,
representation: 'CONTENT' as any,
},
{
id: randomUUID(),
userId,
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test003',
quote: 'Third important quote',
color: 'red',
highlightPositionPercent: 50,
highlightPositionAnchorIndex: 0,
highlightType: 'HIGHLIGHT' as any,
representation: 'CONTENT' as any,
},
{
id: randomUUID(),
userId,
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: 'test004',
quote: 'Fourth important quote',
annotation: 'Key insight',
color: 'blue',
highlightPositionPercent: 75,
highlightPositionAnchorIndex: 0,
highlightType: 'HIGHLIGHT' as any,
representation: 'CONTENT' as any,
},
]
await highlightRepository.save(highlights)
})
it('retrieves all highlights for a library item', async () => {
const response = await executeQuery(HIGHLIGHTS_QUERY, {
libraryItemId: testLibraryItemId,
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.highlights).toHaveLength(4)
expect(response.body.data.highlights[0]).toHaveProperty('id')
expect(response.body.data.highlights[0]).toHaveProperty('quote')
expect(response.body.data.highlights[0]).toHaveProperty('color')
})
it('returns highlights sorted by position', async () => {
const response = await executeQuery(HIGHLIGHTS_QUERY, {
libraryItemId: testLibraryItemId,
})
expect(response.body.errors).toBeUndefined()
const positions = response.body.data.highlights.map(
(h: any) => h.highlightPositionPercent,
)
expect(positions).toEqual([10, 25, 50, 75])
})
it('returns highlights with all color variations', async () => {
const response = await executeQuery(HIGHLIGHTS_QUERY, {
libraryItemId: testLibraryItemId,
})
expect(response.body.errors).toBeUndefined()
const colors = response.body.data.highlights.map((h: any) => h.color)
expect(colors).toContain('yellow')
expect(colors).toContain('green')
expect(colors).toContain('red')
expect(colors).toContain('blue')
})
it('retrieves a single highlight by id', async () => {
const existing = await highlightRepository.findOneBy({
libraryItemId: testLibraryItemId,
shortId: 'test001',
})
const response = await executeQuery(HIGHLIGHT_QUERY, {
id: existing!.id,
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.highlight).toMatchObject({
id: existing!.id,
quote: 'First important quote',
annotation: 'This is significant',
color: 'yellow',
})
})
it('returns empty array for library item with no highlights', async () => {
const emptyItem = libraryRepository.create({
id: randomUUID(),
userId,
user: { id: userId } as any,
title: 'Empty Article',
slug: `empty-${Date.now()}`,
originalUrl: `https://example.com/empty-${Date.now()}`,
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
itemType: 'ARTICLE',
})
const saved = await libraryRepository.save(emptyItem)
const response = await executeQuery(HIGHLIGHTS_QUERY, {
libraryItemId: saved.id,
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.highlights).toHaveLength(0)
})
})
describe('createHighlight', () => {
it('creates a highlight with default yellow color', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'New highlight quote',
annotation: 'My thoughts',
highlightPositionPercent: 33,
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.createHighlight).toMatchObject({
quote: 'New highlight quote',
annotation: 'My thoughts',
color: 'yellow',
})
expect(response.body.data.createHighlight.id).toBeTruthy()
expect(response.body.data.createHighlight.shortId).toBeTruthy()
// Verify in database
const highlight = await highlightRepository.findOneBy({
id: response.body.data.createHighlight.id,
})
expect(highlight?.color).toBe('yellow')
})
it('creates a highlight with red color', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Important red highlight',
color: 'red',
highlightPositionPercent: 42,
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.createHighlight).toMatchObject({
quote: 'Important red highlight',
color: 'red',
})
// Verify in database
const highlight = await highlightRepository.findOneBy({
id: response.body.data.createHighlight.id,
})
expect(highlight?.color).toBe('red')
})
it('creates a highlight with green color', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Positive green highlight',
color: 'green',
highlightPositionPercent: 55,
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.createHighlight.color).toBe('green')
})
it('creates a highlight with blue color', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Information blue highlight',
color: 'blue',
highlightPositionPercent: 68,
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.createHighlight.color).toBe('blue')
})
it('creates a highlight without annotation', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Quote without annotation',
color: 'yellow',
highlightPositionPercent: 20,
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.createHighlight.quote).toBe(
'Quote without annotation',
)
expect(response.body.data.createHighlight.annotation).toBeNull()
})
it('creates a highlight with prefix and suffix context', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'highlighted text',
prefix: 'This is the ',
suffix: ' with context',
color: 'yellow',
highlightPositionPercent: 30,
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.createHighlight).toMatchObject({
quote: 'highlighted text',
})
// Verify in database
const highlight = await highlightRepository.findOneBy({
id: response.body.data.createHighlight.id,
})
expect(highlight?.prefix).toBe('This is the ')
expect(highlight?.suffix).toBe(' with context')
})
it('returns error for invalid color', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Test quote',
color: 'purple', // Invalid color
highlightPositionPercent: 40,
},
})
expect(response.body.errors).toBeDefined()
})
it('returns error for non-existent library item', async () => {
const response = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: randomUUID(),
quote: 'Test quote',
color: 'yellow',
highlightPositionPercent: 40,
},
})
expect(response.body.errors).toBeDefined()
expect(response.body.errors[0].message).toContain('not found')
})
it('generates unique shortId for each highlight', async () => {
const response1 = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'First quote',
color: 'yellow',
highlightPositionPercent: 11,
},
})
const response2 = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Second quote',
color: 'yellow',
highlightPositionPercent: 12,
},
})
expect(response1.body.data.createHighlight.shortId).not.toBe(
response2.body.data.createHighlight.shortId,
)
})
})
describe('updateHighlight', () => {
let testHighlightId: string
beforeEach(async () => {
const highlight = highlightRepository.create({
id: randomUUID(),
userId,
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: `update-${Date.now()}`,
quote: 'Original quote',
annotation: 'Original annotation',
color: 'yellow',
highlightPositionPercent: 50,
highlightPositionAnchorIndex: 0,
highlightType: 'HIGHLIGHT' as any,
representation: 'CONTENT' as any,
})
const saved = await highlightRepository.save(highlight)
testHighlightId = saved.id
})
it('updates highlight annotation', async () => {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: { annotation: 'Updated annotation' },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateHighlight).toMatchObject({
id: testHighlightId,
annotation: 'Updated annotation',
color: 'yellow', // Unchanged
})
// Verify in database
const highlight = await highlightRepository.findOneBy({ id: testHighlightId })
expect(highlight?.annotation).toBe('Updated annotation')
expect(highlight?.color).toBe('yellow')
})
it('updates highlight color from yellow to red', async () => {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: { color: 'red' },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateHighlight).toMatchObject({
id: testHighlightId,
color: 'red',
annotation: 'Original annotation', // Unchanged
})
// Verify in database
const highlight = await highlightRepository.findOneBy({ id: testHighlightId })
expect(highlight?.color).toBe('red')
expect(highlight?.annotation).toBe('Original annotation')
})
it('updates both annotation and color simultaneously', async () => {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: {
annotation: 'New annotation',
color: 'blue',
},
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateHighlight).toMatchObject({
id: testHighlightId,
annotation: 'New annotation',
color: 'blue',
})
// Verify in database
const highlight = await highlightRepository.findOneBy({ id: testHighlightId })
expect(highlight?.annotation).toBe('New annotation')
expect(highlight?.color).toBe('blue')
})
it('clears annotation with empty string', async () => {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: { annotation: '' },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateHighlight.annotation).toBe('')
// Verify in database
const highlight = await highlightRepository.findOneBy({ id: testHighlightId })
expect(highlight?.annotation).toBe('')
})
it('cycles through all color options', async () => {
const colors = ['yellow', 'red', 'green', 'blue']
for (const color of colors) {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: { color },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateHighlight.color).toBe(color)
}
// Verify final state in database
const highlight = await highlightRepository.findOneBy({ id: testHighlightId })
expect(highlight?.color).toBe('blue')
})
it('returns error for invalid color', async () => {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: { color: 'orange' }, // Invalid color
})
expect(response.body.errors).toBeDefined()
})
it('returns error for non-existent highlight', async () => {
const response = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: randomUUID(),
input: { annotation: 'Test' },
})
expect(response.body.errors).toBeDefined()
expect(response.body.errors[0].message).toContain('not found')
})
it('updates updatedAt timestamp', async () => {
const before = await highlightRepository.findOneBy({ id: testHighlightId })
const originalUpdatedAt = before!.updatedAt
// Wait a bit to ensure timestamp difference
await new Promise((resolve) => setTimeout(resolve, 10))
await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: testHighlightId,
input: { annotation: 'Updated' },
})
const after = await highlightRepository.findOneBy({ id: testHighlightId })
expect(after!.updatedAt.getTime()).toBeGreaterThan(
originalUpdatedAt.getTime(),
)
})
})
describe('deleteHighlight', () => {
it('deletes a highlight successfully', async () => {
const highlight = highlightRepository.create({
id: randomUUID(),
userId,
user: { id: userId } as any,
libraryItemId: testLibraryItemId,
libraryItem: { id: testLibraryItemId } as any,
shortId: `delete-${Date.now()}`,
quote: 'To be deleted',
color: 'yellow',
highlightPositionPercent: 50,
highlightPositionAnchorIndex: 0,
highlightType: 'HIGHLIGHT' as any,
representation: 'CONTENT' as any,
})
const saved = await highlightRepository.save(highlight)
const response = await executeQuery(DELETE_HIGHLIGHT_MUTATION, {
id: saved.id,
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.deleteHighlight).toMatchObject({
success: true,
message: 'Highlight deleted successfully',
itemId: saved.id,
})
// Verify deleted in database
const deleted = await highlightRepository.findOneBy({ id: saved.id })
expect(deleted).toBeNull()
})
it('returns error for non-existent highlight', async () => {
const response = await executeQuery(DELETE_HIGHLIGHT_MUTATION, {
id: randomUUID(),
})
expect(response.body.errors).toBeDefined()
expect(response.body.errors[0].message).toContain('not found')
})
})
describe('Color-based workflows', () => {
it('supports creating highlights with different colors for organization', async () => {
// Create highlights with semantic colors
const importantQuote = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Critical information',
annotation: 'Must remember',
color: 'red',
highlightPositionPercent: 15,
},
})
const actionItem = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'To do item',
annotation: 'Action required',
color: 'green',
highlightPositionPercent: 35,
},
})
const reference = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Reference material',
annotation: 'For later',
color: 'blue',
highlightPositionPercent: 65,
},
})
expect(importantQuote.body.data.createHighlight.color).toBe('red')
expect(actionItem.body.data.createHighlight.color).toBe('green')
expect(reference.body.data.createHighlight.color).toBe('blue')
// Verify all highlights are retrievable
const allHighlights = await executeQuery(HIGHLIGHTS_QUERY, {
libraryItemId: testLibraryItemId,
})
const colors = allHighlights.body.data.highlights.map((h: any) => h.color)
expect(colors).toContain('red')
expect(colors).toContain('green')
expect(colors).toContain('blue')
})
it('supports changing highlight color based on re-evaluation', async () => {
// Create with initial color
const createResponse = await executeQuery(CREATE_HIGHLIGHT_MUTATION, {
input: {
libraryItemId: testLibraryItemId,
quote: 'Initially interesting',
color: 'yellow',
highlightPositionPercent: 45,
},
})
const highlightId = createResponse.body.data.createHighlight.id
// Re-evaluate as more important
const updateResponse = await executeQuery(UPDATE_HIGHLIGHT_MUTATION, {
id: highlightId,
input: {
color: 'red',
annotation: 'Actually very important!',
},
})
expect(updateResponse.body.data.updateHighlight).toMatchObject({
color: 'red',
annotation: 'Actually very important!',
})
})
})
})

View file

@ -0,0 +1,285 @@
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_ITEM_QUERY = `
query LibraryItem($id: String!) {
libraryItem(id: $id) {
id
title
note
noteUpdatedAt
}
}
`
const UPDATE_NOTEBOOK_MUTATION = `
mutation UpdateNotebook($id: String!, $input: UpdateNotebookInput!) {
updateNotebook(id: $id, input: $input) {
id
note
noteUpdatedAt
}
}
`
describe('Notebook GraphQL (e2e)', () => {
let app: INestApplication
let authToken: string
let userId: string
let libraryRepository: Repository<LibraryItemEntity>
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<Repository<LibraryItemEntity>>(
getRepositoryToken(LibraryItemEntity),
)
const registerResponse = await request(app.getHttpServer())
.post('/api/v2/auth/register')
.send({
email: `notebook-test-${Date.now()}@omnivore.app`,
name: 'Notebook Test User',
password: 'notebookPassword123',
})
.expect(201)
authToken = registerResponse.body.accessToken
userId = registerResponse.body.user.id
})
afterAll(async () => {
await app.close()
}, 30000)
const executeQuery = (query: string, variables: Record<string, unknown> = {}) =>
request(app.getHttpServer())
.post('/api/graphql')
.set('Authorization', `Bearer ${authToken}`)
.send({ query, variables })
.expect(200)
describe('updateNotebook', () => {
let testItemId: string
beforeEach(async () => {
const timestamp = Date.now()
const testItem = libraryRepository.create({
id: randomUUID(),
userId,
user: { id: userId } as any,
title: 'Test Article with Notebook',
slug: `notebook-test-${timestamp}`,
originalUrl: `https://example.com/notebook-test-${timestamp}`,
savedAt: new Date(),
state: LibraryItemState.SUCCEEDED,
contentReader: ContentReaderType.WEB,
folder: 'inbox',
itemType: 'ARTICLE',
})
const saved = await libraryRepository.save(testItem)
testItemId = saved.id
})
it('creates a new notebook for a library item', async () => {
const noteContent = '# My Thoughts\n\nThis is an interesting article about TypeScript.'
const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: noteContent },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateNotebook).toMatchObject({
id: testItemId,
note: noteContent,
})
expect(response.body.data.updateNotebook.noteUpdatedAt).toBeTruthy()
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.note).toBe(noteContent)
expect(item?.noteUpdatedAt).toBeInstanceOf(Date)
})
it('updates an existing notebook', async () => {
const initialNote = 'Initial thoughts'
await libraryRepository.update(testItemId, { note: initialNote })
const updatedNote = 'Updated thoughts with more details'
const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: updatedNote },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateNotebook).toMatchObject({
id: testItemId,
note: updatedNote,
})
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.note).toBe(updatedNote)
expect(item?.noteUpdatedAt).toBeInstanceOf(Date)
})
it('clears a notebook with empty string', async () => {
await libraryRepository.update(testItemId, { note: 'Some notes' })
const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: '' },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateNotebook.note).toBe('')
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.note).toBe('')
})
it('supports markdown formatting in notebooks', async () => {
const markdownNote = `# Summary
## Key Points
- **Important**: TypeScript provides type safety
- *Interesting*: Works well with React
- \`code example\`: const x: number = 5
### Links
[Official Docs](https://typescriptlang.org)`
const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: markdownNote },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateNotebook.note).toBe(markdownNote)
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.note).toBe(markdownNote)
})
it('handles long notebook content', async () => {
const longNote = 'A'.repeat(10000) // 10KB of text
const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: longNote },
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.updateNotebook.note).toBe(longNote)
// Verify in database
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.note).toBe(longNote)
})
it('returns error for non-existent library item', async () => {
const response = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: randomUUID(),
input: { note: 'Test note' },
})
expect(response.body.errors).toBeDefined()
expect(response.body.errors[0].message).toContain('not found')
})
it('retrieves notebook via libraryItem query', async () => {
const noteContent = 'My personal notes about this article'
await libraryRepository.update(testItemId, { note: noteContent })
const response = await executeQuery(LIBRARY_ITEM_QUERY, {
id: testItemId,
})
expect(response.body.errors).toBeUndefined()
expect(response.body.data.libraryItem).toMatchObject({
id: testItemId,
note: noteContent,
})
expect(response.body.data.libraryItem.noteUpdatedAt).toBeTruthy()
})
it('updates noteUpdatedAt timestamp on each update', async () => {
// First update
const firstResponse = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: 'First version' },
})
const firstTimestamp = new Date(
firstResponse.body.data.updateNotebook.noteUpdatedAt,
)
// Wait a bit to ensure timestamp difference
await new Promise((resolve) => setTimeout(resolve, 10))
// Second update
const secondResponse = await executeQuery(UPDATE_NOTEBOOK_MUTATION, {
id: testItemId,
input: { note: 'Second version' },
})
const secondTimestamp = new Date(
secondResponse.body.data.updateNotebook.noteUpdatedAt,
)
expect(secondTimestamp.getTime()).toBeGreaterThan(firstTimestamp.getTime())
})
it('preserves notebook when updating other library item fields', async () => {
const noteContent = 'My preserved notes'
await libraryRepository.update(testItemId, { note: noteContent })
// Update reading progress (different field)
await libraryRepository.update(testItemId, {
readingProgressTopPercent: 50,
})
// Verify notebook is preserved
const item = await libraryRepository.findOneBy({ id: testItemId })
expect(item?.note).toBe(noteContent)
expect(item?.readingProgressTopPercent).toBe(50)
})
})
})

View file

@ -0,0 +1,123 @@
-- Type: DO
-- Name: consolidate_notebooks
-- Description: Consolidate notebooks from highlight(type='NOTE') to library_item.note
--
-- Context: The notebook feature was originally stored as a special highlight type='NOTE',
-- but migration 0120 added library_item.note as the preferred location. This migration
-- completes the consolidation by moving any remaining type='NOTE' highlights to the
-- library_item table and removing the redundancy.
--
-- Safety: This is a read-only copy operation. Existing library_item.note values are preserved.
-- If both exist, library_item.note takes precedence (source of truth).
BEGIN;
-- Step 1: Add note_updated_at column to track when notebooks are modified
ALTER TABLE omnivore.library_item
ADD COLUMN IF NOT EXISTS note_updated_at timestamptz;
-- Step 2: Log current state for verification
DO $$
DECLARE
notes_in_lib_item integer;
notes_in_highlights integer;
BEGIN
SELECT COUNT(*) INTO notes_in_lib_item
FROM omnivore.library_item
WHERE note IS NOT NULL AND note != '';
SELECT COUNT(*) INTO notes_in_highlights
FROM omnivore.highlight
WHERE highlight_type = 'NOTE';
RAISE NOTICE 'Before migration: % notebooks in library_item, % in highlights',
notes_in_lib_item, notes_in_highlights;
END $$;
-- Step 3: Migrate notebooks from highlights to library_item
-- Strategy: Only copy if library_item.note is NULL or empty (preserve existing notes)
UPDATE omnivore.library_item li
SET
note = h.annotation,
note_updated_at = h.updated_at,
updated_at = CURRENT_TIMESTAMP
FROM omnivore.highlight h
WHERE h.library_item_id = li.id
AND h.highlight_type = 'NOTE'
AND h.annotation IS NOT NULL
AND h.annotation != ''
AND (li.note IS NULL OR li.note = '');
-- Step 4: Log conflicts (library items with both note and NOTE highlight)
-- These are cases where library_item.note already exists and differs from highlight
DO $$
DECLARE
conflict_record RECORD;
conflict_count integer := 0;
BEGIN
FOR conflict_record IN
SELECT
li.id,
li.title,
li.note,
h.annotation,
li.updated_at as note_updated,
h.updated_at as highlight_updated
FROM omnivore.library_item li
INNER JOIN omnivore.highlight h ON h.library_item_id = li.id
WHERE h.highlight_type = 'NOTE'
AND li.note IS NOT NULL
AND li.note != ''
AND h.annotation IS NOT NULL
AND h.annotation != ''
AND li.note != h.annotation
LOOP
conflict_count := conflict_count + 1;
RAISE NOTICE 'CONFLICT on library_item % ("%"): library_item.note="%..." (updated %), highlight.annotation="%..." (updated %)',
conflict_record.id,
LEFT(conflict_record.title, 30),
LEFT(conflict_record.note, 40),
conflict_record.note_updated,
LEFT(conflict_record.annotation, 40),
conflict_record.highlight_updated;
END LOOP;
IF conflict_count > 0 THEN
RAISE NOTICE 'Found % conflicts. library_item.note was kept as source of truth.', conflict_count;
ELSE
RAISE NOTICE 'No conflicts found. All notebooks consolidated cleanly.';
END IF;
END $$;
-- Step 5: Delete all type='NOTE' highlights (data now safely in library_item.note)
-- This removes the redundancy and completes the consolidation
DELETE FROM omnivore.highlight
WHERE highlight_type = 'NOTE';
-- Step 6: Log final state
DO $$
DECLARE
notes_after integer;
type_note_remaining integer;
BEGIN
SELECT COUNT(*) INTO notes_after
FROM omnivore.library_item
WHERE note IS NOT NULL AND note != '';
SELECT COUNT(*) INTO type_note_remaining
FROM omnivore.highlight
WHERE highlight_type = 'NOTE';
RAISE NOTICE 'After migration: % notebooks in library_item, % type=NOTE highlights remaining',
notes_after, type_note_remaining;
IF type_note_remaining > 0 THEN
RAISE WARNING 'Unexpected: % type=NOTE highlights still exist after deletion!', type_note_remaining;
END IF;
END $$;
-- Step 7: Update table statistics for query planner
ANALYZE omnivore.library_item;
ANALYZE omnivore.highlight;
COMMIT;

View file

@ -0,0 +1,98 @@
-- Type: UNDO
-- Name: consolidate_notebooks
-- Description: Rollback notebook consolidation (restores type='NOTE' highlights from library_item.note)
--
-- WARNING: This undo is LOSSY. We cannot perfectly restore the deleted highlights because:
-- 1. Original highlight metadata (position, created_at, etc.) is lost
-- 2. We can only recreate highlights from library_item.note
-- 3. Any conflicts that were resolved in favor of library_item.note cannot be restored
--
-- This undo should only be used in emergency situations where the migration caused issues.
-- Recommendation: Do NOT run this unless absolutely necessary. Instead, fix forward.
BEGIN;
-- Step 1: Log current state
DO $$
DECLARE
notes_in_lib_item integer;
notes_in_highlights integer;
BEGIN
SELECT COUNT(*) INTO notes_in_lib_item
FROM omnivore.library_item
WHERE note IS NOT NULL AND note != '';
SELECT COUNT(*) INTO notes_in_highlights
FROM omnivore.highlight
WHERE highlight_type = 'NOTE';
RAISE NOTICE 'Before undo: % notebooks in library_item, % in highlights',
notes_in_lib_item, notes_in_highlights;
RAISE WARNING 'Running LOSSY undo migration. Original highlight metadata will not be restored.';
END $$;
-- Step 2: Recreate type='NOTE' highlights from library_item.note
-- Note: This creates NEW highlights, not restoring the originals
INSERT INTO omnivore.highlight (
user_id,
library_item_id,
short_id,
annotation,
highlight_type,
highlight_position_percent,
highlight_position_anchor_index,
created_at,
updated_at
)
SELECT
li.user_id,
li.id,
-- Generate new short_id (original is lost)
substring(md5(random()::text) from 1 for 8),
li.note,
'NOTE'::highlight_type,
0, -- Default position (original lost)
0, -- Default anchor (original lost)
COALESCE(li.note_updated_at, li.updated_at, li.created_at),
COALESCE(li.note_updated_at, li.updated_at, li.created_at)
FROM omnivore.library_item li
WHERE li.note IS NOT NULL
AND li.note != ''
-- Only create highlight if one doesn't already exist
AND NOT EXISTS (
SELECT 1 FROM omnivore.highlight h
WHERE h.library_item_id = li.id
AND h.highlight_type = 'NOTE'
);
-- Step 3: Remove note_updated_at column
ALTER TABLE omnivore.library_item
DROP COLUMN IF EXISTS note_updated_at;
-- Step 4: Log final state
DO $$
DECLARE
notes_in_lib_item integer;
notes_in_highlights integer;
BEGIN
SELECT COUNT(*) INTO notes_in_lib_item
FROM omnivore.library_item
WHERE note IS NOT NULL AND note != '';
SELECT COUNT(*) INTO notes_in_highlights
FROM omnivore.highlight
WHERE highlight_type = 'NOTE';
RAISE NOTICE 'After undo: % notebooks in library_item, % in highlights',
notes_in_lib_item, notes_in_highlights;
RAISE NOTICE 'Undo complete. type=NOTE highlights recreated from library_item.note.';
RAISE WARNING 'Remember: This is a lossy restoration. Original highlight metadata was not recovered.';
END $$;
-- Step 5: Update table statistics
ANALYZE omnivore.library_item;
ANALYZE omnivore.highlight;
COMMIT;