;
+ itemCount: number;
+ itemData: ItemData;
+ height: number | string;
+ width: number | string;
+}) => {
+ const Row = children
+ const items = []
+ for (let i = 0; i < itemCount; i++) {
+ items.push(
+
+ )
+ }
+ return {items}
+}
+
+describe('VirtualNoteList', () => {
+ const mockNotes: Note[] = [
+ {
+ id: '1',
+ title: 'Note 1',
+ description: 'Description 1',
+ tags: ['tag1', 'tag2'],
+ updated_at: '2023-01-01T00:00:00Z',
+ user_id: 'user1',
+ created_at: '2023-01-01T00:00:00Z',
+ },
+ {
+ id: '2',
+ title: 'Note 2',
+ description: 'Description 2',
+ tags: [],
+ updated_at: '2023-01-02T00:00:00Z',
+ user_id: 'user1',
+ created_at: '2023-01-02T00:00:00Z',
+ },
+ ]
+
+ it('renders nothing if notes list is empty', () => {
+ cy.mount(
+
+ )
+ cy.get('[role="list"]').should('not.exist')
+ })
+
+ it('renders visible notes', () => {
+ cy.mount(
+
+
+
+ )
+ cy.contains('Note 1').should('be.visible')
+ cy.contains('Description 1').should('be.visible')
+ cy.contains('Note 2').should('be.visible')
+ cy.contains('Description 2').should('be.visible')
+ })
+
+ it('highlights selected note', () => {
+ cy.mount(
+
+
+
+ )
+ // Selected note has specific classes
+ cy.contains('Note 1').closest('.group').should('have.class', 'bg-accent')
+ cy.contains('Note 2').closest('.group').should('not.have.class', 'bg-accent')
+ })
+
+ it('calls onSelectNote when a note is clicked', () => {
+ const onSelectNote = cy.spy().as('onSelectNote')
+ cy.mount(
+
+
+
+ )
+ cy.contains('Note 1').click()
+ cy.get('@onSelectNote').should('have.been.calledWith', mockNotes[0])
+ })
+
+ it('calls onTagClick when a tag is clicked and stops propagation', () => {
+ const onTagClick = cy.spy().as('onTagClick')
+ const onSelectNote = cy.spy().as('onSelectNote')
+
+ cy.mount(
+
+
+
+ )
+
+ // Note 1 has tags
+ cy.contains('tag1').click()
+
+ cy.get('@onTagClick').should('have.been.calledWith', 'tag1')
+ cy.get('@onSelectNote').should('not.have.been.called')
+ })
+})
diff --git a/cypress/component/components/features/notes/NoteCard.cy.tsx b/cypress/component/components/features/notes/NoteCard.cy.tsx
new file mode 100644
index 00000000000..15b5df42b82
--- /dev/null
+++ b/cypress/component/components/features/notes/NoteCard.cy.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import { NoteCard } from '@/components/features/notes/NoteCard'
+import { SearchResult, NoteViewModel } from '@/types/domain'
+
+const mockNote: NoteViewModel = {
+ id: '1',
+ title: 'Test Note',
+ content: 'Test Content
',
+ description: 'Test Description',
+ tags: ['tag1', 'tag2'],
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ user_id: 'user1'
+}
+
+const mockSearchResult: SearchResult = {
+ ...mockNote,
+ headline: 'Test Headline',
+ rank: 0.85
+}
+
+describe('NoteCard', () => {
+ it('renders compact variant', () => {
+ cy.mount(
+
+ )
+ cy.contains('Test Note').should('be.visible')
+ cy.contains('Test Description').should('be.visible')
+ cy.contains('tag1').should('be.visible')
+ // Date format: 02.01.2023 (ru-RU)
+ // Note: Date formatting might depend on locale. Assuming ru-RU as per code.
+ cy.contains('2.01.2023').should('exist') // numeric month might be 1 or 01 depending on browser/locale implementation
+ })
+
+ it('renders search variant', () => {
+ cy.mount(
+
+ )
+ cy.contains('Test Note').should('be.visible')
+ // Rank: 0.85 * 100 = 85.0%
+ cy.contains('85.0%').should('be.visible')
+
+ // Headline with mark
+ cy.get('mark').should('contain.text', 'Headline')
+ })
+
+ it('handles clicks', () => {
+ const onClick = cy.spy().as('onClick')
+ cy.mount(
+
+ )
+ cy.contains('Test Note').click()
+ cy.get('@onClick').should('have.been.called')
+ })
+
+ it('handles tag clicks', () => {
+ const onTagClick = cy.spy().as('onTagClick')
+ cy.mount(
+
+ )
+ cy.contains('tag1').click()
+ cy.get('@onTagClick').should('have.been.calledWith', 'tag1')
+ })
+
+ it('shows selected state', () => {
+ cy.mount(
+
+ )
+ // Check for bg-accent class on the container
+ cy.contains('Test Note').closest('div').should('have.class', 'bg-accent')
+ })
+})
diff --git a/cypress/component/components/features/notes/NoteEditor.cy.tsx b/cypress/component/components/features/notes/NoteEditor.cy.tsx
new file mode 100644
index 00000000000..8484c27ed17
--- /dev/null
+++ b/cypress/component/components/features/notes/NoteEditor.cy.tsx
@@ -0,0 +1,63 @@
+import React from 'react'
+import { NoteEditor } from '@/components/features/notes/NoteEditor'
+
+describe('NoteEditor', () => {
+ const getDefaultProps = () => ({
+ title: 'Test Title',
+ description: 'Test Description
',
+ tags: 'tag1, tag2',
+ isSaving: false,
+ isNew: false,
+ onTitleChange: cy.spy().as('onTitleChange'),
+ onDescriptionChange: cy.spy().as('onDescriptionChange'),
+ onTagsChange: cy.spy().as('onTagsChange'),
+ onSave: cy.spy().as('onSave'),
+ onCancel: cy.spy().as('onCancel')
+ })
+
+ it('renders correctly', () => {
+ cy.mount()
+ cy.get('input[placeholder="Note title"]').should('have.value', 'Test Title')
+ cy.get('input[placeholder="work, personal, ideas"]').should('have.value', 'tag1, tag2')
+ cy.contains('Edit Note').should('be.visible')
+ cy.contains('Test Description').should('be.visible')
+ })
+
+ it('renders new note state', () => {
+ cy.mount()
+ cy.contains('New Note').should('be.visible')
+ })
+
+ it('handles title change', () => {
+ const props = getDefaultProps()
+ cy.mount()
+ cy.get('input[placeholder="Note title"]').clear().type('New Title')
+ cy.get('@onTitleChange').should('have.been.called')
+ })
+
+ it('handles tags change', () => {
+ const props = getDefaultProps()
+ cy.mount()
+ cy.get('input[placeholder="work, personal, ideas"]').clear().type('tag3')
+ cy.get('@onTagsChange').should('have.been.called')
+ })
+
+ it('handles save', () => {
+ const props = getDefaultProps()
+ cy.mount()
+ cy.contains('button', 'Save').click()
+ cy.get('@onSave').should('have.been.called')
+ })
+
+ it('shows saving state', () => {
+ cy.mount()
+ cy.contains('button', 'Saving...').should('be.disabled')
+ })
+
+ it('handles cancel', () => {
+ const props = getDefaultProps()
+ cy.mount()
+ cy.contains('button', 'Cancel').click()
+ cy.get('@onCancel').should('have.been.called')
+ })
+})
diff --git a/cypress/component/components/features/notes/NoteList.cy.tsx b/cypress/component/components/features/notes/NoteList.cy.tsx
new file mode 100644
index 00000000000..37bb81748ec
--- /dev/null
+++ b/cypress/component/components/features/notes/NoteList.cy.tsx
@@ -0,0 +1,115 @@
+import React from 'react'
+import { NoteList } from '@/components/features/notes/NoteList'
+import { SearchResult, NoteViewModel } from '@/types/domain'
+
+const mockNotes: NoteViewModel[] = [
+ {
+ id: '1',
+ title: 'Note 1',
+ content: 'Content 1',
+ description: 'Description 1',
+ tags: [],
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ user_id: 'u1'
+ },
+ {
+ id: '2',
+ title: 'Note 2',
+ content: 'Content 2',
+ description: 'Description 2',
+ tags: [],
+ created_at: '2023-01-02T00:00:00Z',
+ updated_at: '2023-01-02T00:00:00Z',
+ user_id: 'u1'
+ }
+]
+
+const mockFTSResults: SearchResult[] = [
+ {
+ ...mockNotes[0],
+ headline: 'Found Note',
+ rank: 0.9
+ }
+]
+
+describe('NoteList', () => {
+ const getDefaultProps = () => ({
+ notes: [],
+ isLoading: false,
+ onSelectNote: cy.spy().as('onSelectNote'),
+ onTagClick: cy.spy().as('onTagClick'),
+ onLoadMore: cy.spy().as('onLoadMore'),
+ hasMore: false,
+ isFetchingNextPage: false,
+ ftsQuery: '',
+ ftsLoading: false,
+ showFTSResults: false,
+ onSearchResultClick: cy.spy().as('onSearchResultClick')
+ })
+
+ it('renders loading skeleton', () => {
+ cy.mount()
+ // NoteListSkeleton renders divs with p-3
+ cy.get('.space-y-1 > div').should('have.length', 5)
+ })
+
+ it('renders empty state', () => {
+ cy.mount()
+ cy.contains('No notes yet').should('be.visible')
+ })
+
+ it('renders regular list', () => {
+ cy.mount()
+ cy.contains('Note 1').should('be.visible')
+ cy.contains('Note 2').should('be.visible')
+ })
+
+ it('renders FTS loading', () => {
+ cy.mount()
+ cy.contains('Поиск заметок...').should('be.visible')
+ })
+
+ it('renders FTS results', () => {
+ cy.mount(
+
+ )
+ cy.contains('Найдено: 1 заметка').should('be.visible')
+ cy.contains('10ms').should('be.visible')
+ cy.contains('Note 1').should('be.visible')
+ })
+
+ it('handles load more', () => {
+ const props = getDefaultProps()
+ cy.mount(
+
+ )
+ cy.contains('Load More').click()
+ cy.get('@onLoadMore').should('have.been.called')
+ })
+
+ it('handles note selection', () => {
+ const props = getDefaultProps()
+ cy.mount(
+
+ )
+ cy.contains('Note 1').click()
+ cy.get('@onSelectNote').should('have.been.calledWith', mockNotes[0])
+ })
+})
diff --git a/cypress/component/core/services/AuthService.cy.ts b/cypress/component/core/services/AuthService.cy.ts
new file mode 100644
index 00000000000..8261535f0d5
--- /dev/null
+++ b/cypress/component/core/services/AuthService.cy.ts
@@ -0,0 +1,46 @@
+import { AuthService } from '@/core/services/auth'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+describe('core/services/AuthService', () => {
+ let mockSupabase: SupabaseClient
+ let service: AuthService
+
+ beforeEach(() => {
+ mockSupabase = {
+ auth: {
+ signInWithOAuth: cy.stub().resolves({ error: null }),
+ signInWithPassword: cy.stub().resolves({ data: { user: { id: '1' } }, error: null }),
+ signOut: cy.stub().resolves({ error: null }),
+ getSession: cy.stub().resolves({ data: { session: { user: { id: '1' } } }, error: null })
+ }
+ } as unknown as SupabaseClient
+
+ service = new AuthService(mockSupabase)
+ })
+
+ it('signInWithGoogle', async () => {
+ await service.signInWithGoogle('http://localhost:3000')
+ expect(mockSupabase.auth.signInWithOAuth).to.have.been.calledWith({
+ provider: 'google',
+ options: { redirectTo: 'http://localhost:3000' }
+ })
+ })
+
+ it('signInWithPassword', async () => {
+ await service.signInWithPassword('test@example.com', 'password')
+ expect(mockSupabase.auth.signInWithPassword).to.have.been.calledWith({
+ email: 'test@example.com',
+ password: 'password'
+ })
+ })
+
+ it('signOut', async () => {
+ await service.signOut()
+ expect(mockSupabase.auth.signOut).to.have.been.called
+ })
+
+ it('getSession', async () => {
+ await service.getSession()
+ expect(mockSupabase.auth.getSession).to.have.been.called
+ })
+})
diff --git a/cypress/component/core/services/NoteService.cy.ts b/cypress/component/core/services/NoteService.cy.ts
new file mode 100644
index 00000000000..a6cd4bea8de
--- /dev/null
+++ b/cypress/component/core/services/NoteService.cy.ts
@@ -0,0 +1,178 @@
+import { NoteService } from '@/core/services/notes'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+// Helper type for Sinon stubs
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type SinonStub = any
+
+describe('core/services/NoteService', () => {
+ let mockSupabase: SupabaseClient
+ let service: NoteService
+ let mockQueryBuilder: {
+ select: SinonStub,
+ order: SinonStub,
+ range: SinonStub,
+ contains: SinonStub,
+ or: SinonStub,
+ insert: SinonStub,
+ update: SinonStub,
+ delete: SinonStub,
+ eq: SinonStub,
+ single: SinonStub,
+ then: (resolve: (res: unknown) => void) => void
+ }
+
+ beforeEach(() => {
+ mockQueryBuilder = {
+ select: cy.stub().returnsThis(),
+ order: cy.stub().returnsThis(),
+ range: cy.stub().returnsThis(),
+ contains: cy.stub().returnsThis(),
+ or: cy.stub().returnsThis(),
+ insert: cy.stub().returnsThis(),
+ update: cy.stub().returnsThis(),
+ delete: cy.stub().returnsThis(),
+ eq: cy.stub().returnsThis(),
+ single: cy.stub().resolves({ data: { id: '1' }, error: null }),
+ then: (resolve: (res: unknown) => void) => resolve({ data: [], error: null, count: 0 })
+ }
+
+ mockSupabase = {
+ from: cy.stub().returns(mockQueryBuilder)
+ } as unknown as SupabaseClient
+
+ service = new NoteService(mockSupabase)
+ })
+
+ describe('getNotes', () => {
+ it('fetches notes with default options', async () => {
+ const mockData = [{ id: '1', title: 'Note 1' }]
+ mockQueryBuilder.then = (resolve: (res: unknown) => void) => resolve({ data: mockData, error: null, count: 1 })
+
+ const result = await service.getNotes('user-1')
+
+ expect(mockSupabase.from).to.have.been.calledWith('notes')
+ expect(mockQueryBuilder.select).to.have.been.calledWith(
+ 'id, title, description, tags, created_at, updated_at',
+ { count: 'exact' }
+ )
+ expect(mockQueryBuilder.range).to.have.been.calledWith(0, 49)
+ expect(result.notes).to.deep.equal(mockData)
+ expect(result.totalCount).to.equal(1)
+ })
+
+ it('applies pagination', async () => {
+ await service.getNotes('user-1', { page: 1, pageSize: 10 })
+ expect(mockQueryBuilder.range).to.have.been.calledWith(10, 19)
+ })
+
+ it('applies tag filter', async () => {
+ await service.getNotes('user-1', { tag: 'test-tag' })
+ expect(mockQueryBuilder.contains).to.have.been.calledWith('tags', ['test-tag'])
+ })
+
+ it('applies search query', async () => {
+ await service.getNotes('user-1', { searchQuery: 'test' })
+ expect(mockQueryBuilder.or).to.have.been.calledWith(
+ Cypress.sinon.match((val: string) => val.includes('test'))
+ )
+ })
+
+ it('sanitizes search query', async () => {
+ await service.getNotes('user-1', { searchQuery: 'test,query' })
+ expect(mockQueryBuilder.or).to.have.been.calledWith(
+ Cypress.sinon.match((val: string) => val.includes('test query'))
+ )
+ })
+
+ it('handles error', async () => {
+ mockQueryBuilder.then = (resolve: (res: unknown) => void) => resolve({ data: null, error: { message: 'DB Error' } })
+
+ try {
+ await service.getNotes('user-1')
+ expect.fail('Should have thrown')
+ } catch (e: unknown) {
+ expect((e as Error).message).to.equal('DB Error')
+ }
+ })
+
+ it('calculates hasMore correctly', async () => {
+ const mockData = Array(10).fill({ id: '1' })
+ mockQueryBuilder.then = (resolve: (res: unknown) => void) => resolve({ data: mockData, error: null, count: 20 })
+
+ const result = await service.getNotes('user-1', { pageSize: 10 })
+ expect(result.hasMore).to.be.true
+ expect(result.nextCursor).to.equal(1)
+ })
+ })
+
+ describe('createNote', () => {
+ it('creates a note', async () => {
+ const newNote = { title: 'New', description: 'Desc', tags: [], userId: 'user-1' }
+ await service.createNote(newNote)
+
+ expect(mockQueryBuilder.insert).to.have.been.calledWith([
+ {
+ title: newNote.title,
+ description: newNote.description,
+ tags: newNote.tags,
+ user_id: newNote.userId
+ }
+ ])
+ })
+
+ it('handles create error', async () => {
+ mockQueryBuilder.single.resolves({ data: null, error: { message: 'Create Error' } })
+
+ try {
+ await service.createNote({ title: 'New', description: '', tags: [], userId: '1' })
+ expect.fail('Should have thrown')
+ } catch (e: unknown) {
+ expect((e as Error).message).to.equal('Create Error')
+ }
+ })
+ })
+
+ describe('updateNote', () => {
+ it('updates a note', async () => {
+ await service.updateNote('1', { title: 'Updated' })
+
+ expect(mockQueryBuilder.update).to.have.been.calledWith(
+ Cypress.sinon.match({ title: 'Updated' })
+ )
+ expect(mockQueryBuilder.eq).to.have.been.calledWith('id', '1')
+ })
+
+ it('handles update error', async () => {
+ mockQueryBuilder.single.resolves({ data: null, error: { message: 'Update Error' } })
+
+ try {
+ await service.updateNote('1', { title: 'Updated' })
+ expect.fail('Should have thrown')
+ } catch (e: unknown) {
+ expect((e as Error).message).to.equal('Update Error')
+ }
+ })
+ })
+
+ describe('deleteNote', () => {
+ it('deletes a note', async () => {
+ mockQueryBuilder.then = (resolve: (res: unknown) => void) => resolve({ error: null })
+
+ await service.deleteNote('1')
+ expect(mockQueryBuilder.delete).to.have.been.called
+ expect(mockQueryBuilder.eq).to.have.been.calledWith('id', '1')
+ })
+
+ it('handles delete error', async () => {
+ mockQueryBuilder.then = (resolve: (res: unknown) => void) => resolve({ error: { message: 'Delete Error' } })
+
+ try {
+ await service.deleteNote('1')
+ expect.fail('Should have thrown')
+ } catch (e: unknown) {
+ expect((e as Error).message).to.equal('Delete Error')
+ }
+ })
+ })
+})
diff --git a/cypress/component/core/services/SearchService.cy.ts b/cypress/component/core/services/SearchService.cy.ts
new file mode 100644
index 00000000000..7c077bfc117
--- /dev/null
+++ b/cypress/component/core/services/SearchService.cy.ts
@@ -0,0 +1,109 @@
+import { SearchService } from '@/core/services/search'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+describe('core/services/SearchService', () => {
+ let mockSupabase: SupabaseClient
+ let service: SearchService
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let mockQueryBuilder: any
+
+ beforeEach(() => {
+ mockQueryBuilder = {
+ select: cy.stub().returnsThis(),
+ eq: cy.stub().returnsThis(),
+ or: cy.stub().returnsThis(),
+ contains: cy.stub().returnsThis(),
+ range: cy.stub().returnsThis(),
+ order: cy.stub().resolves({ data: [], error: null })
+ }
+
+ mockSupabase = {
+ rpc: cy.stub().resolves({ data: [], error: null }),
+ from: cy.stub().returns(mockQueryBuilder)
+ } as unknown as SupabaseClient
+
+ service = new SearchService(mockSupabase)
+ })
+
+ describe('searchNotes', () => {
+ it('uses FTS when available', async () => {
+ const mockData = [{ id: '1', title: 'Test', rank: 0.5 }]
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(mockSupabase.rpc as any).resolves({ data: mockData, error: null })
+
+ const result = await service.searchNotes('user-1', 'test query')
+
+ expect(mockSupabase.rpc).to.have.been.calledWith('search_notes_fts', {
+ search_query: 'test:* & query:*',
+ search_language: 'english',
+ min_rank: 0.01,
+ result_limit: 20,
+ result_offset: 0,
+ search_user_id: 'user-1'
+ })
+
+ expect(result.method).to.equal('fts')
+ expect(result.results).to.deep.equal(mockData)
+ })
+
+ it('filters FTS results by tag', async () => {
+ const mockData = [
+ { id: '1', title: 'Test 1', tags: ['tag1'] },
+ { id: '2', title: 'Test 2', tags: ['tag2'] }
+ ]
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(mockSupabase.rpc as any).resolves({ data: mockData, error: null })
+
+ const result = await service.searchNotes('user-1', 'test', { tag: 'tag1' })
+
+ expect(result.results).to.have.length(1)
+ expect(result.results[0].id).to.equal('1')
+ })
+
+ it('falls back to ILIKE when FTS fails', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(mockSupabase.rpc as any).resolves({ data: null, error: { message: 'FTS Error' } })
+
+ const mockFallbackData = [{ id: '1', title: 'Fallback', description: 'Desc' }]
+ mockQueryBuilder.order.resolves({ data: mockFallbackData, error: null })
+
+ const result = await service.searchNotes('user-1', 'test')
+
+ expect(result.method).to.equal('fallback')
+ expect(mockSupabase.from).to.have.been.calledWith('notes')
+ expect(mockQueryBuilder.or).to.have.been.called
+ })
+
+ it('sanitizes input for ILIKE', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(mockSupabase.rpc as any).resolves({ error: true })
+
+ await service.searchNotes('user-1', 'test,query')
+
+ // Should replace comma with space
+ expect(mockQueryBuilder.or).to.have.been.calledWith(
+ Cypress.sinon.match((val: string) => val.includes('test query'))
+ )
+ })
+
+ it('applies tag filter in fallback', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(mockSupabase.rpc as any).resolves({ error: true })
+
+ await service.searchNotes('user-1', 'test', { tag: 'tag1' })
+
+ expect(mockQueryBuilder.contains).to.have.been.calledWith('tags', ['tag1'])
+ })
+
+ it('handles fallback error', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(mockSupabase.rpc as any).resolves({ error: true })
+ mockQueryBuilder.order.resolves({ data: null, error: { message: 'DB Error' } })
+
+ const result = await service.searchNotes('user-1', 'test')
+
+ expect(result.error).to.equal('DB Error')
+ expect(result.results).to.be.empty
+ })
+ })
+})
diff --git a/cypress/component/core/utils/search.cy.ts b/cypress/component/core/utils/search.cy.ts
new file mode 100644
index 00000000000..966b55b1a3c
--- /dev/null
+++ b/cypress/component/core/utils/search.cy.ts
@@ -0,0 +1,111 @@
+import { buildTsQuery, detectLanguage, ftsLanguage, mapNotesToFtsResult } from '@/core/utils/search'
+import type { Tables } from '@/supabase/types'
+
+describe('core/utils/search', () => {
+ describe('buildTsQuery', () => {
+ it('builds simple query', () => {
+ expect(buildTsQuery('test')).to.equal('test:*')
+ })
+
+ it('builds query with multiple words', () => {
+ expect(buildTsQuery('test query')).to.equal('test:* & query:*')
+ })
+
+ it('trims whitespace', () => {
+ expect(buildTsQuery(' test ')).to.equal('test:*')
+ })
+
+ it('removes special characters', () => {
+ expect(buildTsQuery('test! & query|')).to.equal('test:* & query:*')
+ })
+
+ it('throws error for empty query', () => {
+ expect(() => buildTsQuery('')).to.throw('Query must be a non-empty string')
+ })
+
+ it('throws error for short query', () => {
+ expect(() => buildTsQuery('ab')).to.throw('Query must be at least 3 characters')
+ })
+
+ it('throws error for long query', () => {
+ const longQuery = 'a'.repeat(1001)
+ expect(() => buildTsQuery(longQuery)).to.throw('Query exceeds maximum length')
+ })
+
+ it('throws error if query becomes empty after sanitization', () => {
+ expect(() => buildTsQuery('!!!')).to.throw('Query is empty after sanitization')
+ })
+ })
+
+ describe('detectLanguage', () => {
+ it('detects russian for cyrillic', () => {
+ expect(detectLanguage('тест')).to.equal('ru')
+ })
+
+ it('detects english for latin', () => {
+ expect(detectLanguage('test')).to.equal('en')
+ })
+
+ it('defaults to ru for empty', () => {
+ expect(detectLanguage('')).to.equal('ru')
+ })
+
+ it('detects russian if mixed', () => {
+ expect(detectLanguage('test тест')).to.equal('ru')
+ })
+ })
+
+ describe('ftsLanguage', () => {
+ it('returns russian for ru', () => {
+ expect(ftsLanguage('ru')).to.equal('russian')
+ })
+
+ it('returns english for en', () => {
+ expect(ftsLanguage('en')).to.equal('english')
+ })
+
+ it('returns russian for uk', () => {
+ expect(ftsLanguage('uk')).to.equal('russian')
+ })
+ })
+
+ describe('mapNotesToFtsResult', () => {
+ it('maps notes correctly', () => {
+ const notes: Tables<'notes'>[] = [
+ {
+ id: '1',
+ title: 'Test',
+ description: 'Description',
+ tags: [],
+ created_at: '2023-01-01',
+ updated_at: '2023-01-01',
+ user_id: 'old-user',
+ }
+ ]
+ const userId = 'new-user'
+ const result = mapNotesToFtsResult(notes, userId)
+
+ expect(result).to.have.length(1)
+ expect(result[0].user_id).to.equal(userId)
+ expect(result[0].rank).to.equal(0)
+ expect(result[0].headline).to.equal('Description')
+ })
+
+ it('truncates headline', () => {
+ const longDesc = 'a'.repeat(300)
+ const notes: Tables<'notes'>[] = [
+ {
+ id: '1',
+ title: 'Test',
+ description: longDesc,
+ tags: [],
+ created_at: '2023-01-01',
+ updated_at: '2023-01-01',
+ user_id: 'user',
+ }
+ ]
+ const result = mapNotesToFtsResult(notes, 'user')
+ expect(result[0].headline).to.have.length(200)
+ })
+ })
+})
diff --git a/cypress/component/extensions/FontSize.cy.tsx b/cypress/component/extensions/FontSize.cy.tsx
new file mode 100644
index 00000000000..84198376925
--- /dev/null
+++ b/cypress/component/extensions/FontSize.cy.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import { useEditor, EditorContent } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import { TextStyle } from '@tiptap/extension-text-style'
+import { FontSize } from '@/extensions/FontSize'
+
+const Editor = () => {
+ const editor = useEditor({
+ extensions: [StarterKit, TextStyle, FontSize],
+ content: 'Hello World
',
+ })
+
+ if (!editor) return null
+
+ return (
+
+
+
+
+
+ )
+}
+
+describe('FontSize Extension', () => {
+ it('sets and unsets font size', () => {
+ cy.mount()
+
+ // Select text
+ cy.get('.ProseMirror').type('{selectall}')
+
+ // Set size
+ cy.get('[data-cy="set-size"]').click()
+ cy.get('.ProseMirror span').should('have.css', 'font-size', '20px')
+
+ // Unset size
+ cy.get('[data-cy="unset-size"]').click()
+ // Should not have inline font-size style (or span might be removed if it was the only style)
+ cy.get('.ProseMirror span').should('not.exist')
+ })
+
+ it('parses font size from HTML', () => {
+ const EditorWithContent = () => {
+ const editor = useEditor({
+ extensions: [StarterKit, TextStyle, FontSize],
+ content: 'Big Text
',
+ })
+
+ if (!editor) return null
+
+ return
+ }
+
+ cy.mount()
+ cy.get('.ProseMirror span').should('have.css', 'font-size', '24px')
+ })
+})
diff --git a/cypress/component/import/ImportButton.cy.tsx b/cypress/component/import/ImportButton.cy.tsx
deleted file mode 100644
index 6cc179a4a6b..00000000000
--- a/cypress/component/import/ImportButton.cy.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import React from 'react'
-import { ImportButton } from '@/components/ImportButton'
-import { SupabaseTestProvider } from '@/lib/providers/SupabaseProvider'
-import type { SupabaseClient, User } from '@supabase/supabase-js'
-import { EnexParser } from '@/lib/enex/parser'
-import { ContentConverter } from '@/lib/enex/converter'
-import { NoteCreator } from '@/lib/enex/note-creator'
-
-const createMockSupabase = (user: User | null = { id: 'user-1' } as User) => {
- return {
- auth: {
- getUser: cy.stub().resolves({ data: { user } }),
- signInWithOAuth: cy.stub().resolves({ error: null }),
- signInWithPassword: cy.stub().resolves({ data: { user: null }, error: null }),
- signOut: cy.stub().resolves({ error: null }),
- },
- storage: {
- from: cy.stub().returns({
- upload: cy.stub().resolves({ data: { path: '' }, error: null }),
- getPublicUrl: cy.stub().returns({ data: { publicUrl: 'https://example.com' }, error: null }),
- }),
- },
- } as unknown as SupabaseClient
-}
-
-const wrapWithProvider = (node: React.ReactNode, supabase = createMockSupabase()) => (
-
- {node}
-
-)
-
-describe('ImportButton Component', () => {
- beforeEach(() => {
- // Mock localStorage
- cy.window().then((win) => {
- win.localStorage.clear()
- })
- })
-
- it('renders import button with correct text', () => {
- cy.mount(wrapWithProvider())
-
- cy.contains('Import from Evernote').should('be.visible')
- cy.get('button').should('have.class', 'w-full')
- cy.get('svg').should('exist') // Upload icon
- })
-
- it('opens import dialog on click', () => {
- cy.mount(wrapWithProvider())
-
- cy.contains('Import from Evernote').click()
-
- // Dialog should be visible
- cy.contains('Drag and drop .enex files or click to browse').should('be.visible')
- })
-
- it('shows disabled state when importing', () => {
- cy.mount(wrapWithProvider())
-
- // Open dialog and start import (we'll need to mock this)
- cy.contains('Import from Evernote').should('not.be.disabled')
- })
-
- it('shows loading text when importing', () => {
- cy.mount(wrapWithProvider())
-
- // Button should show normal text initially
- cy.contains('Import from Evernote').should('be.visible')
- cy.contains('Importing...').should('not.exist')
- })
-
- it('runs full import flow and calls onImportComplete', () => {
- const onImportComplete = cy.stub().as('onImportComplete')
-
- // Stub heavy dependencies to avoid real parsing/requests
- cy.stub(EnexParser.prototype, 'parse').resolves([{
- title: 'Stub Note',
- description: 'desc',
- tags: ['stub'],
- content: 'content
',
- resources: [],
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- user_id: 'user-1'
- }])
- cy.stub(ContentConverter.prototype, 'convert').resolves('converted
')
- cy.stub(NoteCreator.prototype, 'create').resolves()
- cy.stub(globalThis.crypto, 'randomUUID').returns('uuid-1')
-
- cy.mount(wrapWithProvider(, createMockSupabase()))
-
- cy.contains('Import from Evernote').click()
-
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from(''),
- fileName: 'stub.enex',
- mimeType: 'application/xml'
- }, { force: true })
-
- cy.contains('button', 'Import (1)').click()
-
- cy.get('@onImportComplete').should('have.been.calledWith', 'success', { successCount: 1, errorCount: 0 })
- cy.contains('Importing...').should('not.exist')
- })
-
- it('aborts import when user is not authenticated', () => {
- cy.stub(EnexParser.prototype, 'parse').as('parseStub')
- cy.stub(ContentConverter.prototype, 'convert').as('convertStub')
- cy.stub(NoteCreator.prototype, 'create').as('createStub')
-
- cy.mount(wrapWithProvider(, createMockSupabase(null)))
-
- cy.contains('Import from Evernote').click()
-
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from(''),
- fileName: 'stub.enex',
- mimeType: 'application/xml'
- }, { force: true })
-
- cy.contains('button', 'Import (1)').click()
-
- cy.get('@parseStub').should('not.have.been.called')
- cy.get('@convertStub').should('not.have.been.called')
- cy.get('@createStub').should('not.have.been.called')
- cy.contains('Importing from Evernote').should('not.exist')
- })
-
- it('calls onImportComplete callback on successful import', () => {
- const onImportComplete = cy.stub().as('onImportComplete')
- cy.mount(wrapWithProvider())
-
- // Verify callback prop is accepted
- cy.wrap(null).should(() => {
- expect(onImportComplete).to.not.have.been.called
- })
- })
-
- it('handles interrupted import warning on mount', () => {
- // Set up interrupted import state
- cy.window().then((win) => {
- win.localStorage.setItem('everfreenote-import-state', JSON.stringify({
- currentFile: 1,
- totalFiles: 2,
- successCount: 5,
- errorCount: 0
- }))
- })
-
- cy.mount(wrapWithProvider())
-
- // Should show warning toast (we can't easily test toast, but we can verify localStorage is cleared)
- cy.window().then((win) => {
- // Wait a bit for useEffect to run
- cy.wait(100).then(() => {
- expect(win.localStorage.getItem('everfreenote-import-state')).to.be.null
- })
- })
- })
-
- it('renders all three dialogs (main, progress, result)', () => {
- cy.mount(wrapWithProvider())
-
- // ImportDialog should be in DOM (but not visible)
- cy.get('[role="dialog"]').should('not.exist')
-
- // Open dialog
- cy.contains('Import from Evernote').click()
- cy.get('[role="dialog"]').should('exist')
- })
-
- it('has correct button styling', () => {
- cy.mount(wrapWithProvider())
-
- cy.get('button')
- .should('have.class', 'w-full')
- .and('be.visible')
- })
-})
-
diff --git a/cypress/component/import/ImportDialog.cy.tsx b/cypress/component/import/ImportDialog.cy.tsx
deleted file mode 100644
index c7ed845a667..00000000000
--- a/cypress/component/import/ImportDialog.cy.tsx
+++ /dev/null
@@ -1,281 +0,0 @@
-import React from 'react'
-import { ImportDialog } from '@/components/ImportDialog'
-
-describe('ImportDialog Component', () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let mockOnImport: any
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let mockOnOpenChange: any
-
- beforeEach(() => {
- mockOnImport = cy.stub().as('onImport')
- mockOnOpenChange = cy.stub().as('onOpenChange')
- })
-
- it('renders when open prop is true', () => {
- cy.mount(
-
- )
-
- cy.contains('Import from Evernote').should('be.visible')
- cy.contains('Drag and drop .enex files or click to browse').should('be.visible')
- })
-
- it('does not render when open prop is false', () => {
- cy.mount(
-
- )
-
- cy.contains('Import from Evernote').should('not.exist')
- })
-
- it('shows drag and drop zone', () => {
- cy.mount(
-
- )
-
- cy.contains('Drag & drop .enex files').should('be.visible')
- cy.contains('or click to browse').should('be.visible')
- cy.get('input[type="file"]').should('exist').and('have.attr', 'accept', '.enex')
- })
-
- it('accepts file selection via input', () => {
- cy.mount(
-
- )
-
- // Create a test file
- const fileName = 'test-notes.enex'
- const fileContent = ''
-
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from(fileContent),
- fileName: fileName,
- mimeType: 'application/xml'
- }, { force: true })
-
- // Should show selected file
- cy.contains('Selected files (1)').should('be.visible')
- cy.contains(fileName).should('be.visible')
- })
-
- it('filters non-.enex files', () => {
- cy.mount(
-
- )
-
- // Try to select a non-.enex file
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from('test content'),
- fileName: 'test.txt',
- mimeType: 'text/plain'
- }, { force: true })
-
- // Should not show selected files
- cy.contains('Selected files').should('not.exist')
- })
-
- it('allows selecting multiple files', () => {
- cy.mount(
-
- )
-
- const files = [
- {
- contents: Cypress.Buffer.from(''),
- fileName: 'notes1.enex',
- mimeType: 'application/xml'
- },
- {
- contents: Cypress.Buffer.from(''),
- fileName: 'notes2.enex',
- mimeType: 'application/xml'
- }
- ]
-
- cy.get('input[type="file"]').selectFile(files, { force: true })
-
- cy.contains('Selected files (2)').should('be.visible')
- cy.contains('notes1.enex').should('be.visible')
- cy.contains('notes2.enex').should('be.visible')
- })
-
- it('allows removing selected files', () => {
- cy.mount(
-
- )
-
- // Select a file
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from(''),
- fileName: 'test.enex',
- mimeType: 'application/xml'
- }, { force: true })
-
- cy.contains('test.enex').should('be.visible')
-
- // Remove the file
- cy.contains('test.enex').parent().parent().find('button').click()
-
- cy.contains('Selected files').should('not.exist')
- })
-
- it('shows duplicate strategy options', () => {
- cy.mount(
-
- )
-
- cy.contains('Import Settings').should('be.visible')
- cy.contains('What to do with duplicate notes?').should('be.visible')
- cy.contains('Add [duplicate] prefix to title').should('be.visible')
- cy.contains('Skip duplicate notes').should('be.visible')
- cy.contains('Replace existing notes').should('be.visible')
- })
-
- it('allows changing duplicate strategy', () => {
- cy.mount(
-
- )
-
- // Default should be 'prefix' - check by data-state attribute
- cy.get('#prefix').should('have.attr', 'data-state', 'checked')
-
- // Change to 'skip'
- cy.get('#skip').click()
- cy.get('#skip').should('have.attr', 'data-state', 'checked')
- cy.get('#prefix').should('have.attr', 'data-state', 'unchecked')
-
- // Change to 'replace'
- cy.get('#replace').click()
- cy.get('#replace').should('have.attr', 'data-state', 'checked')
- cy.get('#skip').should('have.attr', 'data-state', 'unchecked')
- })
-
- it('disables import button when no files selected', () => {
- cy.mount(
-
- )
-
- cy.contains('button', 'Import').should('be.disabled')
- })
-
- it('enables import button when files are selected', () => {
- cy.mount(
-
- )
-
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from(''),
- fileName: 'test.enex',
- mimeType: 'application/xml'
- }, { force: true })
-
- cy.contains('button', 'Import (1)').should('not.be.disabled')
- })
-
- it('calls onImport with files and settings when import is clicked', () => {
- cy.mount(
-
- )
-
- // Select file
- cy.get('input[type="file"]').selectFile({
- contents: Cypress.Buffer.from(''),
- fileName: 'test.enex',
- mimeType: 'application/xml'
- }, { force: true })
-
- // Change duplicate strategy
- cy.get('#skip').click()
-
- // Click import
- cy.contains('button', 'Import (1)').click()
-
- cy.get('@onImport').should('have.been.calledOnce')
- cy.get('@onOpenChange').should('have.been.calledWith', false)
- })
-
- it('calls onOpenChange when cancel is clicked', () => {
- cy.mount(
-
- )
-
- cy.contains('button', 'Cancel').click()
-
- cy.get('@onOpenChange').should('have.been.calledWith', false)
- })
-
- it('shows drag over state', () => {
- cy.mount(
-
- )
-
- // Initially shows "Drag & drop"
- cy.contains('Drag & drop .enex files').should('be.visible')
-
- // Find the drop zone div (the one with border-2 border-dashed)
- cy.get('input[type="file"]').parent()
- .trigger('dragover', { force: true })
- .wait(100) // Wait for state update
-
- // Should show "Drop files here" after dragover
- cy.contains('Drop files here').should('be.visible')
- })
-})
-
diff --git a/cypress/component/import/ImportProgressDialog.cy.tsx b/cypress/component/import/ImportProgressDialog.cy.tsx
deleted file mode 100644
index 757c2fdab7d..00000000000
--- a/cypress/component/import/ImportProgressDialog.cy.tsx
+++ /dev/null
@@ -1,387 +0,0 @@
-import React from 'react'
-import { ImportProgressDialog } from '@/components/ImportProgressDialog'
-
-describe('ImportProgressDialog Component', () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let mockOnClose: any
-
- beforeEach(() => {
- mockOnClose = cy.stub().as('onClose')
- })
-
- it('does not render when open is false', () => {
- cy.mount(
-
- )
-
- cy.contains('Importing from Evernote').should('not.exist')
- })
-
- it('renders progress dialog when open and importing', () => {
- cy.mount(
-
- )
-
- cy.contains('Importing from Evernote').should('be.visible')
- cy.contains('Please wait while we import your notes...').should('be.visible')
- })
-
- it('shows file progress when multiple files', () => {
- cy.mount(
-
- )
-
- cy.contains('Files').should('be.visible')
- cy.contains('1 of 3').should('be.visible')
- })
-
- it('hides file progress when single file', () => {
- cy.mount(
-
- )
-
- // Should not show file progress for single file
- cy.contains('1 of 1').should('not.exist')
- })
-
- it('shows current file name', () => {
- cy.mount(
-
- )
-
- cy.contains('Current file:').should('be.visible')
- cy.contains('my-notes.enex').should('be.visible')
- })
-
- it('shows note progress', () => {
- cy.mount(
-
- )
-
- cy.contains('Notes').should('be.visible')
- cy.contains('7 of 10').should('be.visible')
- cy.contains('70%').should('be.visible')
- })
-
- it('shows progress bars', () => {
- cy.mount(
-
- )
-
- // Should have progress bars (role="progressbar")
- cy.get('[role="progressbar"]').should('have.length.at.least', 1)
- })
-
- it('shows loading spinner during import', () => {
- cy.mount(
-
- )
-
- // Should have loading spinner (Loader2 with animate-spin)
- cy.get('.animate-spin').should('exist')
- })
-
- it('prevents closing during import', () => {
- cy.mount(
-
- )
-
- // Dialog should be open
- cy.get('[role="dialog"]').should('exist')
-
- // Should show "Importing from Evernote" title during import
- cy.contains('Importing from Evernote').should('be.visible')
-
- // DialogFooter with Close button should not exist during import
- cy.get('[role="dialog"]').find('footer').should('not.exist')
- })
-
- it('shows success result', () => {
- cy.mount(
-
- )
-
- cy.contains('Import Complete').should('be.visible')
- cy.contains('Your import has finished.').should('be.visible')
- cy.contains('10').should('be.visible') // Success count
- cy.contains('Successful').should('be.visible')
- cy.contains('Successfully imported 10 notes').should('be.visible')
- })
-
- it('shows partial success result', () => {
- cy.mount(
-
- )
-
- cy.contains('Import Complete').should('be.visible')
- cy.contains('7').should('be.visible') // Success count
- cy.contains('3').should('be.visible') // Error count
- cy.contains('Successfully imported 7 notes').should('be.visible')
- })
-
- it('shows failed notes details', () => {
- cy.mount(
-
- )
-
- cy.contains('View failed notes (2)').should('be.visible')
-
- // Expand details
- cy.contains('View failed notes (2)').click()
-
- cy.contains('Failed Note 1').should('be.visible')
- cy.contains('Database error').should('be.visible')
- cy.contains('Failed Note 2').should('be.visible')
- cy.contains('Invalid content').should('be.visible')
- })
-
- it('shows complete failure result', () => {
- cy.mount(
-
- )
-
- cy.contains('Import Complete').should('be.visible')
- cy.contains('0').should('be.visible') // Success count
- cy.contains('5').should('be.visible') // Error count
- cy.contains('All imports failed').should('be.visible')
-
- // Should show error icon (XCircle)
- cy.get('.text-destructive').should('exist')
- })
-
- it('allows closing after completion', () => {
- cy.mount(
-
- )
-
- cy.contains('button', 'Close').should('be.visible').click()
-
- cy.get('@onClose').should('have.been.calledOnce')
- })
-
- it('shows success icon on successful import', () => {
- cy.mount(
-
- )
-
- // Should have CheckCircle2 icon with green color
- cy.get('.text-green-600').should('exist')
- })
-
- it('calculates progress percentage correctly', () => {
- cy.mount(
-
- )
-
- // 3/4 = 75%
- cy.contains('75%').should('be.visible')
- })
-})
-
diff --git a/cypress/component/lib/adapters/browser.cy.ts b/cypress/component/lib/adapters/browser.cy.ts
new file mode 100644
index 00000000000..4a4f71a5436
--- /dev/null
+++ b/cypress/component/lib/adapters/browser.cy.ts
@@ -0,0 +1,66 @@
+import { browser } from '../../../../lib/adapters/browser';
+
+describe('WebBrowserAdapter', () => {
+ it('calls window.alert', () => {
+ const stub = cy.stub(window, 'alert');
+ browser.alert('test message');
+ expect(stub).to.have.been.calledWith('test message');
+ });
+
+ it('calls window.confirm and returns result', () => {
+ const stub = cy.stub(window, 'confirm').returns(true);
+ const result = browser.confirm('Are you sure?');
+ expect(stub).to.have.been.calledWith('Are you sure?');
+ expect(result).to.be.true;
+ });
+
+ it('calls window.prompt and returns result', () => {
+ const stub = cy.stub(window, 'prompt').returns('user input');
+ const result = browser.prompt('Enter value', 'default');
+ expect(stub).to.have.been.calledWith('Enter value', 'default');
+ expect(result).to.equal('user input');
+ });
+
+ describe('localStorage', () => {
+ it('calls localStorage.getItem', () => {
+ const stub = cy.stub(window.localStorage, 'getItem').returns('stored value');
+ const result = browser.localStorage.getItem('key');
+ expect(stub).to.have.been.calledWith('key');
+ expect(result).to.equal('stored value');
+ });
+
+ it('calls localStorage.setItem', () => {
+ const stub = cy.stub(window.localStorage, 'setItem');
+ browser.localStorage.setItem('key', 'value');
+ expect(stub).to.have.been.calledWith('key', 'value');
+ });
+
+ it('calls localStorage.removeItem', () => {
+ const stub = cy.stub(window.localStorage, 'removeItem');
+ browser.localStorage.removeItem('key');
+ expect(stub).to.have.been.calledWith('key');
+ });
+ });
+
+ describe('location', () => {
+ it('returns window.location.origin', () => {
+ expect(browser.location.origin).to.equal(window.location.origin);
+ });
+
+ it('returns window.location.search', () => {
+ expect(browser.location.search).to.equal(window.location.search);
+ });
+
+ it('calls window.location.reload', () => {
+ // We need to be careful stubbing reload as it might reload the test runner
+ // However, since we are wrapping the native object, we can try to stub the property on the window object if configurable
+ // Or just verify the wrapper delegates.
+
+ // Since window.location is non-configurable in some browsers, we might not be able to stub reload directly on window.location easily in all environments without causing issues.
+ // But let's try stubbing the method on the instance if possible, or just skip if too risky.
+ // Actually, browser.location returns window.location directly in the implementation.
+
+ expect(browser.location.reload).to.be.a('function');
+ })
+ })
+})
diff --git a/cypress/component/lib/enex/converter.cy.ts b/cypress/component/lib/enex/converter.cy.ts
new file mode 100644
index 00000000000..5435e4c67a7
--- /dev/null
+++ b/cypress/component/lib/enex/converter.cy.ts
@@ -0,0 +1,94 @@
+import { ContentConverter } from '../../../../lib/enex/converter'
+import { ImageProcessor } from '../../../../lib/enex/image-processor'
+import type { EnexResource } from '../../../../lib/enex/types'
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type SinonStub = any
+
+type ImageProcessorStub = {
+ upload: SinonStub
+}
+
+describe('ContentConverter', () => {
+ let converter: ContentConverter
+ let mockImageProcessor: ImageProcessorStub
+
+ beforeEach(() => {
+ mockImageProcessor = {
+ upload: cy.stub().resolves('https://example.com/image.png')
+ }
+ converter = new ContentConverter(mockImageProcessor as unknown as ImageProcessor)
+ })
+
+ it('converts basic ENML to HTML', async () => {
+ const enml = 'Hello World
'
+ const result = await converter.convert(enml, [], 'user1', 'note1')
+
+ expect(result).to.contain('Hello World
')
+ expect(result).not.to.contain('')
+ })
+
+ it('replaces unsupported tags', async () => {
+ const enml = ''
+ const result = await converter.convert(enml, [], 'user1', 'note1')
+
+ expect(result).to.contain('[Unsupported content: Table]')
+ // DOMPurify strips the table tags but keeps the content inside the hidden div
+ expect(result).to.contain('Cell
')
+ })
+
+ it('processes images', async () => {
+ const enml = '
'
+ const resources: EnexResource[] = [{
+ data: 'base64data',
+ mime: 'image/png',
+ width: 100,
+ height: 100
+ }]
+
+ const result = await converter.convert(enml, resources, 'user1', 'note1')
+
+ expect(mockImageProcessor.upload).to.have.been.calledWith(
+ 'base64data',
+ 'image/png',
+ 'user1',
+ 'note1',
+ 'image_0'
+ )
+ expect(result).to.contain('
{
+ mockImageProcessor.upload.rejects(new Error('Upload failed'))
+
+ const enml = ''
+ const resources: EnexResource[] = [{
+ data: 'base64data',
+ mime: 'image/png'
+ }]
+
+ const result = await converter.convert(enml, resources, 'user1', 'note1')
+
+ expect(result).to.contain('[Image failed to upload]')
+ })
+
+ it('sanitizes HTML', async () => {
+ const enml = 'Safe
'
+ const result = await converter.convert(enml, [], 'user1', 'note1')
+
+ expect(result).not.to.contain('