chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
92
memento-note/tests/ai-provider.spec.ts
Normal file
92
memento-note/tests/ai-provider.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Use localhost for local testing, can be overridden with env var
|
||||
const BASE_URL = process.env.TEST_URL || 'http://localhost:3000';
|
||||
|
||||
test.describe('AI Provider Configuration Tests', () => {
|
||||
test('should check AI provider configuration in database', async ({ page }) => {
|
||||
// This test checks the debug endpoint we created
|
||||
const response = await page.request.get(`${BASE_URL}/api/debug/config`);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const config = await response.json();
|
||||
console.log('AI Configuration:', config);
|
||||
|
||||
// Verify OpenAI is configured
|
||||
expect(config.AI_PROVIDER_TAGS).toBe('openai');
|
||||
expect(config.AI_PROVIDER_EMBEDDING).toBe('openai');
|
||||
expect(config.AI_MODEL_TAGS).toBeTruthy();
|
||||
expect(config.AI_MODEL_EMBEDDING).toBeTruthy();
|
||||
expect(config.OPENAI_API_KEY).toContain('set');
|
||||
});
|
||||
|
||||
test('should test OpenAI provider connectivity', async ({ page }) => {
|
||||
const response = await page.request.get(`${BASE_URL}/api/ai/test`);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const result = await response.json();
|
||||
console.log('AI Test Result:', result);
|
||||
|
||||
// Should indicate success with OpenAI
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tagsProvider).toBe('openai');
|
||||
expect(result.embeddingsProvider).toBe('openai');
|
||||
expect(result.embeddingLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should check embeddings provider via main test endpoint', async ({ page }) => {
|
||||
// The /api/ai/test endpoint already tests both tags and embeddings providers
|
||||
const response = await page.request.get(`${BASE_URL}/api/ai/test`);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Embeddings via AI Test:', result);
|
||||
|
||||
// Verify embeddings provider is working
|
||||
expect(result.embeddingsProvider).toBe('openai');
|
||||
expect(result.embeddingLength).toBe(1536); // OpenAI text-embedding-3-small
|
||||
expect(result.details?.provider).toBe('OpenAI');
|
||||
});
|
||||
|
||||
test('should verify no OLLAMA errors in provider', async ({ page }) => {
|
||||
// Test the AI test endpoint to ensure it's using OpenAI, not Ollama
|
||||
const response = await page.request.get(`${BASE_URL}/api/ai/test`);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const result = await response.json();
|
||||
const resultStr = JSON.stringify(result);
|
||||
|
||||
// Should not contain OLLAMA references
|
||||
expect(resultStr).not.toContain('OLLAMA');
|
||||
expect(resultStr).not.toContain('ECONNREFUSED');
|
||||
expect(resultStr).not.toContain('127.0.0.1:11434');
|
||||
|
||||
// Should contain OpenAI references
|
||||
expect(resultStr).toContain('openai');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('AI Provider - Docker Deployment Tests', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Skip these tests if not testing against Docker
|
||||
test.skip(!process.env.TEST_URL?.includes('192.168'), 'Skipping Docker tests - set TEST_URL=http://192.168.1.190:3000');
|
||||
});
|
||||
|
||||
test('should verify paragraph refactor service uses OpenAI', async ({ page }) => {
|
||||
// This test verifies that the refactor service uses the configured provider
|
||||
// instead of hardcoding Ollama
|
||||
const response = await page.request.get(`${BASE_URL}/api/ai/test`);
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Verify the tags provider is OpenAI (this is what refactor uses)
|
||||
expect(result.tagsProvider).toBe('openai');
|
||||
expect(result.details?.provider).toBe('OpenAI');
|
||||
});
|
||||
});
|
||||
104
memento-note/tests/blue-color-test.spec.ts
Normal file
104
memento-note/tests/blue-color-test.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Test des couleurs bleues', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
});
|
||||
|
||||
test('Vérifier les couleurs bleues dans la page', async ({ page }) => {
|
||||
// Attendre que la page charge
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Capturer tous les éléments avec des couleurs bleues via les classes
|
||||
const blueElements = await page.locator('*[class*="blue"]').all();
|
||||
|
||||
console.log('Nombre d\'éléments avec "blue" dans leur classe:', blueElements.length);
|
||||
|
||||
// Pour chaque élément avec une classe bleue, capturer les détails
|
||||
for (let i = 0; i < Math.min(blueElements.length, 20); i++) {
|
||||
const element = blueElements[i];
|
||||
const tagName = await element.evaluate(el => el.tagName);
|
||||
const className = await element.evaluate(el => el.className);
|
||||
const backgroundColor = await element.evaluate(el => window.getComputedStyle(el).backgroundColor);
|
||||
const color = await element.evaluate(el => window.getComputedStyle(el).color);
|
||||
const textContent = await element.evaluate(el => el.textContent?.substring(0, 50));
|
||||
|
||||
console.log(`Élément ${i + 1}:`, {
|
||||
tagName,
|
||||
className: className.substring(0, 100),
|
||||
backgroundColor,
|
||||
color,
|
||||
textContent
|
||||
});
|
||||
}
|
||||
|
||||
// Chercher aussi les styles inline bleus
|
||||
const elementsWithBlueStyle = await page.locator('*[style*="blue"]').all();
|
||||
console.log('Nombre d\'éléments avec style "blue":', elementsWithBlueStyle.length);
|
||||
|
||||
// Prendre une capture d'écran pour visualisation
|
||||
await page.screenshot({ path: 'blue-color-test.png', fullPage: true });
|
||||
|
||||
// Vérifier spécifiquement les boutons
|
||||
const buttons = await page.locator('button').all();
|
||||
console.log('Nombre total de boutons:', buttons.length);
|
||||
|
||||
for (let i = 0; i < Math.min(buttons.length, 10); i++) {
|
||||
const button = buttons[i];
|
||||
const backgroundColor = await button.evaluate(el => window.getComputedStyle(el).backgroundColor);
|
||||
const color = await button.evaluate(el => window.getComputedStyle(el).color);
|
||||
const textContent = await button.evaluate(el => el.textContent?.substring(0, 30));
|
||||
|
||||
if (backgroundColor.includes('blue') || backgroundColor.includes('rgb(37, 99, 235)') ||
|
||||
backgroundColor.includes('rgb(29, 78, 216)') || backgroundColor.includes('rgb(59, 130, 246)')) {
|
||||
console.log('Bouton bleu trouvé:', {
|
||||
backgroundColor,
|
||||
color,
|
||||
textContent
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Vérifier les variables CSS du thème', async ({ page }) => {
|
||||
// Attendre que la page charge
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Récupérer les variables CSS du root
|
||||
const cssVars = await page.evaluate(() => {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const variables: Record<string, string> = {};
|
||||
|
||||
// Variables de thème principales
|
||||
const varNames = [
|
||||
'--background',
|
||||
'--foreground',
|
||||
'--primary',
|
||||
'--primary-foreground',
|
||||
'--secondary',
|
||||
'--accent',
|
||||
'--border',
|
||||
'--ring'
|
||||
];
|
||||
|
||||
varNames.forEach(varName => {
|
||||
variables[varName] = rootStyle.getPropertyValue(varName).trim();
|
||||
});
|
||||
|
||||
return variables;
|
||||
});
|
||||
|
||||
console.log('Variables CSS du thème:', cssVars);
|
||||
|
||||
// Vérifier si les teintes contiennent des valeurs bleues (220-260)
|
||||
for (const [varName, value] of Object.entries(cssVars)) {
|
||||
const hueMatch = value.match(/(\d+)\s*\)/);
|
||||
if (hueMatch) {
|
||||
const hue = parseInt(hueMatch[1]);
|
||||
if (hue >= 220 && hue <= 260) {
|
||||
console.log(`⚠️ ${varName} contient une teinte bleue: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
227
memento-note/tests/bug-auto-labeling.spec.ts
Normal file
227
memento-note/tests/bug-auto-labeling.spec.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E Test: Auto-Labeling Bug Fix (Story 7.1)
|
||||
*
|
||||
* This test verifies that auto-labeling works correctly when creating a new note.
|
||||
*
|
||||
* Acceptance Criteria:
|
||||
* 1. Given a user creates a new note with content
|
||||
* 2. When the note is saved
|
||||
* 3. Then the system should:
|
||||
* - Automatically analyze the note content for relevant labels
|
||||
* - Assign suggested labels to the note
|
||||
* - Display the note in the UI with labels visible
|
||||
* - NOT require a page refresh to see labels
|
||||
*/
|
||||
|
||||
test.describe('Auto-Labeling Bug Fix', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/sign-in', { timeout: 5000 });
|
||||
|
||||
// Fill login form
|
||||
await page.fill('input[type="email"]', 'test@example.com');
|
||||
await page.fill('input[type="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation to home page
|
||||
await page.waitForURL('/', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should auto-label a note about programming', async ({ page }) => {
|
||||
// Create a new note about programming
|
||||
const noteContent = 'Need to learn React and TypeScript for web development';
|
||||
|
||||
// Click "New Note" button
|
||||
await page.click('[data-testid="new-note-button"], button:has-text("New Note"), .create-note-btn');
|
||||
|
||||
// Wait for note to be created
|
||||
await page.waitForSelector('.note-card, [data-testid^="note-"]', { timeout: 3000 });
|
||||
|
||||
// Find the newly created note (first one in the list)
|
||||
const firstNote = page.locator('.note-card, [data-testid^="note-"]').first();
|
||||
await expect(firstNote).toBeVisible();
|
||||
|
||||
// Click on the note to edit it
|
||||
await firstNote.click();
|
||||
|
||||
// Wait for note editor to appear
|
||||
await page.waitForSelector('[data-testid="note-editor"], textarea.note-content, .note-editor', { timeout: 3000 });
|
||||
|
||||
// Type content about programming
|
||||
const textarea = page.locator('[data-testid="note-editor"] textarea, textarea.note-content, .note-editor textarea');
|
||||
await textarea.fill(noteContent);
|
||||
|
||||
// Wait a moment for auto-labeling to process
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Find labels in the note
|
||||
const labels = page.locator('.label-badge, .tag, [data-testid*="label"]');
|
||||
|
||||
// Verify that labels appear (auto-labeling should have assigned them)
|
||||
await expect(labels.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the label text contains relevant keywords (like "code", "development", "programming", etc.)
|
||||
const labelText = await labels.first().textContent();
|
||||
expect(labelText?.toLowerCase()).toMatch(/code|development|programming|react|typescript|web/);
|
||||
|
||||
console.log('✓ Auto-labeling applied relevant labels:', labelText);
|
||||
});
|
||||
|
||||
test('should auto-label a note about meetings', async ({ page }) => {
|
||||
// Create a note about a meeting
|
||||
const noteContent = 'Team meeting scheduled for tomorrow at 2pm to discuss project roadmap';
|
||||
|
||||
// Click "New Note" button
|
||||
await page.click('[data-testid="new-note-button"], button:has-text("New Note"), .create-note-btn');
|
||||
|
||||
// Wait for note to be created
|
||||
await page.waitForSelector('.note-card, [data-testid^="note-"]', { timeout: 3000 });
|
||||
|
||||
// Find the newly created note
|
||||
const firstNote = page.locator('.note-card, [data-testid^="note-"]').first();
|
||||
await expect(firstNote).toBeVisible();
|
||||
|
||||
// Click on the note to edit it
|
||||
await firstNote.click();
|
||||
|
||||
// Wait for note editor
|
||||
await page.waitForSelector('[data-testid="note-editor"], textarea.note-content, .note-editor', { timeout: 3000 });
|
||||
|
||||
// Type content about meeting
|
||||
const textarea = page.locator('[data-testid="note-editor"] textarea, textarea.note-content, .note-editor textarea');
|
||||
await textarea.fill(noteContent);
|
||||
|
||||
// Wait for auto-labeling
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for labels
|
||||
const labels = page.locator('.label-badge, .tag, [data-testid*="label"]');
|
||||
await expect(labels.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify meeting-related label
|
||||
const labelText = await labels.first().textContent();
|
||||
expect(labelText?.toLowerCase()).toMatch(/meeting|team|project|roadmap|discussion/);
|
||||
|
||||
console.log('✓ Auto-labeling applied meeting-related labels:', labelText);
|
||||
});
|
||||
|
||||
test('should display labels immediately without page refresh', async ({ page }) => {
|
||||
// This test verifies the critical requirement: labels should be visible WITHOUT refreshing
|
||||
|
||||
const noteContent = 'Need to buy groceries: milk, bread, eggs, and vegetables';
|
||||
|
||||
// Click "New Note"
|
||||
await page.click('[data-testid="new-note-button"], button:has-text("New Note"), .create-note-btn');
|
||||
|
||||
// Wait for note creation
|
||||
await page.waitForSelector('.note-card, [data-testid^="note-"]', { timeout: 3000 });
|
||||
|
||||
// Click on the note
|
||||
const firstNote = page.locator('.note-card, [data-testid^="note-"]').first();
|
||||
await firstNote.click();
|
||||
|
||||
// Wait for editor
|
||||
await page.waitForSelector('[data-testid="note-editor"], textarea.note-content, .note-editor', { timeout: 3000 });
|
||||
|
||||
// Type content
|
||||
const textarea = page.locator('[data-testid="note-editor"] textarea, textarea.note-content, .note-editor textarea');
|
||||
await textarea.fill(noteContent);
|
||||
|
||||
// CRITICAL: Wait for labels to appear WITHOUT refreshing
|
||||
const labels = page.locator('.label-badge, .tag, [data-testid*="label"]');
|
||||
|
||||
// Labels should appear within 5 seconds (optimistic update + server processing)
|
||||
await expect(labels.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
console.log('✓ Labels appeared immediately without page refresh');
|
||||
});
|
||||
|
||||
test('should handle auto-labeling failure gracefully', async ({ page }) => {
|
||||
// This test verifies error handling: if auto-labeling fails, the note should still be created
|
||||
|
||||
const noteContent = 'Test note with very short content';
|
||||
|
||||
// Click "New Note"
|
||||
await page.click('[data-testid="new-note-button"], button:has-text("New Note"), .create-note-btn');
|
||||
|
||||
// Wait for note creation
|
||||
await page.waitForSelector('.note-card, [data-testid^="note-"]', { timeout: 3000 });
|
||||
|
||||
// Click on the note
|
||||
const firstNote = page.locator('.note-card, [data-testid^="note-"]').first();
|
||||
await firstNote.click();
|
||||
|
||||
// Wait for editor
|
||||
await page.waitForSelector('[data-testid="note-editor"], textarea.note-content, .note-editor', { timeout: 3000 });
|
||||
|
||||
// Type very short content (may not generate labels)
|
||||
const textarea = page.locator('[data-testid="note-editor"] textarea, textarea.note-content, .note-editor textarea');
|
||||
await textarea.fill(noteContent);
|
||||
|
||||
// Wait for processing
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Note should still be visible even if no labels are assigned
|
||||
await expect(firstNote).toBeVisible();
|
||||
|
||||
console.log('✓ Note created successfully even if auto-labeling fails or returns no suggestions');
|
||||
});
|
||||
|
||||
test('should auto-label in notebook context', async ({ page }) => {
|
||||
// Test that auto-labeling uses notebook context for suggestions
|
||||
|
||||
const noteContent = 'Planning a trip to Japan next month';
|
||||
|
||||
// Create a notebook first (if not exists)
|
||||
const notebookExists = await page.locator('.notebook-item:has-text("Travel")').count();
|
||||
if (notebookExists === 0) {
|
||||
await page.click('[data-testid="create-notebook-button"], button:has-text("Create Notebook")');
|
||||
await page.fill('input[placeholder*="notebook name"], input[placeholder*="name"]', 'Travel');
|
||||
await page.click('button:has-text("Create"), button[type="submit"]');
|
||||
}
|
||||
|
||||
// Navigate to Travel notebook
|
||||
await page.click('.notebook-item:has-text("Travel")');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click "New Note"
|
||||
await page.click('[data-testid="new-note-button"], button:has-text("New Note"), .create-note-btn');
|
||||
|
||||
// Wait for note creation
|
||||
await page.waitForSelector('.note-card, [data-testid^="note-"]', { timeout: 3000 });
|
||||
|
||||
// Click on the note
|
||||
const firstNote = page.locator('.note-card, [data-testid^="note-"]').first();
|
||||
await firstNote.click();
|
||||
|
||||
// Wait for editor
|
||||
await page.waitForSelector('[data-testid="note-editor"], textarea.note-content, .note-editor', { timeout: 3000 });
|
||||
|
||||
// Type travel-related content
|
||||
const textarea = page.locator('[data-testid="note-editor"] textarea, textarea.note-content, .note-editor textarea');
|
||||
await textarea.fill(noteContent);
|
||||
|
||||
// Wait for auto-labeling
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for labels (should be travel-related)
|
||||
const labels = page.locator('.label-badge, .tag, [data-testid*="label"]');
|
||||
const labelCount = await labels.count();
|
||||
|
||||
// At least one label should appear (or note should still be visible if no labels)
|
||||
if (labelCount > 0) {
|
||||
const labelText = await labels.first().textContent();
|
||||
console.log('✓ Auto-labeling in notebook context applied labels:', labelText);
|
||||
} else {
|
||||
console.log('✓ Note created in notebook context (no labels generated for this content)');
|
||||
}
|
||||
|
||||
// Note should be visible regardless
|
||||
await expect(firstNote).toBeVisible();
|
||||
});
|
||||
});
|
||||
176
memento-note/tests/bug-move-direct.spec.ts
Normal file
176
memento-note/tests/bug-move-direct.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Bug: Move note to notebook - DIRECT TEST', () => {
|
||||
test('BUG: Note should disappear from main page after moving to notebook', async ({ page }) => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Step 1: Go to homepage
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Get initial note count
|
||||
const notesBefore = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Initial notes count:', notesBefore)
|
||||
|
||||
// Step 2: Create a test note
|
||||
const testNoteTitle = `TEST-BUG-${timestamp}`
|
||||
const testNoteContent = `Test content ${timestamp}`
|
||||
|
||||
console.log('[TEST] Creating note:', testNoteTitle)
|
||||
|
||||
await page.click('input[placeholder="Take a note..."]')
|
||||
await page.fill('input[placeholder="Title"]', testNoteTitle)
|
||||
await page.fill('textarea[placeholder="Take a note..."]', testNoteContent)
|
||||
await page.click('button:has-text("Add")')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const notesAfterCreation = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes after creation:', notesAfterCreation)
|
||||
expect(notesAfterCreation).toBe(notesBefore + 1)
|
||||
|
||||
// Step 3: Create a test notebook
|
||||
console.log('[TEST] Creating notebook')
|
||||
|
||||
// Try to find and click "Create Notebook" button
|
||||
const createNotebookButtons = page.locator('button')
|
||||
const createButtonsCount = await createNotebookButtons.count()
|
||||
console.log('[TEST] Total buttons found:', createButtonsCount)
|
||||
|
||||
// List all button texts for debugging
|
||||
for (let i = 0; i < Math.min(createButtonsCount, 10); i++) {
|
||||
const btn = createNotebookButtons.nth(i)
|
||||
const btnText = await btn.textContent()
|
||||
console.log(`[TEST] Button ${i}: "${btnText?.trim()}"`)
|
||||
}
|
||||
|
||||
// Look for a "+" button or "Créer" button
|
||||
let createBtn = page.locator('button').filter({ hasText: '+' }).first()
|
||||
if (await createBtn.count() === 0) {
|
||||
createBtn = page.locator('button').filter({ hasText: 'Créer' }).first()
|
||||
}
|
||||
|
||||
if (await createBtn.count() > 0) {
|
||||
await createBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Try to fill notebook name
|
||||
const testNotebookName = `TEST-NOTEBOOK-${timestamp}`
|
||||
|
||||
// Look for any input field
|
||||
const inputs = page.locator('input')
|
||||
const inputCount = await inputs.count()
|
||||
console.log('[TEST] Input fields found:', inputCount)
|
||||
|
||||
// Fill first input with notebook name
|
||||
if (inputCount > 0) {
|
||||
const firstInput = inputs.first()
|
||||
await firstInput.fill(testNotebookName)
|
||||
|
||||
// Submit the form
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
console.log('[TEST] Notebook created (or attempted)')
|
||||
}
|
||||
} else {
|
||||
console.log('[TEST] No create button found!')
|
||||
}
|
||||
|
||||
// Take screenshot to see current state
|
||||
await page.screenshot({ path: `playwright-report/after-notebook-creation-${timestamp}.png` })
|
||||
console.log('[TEST] Screenshot saved')
|
||||
|
||||
// Step 4: Try to move the note to the notebook using UI
|
||||
console.log('[TEST] Attempting to move note...')
|
||||
|
||||
// Find the note we just created
|
||||
const ourNote = page.locator('[data-draggable="true"]').filter({ hasText: testNoteTitle })
|
||||
const ourNoteCount = await ourNote.count()
|
||||
console.log('[TEST] Our note found:', ourNoteCount)
|
||||
|
||||
if (ourNoteCount > 0) {
|
||||
console.log('[TEST] Found our note, trying to move it')
|
||||
|
||||
// Try to find any button/element in the note card
|
||||
const noteElement = await ourNote.innerHTML()
|
||||
console.log('[TEST] Note HTML:', noteElement.substring(0, 500))
|
||||
|
||||
// Look for any clickable elements in the note
|
||||
const allButtonsInNote = await ourNote.locator('button').all()
|
||||
console.log('[TEST] Buttons in note:', allButtonsInNote.length)
|
||||
|
||||
for (let i = 0; i < allButtonsInNote.length; i++) {
|
||||
const btn = allButtonsInNote[i]
|
||||
const btnText = await btn.textContent()
|
||||
const btnAriaLabel = await btn.getAttribute('aria-label')
|
||||
console.log(`[TEST] Note button ${i}: text="${btnText}" aria-label="${btnAriaLabel}"`)
|
||||
}
|
||||
|
||||
// Take screenshot before move attempt
|
||||
await page.screenshot({ path: `playwright-report/before-move-${timestamp}.png` })
|
||||
|
||||
// Try to click any button that might be related to notebooks
|
||||
if (allButtonsInNote.length > 0) {
|
||||
// Try clicking the first few buttons
|
||||
for (let i = 0; i < Math.min(allButtonsInNote.length, 3); i++) {
|
||||
const btn = allButtonsInNote[i]
|
||||
const btnText = await btn.textContent()
|
||||
|
||||
console.log(`[TEST] Clicking button ${i}: "${btnText}"`)
|
||||
await btn.click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Take screenshot after each click
|
||||
await page.screenshot({ path: `playwright-report/after-click-${i}-${timestamp}.png` })
|
||||
|
||||
// Check if a menu opened
|
||||
const menuItems = page.locator('[role="menuitem"], [role="option"]')
|
||||
const menuItemCount = await menuItems.count()
|
||||
console.log(`[TEST] Menu items after click ${i}:`, menuItemCount)
|
||||
|
||||
if (menuItemCount > 0) {
|
||||
// List menu items
|
||||
for (let j = 0; j < Math.min(menuItemCount, 5); j++) {
|
||||
const item = menuItems.nth(j)
|
||||
const itemText = await item.textContent()
|
||||
console.log(`[TEST] Menu item ${j}: "${itemText}"`)
|
||||
}
|
||||
|
||||
// Try to click the first menu item (likely our notebook)
|
||||
const firstMenuItem = menuItems.first()
|
||||
await firstMenuItem.click()
|
||||
await page.waitForTimeout(2000)
|
||||
break
|
||||
}
|
||||
|
||||
// Close menu if any (press Escape)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: Check if note is still visible WITHOUT refresh
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const ourNoteAfterMove = await page.locator('[data-draggable="true"]').filter({ hasText: testNoteTitle }).count()
|
||||
const allNotesAfterMove = await page.locator('[data-draggable="true"]').count()
|
||||
|
||||
console.log('[TEST] ===== RESULTS =====')
|
||||
console.log('[TEST] Our note still visible in main page:', ourNoteAfterMove)
|
||||
console.log('[TEST] Total notes in main page:', allNotesAfterMove)
|
||||
|
||||
// Take final screenshot
|
||||
await page.screenshot({ path: `playwright-report/final-state-${timestamp}.png` })
|
||||
console.log('[TEST] Final screenshot saved')
|
||||
|
||||
// THE BUG: If ourNoteAfterMove === 1, the note is still visible - triggerRefresh() didn't work!
|
||||
if (ourNoteAfterMove === 1) {
|
||||
console.log('[BUG CONFIRMED] triggerRefresh() is NOT working - note still visible!')
|
||||
console.log('[BUG CONFIRMED] This is the exact bug the user reported')
|
||||
} else {
|
||||
console.log('[SUCCESS] Note disappeared from main page - triggerRefresh() worked!')
|
||||
}
|
||||
})
|
||||
})
|
||||
138
memento-note/tests/bug-note-move-refresh.spec.ts
Normal file
138
memento-note/tests/bug-note-move-refresh.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Bug: Note move to notebook - REFRESH ISSUE', () => {
|
||||
test('should update UI immediately when moving note to notebook WITHOUT page refresh', async ({ page }) => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Step 1: Go to homepage (no login needed based on drag-drop tests)
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 2: Create a test note
|
||||
const testNoteTitle = `TEST-${timestamp}-Move Note`
|
||||
const testNoteContent = `This is a test note to verify move bug. Timestamp: ${timestamp}`
|
||||
|
||||
console.log('[TEST] Creating test note:', testNoteTitle)
|
||||
await page.click('input[placeholder="Take a note..."]')
|
||||
await page.fill('input[placeholder="Title"]', testNoteTitle)
|
||||
await page.fill('textarea[placeholder="Take a note..."]', testNoteContent)
|
||||
await page.click('button:has-text("Add")')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 3: Find the created note
|
||||
const notesBefore = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count after creation:', notesBefore)
|
||||
expect(notesBefore).toBeGreaterThan(0)
|
||||
|
||||
// Step 4: Get the first note's ID and title
|
||||
const firstNote = page.locator('[data-draggable="true"]').first()
|
||||
const noteText = await firstNote.textContent()
|
||||
console.log('[TEST] First note text:', noteText?.substring(0, 100))
|
||||
|
||||
// Step 5: Create a test notebook
|
||||
console.log('[TEST] Creating test notebook')
|
||||
const testNotebookName = `TEST-NOTEBOOK-${timestamp}`
|
||||
|
||||
// Click the "Create Notebook" button (check for different possible selectors)
|
||||
const createNotebookBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button:has-text("+")').first()
|
||||
await createNotebookBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Fill notebook name
|
||||
const notebookInput = page.locator('input[name="name"], input[placeholder*="notebook"], input[placeholder*="nom"]').first()
|
||||
await notebookInput.fill(testNotebookName)
|
||||
|
||||
// Submit
|
||||
const submitBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button[type="submit"]').first()
|
||||
await submitBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
console.log('[TEST] Notebook created:', testNotebookName)
|
||||
|
||||
// Step 6: Move the first note to the notebook
|
||||
console.log('[TEST] Moving note to notebook...')
|
||||
|
||||
// Look for a way to move the note - try to find a menu or button on the note
|
||||
// Try to find a notebook menu or context menu
|
||||
const notebookMenuBtn = firstNote.locator('button[aria-label*="notebook"], button[title*="notebook"], button:has(.icon-folder)').first()
|
||||
|
||||
if (await notebookMenuBtn.count() > 0) {
|
||||
console.log('[TEST] Found notebook menu button')
|
||||
await notebookMenuBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Select the created notebook
|
||||
const notebookOption = page.locator(`[role="menuitem"]:has-text("${testNotebookName}")`).first()
|
||||
if (await notebookOption.count() > 0) {
|
||||
await notebookOption.click()
|
||||
console.log('[TEST] Clicked notebook option')
|
||||
} else {
|
||||
console.log('[TEST] Notebook option not found in menu')
|
||||
// List all menu items for debugging
|
||||
const allMenuItems = await page.locator('[role="menuitem"]').allTextContents()
|
||||
console.log('[TEST] Available menu items:', allMenuItems)
|
||||
}
|
||||
} else {
|
||||
console.log('[TEST] Notebook menu button not found, trying drag and drop')
|
||||
|
||||
// Alternative: Use drag and drop to move note to notebook
|
||||
const notebooksList = page.locator('[class*="notebook"], [class*="sidebar"]').locator(`text=${testNotebookName}`)
|
||||
const notebookCount = await notebooksList.count()
|
||||
console.log('[TEST] Found notebook in list:', notebookCount)
|
||||
|
||||
if (notebookCount > 0) {
|
||||
const notebookElement = notebooksList.first()
|
||||
await firstNote.dragTo(notebookElement)
|
||||
console.log('[TEST] Dragged note to notebook')
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the move operation to complete
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// CRITICAL CHECK: Is the note still visible WITHOUT refresh?
|
||||
const notesAfterMove = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count AFTER move (NO REFRESH):', notesAfterMove)
|
||||
|
||||
// Get title of first note after move
|
||||
const firstNoteAfter = page.locator('[data-draggable="true"]').first()
|
||||
const firstNoteAfterTitle = await firstNoteAfter.locator('[class*="title"], h1, h2, h3').first().textContent()
|
||||
|
||||
console.log('[TEST] First note title after move:', firstNoteAfterTitle)
|
||||
console.log('[TEST] Original note title:', testNoteTitle)
|
||||
|
||||
// The bug is: the moved note is STILL VISIBLE in "Notes générales"
|
||||
// This means the UI did NOT update after the move
|
||||
if (firstNoteAfterTitle?.includes(testNoteTitle)) {
|
||||
console.log('[BUG CONFIRMED] Moved note is STILL VISIBLE - UI did not update!')
|
||||
console.log('[BUG CONFIRMED] This confirms the bug: triggerRefresh() is not working')
|
||||
|
||||
// Take a screenshot for debugging
|
||||
await page.screenshot({ path: `playwright-report/bug-screenshot-${timestamp}.png` })
|
||||
} else {
|
||||
console.log('[SUCCESS] Moved note is no longer visible - UI updated correctly!')
|
||||
}
|
||||
|
||||
// Now refresh the page to see what happens
|
||||
console.log('[TEST] Refreshing page...')
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const notesAfterRefresh = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count AFTER REFRESH:', notesAfterRefresh)
|
||||
|
||||
// Check if the note is now gone after refresh
|
||||
const firstNoteAfterRefresh = await page.locator('[data-draggable="true"]').first()
|
||||
const firstNoteAfterRefreshTitle = await firstNoteAfterRefresh.locator('[class*="title"], h1, h2, h3').first().textContent()
|
||||
|
||||
console.log('[TEST] First note title AFTER REFRESH:', firstNoteAfterRefreshTitle)
|
||||
|
||||
if (firstNoteAfterRefreshTitle?.includes(testNoteTitle)) {
|
||||
console.log('[BUG CONFIRMED] Note is STILL visible after refresh too - move might have failed')
|
||||
} else {
|
||||
console.log('[SUCCESS] Note is gone after refresh - it was moved to notebook')
|
||||
}
|
||||
})
|
||||
})
|
||||
68
memento-note/tests/bug-note-move-to-notebook.spec.ts
Normal file
68
memento-note/tests/bug-note-move-to-notebook.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Bug: Note move to notebook', () => {
|
||||
test('should update UI immediately when moving note to notebook', async ({ page }) => {
|
||||
// Step 1: Login
|
||||
await page.goto('http://localhost:3000/login')
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.fill('input[name="password"]', 'password123')
|
||||
await page.click('button[type="submit"]')
|
||||
await page.waitForURL('http://localhost:3000/')
|
||||
|
||||
// Step 2: Create a test note in "Notes générales"
|
||||
const testNoteContent = `Test note for move bug ${Date.now()}`
|
||||
await page.fill('textarea[placeholder*="note"]', testNoteContent)
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Wait for note creation
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 3: Find the created note
|
||||
const notes = await page.locator('.note-card').all()
|
||||
expect(notes.length).toBeGreaterThan(0)
|
||||
|
||||
const firstNote = notes[0]
|
||||
const noteId = await firstNote.getAttribute('data-note-id')
|
||||
console.log('Created note with ID:', noteId)
|
||||
|
||||
// Step 4: Create a test notebook
|
||||
await page.click('button:has-text("Créer un notebook")')
|
||||
await page.fill('input[name="name"]', `Test Notebook ${Date.now()}`)
|
||||
await page.click('button:has-text("Créer")')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 5: Move the note to the notebook
|
||||
// Open notebook menu on the note
|
||||
await firstNote.click('button[aria-label*="notebook"]')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Select the first notebook in the list
|
||||
const notebookOption = page.locator('[role="menuitem"]').first()
|
||||
const notebookName = await notebookOption.textContent()
|
||||
await notebookOption.click()
|
||||
|
||||
// Wait for the move operation
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 6: Verify the note is NO LONGER visible in "Notes générales"
|
||||
const notesAfterMove = await page.locator('.note-card').all()
|
||||
console.log('Notes in "Notes générales" after move:', notesAfterMove.length)
|
||||
|
||||
// The note should be gone from "Notes générales"
|
||||
const movedNote = await page.locator(`.note-card[data-note-id="${noteId}"]`).count()
|
||||
console.log('Moved note still visible in "Notes générales":', movedNote)
|
||||
expect(movedNote).toBe(0) // This should pass!
|
||||
|
||||
// Step 7: Navigate to the notebook
|
||||
await page.click(`text="${notebookName}"`)
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 8: Verify the note IS visible in the notebook
|
||||
const notesInNotebook = await page.locator('.note-card').all()
|
||||
console.log('Notes in notebook:', notesInNotebook.length)
|
||||
|
||||
const movedNoteInNotebook = await page.locator(`.note-card[data-note-id="${noteId}"]`).count()
|
||||
console.log('Moved note visible in notebook:', movedNoteInNotebook)
|
||||
expect(movedNoteInNotebook).toBe(1) // This should pass!
|
||||
})
|
||||
})
|
||||
194
memento-note/tests/bug-note-visibility.spec.ts
Normal file
194
memento-note/tests/bug-note-visibility.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Bug: Note visibility after creation', () => {
|
||||
test('should display note immediately after creation in inbox WITHOUT page refresh', async ({ page }) => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Step 1: Go to homepage
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 2: Count notes before creation
|
||||
const notesBefore = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count before creation:', notesBefore)
|
||||
|
||||
// Step 3: Create a test note in inbox
|
||||
const testNoteTitle = `TEST-${timestamp}-Visibility Inbox`
|
||||
const testNoteContent = `This is a test note to verify visibility bug. Timestamp: ${timestamp}`
|
||||
|
||||
console.log('[TEST] Creating test note in inbox:', testNoteTitle)
|
||||
|
||||
// Click the note input
|
||||
const noteInput = page.locator('input[placeholder*="Take a note"], textarea[placeholder*="Take a note"]').first()
|
||||
await noteInput.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Fill title if input exists
|
||||
const titleInput = page.locator('input[placeholder*="Title"], input[name="title"]').first()
|
||||
if (await titleInput.count() > 0) {
|
||||
await titleInput.fill(testNoteTitle)
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// Fill content
|
||||
const contentInput = page.locator('textarea[placeholder*="Take a note"], textarea').first()
|
||||
await contentInput.fill(testNoteContent)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Submit note
|
||||
const submitBtn = page.locator('button:has-text("Add"), button:has-text("Ajouter"), button[type="submit"]').first()
|
||||
await submitBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 4: Verify note appears immediately WITHOUT refresh
|
||||
const notesAfter = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count after creation (NO REFRESH):', notesAfter)
|
||||
|
||||
// Note count should increase
|
||||
expect(notesAfter).toBeGreaterThan(notesBefore)
|
||||
|
||||
// Verify the note is visible with the correct title
|
||||
const noteCards = page.locator('[data-draggable="true"]')
|
||||
let noteFound = false
|
||||
const noteCount = await noteCards.count()
|
||||
|
||||
for (let i = 0; i < noteCount; i++) {
|
||||
const noteText = await noteCards.nth(i).textContent()
|
||||
if (noteText?.includes(testNoteTitle) || noteText?.includes(testNoteContent.substring(0, 20))) {
|
||||
noteFound = true
|
||||
console.log('[SUCCESS] Note found immediately after creation!')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(noteFound).toBe(true)
|
||||
})
|
||||
|
||||
test('should display note immediately after creation in notebook WITHOUT page refresh', async ({ page }) => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Step 1: Go to homepage
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 2: Create a test notebook
|
||||
const testNotebookName = `TEST-NOTEBOOK-${timestamp}`
|
||||
console.log('[TEST] Creating test notebook:', testNotebookName)
|
||||
|
||||
const createNotebookBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button:has-text("+")').first()
|
||||
await createNotebookBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const notebookInput = page.locator('input[name="name"], input[placeholder*="notebook"], input[placeholder*="nom"]').first()
|
||||
await notebookInput.fill(testNotebookName)
|
||||
|
||||
const submitNotebookBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button[type="submit"]').first()
|
||||
await submitNotebookBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 3: Select the notebook
|
||||
const notebooksList = page.locator('[class*="notebook"], [class*="sidebar"]')
|
||||
const notebookItem = notebooksList.locator(`text=${testNotebookName}`).first()
|
||||
await notebookItem.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 4: Count notes in notebook before creation
|
||||
const notesBefore = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count in notebook before creation:', notesBefore)
|
||||
|
||||
// Step 5: Create a test note in the notebook
|
||||
const testNoteTitle = `TEST-${timestamp}-Visibility Notebook`
|
||||
const testNoteContent = `This is a test note in notebook. Timestamp: ${timestamp}`
|
||||
|
||||
console.log('[TEST] Creating test note in notebook:', testNoteTitle)
|
||||
|
||||
// Click the note input
|
||||
const noteInput = page.locator('input[placeholder*="Take a note"], textarea[placeholder*="Take a note"]').first()
|
||||
await noteInput.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Fill title if input exists
|
||||
const titleInput = page.locator('input[placeholder*="Title"], input[name="title"]').first()
|
||||
if (await titleInput.count() > 0) {
|
||||
await titleInput.fill(testNoteTitle)
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// Fill content
|
||||
const contentInput = page.locator('textarea[placeholder*="Take a note"], textarea').first()
|
||||
await contentInput.fill(testNoteContent)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Submit note
|
||||
const submitBtn = page.locator('button:has-text("Add"), button:has-text("Ajouter"), button[type="submit"]').first()
|
||||
await submitBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 6: Verify note appears immediately WITHOUT refresh
|
||||
const notesAfter = await page.locator('[data-draggable="true"]').count()
|
||||
console.log('[TEST] Notes count in notebook after creation (NO REFRESH):', notesAfter)
|
||||
|
||||
// Note count should increase
|
||||
expect(notesAfter).toBeGreaterThan(notesBefore)
|
||||
|
||||
// Verify the note is visible with the correct title
|
||||
const noteCards = page.locator('[data-draggable="true"]')
|
||||
let noteFound = false
|
||||
const noteCount = await noteCards.count()
|
||||
|
||||
for (let i = 0; i < noteCount; i++) {
|
||||
const noteText = await noteCards.nth(i).textContent()
|
||||
if (noteText?.includes(testNoteTitle) || noteText?.includes(testNoteContent.substring(0, 20))) {
|
||||
noteFound = true
|
||||
console.log('[SUCCESS] Note found immediately after creation in notebook!')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(noteFound).toBe(true)
|
||||
})
|
||||
|
||||
test('should maintain scroll position after note creation', async ({ page }) => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Step 1: Go to homepage
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 2: Scroll down a bit
|
||||
await page.evaluate(() => window.scrollTo(0, 300))
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Get scroll position before
|
||||
const scrollBefore = await page.evaluate(() => window.scrollY)
|
||||
console.log('[TEST] Scroll position before creation:', scrollBefore)
|
||||
|
||||
// Step 3: Create a test note
|
||||
const testNoteContent = `TEST-${timestamp}-Scroll Position`
|
||||
|
||||
console.log('[TEST] Creating test note...')
|
||||
|
||||
const noteInput = page.locator('input[placeholder*="Take a note"], textarea[placeholder*="Take a note"]').first()
|
||||
await noteInput.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const contentInput = page.locator('textarea[placeholder*="Take a note"], textarea').first()
|
||||
await contentInput.fill(testNoteContent)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const submitBtn = page.locator('button:has-text("Add"), button:has-text("Ajouter"), button[type="submit"]').first()
|
||||
await submitBtn.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Step 4: Verify scroll position is maintained (should be similar, not reset to 0)
|
||||
const scrollAfter = await page.evaluate(() => window.scrollY)
|
||||
console.log('[TEST] Scroll position after creation:', scrollAfter)
|
||||
|
||||
// Scroll position should be maintained (not reset to 0)
|
||||
// Allow some tolerance for UI updates
|
||||
expect(Math.abs(scrollAfter - scrollBefore)).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
62
memento-note/tests/bug-repro-recent-notes.spec.ts
Normal file
62
memento-note/tests/bug-repro-recent-notes.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Recent Notes Dismissal Bug', () => {
|
||||
test('should not replace dismissed note immediately', async ({ page }) => {
|
||||
// 1. Create 4 notes to ensure we have enough to fill the list (limit is 3) + 1 extra
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Create 4 notes
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const noteInput = page.locator('[data-testid="note-input-textarea"]')
|
||||
if (await noteInput.isVisible()) {
|
||||
await noteInput.fill(`Recent Note Test ${i} - ${Date.now()}`)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
// Wait for creation
|
||||
await page.waitForTimeout(500)
|
||||
} else {
|
||||
// If input not visible, click "New Note" or "Add Note" button first?
|
||||
// Assuming input is visible or toggleable.
|
||||
// Let's try to just open it if needed.
|
||||
const addBtn = page.locator('button:has-text("New Note")').first()
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click()
|
||||
}
|
||||
await page.locator('[data-testid="note-input-textarea"]').fill(`Recent Note Test ${i} - ${Date.now()}`)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to update recent list
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
await expect(recentSection).toBeVisible()
|
||||
|
||||
// Should see 3 notes
|
||||
const noteCards = recentSection.locator('[data-testid^="note-card-"]')
|
||||
await expect(noteCards).toHaveCount(3)
|
||||
|
||||
// 2. Dismiss one note
|
||||
const firstNote = noteCards.first()
|
||||
// Hover to see the dismiss button
|
||||
await firstNote.hover()
|
||||
const dismissBtn = firstNote.locator('button[title*="Dismiss"], button[title*="Fermer"]') // Trying both EN and FR just in case
|
||||
|
||||
// We might need to force click if hover doesn't work perfectly in test
|
||||
await dismissBtn.click({ force: true })
|
||||
|
||||
// 3. Verify behavior
|
||||
// PRE-FIX: The list refreshes and shows 3 notes (the 4th one pops in)
|
||||
// POST-FIX: The list should show 2 notes
|
||||
|
||||
// We expect 2 notes if the fix works.
|
||||
// If the bug is present, this assertion might fail (it will see 3).
|
||||
// For reproduction, we might want to assert failure or just see what happens.
|
||||
// Let's assert the DESIRED behavior (2 notes).
|
||||
await expect(noteCards).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
9
memento-note/tests/capture-masonry.spec.ts
Normal file
9
memento-note/tests/capture-masonry.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('capture masonry layout', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
// Attendre que Muuri s'initialise et que le layout se stabilise
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: '.playwright-mcp/muuri-masonry-layout.png', fullPage: true });
|
||||
console.log('Screenshot saved to .playwright-mcp/muuri-masonry-layout.png');
|
||||
});
|
||||
132
memento-note/tests/collaboration.spec.ts
Normal file
132
memento-note/tests/collaboration.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Collaboration Feature', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('http://localhost:3000/login');
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('http://localhost:3000/');
|
||||
});
|
||||
|
||||
test('COLLAB-2: Add collaborator to existing note', async ({ page, context }) => {
|
||||
// Start from the main page
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
// Find a note card (should have at least one)
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await expect(noteCard).toBeVisible();
|
||||
|
||||
// Open collaborator dialog from the note menu
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
|
||||
// Click "Share with collaborators"
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
// Verify dialog opens
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('text=Share with collaborators')).toBeVisible();
|
||||
|
||||
// Try to add a collaborator (note: user may not exist)
|
||||
await page.fill('input[type="email"]', 'collaborator@example.com');
|
||||
await page.click('button:has-text("Invite")');
|
||||
|
||||
// Wait for response (either success or error toast)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify either:
|
||||
// 1. Success: User added to list
|
||||
// 2. Error: User not found message
|
||||
const collaboratorInList = page.locator('text=collaborator@example.com');
|
||||
const errorMessage = page.locator('.toast:has-text("User not found")');
|
||||
|
||||
const collaboratorAdded = await collaboratorInList.count() > 0;
|
||||
const errorShown = await errorMessage.count() > 0;
|
||||
|
||||
expect(collaboratorAdded || errorShown).toBeTruthy();
|
||||
});
|
||||
|
||||
test('COLLAB-2: Remove collaborator from note', async ({ page }) => {
|
||||
// This test assumes a note with collaborators exists
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
// Find a note card
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Check if there are any collaborators listed
|
||||
const collaboratorItems = dialog.locator('[data-testid="collaborator-item"]');
|
||||
const count = await collaboratorItems.count();
|
||||
|
||||
if (count > 0) {
|
||||
// Get initial count
|
||||
const initialCount = count;
|
||||
|
||||
// Remove first collaborator
|
||||
await collaboratorItems.first().hover();
|
||||
await collaboratorItems.first().locator('button[aria-label="Remove"]').click();
|
||||
|
||||
// Wait for update
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify count decreased
|
||||
const newCount = await dialog.locator('[data-testid="collaborator-item"]').count();
|
||||
expect(newCount).toBeLessThan(initialCount);
|
||||
} else {
|
||||
// Skip test if no collaborators exist
|
||||
test.skip(true, 'No collaborators to remove');
|
||||
}
|
||||
});
|
||||
|
||||
test('COLLAB-2: View collaborators list', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Verify "People with access" section exists
|
||||
await expect(dialog.locator('text=People with access')).toBeVisible();
|
||||
|
||||
// Check for owner badge
|
||||
const ownerBadge = dialog.locator('text=Owner');
|
||||
await expect(ownerBadge).toBeVisible();
|
||||
});
|
||||
|
||||
test('COLLAB-2: Non-owner cannot remove collaborators', async ({ page }) => {
|
||||
// This test requires setup with a shared note where current user is not owner
|
||||
// For now, we'll test the UI shows correct state
|
||||
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Verify "Only the owner can manage collaborators" message appears if not owner
|
||||
const description = dialog.locator('text=You have access to this note');
|
||||
const isNotOwner = await description.count() > 0;
|
||||
|
||||
if (isNotOwner) {
|
||||
// Verify no remove buttons are visible
|
||||
const removeButtons = dialog.locator('button[aria-label="Remove"]');
|
||||
expect(await removeButtons.count()).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
302
memento-note/tests/drag-drop.spec.ts
Normal file
302
memento-note/tests/drag-drop.spec.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { test, expect, request } from '@playwright/test';
|
||||
|
||||
test.describe('Note Grid - Drag and Drop', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
console.log('[CLEANUP] beforeAll: Cleaning up any existing test notes...');
|
||||
|
||||
// Clean up any existing test notes from previous runs
|
||||
try {
|
||||
const response = await request.get('http://localhost:3000/api/notes');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const testNotes = data.data.filter((note: any) =>
|
||||
note.title?.startsWith('test-') &&
|
||||
note.content?.startsWith('Content ')
|
||||
);
|
||||
|
||||
console.log(`[CLEANUP] beforeAll: Found ${testNotes.length} test notes to delete`);
|
||||
|
||||
for (const note of testNotes) {
|
||||
try {
|
||||
await request.delete(`http://localhost:3000/api/notes?id=${note.id}`);
|
||||
console.log(`[CLEANUP] beforeAll: Deleted note ${note.id}`);
|
||||
} catch (error) {
|
||||
console.log(`[CLEANUP] beforeAll: Failed to delete note ${note.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CLEANUP] beforeAll: Error fetching notes for cleanup', error);
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Generate unique timestamp for this test run to avoid conflicts
|
||||
const timestamp = Date.now();
|
||||
const testId = `test-${timestamp}`;
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for page to fully load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Log initial note count before creating new notes
|
||||
const initialNotes = page.locator('[data-draggable="true"]');
|
||||
const initialCount = await initialNotes.count();
|
||||
console.log(`[DEBUG] [${testId}] Initial note count: ${initialCount}`);
|
||||
|
||||
// Create multiple notes for testing drag and drop with unique identifiers
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const noteTitle = `${testId}-Note ${i}`;
|
||||
const noteContent = `${testId}-Content ${i}`;
|
||||
|
||||
console.log(`[DEBUG] [${testId}] Creating ${noteTitle}`);
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await page.fill('input[placeholder="Title"]', noteTitle);
|
||||
await page.fill('textarea[placeholder="Take a note..."]', noteContent);
|
||||
await page.click('button:has-text("Add")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Log note count after each creation
|
||||
const currentCount = await page.locator('[data-draggable="true"]').count();
|
||||
console.log(`[DEBUG] [${testId}] Note count after creating ${noteTitle}: ${currentCount}`);
|
||||
}
|
||||
|
||||
// Log final note count
|
||||
const finalCount = await page.locator('[data-draggable="true"]').count();
|
||||
console.log(`[DEBUG] [${testId}] Final note count after all creations: ${finalCount}`);
|
||||
});
|
||||
|
||||
test('should have draggable notes', async ({ page }) => {
|
||||
console.log('[DEBUG] Test: should have draggable notes');
|
||||
|
||||
// Wait for notes to appear (use a more flexible selector that matches pattern)
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
// Check that notes have data-draggable attribute (dnd-kit uses this)
|
||||
const noteCards = page.locator('[data-draggable="true"]');
|
||||
const count = await noteCards.count();
|
||||
console.log(`[DEBUG] Found ${count} notes with data-draggable="true"`);
|
||||
|
||||
// Log first few notes details
|
||||
for (let i = 0; i < Math.min(count, 3); i++) {
|
||||
const note = noteCards.nth(i);
|
||||
const text = await note.textContent();
|
||||
const draggableAttr = await note.getAttribute('data-draggable');
|
||||
console.log(`[DEBUG] Note ${i}: "${text?.substring(0, 50)}", data-draggable="${draggableAttr}"`);
|
||||
}
|
||||
|
||||
expect(count).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
test('should show cursor-move on note cards', async ({ page }) => {
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
// Check CSS class for cursor-move on the note card inside
|
||||
const firstNote = page.locator('[data-draggable="true"]').first();
|
||||
const noteCard = firstNote.locator('.note-card-main');
|
||||
const className = await noteCard.getAttribute('class');
|
||||
expect(className).toContain('cursor-move');
|
||||
});
|
||||
|
||||
test('should change opacity when dragging', async ({ page }) => {
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
const firstNote = page.locator('[data-draggable="true"]').first();
|
||||
|
||||
// Start drag
|
||||
const box = await firstNote.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
|
||||
// Move to trigger drag
|
||||
await page.mouse.move(box.x + box.width / 2 + 50, box.y + box.height / 2 + 50);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Check if opacity changed (style should have opacity: 0.5)
|
||||
const style = await firstNote.getAttribute('style');
|
||||
expect(style).toContain('opacity');
|
||||
|
||||
await page.mouse.up();
|
||||
}
|
||||
});
|
||||
|
||||
test('should reorder notes when dropped on another note', async ({ page }) => {
|
||||
console.log('[DEBUG] Test: should reorder notes when dropped on another note');
|
||||
|
||||
// Wait for notes to be fully loaded and drag-and-drop initialized
|
||||
await page.waitForSelector('[data-draggable="true"]', { state: 'attached' });
|
||||
await page.waitForTimeout(500); // Extra wait for dnd-kit to initialize
|
||||
|
||||
// Get initial order
|
||||
const notes = page.locator('[data-draggable="true"]');
|
||||
const firstNoteText = await notes.first().textContent();
|
||||
const secondNoteText = await notes.nth(1).textContent();
|
||||
|
||||
console.log(`[DEBUG] Initial first note: ${firstNoteText?.substring(0, 30)}`);
|
||||
console.log(`[DEBUG] Initial second note: ${secondNoteText?.substring(0, 30)}`);
|
||||
|
||||
expect(firstNoteText).toMatch(/Note \d+/);
|
||||
expect(secondNoteText).toMatch(/Note \d+/);
|
||||
|
||||
// Use dragTo for more reliable drag and drop
|
||||
const firstNote = notes.first();
|
||||
const secondNote = notes.nth(1);
|
||||
|
||||
console.log('[DEBUG] Starting drag operation...');
|
||||
await firstNote.dragTo(secondNote);
|
||||
console.log('[DEBUG] Drag operation completed');
|
||||
|
||||
// Wait for reorder to complete and database to update
|
||||
await page.waitForTimeout(2000); // Increased wait time for async operations
|
||||
|
||||
// Check that order changed
|
||||
// Note: This depends on order persisting in the database
|
||||
console.log('[DEBUG] Reloading page to verify persistence...');
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-draggable="true"]', { state: 'attached' });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify that order changed (implementation dependent)
|
||||
console.log('[DEBUG] Page reloaded, checking order...');
|
||||
});
|
||||
|
||||
test('should work with pinned and unpinned notes separately', async ({ page }) => {
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
// Pin first note by hovering and clicking pin button
|
||||
const firstNoteCard = page.locator('[data-draggable="true"]').first();
|
||||
await firstNoteCard.hover();
|
||||
|
||||
// Click the pin button
|
||||
const pinButton = firstNoteCard.locator('button[title="Pin"]');
|
||||
await pinButton.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that "Pinned" section appears
|
||||
await expect(page.locator('h2:has-text("Pinned")')).toBeVisible();
|
||||
|
||||
// Verify note is in pinned section
|
||||
const pinnedSection = page.locator('h2:has-text("Pinned")').locator('..').locator('div[data-draggable="true"]');
|
||||
expect(await pinnedSection.count()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('should not mix pinned and unpinned notes when dragging', async ({ page }) => {
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
// Pin first note by hovering and clicking pin button
|
||||
const firstNoteCard = page.locator('[data-draggable="true"]').first();
|
||||
await firstNoteCard.hover();
|
||||
|
||||
// Click the pin button
|
||||
const pinButton = firstNoteCard.locator('button[title="Pin"]');
|
||||
await pinButton.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should have both Pinned and Others sections
|
||||
await expect(page.locator('h2:has-text("Pinned")')).toBeVisible();
|
||||
await expect(page.locator('h2:has-text("Others")')).toBeVisible();
|
||||
|
||||
// Count notes in each section using data-draggable attribute
|
||||
const pinnedNotes = page.locator('h2:has-text("Pinned") ~ div [data-draggable="true"]');
|
||||
const unpinnedNotes = page.locator('h2:has-text("Others") ~ div [data-draggable="true"]');
|
||||
|
||||
expect(await pinnedNotes.count()).toBeGreaterThanOrEqual(1);
|
||||
expect(await unpinnedNotes.count()).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('should persist note order after page reload', async ({ page }) => {
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
// Get initial order
|
||||
const notes = page.locator('[data-draggable="true"]');
|
||||
const initialOrder: string[] = [];
|
||||
const count = await notes.count();
|
||||
|
||||
for (let i = 0; i < Math.min(count, 4); i++) {
|
||||
const text = await notes.nth(i).textContent();
|
||||
if (text) initialOrder.push(text);
|
||||
}
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForSelector('[data-draggable="true"]');
|
||||
|
||||
// Get order after reload
|
||||
const notesAfterReload = page.locator('[data-draggable="true"]');
|
||||
const reloadedOrder: string[] = [];
|
||||
const countAfterReload = await notesAfterReload.count();
|
||||
|
||||
for (let i = 0; i < Math.min(countAfterReload, 4); i++) {
|
||||
const text = await notesAfterReload.nth(i).textContent();
|
||||
if (text) reloadedOrder.push(text);
|
||||
}
|
||||
|
||||
// Order should be the same
|
||||
expect(reloadedOrder.length).toBe(initialOrder.length);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
console.log('[CLEANUP] Starting cleanup...');
|
||||
|
||||
// Clean up created notes via API - more reliable than UI interaction
|
||||
try {
|
||||
const response = await request.get('http://localhost:3000/api/notes');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const testNotes = data.data.filter((note: any) =>
|
||||
note.title?.startsWith('test-') &&
|
||||
note.content?.startsWith('Content ')
|
||||
);
|
||||
|
||||
console.log(`[CLEANUP] Found ${testNotes.length} test notes to delete via API`);
|
||||
|
||||
for (const note of testNotes) {
|
||||
try {
|
||||
await request.delete(`http://localhost:3000/api/notes?id=${note.id}`);
|
||||
console.log(`[CLEANUP] Deleted note ${note.id}`);
|
||||
} catch (error) {
|
||||
console.log(`[CLEANUP] Failed to delete note ${note.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CLEANUP] Error fetching notes for cleanup', error);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
console.log('[CLEANUP] afterAll: Final cleanup of any remaining test notes...');
|
||||
|
||||
// Final cleanup to ensure no test notes remain
|
||||
try {
|
||||
const response = await request.get('http://localhost:3000/api/notes');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const testNotes = data.data.filter((note: any) =>
|
||||
note.title?.startsWith('test-') &&
|
||||
note.content?.startsWith('Content ')
|
||||
);
|
||||
|
||||
console.log(`[CLEANUP] afterAll: Found ${testNotes.length} remaining test notes to delete`);
|
||||
|
||||
for (const note of testNotes) {
|
||||
try {
|
||||
await request.delete(`http://localhost:3000/api/notes?id=${note.id}`);
|
||||
console.log(`[CLEANUP] afterAll: Deleted note ${note.id}`);
|
||||
} catch (error) {
|
||||
console.log(`[CLEANUP] afterAll: Failed to delete note ${note.id}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CLEANUP] afterAll: Error fetching notes for final cleanup', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
147
memento-note/tests/e2e/admin-dashboard.spec.ts
Normal file
147
memento-note/tests/e2e/admin-dashboard.spec.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Admin Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to admin dashboard
|
||||
await page.goto('/admin')
|
||||
})
|
||||
|
||||
test('should redirect to home if not authenticated', async ({ page, context }) => {
|
||||
// Clear authentication
|
||||
await context.clearCookies()
|
||||
await page.goto('/admin')
|
||||
await expect(page).toHaveURL('/')
|
||||
})
|
||||
|
||||
test('should show sidebar navigation with all sections', async ({ page }) => {
|
||||
// Check sidebar exists
|
||||
const sidebar = page.locator('aside')
|
||||
await expect(sidebar).toBeVisible()
|
||||
|
||||
// Check all navigation items exist
|
||||
await expect(page.getByRole('link', { name: /dashboard/i })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /users/i })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /ai management/i })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /settings/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show main content area with metrics', async ({ page }) => {
|
||||
// Check main content area exists
|
||||
const main = page.locator('main')
|
||||
await expect(main).toBeVisible()
|
||||
|
||||
// Check metrics are displayed
|
||||
await expect(page.getByText(/total users/i)).toBeVisible()
|
||||
await expect(page.getByText(/active sessions/i)).toBeVisible()
|
||||
await expect(page.getByText(/total notes/i)).toBeVisible()
|
||||
await expect(page.getByText(/ai requests/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should highlight active section in sidebar', async ({ page }) => {
|
||||
// Dashboard should be active on /admin
|
||||
const dashboardLink = page.getByRole('link', { name: /dashboard/i })
|
||||
await expect(dashboardLink).toHaveClass(/bg-gray-100|bg-zinc-800/)
|
||||
})
|
||||
|
||||
test('should navigate between sections', async ({ page }) => {
|
||||
// Navigate to Users
|
||||
await page.click('a[href="/admin/users"]')
|
||||
await expect(page).toHaveURL(/\/admin\/users/)
|
||||
await expect(page.getByRole('heading', { name: /users/i })).toBeVisible()
|
||||
|
||||
// Navigate to AI Management
|
||||
await page.click('a[href="/admin/ai"]')
|
||||
await expect(page).toHaveURL(/\/admin\/ai/)
|
||||
await expect(page.getByRole('heading', { name: /ai management/i })).toBeVisible()
|
||||
|
||||
// Navigate to Settings
|
||||
await page.click('a[href="/admin/settings"]')
|
||||
await expect(page).toHaveURL(/\/admin\/settings/)
|
||||
await expect(page.getByRole('heading', { name: /settings/i }).first()).toBeVisible()
|
||||
|
||||
// Navigate back to Dashboard
|
||||
await page.click('a[href="/admin"]')
|
||||
await expect(page).toHaveURL(/\/admin\/?$/)
|
||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should be responsive on desktop (1024px+)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 })
|
||||
|
||||
// Check sidebar is visible on desktop
|
||||
const sidebar = page.locator('aside')
|
||||
await expect(sidebar).toBeVisible()
|
||||
await expect(sidebar).toHaveCSS('width', '256px')
|
||||
|
||||
// Check content area takes remaining space
|
||||
const main = page.locator('main')
|
||||
await expect(main).toBeVisible()
|
||||
})
|
||||
|
||||
test('should be responsive on tablet (640px-1023px)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 })
|
||||
|
||||
// Check layout still works on tablet
|
||||
const sidebar = page.locator('aside')
|
||||
await expect(sidebar).toBeVisible()
|
||||
|
||||
const main = page.locator('main')
|
||||
await expect(main).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show metrics with trend indicators', async ({ page }) => {
|
||||
// Check for trend indicators in metrics
|
||||
const trends = page.locator('.text-green-600, .text-red-600, .dark\\:text-green-400, .dark\\:text-red-400')
|
||||
await expect(trends).toHaveCount(3) // 3 metrics have trend indicators
|
||||
})
|
||||
|
||||
test('should show users page correctly', async ({ page }) => {
|
||||
await page.goto('/admin/users')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /users/i })).toBeVisible()
|
||||
await expect(page.getByText(/manage application users and permissions/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show AI management page correctly', async ({ page }) => {
|
||||
await page.goto('/admin/ai')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /ai management/i })).toBeVisible()
|
||||
await expect(page.getByText(/monitor and configure ai features/i)).toBeVisible()
|
||||
await expect(page.getByText(/total requests/i)).toBeVisible()
|
||||
await expect(page.getByText(/active ai features/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show settings page correctly', async ({ page }) => {
|
||||
await page.goto('/admin/settings')
|
||||
|
||||
await expect(page.getByRole('heading', { name: /settings/i }).first()).toBeVisible()
|
||||
await expect(page.getByText(/configure application-wide settings/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Admin Dashboard Accessibility', () => {
|
||||
test('should be keyboard navigable', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
// Check tab order
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('link', { name: /dashboard/i })).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('link', { name: /users/i })).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('link', { name: /ai management/i })).toBeFocused()
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.getByRole('link', { name: /settings/i })).toBeFocused()
|
||||
})
|
||||
|
||||
test('should have proper heading hierarchy', async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
|
||||
// Check main heading is h1
|
||||
const h1 = page.getByRole('heading', { level: 1, name: /dashboard/i })
|
||||
await expect(h1).toBeVisible()
|
||||
})
|
||||
})
|
||||
165
memento-note/tests/favorites-section.spec.ts
Normal file
165
memento-note/tests/favorites-section.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Favorites Section', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('should not show favorites section when no notes are pinned', async ({ page }) => {
|
||||
const favoritesSection = page.locator('[data-testid="favorites-section"]')
|
||||
await expect(favoritesSection).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should pin a note and show it in favorites section', async ({ page }) => {
|
||||
const existingNotes = page.locator('[data-testid="note-card"]')
|
||||
const noteCount = await existingNotes.count()
|
||||
|
||||
if (noteCount === 0) {
|
||||
test.skip(true, 'No notes exist to test pinning')
|
||||
return
|
||||
}
|
||||
|
||||
const firstNoteCard = existingNotes.first()
|
||||
await firstNoteCard.hover()
|
||||
|
||||
const pinButton = firstNoteCard.locator('[data-testid="pin-button"]')
|
||||
await expect(pinButton).toBeVisible({ timeout: 5000 })
|
||||
await pinButton.click()
|
||||
|
||||
await expect(page.locator('[data-testid="favorites-section"]')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
const sectionTitle = page.locator('[data-testid="favorites-section"] h2')
|
||||
await expect(sectionTitle).toContainText('Pinned')
|
||||
|
||||
const pinnedNotes = page.locator('[data-testid="favorites-section"] [data-testid="note-card"]')
|
||||
await expect(pinnedNotes.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should unpin a note and remove it from favorites', async ({ page }) => {
|
||||
const favoritesSection = page.locator('[data-testid="favorites-section"]')
|
||||
const isFavoritesVisible = await favoritesSection.isVisible().catch(() => false)
|
||||
|
||||
if (!isFavoritesVisible) {
|
||||
test.skip(true, 'No favorites exist to test unpinning')
|
||||
return
|
||||
}
|
||||
|
||||
const pinnedNotes = favoritesSection.locator('[data-testid="note-card"]')
|
||||
const pinnedCount = await pinnedNotes.count()
|
||||
|
||||
if (pinnedCount === 0) {
|
||||
test.skip(true, 'No pinned notes to unpin')
|
||||
return
|
||||
}
|
||||
|
||||
const firstPinnedNote = pinnedNotes.first()
|
||||
await firstPinnedNote.hover()
|
||||
|
||||
const pinButton = firstPinnedNote.locator('[data-testid="pin-button"]')
|
||||
await pinButton.click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const updatedPinnedCount = await favoritesSection.locator('[data-testid="note-card"]').count().catch(() => 0)
|
||||
|
||||
if (pinnedCount === 1) {
|
||||
await expect(favoritesSection).not.toBeVisible({ timeout: 10000 })
|
||||
} else {
|
||||
expect(updatedPinnedCount).toBe(pinnedCount - 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('should show multiple pinned notes in favorites section', async ({ page }) => {
|
||||
const existingNotes = page.locator('[data-testid="note-card"]')
|
||||
const noteCount = await existingNotes.count()
|
||||
|
||||
if (noteCount < 2) {
|
||||
test.skip(true, 'Not enough notes to test multiple pins')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const noteCard = existingNotes.nth(i)
|
||||
await noteCard.hover()
|
||||
|
||||
const pinButton = noteCard.locator('[data-testid="pin-button"]')
|
||||
await expect(pinButton).toBeVisible({ timeout: 5000 })
|
||||
await pinButton.click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const favoritesSection = page.locator('[data-testid="favorites-section"]')
|
||||
await expect(favoritesSection).toBeVisible({ timeout: 10000 })
|
||||
|
||||
const pinnedNotes = favoritesSection.locator('[data-testid="note-card"]')
|
||||
const pinnedCount = await pinnedNotes.count()
|
||||
expect(pinnedCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('should show favorites section above main notes', async ({ page }) => {
|
||||
const existingNotes = page.locator('[data-testid="note-card"]')
|
||||
const noteCount = await existingNotes.count()
|
||||
|
||||
if (noteCount === 0) {
|
||||
test.skip(true, 'No notes exist to test ordering')
|
||||
return
|
||||
}
|
||||
|
||||
const firstNoteCard = existingNotes.first()
|
||||
await firstNoteCard.hover()
|
||||
|
||||
const pinButton = firstNoteCard.locator('[data-testid="pin-button"]')
|
||||
await expect(pinButton).toBeVisible({ timeout: 5000 })
|
||||
await pinButton.click()
|
||||
|
||||
const favoritesSection = page.locator('[data-testid="favorites-section"]')
|
||||
const mainNotesGrid = page.locator('[data-testid="notes-grid"]')
|
||||
|
||||
await expect(favoritesSection).toBeVisible({ timeout: 10000 })
|
||||
|
||||
const hasUnpinnedNotes = await mainNotesGrid.isVisible().catch(() => false)
|
||||
if (hasUnpinnedNotes) {
|
||||
const favoritesY = await favoritesSection.boundingBox()
|
||||
const mainNotesY = await mainNotesGrid.boundingBox()
|
||||
|
||||
if (favoritesY && mainNotesY) {
|
||||
expect(favoritesY.y).toBeLessThan(mainNotesY.y)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('should collapse and expand favorites section', async ({ page }) => {
|
||||
const favoritesSection = page.locator('[data-testid="favorites-section"]')
|
||||
const isFavoritesVisible = await favoritesSection.isVisible().catch(() => false)
|
||||
|
||||
if (!isFavoritesVisible) {
|
||||
const existingNotes = page.locator('[data-testid="note-card"]')
|
||||
const noteCount = await existingNotes.count()
|
||||
|
||||
if (noteCount === 0) {
|
||||
test.skip(true, 'No notes to pin for collapse test')
|
||||
return
|
||||
}
|
||||
|
||||
const firstNoteCard = existingNotes.first()
|
||||
await firstNoteCard.hover()
|
||||
const pinButton = firstNoteCard.locator('[data-testid="pin-button"]')
|
||||
await pinButton.click()
|
||||
|
||||
await expect(favoritesSection).toBeVisible({ timeout: 10000 })
|
||||
}
|
||||
|
||||
const collapseButton = favoritesSection.locator('button').first()
|
||||
const pinnedNoteCards = favoritesSection.locator('[data-testid="note-card"]')
|
||||
|
||||
await expect(pinnedNoteCards.first()).toBeVisible()
|
||||
|
||||
await collapseButton.click()
|
||||
await expect(pinnedNoteCards.first()).not.toBeVisible()
|
||||
|
||||
await collapseButton.click()
|
||||
await expect(pinnedNoteCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
82
memento-note/tests/layout-spacing.spec.ts
Normal file
82
memento-note/tests/layout-spacing.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Masonry Grid Spacing', () => {
|
||||
test('should have correct spacing between notes', async ({ page }) => {
|
||||
// Go to home page
|
||||
await page.goto('/');
|
||||
|
||||
// Create two notes to ensure we have content
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await page.fill('input[placeholder="Title"]', 'Note A');
|
||||
await page.click('button:has-text("Add")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await page.fill('input[placeholder="Title"]', 'Note B');
|
||||
await page.click('button:has-text("Add")');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Get the first two masonry items
|
||||
const items = page.locator('.masonry-item');
|
||||
const count = await items.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const item1 = items.nth(0);
|
||||
const item2 = items.nth(1);
|
||||
|
||||
// Get bounding boxes
|
||||
const box1 = await item1.boundingBox();
|
||||
const box2 = await item2.boundingBox();
|
||||
|
||||
if (!box1 || !box2) throw new Error('Could not get bounding boxes');
|
||||
|
||||
console.log('Box 1:', box1);
|
||||
console.log('Box 2:', box2);
|
||||
|
||||
// Calculate horizontal distance between centers or edges?
|
||||
// Assuming they are side-by-side in a multi-column layout on desktop
|
||||
|
||||
// Check viewport size
|
||||
const viewport = page.viewportSize();
|
||||
console.log('Viewport:', viewport);
|
||||
|
||||
if (viewport && viewport.width >= 1024) {
|
||||
// Should be at least 2 columns
|
||||
// Distance between left edges
|
||||
const distance = Math.abs(box2.x - box1.x);
|
||||
console.log('Distance betweeen left edges:', distance);
|
||||
|
||||
// Item width
|
||||
console.log('Item 1 width:', box1.width);
|
||||
|
||||
// The visual gap depends on padding if we are using the padding strategy
|
||||
// We can check the computed padding using evaluate
|
||||
const padding = await item1.evaluate((el) => {
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.padding;
|
||||
});
|
||||
console.log('Computed Padding:', padding);
|
||||
}
|
||||
});
|
||||
|
||||
test('should adjust columns on resize', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('.masonry-item');
|
||||
|
||||
// Desktop
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.waitForTimeout(1000);
|
||||
let items = page.locator('.masonry-item');
|
||||
let box1 = await items.nth(0).boundingBox();
|
||||
console.log('1280px width - Item width:', box1?.width);
|
||||
|
||||
// Mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await page.waitForTimeout(1000); // Wait for resize observer
|
||||
items = page.locator('.masonry-item');
|
||||
box1 = await items.nth(0).boundingBox();
|
||||
console.log('375px width - Item width:', box1?.width);
|
||||
|
||||
// Calculate expectation logic here if needed
|
||||
});
|
||||
});
|
||||
448
memento-note/tests/migration-ai-fields.test.ts
Normal file
448
memento-note/tests/migration-ai-fields.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Test suite for AI field migrations
|
||||
* Validates that Note and AiFeedback models work correctly with new AI fields
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
describe('AI Fields Migration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
// Ensure clean test environment
|
||||
await prisma.aiFeedback.deleteMany({})
|
||||
await prisma.note.deleteMany({
|
||||
where: { title: { contains: 'TEST_AI' } }
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await prisma.aiFeedback.deleteMany({})
|
||||
await prisma.note.deleteMany({
|
||||
where: { title: { contains: 'TEST_AI' } }
|
||||
})
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
|
||||
describe('Note Model - AI Fields', () => {
|
||||
test('should create note without AI fields (backward compatibility)', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note without AI',
|
||||
content: 'This is a test note without AI fields',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
expect(note).toBeDefined()
|
||||
expect(note.id).toBeDefined()
|
||||
expect(note.title).toBe('TEST_AI: Note without AI')
|
||||
expect(note.autoGenerated).toBeNull()
|
||||
expect(note.aiProvider).toBeNull()
|
||||
expect(note.aiConfidence).toBeNull()
|
||||
expect(note.language).toBeNull()
|
||||
expect(note.languageConfidence).toBeNull()
|
||||
expect(note.lastAiAnalysis).toBeNull()
|
||||
})
|
||||
|
||||
test('should create note with all AI fields populated', async () => {
|
||||
const testDate = new Date()
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note with AI fields',
|
||||
content: 'This is a test note with AI fields',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'openai',
|
||||
aiConfidence: 95,
|
||||
language: 'fr',
|
||||
languageConfidence: 0.98,
|
||||
lastAiAnalysis: testDate
|
||||
}
|
||||
})
|
||||
|
||||
expect(note).toBeDefined()
|
||||
expect(note.autoGenerated).toBe(true)
|
||||
expect(note.aiProvider).toBe('openai')
|
||||
expect(note.aiConfidence).toBe(95)
|
||||
expect(note.language).toBe('fr')
|
||||
expect(note.languageConfidence).toBe(0.98)
|
||||
expect(note.lastAiAnalysis).toEqual(testDate)
|
||||
})
|
||||
|
||||
test('should update note with AI fields', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for update test',
|
||||
content: 'Initial content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const updatedNote = await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
autoGenerated: true,
|
||||
aiProvider: 'ollama',
|
||||
aiConfidence: 87
|
||||
}
|
||||
})
|
||||
|
||||
expect(updatedNote.autoGenerated).toBe(true)
|
||||
expect(updatedNote.aiProvider).toBe('ollama')
|
||||
expect(updatedNote.aiConfidence).toBe(87)
|
||||
})
|
||||
|
||||
test('should query notes filtered by AI fields', async () => {
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Auto-generated note 1',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'openai'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Auto-generated note 2',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'ollama'
|
||||
}
|
||||
})
|
||||
|
||||
const autoGeneratedNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
title: { contains: 'TEST_AI' },
|
||||
autoGenerated: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(autoGeneratedNotes.length).toBeGreaterThanOrEqual(2)
|
||||
expect(autoGeneratedNotes.every(n => n.autoGenerated === true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AiFeedback Model', () => {
|
||||
test('should create feedback entry', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for feedback',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'AI suggested title',
|
||||
metadata: JSON.stringify({
|
||||
aiProvider: 'openai',
|
||||
confidence: 95,
|
||||
model: 'gpt-4',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback).toBeDefined()
|
||||
expect(feedback.id).toBeDefined()
|
||||
expect(feedback.feedbackType).toBe('thumbs_up')
|
||||
expect(feedback.feature).toBe('title_suggestion')
|
||||
expect(feedback.originalContent).toBe('AI suggested title')
|
||||
expect(feedback.noteId).toBe(note.id)
|
||||
})
|
||||
|
||||
test('should handle thumbs_down feedback', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for thumbs down',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_down',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Bad suggestion'
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback.feedbackType).toBe('thumbs_down')
|
||||
})
|
||||
|
||||
test('should handle correction feedback', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for correction',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'correction',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Wrong suggestion',
|
||||
correctedContent: 'Corrected version'
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback.feedbackType).toBe('correction')
|
||||
expect(feedback.correctedContent).toBe('Corrected version')
|
||||
})
|
||||
|
||||
test('should query feedback by note', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for feedback query',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Feedback 1'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_down',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Feedback 2'
|
||||
}
|
||||
})
|
||||
|
||||
const feedbacks = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
|
||||
expect(feedbacks.length).toBe(2)
|
||||
})
|
||||
|
||||
test('should query feedback by feature', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for feature query',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Feedback 1'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'semantic_search',
|
||||
originalContent: 'Feedback 2'
|
||||
}
|
||||
})
|
||||
|
||||
const titleFeedbacks = await prisma.aiFeedback.findMany({
|
||||
where: { feature: 'title_suggestion' }
|
||||
})
|
||||
|
||||
expect(titleFeedbacks.length).toBeGreaterThanOrEqual(1)
|
||||
expect(titleFeedbacks.every(f => f.feature === 'title_suggestion')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cascade Deletion', () => {
|
||||
test('should cascade delete feedback when note is deleted', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for cascade test',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Feedback to be deleted'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feedback exists
|
||||
const feedbacksBefore = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(feedbacksBefore.length).toBe(1)
|
||||
|
||||
// Delete the note
|
||||
await prisma.note.delete({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
// Verify feedback was cascade deleted
|
||||
const feedbacksAfter = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(feedbacksAfter.length).toBe(0)
|
||||
})
|
||||
|
||||
test('should cascade delete feedback when user is deleted', async () => {
|
||||
// This test would require a User model with proper setup
|
||||
// For now, we'll skip as user deletion is a more complex operation
|
||||
// that may involve authentication and authorization
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Performance', () => {
|
||||
test('should have indexes on critical fields', async () => {
|
||||
// Verify indexes exist by checking query plan or performance
|
||||
// For SQLite, indexes are created in the migration
|
||||
// This is more of a documentation test than a runtime test
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'TEST_AI: Note for index test',
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.createMany({
|
||||
data: [
|
||||
{
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Feedback 1'
|
||||
},
|
||||
{
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_down',
|
||||
feature: 'semantic_search',
|
||||
originalContent: 'Feedback 2'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Query by noteId (should use index)
|
||||
const byNoteId = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(byNoteId.length).toBe(2)
|
||||
|
||||
// Query by userId (should use index)
|
||||
const byUserId = await prisma.aiFeedback.findMany({
|
||||
where: { userId: 'test-user-id' }
|
||||
})
|
||||
expect(byUserId.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Query by feature (should use index)
|
||||
const byFeature = await prisma.aiFeedback.findMany({
|
||||
where: { feature: 'title_suggestion' }
|
||||
})
|
||||
expect(byFeature.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Types Validation', () => {
|
||||
test('should accept valid aiProvider values', async () => {
|
||||
const providers = ['openai', 'ollama', null]
|
||||
|
||||
for (const provider of providers) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `TEST_AI: Note with provider ${provider}`,
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id',
|
||||
aiProvider: provider
|
||||
}
|
||||
})
|
||||
expect(note.aiProvider).toBe(provider)
|
||||
}
|
||||
})
|
||||
|
||||
test('should accept valid aiConfidence range (0-100)', async () => {
|
||||
const confidences = [0, 50, 100]
|
||||
|
||||
for (const conf of confidences) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `TEST_AI: Note with confidence ${conf}`,
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id',
|
||||
aiConfidence: conf
|
||||
}
|
||||
})
|
||||
expect(note.aiConfidence).toBe(conf)
|
||||
}
|
||||
})
|
||||
|
||||
test('should accept valid languageConfidence range (0.0-1.0)', async () => {
|
||||
const confidences = [0.0, 0.5, 0.99, 1.0]
|
||||
|
||||
for (const conf of confidences) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `TEST_AI: Note with lang confidence ${conf}`,
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id',
|
||||
languageConfidence: conf
|
||||
}
|
||||
})
|
||||
expect(note.languageConfidence).toBe(conf)
|
||||
}
|
||||
})
|
||||
|
||||
test('should accept valid ISO 639-1 language codes', async () => {
|
||||
const languages = ['en', 'fr', 'es', 'de', 'fa', null]
|
||||
|
||||
for (const lang of languages) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `TEST_AI: Note in language ${lang}`,
|
||||
content: 'Test note',
|
||||
userId: 'test-user-id',
|
||||
language: lang
|
||||
}
|
||||
})
|
||||
expect(note.language).toBe(lang)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
156
memento-note/tests/migration/README.md
Normal file
156
memento-note/tests/migration/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Migration Tests
|
||||
|
||||
This directory contains comprehensive test suites for validating Prisma schema and data migrations for the Keep notes application.
|
||||
|
||||
## Test Files
|
||||
|
||||
- **setup.ts** - Test utilities for database setup, teardown, and test data generation
|
||||
- **schema-migration.test.ts** - Validates schema migrations (tables, columns, indexes, relationships)
|
||||
- **data-migration.test.ts** - Validates data migration (transformation, integrity, edge cases)
|
||||
- **rollback.test.ts** - Validates rollback capability and data recovery
|
||||
- **performance.test.ts** - Validates migration performance with various dataset sizes
|
||||
- **integrity.test.ts** - Validates data integrity (no loss/corruption, foreign keys, indexes)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all migration tests
|
||||
```bash
|
||||
npm run test:migration
|
||||
```
|
||||
|
||||
### Run migration tests in watch mode
|
||||
```bash
|
||||
npm run test:migration:watch
|
||||
```
|
||||
|
||||
### Run specific test file
|
||||
```bash
|
||||
npm run test:unit tests/migration/schema-migration.test.ts
|
||||
```
|
||||
|
||||
### Run tests with coverage
|
||||
```bash
|
||||
npm run test:unit:coverage
|
||||
```
|
||||
|
||||
## Test Coverage Goals
|
||||
|
||||
- **Minimum threshold:** 80% coverage for migration-related code
|
||||
- **Coverage areas:** Migration scripts, test utilities, schema transformations
|
||||
- **Exclude from coverage:** Test files themselves (`*.test.ts`)
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Schema Migration Tests
|
||||
- Core table existence (User, Note, Notebook, Label, etc.)
|
||||
- AI feature tables (AiFeedback, MemoryEchoInsight, UserAISettings)
|
||||
- Note table AI fields (autoGenerated, aiProvider, aiConfidence, etc.)
|
||||
- Index creation on critical fields
|
||||
- Foreign key relationships
|
||||
- Unique constraints
|
||||
- Default values
|
||||
|
||||
### Data Migration Tests
|
||||
- Empty database migration
|
||||
- Basic note migration
|
||||
- AI fields data migration
|
||||
- AiFeedback data migration
|
||||
- MemoryEchoInsight data migration
|
||||
- UserAISettings data migration
|
||||
- Data integrity verification
|
||||
- Edge cases (empty strings, long content, special characters)
|
||||
- Performance benchmarks
|
||||
|
||||
### Rollback Tests
|
||||
- Schema state verification
|
||||
- Column/table rollback simulation
|
||||
- Data recovery after rollback
|
||||
- Orphaned record handling
|
||||
- Rollback safety checks
|
||||
- Rollback error handling
|
||||
|
||||
### Performance Tests
|
||||
- Empty migration (< 1 second)
|
||||
- Small dataset (10 notes) (< 1 second)
|
||||
- Medium dataset (100 notes) (< 5 seconds)
|
||||
- Target dataset (1,000 notes) (< 30 seconds)
|
||||
- Stress test (10,000 notes) (< 30 seconds)
|
||||
- AI features performance
|
||||
- Database size tracking
|
||||
- Concurrent operations
|
||||
|
||||
### Integrity Tests
|
||||
- No data loss
|
||||
- No data corruption
|
||||
- Foreign key relationship maintenance
|
||||
- Index integrity
|
||||
- AI fields preservation
|
||||
- Batch operations integrity
|
||||
- Data type integrity
|
||||
|
||||
## Test Utilities
|
||||
|
||||
The `setup.ts` file provides reusable utilities:
|
||||
|
||||
- `setupTestEnvironment()` - Initialize test environment
|
||||
- `createTestPrismaClient()` - Create isolated Prisma client
|
||||
- `initializeTestDatabase()` - Apply all migrations
|
||||
- `cleanupTestDatabase()` - Clean up test database
|
||||
- `createSampleNotes()` - Generate sample test notes
|
||||
- `createSampleAINotes()` - Generate AI-enabled test notes
|
||||
- `measureExecutionTime()` - Performance measurement helper
|
||||
- `verifyDataIntegrity()` - Data integrity checks
|
||||
- `verifyTableExists()` - Table existence verification
|
||||
- `verifyColumnExists()` - Column existence verification
|
||||
- `verifyIndexExists()` - Index existence verification
|
||||
- `getTableSchema()` - Get table schema information
|
||||
- `getDatabaseSize()` - Get database file size
|
||||
|
||||
## Acceptance Criteria Coverage
|
||||
|
||||
✅ **AC 1:** Unit tests exist for all migration scripts
|
||||
✅ **AC 2:** Integration tests verify database state before/after migrations
|
||||
✅ **AC 3:** Test suite validates rollback capability
|
||||
✅ **AC 4:** Performance tests ensure migrations complete within acceptable limits
|
||||
✅ **AC 5:** Tests verify data integrity (no loss/corruption)
|
||||
✅ **AC 6:** Test coverage meets minimum threshold (80%)
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
These tests are configured to run in CI/CD pipelines:
|
||||
|
||||
```yaml
|
||||
# Example CI configuration
|
||||
- name: Run migration tests
|
||||
run: npm run test:migration
|
||||
|
||||
- name: Check coverage
|
||||
run: npm run test:unit:coverage
|
||||
```
|
||||
|
||||
## Isolation
|
||||
|
||||
Each test suite runs in an isolated test database:
|
||||
- **Location:** `prisma/test-databases/migration-test.db`
|
||||
- **Lifecycle:** Created before test suite, deleted after
|
||||
- **Conflict:** No conflict with development database
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests fail with database locked error
|
||||
Ensure no other process is using the test database. The test utilities automatically clean up the test database.
|
||||
|
||||
### Tests timeout
|
||||
Increase timeout values in `vitest.config.ts` if necessary.
|
||||
|
||||
### Coverage below threshold
|
||||
Review coverage report in `coverage/index.html` to identify untested code.
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new migrations:
|
||||
|
||||
1. Add corresponding test cases in appropriate test files
|
||||
2. Update this README with new test coverage
|
||||
3. Ensure coverage threshold (80%) is maintained
|
||||
4. Run all migration tests before committing
|
||||
631
memento-note/tests/migration/data-migration.test.ts
Normal file
631
memento-note/tests/migration/data-migration.test.ts
Normal file
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* Data Migration Tests
|
||||
* Validates that data migration scripts work correctly
|
||||
* Tests data transformation, integrity, and edge cases
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
setupTestEnvironment,
|
||||
createTestPrismaClient,
|
||||
initializeTestDatabase,
|
||||
cleanupTestDatabase,
|
||||
createSampleNotes,
|
||||
createSampleAINotes,
|
||||
verifyDataIntegrity,
|
||||
measureExecutionTime
|
||||
} from './setup'
|
||||
|
||||
describe('Data Migration Tests', () => {
|
||||
let prisma: PrismaClient
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestEnvironment()
|
||||
prisma = createTestPrismaClient()
|
||||
await initializeTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDatabase(prisma)
|
||||
})
|
||||
|
||||
describe('Empty Database Migration', () => {
|
||||
test('should migrate empty database successfully', async () => {
|
||||
// Verify database is empty
|
||||
const noteCount = await prisma.note.count()
|
||||
expect(noteCount).toBe(0)
|
||||
|
||||
// Data migration should handle empty database gracefully
|
||||
// No data should be created or lost
|
||||
expect(noteCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Basic Data Migration', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up before each test
|
||||
await prisma.note.deleteMany({})
|
||||
await prisma.aiFeedback.deleteMany({})
|
||||
})
|
||||
|
||||
test('should migrate basic notes without AI fields', async () => {
|
||||
// Create sample notes (simulating pre-migration data)
|
||||
await createSampleNotes(prisma, 10)
|
||||
|
||||
// Verify notes are created
|
||||
const noteCount = await prisma.note.count()
|
||||
expect(noteCount).toBe(10)
|
||||
|
||||
// All notes should have null AI fields (backward compatibility)
|
||||
const notes = await prisma.note.findMany()
|
||||
notes.forEach(note => {
|
||||
expect(note.autoGenerated).toBeNull()
|
||||
expect(note.aiProvider).toBeNull()
|
||||
expect(note.aiConfidence).toBeNull()
|
||||
expect(note.language).toBeNull()
|
||||
expect(note.languageConfidence).toBeNull()
|
||||
expect(note.lastAiAnalysis).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
test('should preserve existing note data during migration', async () => {
|
||||
// Create a note with all fields
|
||||
const originalNote = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Original Note',
|
||||
content: 'Original content',
|
||||
color: 'blue',
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
type: 'text',
|
||||
size: 'medium',
|
||||
userId: 'test-user-id',
|
||||
order: 0
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate migration by querying the note
|
||||
const noteAfterMigration = await prisma.note.findUnique({
|
||||
where: { id: originalNote.id }
|
||||
})
|
||||
|
||||
// Verify all original fields are preserved
|
||||
expect(noteAfterMigration?.title).toBe('Original Note')
|
||||
expect(noteAfterMigration?.content).toBe('Original content')
|
||||
expect(noteAfterMigration?.color).toBe('blue')
|
||||
expect(noteAfterMigration?.isPinned).toBe(true)
|
||||
expect(noteAfterMigration?.isArchived).toBe(false)
|
||||
expect(noteAfterMigration?.type).toBe('text')
|
||||
expect(noteAfterMigration?.size).toBe('medium')
|
||||
expect(noteAfterMigration?.order).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Fields Data Migration', () => {
|
||||
test('should handle notes with all AI fields populated', async () => {
|
||||
const testNote = await prisma.note.create({
|
||||
data: {
|
||||
title: 'AI Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'openai',
|
||||
aiConfidence: 95,
|
||||
language: 'en',
|
||||
languageConfidence: 0.98,
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
// Verify AI fields are correctly stored
|
||||
expect(testNote.autoGenerated).toBe(true)
|
||||
expect(testNote.aiProvider).toBe('openai')
|
||||
expect(testNote.aiConfidence).toBe(95)
|
||||
expect(testNote.language).toBe('en')
|
||||
expect(testNote.languageConfidence).toBe(0.98)
|
||||
expect(testNote.lastAiAnalysis).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle notes with partial AI fields', async () => {
|
||||
// Create note with only some AI fields
|
||||
const note1 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Partial AI Note 1',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'ollama'
|
||||
// Other AI fields are null
|
||||
}
|
||||
})
|
||||
|
||||
expect(note1.autoGenerated).toBe(true)
|
||||
expect(note1.aiProvider).toBe('ollama')
|
||||
expect(note1.aiConfidence).toBeNull()
|
||||
expect(note1.language).toBeNull()
|
||||
|
||||
// Create note with different partial fields
|
||||
const note2 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Partial AI Note 2',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
aiConfidence: 87,
|
||||
language: 'fr',
|
||||
languageConfidence: 0.92
|
||||
// Other AI fields are null
|
||||
}
|
||||
})
|
||||
|
||||
expect(note2.autoGenerated).toBeNull()
|
||||
expect(note2.aiProvider).toBeNull()
|
||||
expect(note2.aiConfidence).toBe(87)
|
||||
expect(note2.language).toBe('fr')
|
||||
expect(note2.languageConfidence).toBe(0.92)
|
||||
})
|
||||
|
||||
test('should handle null values in AI fields correctly', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Null AI Fields Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
// All AI fields are null by default
|
||||
}
|
||||
})
|
||||
|
||||
// Verify all AI fields are null
|
||||
expect(note.autoGenerated).toBeNull()
|
||||
expect(note.aiProvider).toBeNull()
|
||||
expect(note.aiConfidence).toBeNull()
|
||||
expect(note.language).toBeNull()
|
||||
expect(note.languageConfidence).toBeNull()
|
||||
expect(note.lastAiAnalysis).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AiFeedback Data Migration', () => {
|
||||
test('should create and retrieve AiFeedback entries', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feedback Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'AI suggested title',
|
||||
metadata: JSON.stringify({
|
||||
aiProvider: 'openai',
|
||||
confidence: 95,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feedback is correctly stored
|
||||
expect(feedback.noteId).toBe(note.id)
|
||||
expect(feedback.feedbackType).toBe('thumbs_up')
|
||||
expect(feedback.feature).toBe('title_suggestion')
|
||||
expect(feedback.originalContent).toBe('AI suggested title')
|
||||
expect(feedback.metadata).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle different feedback types', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feedback Types Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedbackTypes = [
|
||||
{ type: 'thumbs_up', feature: 'title_suggestion', content: 'Good suggestion' },
|
||||
{ type: 'thumbs_down', feature: 'semantic_search', content: 'Bad result' },
|
||||
{ type: 'correction', feature: 'title_suggestion', content: 'Wrong', corrected: 'Correct' }
|
||||
]
|
||||
|
||||
for (const fb of feedbackTypes) {
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: fb.type,
|
||||
feature: fb.feature,
|
||||
originalContent: fb.content,
|
||||
correctedContent: fb.corrected
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback.feedbackType).toBe(fb.type)
|
||||
}
|
||||
})
|
||||
|
||||
test('should store and retrieve metadata JSON correctly', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Metadata Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const metadata = {
|
||||
aiProvider: 'ollama',
|
||||
model: 'llama2-7b',
|
||||
confidence: 87,
|
||||
timestamp: new Date().toISOString(),
|
||||
additional: {
|
||||
latency: 234,
|
||||
tokens: 456
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test suggestion',
|
||||
metadata: JSON.stringify(metadata)
|
||||
}
|
||||
})
|
||||
|
||||
// Parse and verify metadata
|
||||
const parsedMetadata = JSON.parse(feedback.metadata || '{}')
|
||||
expect(parsedMetadata.aiProvider).toBe('ollama')
|
||||
expect(parsedMetadata.confidence).toBe(87)
|
||||
expect(parsedMetadata.additional).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemoryEchoInsight Data Migration', () => {
|
||||
test('should create and retrieve MemoryEchoInsight entries', async () => {
|
||||
const note1 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Echo Note 1',
|
||||
content: 'Content about programming',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const note2 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Echo Note 2',
|
||||
content: 'Content about coding',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const insight = await prisma.memoryEchoInsight.create({
|
||||
data: {
|
||||
userId: 'test-user-id',
|
||||
note1Id: note1.id,
|
||||
note2Id: note2.id,
|
||||
similarityScore: 0.85,
|
||||
insight: 'These notes are similar because they both discuss programming concepts',
|
||||
insightDate: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
expect(insight.note1Id).toBe(note1.id)
|
||||
expect(insight.note2Id).toBe(note2.id)
|
||||
expect(insight.similarityScore).toBe(0.85)
|
||||
expect(insight.insight).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle insight feedback and dismissal', async () => {
|
||||
const note1 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Echo Feedback Note 1',
|
||||
content: 'Content A',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const note2 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Echo Feedback Note 2',
|
||||
content: 'Content B',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const insight = await prisma.memoryEchoInsight.create({
|
||||
data: {
|
||||
userId: 'test-user-id',
|
||||
note1Id: note1.id,
|
||||
note2Id: note2.id,
|
||||
similarityScore: 0.75,
|
||||
insight: 'Test insight',
|
||||
feedback: 'useful',
|
||||
dismissed: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(insight.feedback).toBe('useful')
|
||||
expect(insight.dismissed).toBe(false)
|
||||
|
||||
// Update insight to mark as dismissed
|
||||
const updatedInsight = await prisma.memoryEchoInsight.update({
|
||||
where: { id: insight.id },
|
||||
data: { dismissed: true }
|
||||
})
|
||||
|
||||
expect(updatedInsight.dismissed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserAISettings Data Migration', () => {
|
||||
test('should create and retrieve UserAISettings', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'ai-settings@test.com',
|
||||
name: 'AI Settings User'
|
||||
}
|
||||
})
|
||||
|
||||
const settings = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
titleSuggestions: true,
|
||||
semanticSearch: false,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'weekly',
|
||||
aiProvider: 'ollama',
|
||||
preferredLanguage: 'fr'
|
||||
}
|
||||
})
|
||||
|
||||
expect(settings.userId).toBe(user.id)
|
||||
expect(settings.titleSuggestions).toBe(true)
|
||||
expect(settings.semanticSearch).toBe(false)
|
||||
expect(settings.memoryEchoFrequency).toBe('weekly')
|
||||
expect(settings.aiProvider).toBe('ollama')
|
||||
expect(settings.preferredLanguage).toBe('fr')
|
||||
})
|
||||
|
||||
test('should handle default values correctly', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'default-ai-settings@test.com',
|
||||
name: 'Default AI User'
|
||||
}
|
||||
})
|
||||
|
||||
const settings = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: user.id
|
||||
// All other fields should use defaults
|
||||
}
|
||||
})
|
||||
|
||||
expect(settings.titleSuggestions).toBe(true)
|
||||
expect(settings.semanticSearch).toBe(true)
|
||||
expect(settings.memoryEcho).toBe(true)
|
||||
expect(settings.memoryEchoFrequency).toBe('daily')
|
||||
expect(settings.aiProvider).toBe('auto')
|
||||
expect(settings.preferredLanguage).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Integrity', () => {
|
||||
test('should verify no data loss after migration', async () => {
|
||||
// Create initial data
|
||||
const initialNotes = await createSampleNotes(prisma, 50)
|
||||
const initialCount = initialNotes.length
|
||||
|
||||
// Simulate migration by querying data
|
||||
const notesAfterMigration = await prisma.note.findMany()
|
||||
const finalCount = notesAfterMigration.length
|
||||
|
||||
// Verify no data loss
|
||||
expect(finalCount).toBe(initialCount)
|
||||
|
||||
// Verify each note's data is intact
|
||||
for (const note of notesAfterMigration) {
|
||||
expect(note.title).toBeDefined()
|
||||
expect(note.content).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('should verify no data corruption after migration', async () => {
|
||||
// Create notes with complex data
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: 'Complex Data Note',
|
||||
content: 'This is a note with **markdown** formatting',
|
||||
checkItems: JSON.stringify([{ text: 'Task 1', done: false }, { text: 'Task 2', done: true }]),
|
||||
images: JSON.stringify([{ url: 'image1.jpg', caption: 'Caption 1' }]),
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { title: 'Complex Data Note' }
|
||||
})
|
||||
|
||||
// Verify complex data is preserved
|
||||
expect(note?.content).toContain('**markdown**')
|
||||
|
||||
if (note?.checkItems) {
|
||||
const checkItems = note.checkItems as any[]
|
||||
expect(checkItems.length).toBe(2)
|
||||
}
|
||||
|
||||
if (note?.images) {
|
||||
const images = note.images as any[]
|
||||
expect(images.length).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
test('should maintain foreign key relationships', async () => {
|
||||
// Create a user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'fk-test@test.com',
|
||||
name: 'FK Test User'
|
||||
}
|
||||
})
|
||||
|
||||
// Create a notebook for the user
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: 'FK Test Notebook',
|
||||
order: 0,
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Create notes in the notebook
|
||||
await prisma.note.createMany({
|
||||
data: [
|
||||
{
|
||||
title: 'FK Note 1',
|
||||
content: 'Content 1',
|
||||
userId: user.id,
|
||||
notebookId: notebook.id
|
||||
},
|
||||
{
|
||||
title: 'FK Note 2',
|
||||
content: 'Content 2',
|
||||
userId: user.id,
|
||||
notebookId: notebook.id
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Verify relationships are maintained
|
||||
const retrievedNotebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebook.id },
|
||||
include: { notes: true }
|
||||
})
|
||||
|
||||
expect(retrievedNotebook?.notes.length).toBe(2)
|
||||
expect(retrievedNotebook?.userId).toBe(user.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('should handle empty strings in text fields', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: '',
|
||||
content: 'Content with empty title',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.title).toBe('')
|
||||
expect(note.content).toBe('Content with empty title')
|
||||
})
|
||||
|
||||
test('should handle very long text content', async () => {
|
||||
const longContent = 'A'.repeat(10000)
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Long Content Note',
|
||||
content: longContent,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.content).toHaveLength(10000)
|
||||
})
|
||||
|
||||
test('should handle special characters in text fields', async () => {
|
||||
const specialChars = 'Note with émojis 🎉 and spëcial çharacters & spåcial ñumbers 123'
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: specialChars,
|
||||
content: specialChars,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.title).toBe(specialChars)
|
||||
expect(note.content).toBe(specialChars)
|
||||
})
|
||||
|
||||
test('should handle null userId in some tables (optional relationships)', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'No User Note',
|
||||
content: 'Note without userId',
|
||||
userId: null
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.userId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Migration Performance', () => {
|
||||
test('should complete migration within acceptable time for 100 notes', async () => {
|
||||
// Clean up
|
||||
await prisma.note.deleteMany({})
|
||||
|
||||
// Create 100 notes and measure time
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
await createSampleNotes(prisma, 100)
|
||||
})
|
||||
|
||||
// Migration should complete quickly (< 5 seconds for 100 notes)
|
||||
expect(duration).toBeLessThan(5000)
|
||||
expect(result.length).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Batch Operations', () => {
|
||||
test('should handle batch insert of notes', async () => {
|
||||
const notesData = Array.from({ length: 20 }, (_, i) => ({
|
||||
title: `Batch Note ${i + 1}`,
|
||||
content: `Batch content ${i + 1}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({
|
||||
data: notesData
|
||||
})
|
||||
|
||||
const count = await prisma.note.count()
|
||||
expect(count).toBe(20)
|
||||
})
|
||||
|
||||
test('should handle batch insert of feedback', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Batch Feedback Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedbackData = Array.from({ length: 10 }, (_, i) => ({
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: `Feedback ${i + 1}`
|
||||
}))
|
||||
|
||||
await prisma.aiFeedback.createMany({
|
||||
data: feedbackData
|
||||
})
|
||||
|
||||
const count = await prisma.aiFeedback.count()
|
||||
expect(count).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
721
memento-note/tests/migration/integrity.test.ts
Normal file
721
memento-note/tests/migration/integrity.test.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* Data Integrity Tests
|
||||
* Validates that data is preserved and not corrupted during migration
|
||||
* Tests data loss prevention, foreign key relationships, and indexes
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
setupTestEnvironment,
|
||||
createTestPrismaClient,
|
||||
initializeTestDatabase,
|
||||
cleanupTestDatabase,
|
||||
createSampleNotes,
|
||||
verifyDataIntegrity
|
||||
} from './setup'
|
||||
|
||||
describe('Data Integrity Tests', () => {
|
||||
let prisma: PrismaClient
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestEnvironment()
|
||||
prisma = createTestPrismaClient()
|
||||
await initializeTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDatabase(prisma)
|
||||
})
|
||||
|
||||
describe('No Data Loss', () => {
|
||||
test('should preserve all notes after migration', async () => {
|
||||
// Create test notes
|
||||
const initialNotes = await createSampleNotes(prisma, 50)
|
||||
const initialCount = initialNotes.length
|
||||
|
||||
// Query after migration
|
||||
const notesAfterMigration = await prisma.note.findMany()
|
||||
const finalCount = notesAfterMigration.length
|
||||
|
||||
// Verify no data loss
|
||||
expect(finalCount).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('should preserve note titles', async () => {
|
||||
const testTitles = [
|
||||
'Important Meeting Notes',
|
||||
'Shopping List',
|
||||
'Project Ideas',
|
||||
'Recipe Collection',
|
||||
'Book Reviews'
|
||||
]
|
||||
|
||||
// Create notes with specific titles
|
||||
for (const title of testTitles) {
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title,
|
||||
content: `Content for ${title}`,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify all titles are preserved
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { title: { in: testTitles } }
|
||||
})
|
||||
|
||||
const preservedTitles = notes.map(n => n.title)
|
||||
for (const title of testTitles) {
|
||||
expect(preservedTitles).toContain(title)
|
||||
}
|
||||
})
|
||||
|
||||
test('should preserve note content', async () => {
|
||||
const testContent = [
|
||||
'This is a simple text note',
|
||||
'Note with **markdown** formatting',
|
||||
'Note with [links](https://example.com)',
|
||||
'Note with numbers: 1, 2, 3, 4, 5',
|
||||
'Note with special characters: émojis 🎉 & çharacters'
|
||||
]
|
||||
|
||||
// Create notes with specific content
|
||||
for (const content of testContent) {
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: `Content Test`,
|
||||
content,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify all content is preserved
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { title: 'Content Test' }
|
||||
})
|
||||
|
||||
const preservedContent = notes.map(n => n.content)
|
||||
for (const content of testContent) {
|
||||
expect(preservedContent).toContain(content)
|
||||
}
|
||||
})
|
||||
|
||||
test('should preserve note metadata', async () => {
|
||||
// Create note with all metadata
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Metadata Test Note',
|
||||
content: 'Test content',
|
||||
color: 'red',
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
type: 'text',
|
||||
size: 'large',
|
||||
userId: 'test-user-id',
|
||||
order: 5
|
||||
}
|
||||
})
|
||||
|
||||
// Verify metadata is preserved
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.color).toBe('red')
|
||||
expect(retrieved?.isPinned).toBe(true)
|
||||
expect(retrieved?.isArchived).toBe(false)
|
||||
expect(retrieved?.type).toBe('text')
|
||||
expect(retrieved?.size).toBe('large')
|
||||
expect(retrieved?.order).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('No Data Corruption', () => {
|
||||
test('should preserve checkItems JSON structure', async () => {
|
||||
const checkItems = JSON.stringify([
|
||||
{ text: 'Buy groceries', done: false },
|
||||
{ text: 'Call dentist', done: true },
|
||||
{ text: 'Finish report', done: false },
|
||||
{ text: 'Schedule meeting', done: false }
|
||||
])
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Checklist Test Note',
|
||||
content: 'My checklist',
|
||||
checkItems,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify checkItems are preserved and can be parsed
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.checkItems).toBeDefined()
|
||||
|
||||
const parsedCheckItems = JSON.parse(retrieved?.checkItems || '[]')
|
||||
expect(parsedCheckItems.length).toBe(4)
|
||||
expect(parsedCheckItems[0].text).toBe('Buy groceries')
|
||||
expect(parsedCheckItems[0].done).toBe(false)
|
||||
expect(parsedCheckItems[1].done).toBe(true)
|
||||
})
|
||||
|
||||
test('should preserve images JSON structure', async () => {
|
||||
const images = JSON.stringify([
|
||||
{ url: 'image1.jpg', caption: 'First image' },
|
||||
{ url: 'image2.jpg', caption: 'Second image' },
|
||||
{ url: 'image3.jpg', caption: 'Third image' }
|
||||
])
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Images Test Note',
|
||||
content: 'Note with images',
|
||||
images,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify images are preserved and can be parsed
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.images).toBeDefined()
|
||||
|
||||
const parsedImages = JSON.parse(retrieved?.images || '[]')
|
||||
expect(parsedImages.length).toBe(3)
|
||||
expect(parsedImages[0].url).toBe('image1.jpg')
|
||||
expect(parsedImages[0].caption).toBe('First image')
|
||||
})
|
||||
|
||||
test('should preserve labels JSON structure', async () => {
|
||||
const labels = JSON.stringify(['work', 'important', 'project'])
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Labels Test Note',
|
||||
content: 'Note with labels',
|
||||
labels,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify labels are preserved and can be parsed
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.labels).toBeDefined()
|
||||
|
||||
const parsedLabels = JSON.parse(retrieved?.labels || '[]')
|
||||
expect(parsedLabels.length).toBe(3)
|
||||
expect(parsedLabels).toContain('work')
|
||||
expect(parsedLabels).toContain('important')
|
||||
expect(parsedLabels).toContain('project')
|
||||
})
|
||||
|
||||
test('should preserve embedding JSON structure', async () => {
|
||||
const embedding = JSON.stringify({
|
||||
vector: [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||
model: 'text-embedding-ada-002',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Embedding Test Note',
|
||||
content: 'Note with embedding',
|
||||
embedding,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify embedding is preserved and can be parsed
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.embedding).toBeDefined()
|
||||
|
||||
const parsedEmbedding = JSON.parse(retrieved?.embedding || '{}')
|
||||
expect(parsedEmbedding.vector).toEqual([0.1, 0.2, 0.3, 0.4, 0.5])
|
||||
expect(parsedEmbedding.model).toBe('text-embedding-ada-002')
|
||||
})
|
||||
|
||||
test('should preserve links JSON structure', async () => {
|
||||
const links = JSON.stringify([
|
||||
{ url: 'https://example.com', title: 'Example' },
|
||||
{ url: 'https://docs.example.com', title: 'Documentation' }
|
||||
])
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Links Test Note',
|
||||
content: 'Note with links',
|
||||
links,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify links are preserved and can be parsed
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.links).toBeDefined()
|
||||
|
||||
const parsedLinks = JSON.parse(retrieved?.links || '[]')
|
||||
expect(parsedLinks.length).toBe(2)
|
||||
expect(parsedLinks[0].url).toBe('https://example.com')
|
||||
expect(parsedLinks[0].title).toBe('Example')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Foreign Key Relationships', () => {
|
||||
test('should maintain Note to User relationship', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'fk-integrity@test.com',
|
||||
name: 'FK Integrity User'
|
||||
}
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'User Integrity Note',
|
||||
content: 'Test content',
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
// Verify relationship is maintained
|
||||
expect(note.userId).toBe(user.id)
|
||||
|
||||
// Query user's notes
|
||||
const userNotes = await prisma.note.findMany({
|
||||
where: { userId: user.id }
|
||||
})
|
||||
|
||||
expect(userNotes.length).toBeGreaterThan(0)
|
||||
expect(userNotes.some(n => n.id === note.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('should maintain Note to Notebook relationship', async () => {
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: 'Integrity Notebook',
|
||||
order: 0,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Notebook Integrity Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
notebookId: notebook.id
|
||||
}
|
||||
})
|
||||
|
||||
// Verify relationship is maintained
|
||||
expect(note.notebookId).toBe(notebook.id)
|
||||
|
||||
// Query notebook's notes
|
||||
const notebookNotes = await prisma.note.findMany({
|
||||
where: { notebookId: notebook.id }
|
||||
})
|
||||
|
||||
expect(notebookNotes.length).toBeGreaterThan(0)
|
||||
expect(notebookNotes.some(n => n.id === note.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('should maintain AiFeedback to Note relationship', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feedback Integrity Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify relationship is maintained
|
||||
expect(feedback.noteId).toBe(note.id)
|
||||
|
||||
// Query note's feedback
|
||||
const noteFeedback = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
|
||||
expect(noteFeedback.length).toBeGreaterThan(0)
|
||||
expect(noteFeedback.some(f => f.id === feedback.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('should maintain AiFeedback to User relationship', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'feedback-user@test.com',
|
||||
name: 'Feedback User'
|
||||
}
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'User Feedback Note',
|
||||
content: 'Test content',
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify relationship is maintained
|
||||
expect(feedback.userId).toBe(user.id)
|
||||
|
||||
// Query user's feedback
|
||||
const userFeedback = await prisma.aiFeedback.findMany({
|
||||
where: { userId: user.id }
|
||||
})
|
||||
|
||||
expect(userFeedback.length).toBeGreaterThan(0)
|
||||
expect(userFeedback.some(f => f.id === feedback.id)).toBe(true)
|
||||
})
|
||||
|
||||
test('should maintain cascade delete correctly', async () => {
|
||||
// Create a note with feedback
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Cascade Delete Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feedback exists
|
||||
const feedbacksBefore = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(feedbacksBefore.length).toBe(1)
|
||||
|
||||
// Delete note
|
||||
await prisma.note.delete({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
// Verify feedback is cascade deleted
|
||||
const feedbacksAfter = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(feedbacksAfter.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Integrity', () => {
|
||||
test('should maintain index on Note.isPinned', async () => {
|
||||
// Create notes with various pinned states
|
||||
await prisma.note.createMany({
|
||||
data: [
|
||||
{ title: 'Pinned 1', content: 'Content 1', userId: 'test-user-id', isPinned: true },
|
||||
{ title: 'Not Pinned 1', content: 'Content 2', userId: 'test-user-id', isPinned: false },
|
||||
{ title: 'Pinned 2', content: 'Content 3', userId: 'test-user-id', isPinned: true },
|
||||
{ title: 'Not Pinned 2', content: 'Content 4', userId: 'test-user-id', isPinned: false }
|
||||
]
|
||||
})
|
||||
|
||||
// Query by isPinned (should use index)
|
||||
const pinnedNotes = await prisma.note.findMany({
|
||||
where: { isPinned: true }
|
||||
})
|
||||
|
||||
expect(pinnedNotes.length).toBe(2)
|
||||
expect(pinnedNotes.every(n => n.isPinned === true)).toBe(true)
|
||||
})
|
||||
|
||||
test('should maintain index on Note.order', async () => {
|
||||
// Create notes with specific order
|
||||
await prisma.note.createMany({
|
||||
data: [
|
||||
{ title: 'Order 0', content: 'Content 0', userId: 'test-user-id', order: 0 },
|
||||
{ title: 'Order 1', content: 'Content 1', userId: 'test-user-id', order: 1 },
|
||||
{ title: 'Order 2', content: 'Content 2', userId: 'test-user-id', order: 2 },
|
||||
{ title: 'Order 3', content: 'Content 3', userId: 'test-user-id', order: 3 }
|
||||
]
|
||||
})
|
||||
|
||||
// Query ordered by order (should use index)
|
||||
const orderedNotes = await prisma.note.findMany({
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
|
||||
expect(orderedNotes[0].order).toBe(0)
|
||||
expect(orderedNotes[1].order).toBe(1)
|
||||
expect(orderedNotes[2].order).toBe(2)
|
||||
expect(orderedNotes[3].order).toBe(3)
|
||||
})
|
||||
|
||||
test('should maintain index on AiFeedback.noteId', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Index Feedback Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create multiple feedback entries
|
||||
await prisma.aiFeedback.createMany({
|
||||
data: [
|
||||
{ noteId: note.id, userId: 'test-user-id', feedbackType: 'thumbs_up', feature: 'title_suggestion', originalContent: 'Feedback 1' },
|
||||
{ noteId: note.id, userId: 'test-user-id', feedbackType: 'thumbs_down', feature: 'semantic_search', originalContent: 'Feedback 2' },
|
||||
{ noteId: note.id, userId: 'test-user-id', feedbackType: 'thumbs_up', feature: 'paragraph_refactor', originalContent: 'Feedback 3' }
|
||||
]
|
||||
})
|
||||
|
||||
// Query by noteId (should use index)
|
||||
const feedbacks = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
|
||||
expect(feedbacks.length).toBe(3)
|
||||
})
|
||||
|
||||
test('should maintain index on AiFeedback.userId', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'User Index Feedback Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create multiple feedback entries for same user
|
||||
await prisma.aiFeedback.createMany({
|
||||
data: [
|
||||
{ noteId: note.id, userId: 'test-user-id', feedbackType: 'thumbs_up', feature: 'title_suggestion', originalContent: 'Feedback 1' },
|
||||
{ noteId: note.id, userId: 'test-user-id', feedbackType: 'thumbs_down', feature: 'semantic_search', originalContent: 'Feedback 2' }
|
||||
]
|
||||
})
|
||||
|
||||
// Query by userId (should use index)
|
||||
const userFeedbacks = await prisma.aiFeedback.findMany({
|
||||
where: { userId: 'test-user-id' }
|
||||
})
|
||||
|
||||
expect(userFeedbacks.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('should maintain index on AiFeedback.feature', async () => {
|
||||
const note1 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feature Index Note 1',
|
||||
content: 'Test content 1',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const note2 = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feature Index Note 2',
|
||||
content: 'Test content 2',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create feedback with same feature
|
||||
await prisma.aiFeedback.createMany({
|
||||
data: [
|
||||
{ noteId: note1.id, userId: 'test-user-id', feedbackType: 'thumbs_up', feature: 'title_suggestion', originalContent: 'Feedback 1' },
|
||||
{ noteId: note2.id, userId: 'test-user-id', feedbackType: 'thumbs_up', feature: 'title_suggestion', originalContent: 'Feedback 2' }
|
||||
]
|
||||
})
|
||||
|
||||
// Query by feature (should use index)
|
||||
const titleFeedbacks = await prisma.aiFeedback.findMany({
|
||||
where: { feature: 'title_suggestion' }
|
||||
})
|
||||
|
||||
expect(titleFeedbacks.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Fields Integrity', () => {
|
||||
test('should preserve AI fields correctly', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'AI Fields Integrity Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'openai',
|
||||
aiConfidence: 95,
|
||||
language: 'en',
|
||||
languageConfidence: 0.98,
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
// Verify all AI fields are preserved
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.autoGenerated).toBe(true)
|
||||
expect(retrieved?.aiProvider).toBe('openai')
|
||||
expect(retrieved?.aiConfidence).toBe(95)
|
||||
expect(retrieved?.language).toBe('en')
|
||||
expect(retrieved?.languageConfidence).toBeCloseTo(0.98)
|
||||
expect(retrieved?.lastAiAnalysis).toBeDefined()
|
||||
})
|
||||
|
||||
test('should preserve null AI fields correctly', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Null AI Fields Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
// All AI fields are null by default
|
||||
}
|
||||
})
|
||||
|
||||
// Verify all AI fields are null
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.autoGenerated).toBeNull()
|
||||
expect(retrieved?.aiProvider).toBeNull()
|
||||
expect(retrieved?.aiConfidence).toBeNull()
|
||||
expect(retrieved?.language).toBeNull()
|
||||
expect(retrieved?.languageConfidence).toBeNull()
|
||||
expect(retrieved?.lastAiAnalysis).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Batch Operations Integrity', () => {
|
||||
test('should preserve data integrity during batch insert', async () => {
|
||||
const notesData = Array.from({ length: 50 }, (_, i) => ({
|
||||
title: `Batch Integrity Note ${i}`,
|
||||
content: `Content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i,
|
||||
isPinned: i % 2 === 0
|
||||
}))
|
||||
|
||||
const result = await prisma.note.createMany({
|
||||
data: notesData
|
||||
})
|
||||
|
||||
expect(result.count).toBe(50)
|
||||
|
||||
// Verify all notes are created correctly
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { title: { contains: 'Batch Integrity Note' } }
|
||||
})
|
||||
|
||||
expect(notes.length).toBe(50)
|
||||
|
||||
// Verify data integrity
|
||||
for (const note of notes) {
|
||||
expect(note.content).toBeDefined()
|
||||
expect(note.userId).toBe('test-user-id')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Type Integrity', () => {
|
||||
test('should preserve boolean values correctly', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Boolean Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
isPinned: true,
|
||||
isArchived: false
|
||||
}
|
||||
})
|
||||
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.isPinned).toBe(true)
|
||||
expect(retrieved?.isArchived).toBe(false)
|
||||
})
|
||||
|
||||
test('should preserve numeric values correctly', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Numeric Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
order: 42,
|
||||
aiConfidence: 87,
|
||||
languageConfidence: 0.95
|
||||
}
|
||||
})
|
||||
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.order).toBe(42)
|
||||
expect(retrieved?.aiConfidence).toBe(87)
|
||||
expect(retrieved?.languageConfidence).toBeCloseTo(0.95)
|
||||
})
|
||||
|
||||
test('should preserve date values correctly', async () => {
|
||||
const testDate = new Date('2024-01-15T10:30:00Z')
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Date Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
reminder: testDate,
|
||||
lastAiAnalysis: testDate
|
||||
}
|
||||
})
|
||||
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.reminder).toBeDefined()
|
||||
expect(retrieved?.lastAiAnalysis).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
573
memento-note/tests/migration/performance.test.ts
Normal file
573
memento-note/tests/migration/performance.test.ts
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Performance Tests
|
||||
* Validates that migrations complete within acceptable time limits
|
||||
* Tests scalability with various data sizes
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
setupTestEnvironment,
|
||||
createTestPrismaClient,
|
||||
initializeTestDatabase,
|
||||
cleanupTestDatabase,
|
||||
createSampleNotes,
|
||||
measureExecutionTime,
|
||||
getDatabaseSize
|
||||
} from './setup'
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
let prisma: PrismaClient
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestEnvironment()
|
||||
prisma = createTestPrismaClient()
|
||||
await initializeTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDatabase(prisma)
|
||||
})
|
||||
|
||||
describe('Empty Migration Performance', () => {
|
||||
test('should complete empty database migration quickly', async () => {
|
||||
// Clean up any existing data
|
||||
await prisma.note.deleteMany({})
|
||||
|
||||
// Measure time to "migrate" empty database
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
const noteCount = await prisma.note.count()
|
||||
return { count: noteCount }
|
||||
})
|
||||
|
||||
// Empty migration should complete instantly (< 1 second)
|
||||
expect(duration).toBeLessThan(1000)
|
||||
expect(result.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Small Dataset Performance (10 notes)', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.note.deleteMany({})
|
||||
})
|
||||
|
||||
test('should complete migration for 10 notes within 1 second', async () => {
|
||||
// Create 10 notes
|
||||
const { result: notes, duration: createDuration } = await measureExecutionTime(async () => {
|
||||
return await createSampleNotes(prisma, 10)
|
||||
})
|
||||
|
||||
expect(notes.length).toBe(10)
|
||||
expect(createDuration).toBeLessThan(1000)
|
||||
|
||||
// Measure query performance
|
||||
const { result, duration: queryDuration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany()
|
||||
})
|
||||
|
||||
expect(result.length).toBe(10)
|
||||
expect(queryDuration).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test('should complete create operation for 10 notes within 1 second', async () => {
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
const notes = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `Perf Test Note ${i}`,
|
||||
content: `Test content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}
|
||||
})
|
||||
notes.push(note)
|
||||
}
|
||||
return notes
|
||||
})
|
||||
|
||||
expect(result.length).toBe(10)
|
||||
expect(duration).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
test('should complete update operation for 10 notes within 1 second', async () => {
|
||||
// Create notes first
|
||||
await createSampleNotes(prisma, 10)
|
||||
|
||||
// Measure update performance
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.updateMany({
|
||||
data: { isPinned: true },
|
||||
where: { title: { contains: 'Test Note' } }
|
||||
})
|
||||
})
|
||||
|
||||
expect(duration).toBeLessThan(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Medium Dataset Performance (100 notes)', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.note.deleteMany({})
|
||||
})
|
||||
|
||||
test('should complete migration for 100 notes within 5 seconds', async () => {
|
||||
// Create 100 notes
|
||||
const { result: notes, duration: createDuration } = await measureExecutionTime(async () => {
|
||||
return await createSampleNotes(prisma, 100)
|
||||
})
|
||||
|
||||
expect(notes.length).toBe(100)
|
||||
expect(createDuration).toBeLessThan(5000)
|
||||
|
||||
// Measure query performance
|
||||
const { result, duration: queryDuration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany()
|
||||
})
|
||||
|
||||
expect(result.length).toBe(100)
|
||||
expect(queryDuration).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
test('should complete create operation for 100 notes within 5 seconds', async () => {
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
const notes = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `Perf Test Note ${i}`,
|
||||
content: `Test content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}
|
||||
})
|
||||
notes.push(note)
|
||||
}
|
||||
return notes
|
||||
})
|
||||
|
||||
expect(result.length).toBe(100)
|
||||
expect(duration).toBeLessThan(5000)
|
||||
})
|
||||
|
||||
test('should complete batch insert for 100 notes within 2 seconds', async () => {
|
||||
const notesData = Array.from({ length: 100 }, (_, i) => ({
|
||||
title: `Batch Note ${i}`,
|
||||
content: `Batch content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.createMany({
|
||||
data: notesData
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.count).toBe(100)
|
||||
expect(duration).toBeLessThan(2000)
|
||||
})
|
||||
|
||||
test('should complete filtered query for 100 notes within 500ms', async () => {
|
||||
await createSampleNotes(prisma, 100)
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
where: {
|
||||
isPinned: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(duration).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Target Dataset Performance (1,000 notes)', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.note.deleteMany({})
|
||||
})
|
||||
|
||||
test('should complete migration for 1,000 notes within 30 seconds', async () => {
|
||||
// Create 1,000 notes in batches for better performance
|
||||
const { result: notes, duration: createDuration } = await measureExecutionTime(async () => {
|
||||
const allNotes = []
|
||||
const batchSize = 100
|
||||
const totalNotes = 1000
|
||||
|
||||
for (let batch = 0; batch < totalNotes / batchSize; batch++) {
|
||||
const batchData = Array.from({ length: batchSize }, (_, i) => ({
|
||||
title: `Perf Note ${batch * batchSize + i}`,
|
||||
content: `Test content ${batch * batchSize + i}`,
|
||||
userId: 'test-user-id',
|
||||
order: batch * batchSize + i
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: batchData })
|
||||
}
|
||||
|
||||
return await prisma.note.findMany()
|
||||
})
|
||||
|
||||
expect(notes.length).toBe(1000)
|
||||
expect(createDuration).toBeLessThan(30000)
|
||||
})
|
||||
|
||||
test('should complete batch insert for 1,000 notes within 10 seconds', async () => {
|
||||
const notesData = Array.from({ length: 1000 }, (_, i) => ({
|
||||
title: `Batch Note ${i}`,
|
||||
content: `Batch content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.createMany({
|
||||
data: notesData
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.count).toBe(1000)
|
||||
expect(duration).toBeLessThan(10000)
|
||||
})
|
||||
|
||||
test('should complete query for 1,000 notes within 1 second', async () => {
|
||||
const notesData = Array.from({ length: 1000 }, (_, i) => ({
|
||||
title: `Query Test Note ${i}`,
|
||||
content: `Query test content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: notesData })
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany()
|
||||
})
|
||||
|
||||
expect(result.length).toBe(1000)
|
||||
expect(duration).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
test('should complete filtered query for 1,000 notes within 1 second', async () => {
|
||||
// Create notes with various pinned states
|
||||
const notesData = Array.from({ length: 1000 }, (_, i) => ({
|
||||
title: `Filter Test Note ${i}`,
|
||||
content: `Filter test content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i,
|
||||
isPinned: i % 3 === 0 // Every 3rd note is pinned
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: notesData })
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
where: {
|
||||
isPinned: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(duration).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
test('should complete indexed query for 1,000 notes within 500ms', async () => {
|
||||
// Create notes
|
||||
const notesData = Array.from({ length: 1000 }, (_, i) => ({
|
||||
title: `Index Test Note ${i}`,
|
||||
content: `Index test content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i,
|
||||
isPinned: i % 2 === 0
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: notesData })
|
||||
|
||||
// Query using indexed field (isPinned)
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
where: {
|
||||
isPinned: true
|
||||
},
|
||||
orderBy: {
|
||||
order: 'asc'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(duration).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stress Test Performance (10,000 notes)', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.note.deleteMany({})
|
||||
})
|
||||
|
||||
test('should complete batch insert for 10,000 notes within 30 seconds', async () => {
|
||||
const notesData = Array.from({ length: 10000 }, (_, i) => ({
|
||||
title: `Stress Note ${i}`,
|
||||
content: `Stress test content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.createMany({
|
||||
data: notesData
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.count).toBe(10000)
|
||||
expect(duration).toBeLessThan(30000)
|
||||
})
|
||||
|
||||
test('should complete query for 10,000 notes within 2 seconds', async () => {
|
||||
const notesData = Array.from({ length: 10000 }, (_, i) => ({
|
||||
title: `Stress Query Note ${i}`,
|
||||
content: `Stress query content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: notesData })
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
take: 100 // Limit to 100 for performance
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.length).toBe(100)
|
||||
expect(duration).toBeLessThan(2000)
|
||||
})
|
||||
|
||||
test('should handle pagination for 10,000 notes efficiently', async () => {
|
||||
const notesData = Array.from({ length: 10000 }, (_, i) => ({
|
||||
title: `Pagination Note ${i}`,
|
||||
content: `Pagination content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: notesData })
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
skip: 100,
|
||||
take: 50
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.length).toBe(50)
|
||||
expect(duration).toBeLessThan(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Features Performance', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.note.deleteMany({})
|
||||
await prisma.aiFeedback.deleteMany({})
|
||||
})
|
||||
|
||||
test('should create AI-enabled notes efficiently (100 notes)', async () => {
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
const notes = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `AI Note ${i}`,
|
||||
content: `AI content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: i % 2 === 0,
|
||||
aiProvider: i % 3 === 0 ? 'openai' : 'ollama',
|
||||
aiConfidence: 70 + i,
|
||||
language: i % 2 === 0 ? 'en' : 'fr',
|
||||
languageConfidence: 0.85 + (i * 0.001),
|
||||
lastAiAnalysis: new Date(),
|
||||
order: i
|
||||
}
|
||||
})
|
||||
notes.push(note)
|
||||
}
|
||||
return notes
|
||||
})
|
||||
|
||||
expect(result.length).toBe(100)
|
||||
expect(duration).toBeLessThan(5000)
|
||||
})
|
||||
|
||||
test('should query by AI fields efficiently (100 notes)', async () => {
|
||||
// Create AI notes
|
||||
const notes = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `AI Query Note ${i}`,
|
||||
content: `Content ${i}`,
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: i % 2 === 0,
|
||||
aiProvider: i % 3 === 0 ? 'openai' : 'ollama',
|
||||
order: i
|
||||
}
|
||||
})
|
||||
notes.push(note)
|
||||
}
|
||||
|
||||
// Query by autoGenerated
|
||||
const { result: autoGenerated, duration: duration1 } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
where: { autoGenerated: true }
|
||||
})
|
||||
})
|
||||
|
||||
expect(autoGenerated.length).toBeGreaterThan(0)
|
||||
expect(duration1).toBeLessThan(500)
|
||||
|
||||
// Query by aiProvider
|
||||
const { result: openaiNotes, duration: duration2 } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.findMany({
|
||||
where: { aiProvider: 'openai' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(openaiNotes.length).toBeGreaterThan(0)
|
||||
expect(duration2).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test('should create AI feedback efficiently (100 feedback entries)', async () => {
|
||||
// Create a note
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feedback Performance Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
const feedbacks = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: i % 3 === 0 ? 'thumbs_up' : 'thumbs_down',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: `Feedback ${i}`,
|
||||
metadata: JSON.stringify({
|
||||
aiProvider: 'openai',
|
||||
confidence: 70 + i,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
feedbacks.push(feedback)
|
||||
}
|
||||
return feedbacks
|
||||
})
|
||||
|
||||
expect(result.length).toBe(100)
|
||||
expect(duration).toBeLessThan(5000)
|
||||
})
|
||||
|
||||
test('should query feedback by note efficiently (100 feedback entries)', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feedback Query Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create feedback
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: `Feedback ${i}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Query by noteId (should use index)
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.length).toBe(100)
|
||||
expect(duration).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Database Size Performance', () => {
|
||||
test('should track database size growth', async () => {
|
||||
// Get initial size
|
||||
const initialSize = await getDatabaseSize(prisma)
|
||||
|
||||
// Add 100 notes
|
||||
const notesData = Array.from({ length: 100 }, (_, i) => ({
|
||||
title: `Size Test Note ${i}`,
|
||||
content: `Size test content ${i}`.repeat(10), // Larger content
|
||||
userId: 'test-user-id',
|
||||
order: i
|
||||
}))
|
||||
|
||||
await prisma.note.createMany({ data: notesData })
|
||||
|
||||
// Get size after adding notes
|
||||
const sizeAfter = await getDatabaseSize(prisma)
|
||||
|
||||
// Database should have grown
|
||||
expect(sizeAfter).toBeGreaterThan(initialSize)
|
||||
})
|
||||
|
||||
test('should handle large content efficiently', async () => {
|
||||
const largeContent = 'A'.repeat(10000) // 10KB per note
|
||||
|
||||
const { result, duration } = await measureExecutionTime(async () => {
|
||||
return await prisma.note.create({
|
||||
data: {
|
||||
title: 'Large Content Note',
|
||||
content: largeContent,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.content).toHaveLength(10000)
|
||||
expect(duration).toBeLessThan(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Concurrent Operations Performance', () => {
|
||||
test('should handle multiple concurrent reads', async () => {
|
||||
// Create test data
|
||||
await createSampleNotes(prisma, 100)
|
||||
|
||||
// Measure concurrent read performance
|
||||
const { duration } = await measureExecutionTime(async () => {
|
||||
const promises = [
|
||||
prisma.note.findMany({ take: 10 }),
|
||||
prisma.note.findMany({ take: 10, skip: 10 }),
|
||||
prisma.note.findMany({ take: 10, skip: 20 }),
|
||||
prisma.note.findMany({ take: 10, skip: 30 }),
|
||||
prisma.note.findMany({ take: 10, skip: 40 })
|
||||
]
|
||||
|
||||
await Promise.all(promises)
|
||||
})
|
||||
|
||||
// All concurrent reads should complete quickly
|
||||
expect(duration).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
})
|
||||
417
memento-note/tests/migration/rollback.test.ts
Normal file
417
memento-note/tests/migration/rollback.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Rollback Tests
|
||||
* Validates that migrations can be safely rolled back
|
||||
* Tests schema rollback, data recovery, and cleanup
|
||||
* Updated for PostgreSQL
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
createTestPrismaClient,
|
||||
initializeTestDatabase,
|
||||
cleanupTestDatabase,
|
||||
createSampleNotes,
|
||||
createSampleAINotes,
|
||||
verifyTableExists,
|
||||
verifyColumnExists,
|
||||
} from './setup'
|
||||
|
||||
describe('Rollback Tests', () => {
|
||||
let prisma: PrismaClient
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrismaClient()
|
||||
await initializeTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDatabase(prisma)
|
||||
})
|
||||
|
||||
describe('Schema Rollback', () => {
|
||||
test('should verify schema state before migration', async () => {
|
||||
const hasUser = await verifyTableExists(prisma, 'User')
|
||||
expect(hasUser).toBe(true)
|
||||
})
|
||||
|
||||
test('should verify AI tables exist after migration', async () => {
|
||||
const hasAiFeedback = await verifyTableExists(prisma, 'AiFeedback')
|
||||
expect(hasAiFeedback).toBe(true)
|
||||
|
||||
const hasMemoryEcho = await verifyTableExists(prisma, 'MemoryEchoInsight')
|
||||
expect(hasMemoryEcho).toBe(true)
|
||||
|
||||
const hasUserAISettings = await verifyTableExists(prisma, 'UserAISettings')
|
||||
expect(hasUserAISettings).toBe(true)
|
||||
})
|
||||
|
||||
test('should verify Note AI columns exist after migration', async () => {
|
||||
const aiColumns = ['autoGenerated', 'aiProvider', 'aiConfidence', 'language', 'languageConfidence', 'lastAiAnalysis']
|
||||
|
||||
for (const column of aiColumns) {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', column)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('should simulate dropping AI columns (rollback scenario)', async () => {
|
||||
// In PostgreSQL, ALTER TABLE DROP COLUMN works directly
|
||||
// This test verifies we can identify which columns would be dropped
|
||||
const aiColumns = ['autoGenerated', 'aiProvider', 'aiConfidence', 'language', 'languageConfidence', 'lastAiAnalysis']
|
||||
|
||||
for (const column of aiColumns) {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', column)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('should simulate dropping AI tables (rollback scenario)', async () => {
|
||||
const aiTables = ['AiFeedback', 'MemoryEchoInsight', 'UserAISettings']
|
||||
|
||||
for (const table of aiTables) {
|
||||
const exists = await verifyTableExists(prisma, table)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Recovery After Rollback', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up before each test
|
||||
await prisma.aiFeedback.deleteMany({})
|
||||
await prisma.note.deleteMany({})
|
||||
})
|
||||
|
||||
test('should preserve basic note data if AI columns are dropped', async () => {
|
||||
const noteWithAI = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Note with AI',
|
||||
content: 'This note has AI fields',
|
||||
userId: 'test-user-id',
|
||||
autoGenerated: true,
|
||||
aiProvider: 'openai',
|
||||
aiConfidence: 95,
|
||||
language: 'en',
|
||||
languageConfidence: 0.98,
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
expect(noteWithAI.id).toBeDefined()
|
||||
expect(noteWithAI.title).toBe('Note with AI')
|
||||
expect(noteWithAI.content).toBe('This note has AI fields')
|
||||
expect(noteWithAI.userId).toBe('test-user-id')
|
||||
|
||||
const basicNote = await prisma.note.findUnique({
|
||||
where: { id: noteWithAI.id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
userId: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(basicNote?.id).toBe(noteWithAI.id)
|
||||
expect(basicNote?.title).toBe(noteWithAI.title)
|
||||
expect(basicNote?.content).toBe(noteWithAI.content)
|
||||
})
|
||||
|
||||
test('should preserve note relationships if AI tables are dropped', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'rollback-test@test.com',
|
||||
name: 'Rollback User'
|
||||
}
|
||||
})
|
||||
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: 'Rollback Notebook',
|
||||
order: 0,
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Rollback Test Note',
|
||||
content: 'Test content',
|
||||
userId: user.id,
|
||||
notebookId: notebook.id
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.userId).toBe(user.id)
|
||||
expect(note.notebookId).toBe(notebook.id)
|
||||
|
||||
const retrievedNote = await prisma.note.findUnique({
|
||||
where: { id: note.id },
|
||||
include: {
|
||||
notebook: true,
|
||||
user: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(retrievedNote?.userId).toBe(user.id)
|
||||
expect(retrievedNote?.notebookId).toBe(notebook.id)
|
||||
})
|
||||
|
||||
test('should handle orphaned records after table drop', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Orphan Test Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback.noteId).toBe(note.id)
|
||||
|
||||
const noteExists = await prisma.note.findUnique({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
expect(noteExists).toBeDefined()
|
||||
expect(noteExists?.id).toBe(note.id)
|
||||
})
|
||||
|
||||
test('should verify no orphaned records exist after proper migration', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Orphan Check Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
const allFeedback = await prisma.aiFeedback.findMany()
|
||||
|
||||
for (const fb of allFeedback) {
|
||||
const noteExists = await prisma.note.findUnique({
|
||||
where: { id: fb.noteId }
|
||||
})
|
||||
expect(noteExists).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rollback Safety Checks', () => {
|
||||
test('should verify data before attempting rollback', async () => {
|
||||
await createSampleNotes(prisma, 10)
|
||||
|
||||
const noteCountBefore = await prisma.note.count()
|
||||
expect(noteCountBefore).toBe(10)
|
||||
|
||||
const notes = await prisma.note.findMany()
|
||||
expect(notes.length).toBe(10)
|
||||
|
||||
for (const note of notes) {
|
||||
expect(note.id).toBeDefined()
|
||||
expect(note.title).toBeDefined()
|
||||
expect(note.content).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('should identify tables created by migration', async () => {
|
||||
const aiTables = ['AiFeedback', 'MemoryEchoInsight', 'UserAISettings']
|
||||
let found = 0
|
||||
|
||||
for (const table of aiTables) {
|
||||
const exists = await verifyTableExists(prisma, table)
|
||||
if (exists) found++
|
||||
}
|
||||
|
||||
expect(found).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
test('should identify columns added by migration', async () => {
|
||||
const aiColumns = ['autoGenerated', 'aiProvider', 'aiConfidence', 'language', 'languageConfidence', 'lastAiAnalysis']
|
||||
let found = 0
|
||||
|
||||
for (const column of aiColumns) {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', column)
|
||||
if (exists) found++
|
||||
}
|
||||
|
||||
expect(found).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rollback with Data', () => {
|
||||
test('should preserve essential note data', async () => {
|
||||
const notes = await createSampleAINotes(prisma, 20)
|
||||
|
||||
for (const note of notes) {
|
||||
expect(note.id).toBeDefined()
|
||||
expect(note.content).toBeDefined()
|
||||
}
|
||||
|
||||
const allNotes = await prisma.note.findMany()
|
||||
expect(allNotes.length).toBe(20)
|
||||
})
|
||||
|
||||
test('should handle rollback with complex data structures', async () => {
|
||||
// With PostgreSQL + Prisma Json type, data is stored as native JSONB
|
||||
const complexNote = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Complex Note',
|
||||
content: '**Markdown** content with [links](https://example.com)',
|
||||
checkItems: [
|
||||
{ text: 'Task 1', done: false },
|
||||
{ text: 'Task 2', done: true },
|
||||
{ text: 'Task 3', done: false }
|
||||
],
|
||||
images: [
|
||||
{ url: 'image1.jpg', caption: 'Caption 1' },
|
||||
{ url: 'image2.jpg', caption: 'Caption 2' }
|
||||
],
|
||||
labels: ['label1', 'label2', 'label3'],
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
const retrieved = await prisma.note.findUnique({
|
||||
where: { id: complexNote.id }
|
||||
})
|
||||
|
||||
expect(retrieved?.content).toContain('**Markdown**')
|
||||
expect(retrieved?.checkItems).toBeDefined()
|
||||
expect(retrieved?.images).toBeDefined()
|
||||
expect(retrieved?.labels).toBeDefined()
|
||||
|
||||
// Json fields come back already parsed
|
||||
if (retrieved?.checkItems) {
|
||||
const checkItems = retrieved.checkItems as any[]
|
||||
expect(checkItems.length).toBe(3)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rollback Error Handling', () => {
|
||||
test('should handle rollback when AI data exists', async () => {
|
||||
await createSampleAINotes(prisma, 10)
|
||||
|
||||
const aiNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ autoGenerated: true },
|
||||
{ aiProvider: { not: null } },
|
||||
{ language: { not: null } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(aiNotes.length).toBeGreaterThan(0)
|
||||
|
||||
const hasAIData = await prisma.note.findFirst({
|
||||
where: {
|
||||
autoGenerated: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(hasAIData).toBeDefined()
|
||||
})
|
||||
|
||||
test('should handle rollback when feedback exists', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Feedback Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.createMany({
|
||||
data: [
|
||||
{
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Feedback 1'
|
||||
},
|
||||
{
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_down',
|
||||
feature: 'semantic_search',
|
||||
originalContent: 'Feedback 2'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const feedbackCount = await prisma.aiFeedback.count()
|
||||
expect(feedbackCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const feedbacks = await prisma.aiFeedback.findMany()
|
||||
expect(feedbacks.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rollback Validation', () => {
|
||||
test('should validate database state after simulated rollback', async () => {
|
||||
await createSampleNotes(prisma, 5)
|
||||
|
||||
const noteCount = await prisma.note.count()
|
||||
expect(noteCount).toBeGreaterThanOrEqual(5)
|
||||
|
||||
const notes = await prisma.note.findMany()
|
||||
expect(notes.every(n => n.id && n.content)).toBe(true)
|
||||
})
|
||||
|
||||
test('should verify no data corruption in core tables', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'corruption-test@test.com',
|
||||
name: 'Corruption Test User'
|
||||
}
|
||||
})
|
||||
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: 'Corruption Test Notebook',
|
||||
order: 0,
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: 'Corruption Test Note',
|
||||
content: 'Test content',
|
||||
userId: user.id,
|
||||
notebookId: notebook.id
|
||||
}
|
||||
})
|
||||
|
||||
const retrievedUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
include: { notebooks: true, notes: true }
|
||||
})
|
||||
|
||||
expect(retrievedUser?.notebooks.length).toBe(1)
|
||||
expect(retrievedUser?.notes.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
513
memento-note/tests/migration/schema-migration.test.ts
Normal file
513
memento-note/tests/migration/schema-migration.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* Schema Migration Tests
|
||||
* Validates that all schema migrations (SQL migrations) work correctly
|
||||
* Tests database structure, indexes, and relationships
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import {
|
||||
createTestPrismaClient,
|
||||
initializeTestDatabase,
|
||||
cleanupTestDatabase,
|
||||
verifyTableExists,
|
||||
verifyIndexExists,
|
||||
verifyColumnExists,
|
||||
getTableSchema
|
||||
} from './setup'
|
||||
|
||||
describe('Schema Migration Tests', () => {
|
||||
let prisma: PrismaClient
|
||||
|
||||
beforeAll(async () => {
|
||||
prisma = createTestPrismaClient()
|
||||
await initializeTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestDatabase(prisma)
|
||||
})
|
||||
|
||||
describe('Core Table Existence', () => {
|
||||
test('should have User table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'User')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have Note table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'Note')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have Notebook table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'Notebook')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have Label table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'Label')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have Account table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'Account')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have Session table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'Session')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AI Feature Tables', () => {
|
||||
test('should have AiFeedback table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'AiFeedback')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have MemoryEchoInsight table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'MemoryEchoInsight')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have UserAISettings table', async () => {
|
||||
const exists = await verifyTableExists(prisma, 'UserAISettings')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Note Table AI Fields Migration', () => {
|
||||
test('should have autoGenerated column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', 'autoGenerated')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have aiProvider column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', 'aiProvider')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have aiConfidence column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', 'aiConfidence')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have language column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', 'language')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have languageConfidence column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', 'languageConfidence')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have lastAiAnalysis column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'Note', 'lastAiAnalysis')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AiFeedback Table Structure', () => {
|
||||
test('should have noteId column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'noteId')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have userId column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'userId')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have feedbackType column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'feedbackType')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have feature column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'feature')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have originalContent column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'originalContent')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have correctedContent column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'correctedContent')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have metadata column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'metadata')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have createdAt column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'AiFeedback', 'createdAt')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MemoryEchoInsight Table Structure', () => {
|
||||
test('should have userId column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'userId')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have note1Id column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'note1Id')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have note2Id column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'note2Id')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have similarityScore column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'similarityScore')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have insight column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'insight')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have insightDate column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'insightDate')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have viewed column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'viewed')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have feedback column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'feedback')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have dismissed column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'MemoryEchoInsight', 'dismissed')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserAISettings Table Structure', () => {
|
||||
test('should have userId column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'userId')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have titleSuggestions column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'titleSuggestions')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have semanticSearch column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'semanticSearch')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have paragraphRefactor column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'paragraphRefactor')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have memoryEcho column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'memoryEcho')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have memoryEchoFrequency column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'memoryEchoFrequency')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have aiProvider column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'aiProvider')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have preferredLanguage column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'preferredLanguage')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have fontSize column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'fontSize')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have demoMode column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'demoMode')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have showRecentNotes column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'showRecentNotes')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have emailNotifications column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'emailNotifications')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have desktopNotifications column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'desktopNotifications')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have anonymousAnalytics column', async () => {
|
||||
const exists = await verifyColumnExists(prisma, 'UserAISettings', 'anonymousAnalytics')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Index Creation', () => {
|
||||
test('should have indexes on AiFeedback.noteId', async () => {
|
||||
const exists = await verifyIndexExists(prisma, 'AiFeedback', 'AiFeedback_noteId_idx')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have indexes on AiFeedback.userId', async () => {
|
||||
const exists = await verifyIndexExists(prisma, 'AiFeedback', 'AiFeedback_userId_idx')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have indexes on AiFeedback.feature', async () => {
|
||||
const exists = await verifyIndexExists(prisma, 'AiFeedback', 'AiFeedback_feature_idx')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have indexes on AiFeedback.createdAt', async () => {
|
||||
const exists = await verifyIndexExists(prisma, 'AiFeedback', 'AiFeedback_createdAt_idx')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('should have indexes on Note table', async () => {
|
||||
// Note table should have indexes on various columns
|
||||
const schema = await getTableSchema(prisma, 'Note')
|
||||
expect(schema).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Foreign Key Relationships', () => {
|
||||
test('should maintain Note to AiFeedback relationship', async () => {
|
||||
// Create a test note
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Test FK Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create feedback linked to the note
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback.noteId).toBe(note.id)
|
||||
})
|
||||
|
||||
test('should maintain User to AiFeedback relationship', async () => {
|
||||
// Create a test note
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Test User FK Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create feedback linked to user
|
||||
const feedback = await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_down',
|
||||
feature: 'semantic_search',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
expect(feedback.userId).toBe('test-user-id')
|
||||
})
|
||||
|
||||
test('should cascade delete AiFeedback when Note is deleted', async () => {
|
||||
// Create a note with feedback
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Test Cascade Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
userId: 'test-user-id',
|
||||
feedbackType: 'thumbs_up',
|
||||
feature: 'title_suggestion',
|
||||
originalContent: 'Test feedback'
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feedback exists
|
||||
const feedbacksBefore = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(feedbacksBefore.length).toBe(1)
|
||||
|
||||
// Delete the note
|
||||
await prisma.note.delete({
|
||||
where: { id: note.id }
|
||||
})
|
||||
|
||||
// Verify feedback is cascade deleted
|
||||
const feedbacksAfter = await prisma.aiFeedback.findMany({
|
||||
where: { noteId: note.id }
|
||||
})
|
||||
expect(feedbacksAfter.length).toBe(0)
|
||||
})
|
||||
|
||||
test('should maintain Note to Notebook relationship', async () => {
|
||||
// Create a notebook
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
name: 'Test Notebook',
|
||||
order: 0,
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
// Create a note in the notebook
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Test Notebook Note',
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id',
|
||||
notebookId: notebook.id
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.notebookId).toBe(notebook.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Unique Constraints', () => {
|
||||
test('should enforce unique constraint on User.email', async () => {
|
||||
// First user should be created
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: 'unique@test.com',
|
||||
name: 'Unique User'
|
||||
}
|
||||
})
|
||||
|
||||
// Second user with same email should fail
|
||||
await expect(
|
||||
prisma.user.create({
|
||||
data: {
|
||||
email: 'unique@test.com',
|
||||
name: 'Duplicate User'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('should enforce unique constraint on Notebook userId+name', async () => {
|
||||
const userId = 'test-user-unique'
|
||||
|
||||
// First notebook should be created
|
||||
await prisma.notebook.create({
|
||||
data: {
|
||||
name: 'Unique Notebook',
|
||||
order: 0,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
// Second notebook with same name for same user should fail
|
||||
await expect(
|
||||
prisma.notebook.create({
|
||||
data: {
|
||||
name: 'Unique Notebook',
|
||||
order: 1,
|
||||
userId
|
||||
}
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default Values', () => {
|
||||
test('should have default values for Note table', async () => {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
content: 'Test content',
|
||||
userId: 'test-user-id'
|
||||
}
|
||||
})
|
||||
|
||||
expect(note.color).toBe('default')
|
||||
expect(note.isPinned).toBe(false)
|
||||
expect(note.isArchived).toBe(false)
|
||||
expect(note.type).toBe('text')
|
||||
expect(note.size).toBe('small')
|
||||
expect(note.order).toBe(0)
|
||||
})
|
||||
|
||||
test('should have default values for UserAISettings', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'default-settings@test.com'
|
||||
}
|
||||
})
|
||||
|
||||
const settings = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
expect(settings.titleSuggestions).toBe(true)
|
||||
expect(settings.semanticSearch).toBe(true)
|
||||
expect(settings.paragraphRefactor).toBe(true)
|
||||
expect(settings.memoryEcho).toBe(true)
|
||||
expect(settings.memoryEchoFrequency).toBe('daily')
|
||||
expect(settings.aiProvider).toBe('auto')
|
||||
expect(settings.preferredLanguage).toBe('auto')
|
||||
expect(settings.fontSize).toBe('medium')
|
||||
expect(settings.demoMode).toBe(false)
|
||||
expect(settings.showRecentNotes).toBe(false)
|
||||
expect(settings.emailNotifications).toBe(false)
|
||||
expect(settings.desktopNotifications).toBe(false)
|
||||
expect(settings.anonymousAnalytics).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema Version Tracking', () => {
|
||||
test('should have all migrations applied', async () => {
|
||||
// Verify the schema is complete by checking core tables
|
||||
const hasUser = await verifyTableExists(prisma, 'User')
|
||||
const hasNote = await verifyTableExists(prisma, 'Note')
|
||||
const hasAiFeedback = await verifyTableExists(prisma, 'AiFeedback')
|
||||
|
||||
expect(hasUser && hasNote && hasAiFeedback).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
214
memento-note/tests/migration/setup.ts
Normal file
214
memento-note/tests/migration/setup.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Test database setup and teardown utilities for migration tests
|
||||
* Updated for PostgreSQL
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
/**
|
||||
* Create a Prisma client instance for testing
|
||||
* Uses DATABASE_URL from environment
|
||||
*/
|
||||
export function createTestPrismaClient(): PrismaClient {
|
||||
return new PrismaClient()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test database schema
|
||||
* Runs prisma migrate deploy or db push
|
||||
*/
|
||||
export async function initializeTestDatabase(prisma: PrismaClient) {
|
||||
await prisma.$connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup test database
|
||||
* Disconnects Prisma client and cleans all data
|
||||
*/
|
||||
export async function cleanupTestDatabase(prisma: PrismaClient) {
|
||||
try {
|
||||
// Delete in dependency order
|
||||
await prisma.aiFeedback.deleteMany()
|
||||
await prisma.memoryEchoInsight.deleteMany()
|
||||
await prisma.noteShare.deleteMany()
|
||||
await prisma.note.deleteMany()
|
||||
await prisma.label.deleteMany()
|
||||
await prisma.notebook.deleteMany()
|
||||
await prisma.userAISettings.deleteMany()
|
||||
await prisma.systemConfig.deleteMany()
|
||||
await prisma.session.deleteMany()
|
||||
await prisma.account.deleteMany()
|
||||
await prisma.verificationToken.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
await prisma.$disconnect()
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up test database:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sample test data
|
||||
*/
|
||||
export async function createSampleNotes(prisma: PrismaClient, count: number = 10) {
|
||||
const notes = []
|
||||
const userId = 'test-user-123'
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `Test Note ${i + 1}`,
|
||||
content: `This is test content for note ${i + 1}`,
|
||||
userId,
|
||||
color: `color-${i % 5}`,
|
||||
order: i,
|
||||
isPinned: i % 3 === 0,
|
||||
isArchived: false,
|
||||
type: 'text',
|
||||
size: i % 3 === 0 ? 'small' : 'medium'
|
||||
}
|
||||
})
|
||||
notes.push(note)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sample AI-enabled notes
|
||||
*/
|
||||
export async function createSampleAINotes(prisma: PrismaClient, count: number = 10) {
|
||||
const notes = []
|
||||
const userId = 'test-user-ai'
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: `AI Test Note ${i + 1}`,
|
||||
content: `This is AI test content for note ${i + 1}`,
|
||||
userId,
|
||||
color: 'default',
|
||||
order: i,
|
||||
autoGenerated: i % 2 === 0,
|
||||
aiProvider: i % 3 === 0 ? 'openai' : 'ollama',
|
||||
aiConfidence: 70 + i * 2,
|
||||
language: i % 2 === 0 ? 'en' : 'fr',
|
||||
languageConfidence: 0.85 + (i * 0.01),
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
notes.push(note)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure execution time for a function
|
||||
*/
|
||||
export async function measureExecutionTime<T>(fn: () => Promise<T>): Promise<{ result: T; duration: number }> {
|
||||
const start = performance.now()
|
||||
const result = await fn()
|
||||
const end = performance.now()
|
||||
return {
|
||||
result,
|
||||
duration: end - start
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify data integrity after migration
|
||||
*/
|
||||
export async function verifyDataIntegrity(prisma: PrismaClient, expectedNoteCount: number) {
|
||||
const noteCount = await prisma.note.count()
|
||||
|
||||
if (noteCount !== expectedNoteCount) {
|
||||
throw new Error(`Data integrity check failed: Expected ${expectedNoteCount} notes, found ${noteCount}`)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database table exists (PostgreSQL version)
|
||||
*/
|
||||
export async function verifyTableExists(prisma: PrismaClient, tableName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ exists: boolean }>>(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)`,
|
||||
tableName
|
||||
)
|
||||
return result[0]?.exists ?? false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if index exists on a table (PostgreSQL version)
|
||||
*/
|
||||
export async function verifyIndexExists(prisma: PrismaClient, tableName: string, indexName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ exists: boolean }>>(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = $1
|
||||
AND indexname = $2
|
||||
)`,
|
||||
tableName,
|
||||
indexName
|
||||
)
|
||||
return result[0]?.exists ?? false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if column exists in table (PostgreSQL version)
|
||||
*/
|
||||
export async function verifyColumnExists(prisma: PrismaClient, tableName: string, columnName: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ exists: boolean }>>(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
AND column_name = $2
|
||||
)`,
|
||||
tableName,
|
||||
columnName
|
||||
)
|
||||
return result[0]?.exists ?? false
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table schema information (PostgreSQL version)
|
||||
*/
|
||||
export async function getTableSchema(prisma: PrismaClient, tableName: string) {
|
||||
try {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{
|
||||
column_name: string
|
||||
data_type: string
|
||||
is_nullable: string
|
||||
column_default: string | null
|
||||
}>>(
|
||||
`SELECT column_name, data_type, is_nullable, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
ORDER BY ordinal_position`,
|
||||
tableName
|
||||
)
|
||||
return result
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
60
memento-note/tests/note-resizing.spec.ts
Normal file
60
memento-note/tests/note-resizing.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Note Resizing', () => {
|
||||
test('should increase note height when changing size to Large', async ({ page }) => {
|
||||
// Go to home page
|
||||
await page.goto('/');
|
||||
|
||||
// Create a new note to ensure we have a clean slate
|
||||
const notesInput = page.locator('input[placeholder="Take a note..."]');
|
||||
// If text is localized, try selector
|
||||
const mainInput = page.locator('.note-input-container, [data-testid="note-input"]');
|
||||
|
||||
// Just click anywhere that looks like the input bar
|
||||
// Or assume the placeholder might be localized.
|
||||
// Best to find by visual structure if possible, but placeholder is common.
|
||||
// Let's stick to the existing notes check.
|
||||
|
||||
// Wait for at least one note card
|
||||
const firstNote = page.locator('.note-card').first();
|
||||
await firstNote.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Get initial height
|
||||
const initialBox = await firstNote.boundingBox();
|
||||
if (!initialBox) throw new Error('Note card has no bounding box');
|
||||
console.log(`Initial height: ${initialBox.height}`);
|
||||
|
||||
// Hover to show actions
|
||||
await firstNote.hover();
|
||||
|
||||
// Click "More options" button (3 vertical dots)
|
||||
// Use selector ensuring it's the one inside THIS note
|
||||
const moreBtn = firstNote.locator('button:has(svg.lucide-more-vertical)');
|
||||
await moreBtn.waitFor({ state: 'visible' });
|
||||
await moreBtn.click();
|
||||
|
||||
// Click "Large" option
|
||||
// It's the 3rd item in the second group usually.
|
||||
// Or we can look for the maximize icon
|
||||
const largeOption = page.locator('div[role="menuitem"]:has(svg.lucide-maximize-2)').last();
|
||||
// The sizes are Small, Medium, Large. All use Maximize2 icon in the current code?
|
||||
// Let's check NoteActions code.
|
||||
// Yes: <Maximize2 className="h-4 w-4 mr-2" /> for all sizes.
|
||||
// And they are rendered in order: Small, Medium, Large.
|
||||
// So "Large" is the LAST one.
|
||||
|
||||
await largeOption.waitFor({ state: 'visible' });
|
||||
await largeOption.click();
|
||||
|
||||
// Wait for update
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Get new height
|
||||
const newBox = await firstNote.boundingBox();
|
||||
if (!newBox) throw new Error('Note card has no bounding box after resize');
|
||||
console.log(`New height: ${newBox.height}`);
|
||||
|
||||
// Assert
|
||||
expect(newBox.height).toBeGreaterThan(initialBox.height + 50);
|
||||
});
|
||||
});
|
||||
161
memento-note/tests/recent-notes-section.spec.ts
Normal file
161
memento-note/tests/recent-notes-section.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Recent Notes Section', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to home page
|
||||
await page.goto('/')
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('should display recent notes section when notes exist', async ({ page }) => {
|
||||
// Check if recent notes section exists
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
|
||||
// The section should be visible when there are recent notes
|
||||
// Note: This test assumes there are notes created/modified in the last 7 days
|
||||
await expect(recentSection).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show section header with clock icon and title', async ({ page }) => {
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
|
||||
// Check for header elements
|
||||
await expect(recentSection.locator('text=Recent Notes')).toBeVisible()
|
||||
await expect(recentSection.locator('text=(last 7 days)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should be collapsible', async ({ page }) => {
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
const collapseButton = recentSection.locator('button[aria-expanded]')
|
||||
|
||||
// Check that collapse button exists
|
||||
await expect(collapseButton).toBeVisible()
|
||||
|
||||
// Click to collapse
|
||||
await collapseButton.click()
|
||||
await expect(collapseButton).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
// Click to expand
|
||||
await collapseButton.click()
|
||||
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('should display notes in grid layout', async ({ page }) => {
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
const collapseButton = recentSection.locator('button[aria-expanded]')
|
||||
|
||||
// Ensure section is expanded
|
||||
if (await collapseButton.getAttribute('aria-expanded') === 'false') {
|
||||
await collapseButton.click()
|
||||
}
|
||||
|
||||
// Check for grid layout
|
||||
const grid = recentSection.locator('.grid')
|
||||
await expect(grid).toBeVisible()
|
||||
|
||||
// Check that grid has correct classes
|
||||
await expect(grid).toHaveClass(/grid-cols-1/)
|
||||
})
|
||||
|
||||
test('should not show pinned notes in recent section', async ({ page }) => {
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
|
||||
// Recent notes should filter out pinned notes
|
||||
// Get all note cards in recent section
|
||||
const recentNoteCards = recentSection.locator('[data-testid^="note-card-"]')
|
||||
|
||||
// If there are recent notes, none should be pinned
|
||||
const count = await recentNoteCards.count()
|
||||
if (count > 0) {
|
||||
// Check that none of the notes in recent section have pin indicator
|
||||
// This is an indirect check - pinned notes are shown in FavoritesSection
|
||||
// The implementation should filter them out
|
||||
const favoriteSection = page.locator('[data-testid="favorites-section"]')
|
||||
await expect(favoriteSection).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should handle empty state (no recent notes)', async ({ page }) => {
|
||||
// This test would need to manipulate the database to ensure no recent notes
|
||||
// For now, we can check that the section doesn't break when empty
|
||||
|
||||
// Reload page to check stability
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Page should load without errors
|
||||
await expect(page).toHaveTitle(/Keep/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Recent Notes - Integration', () => {
|
||||
test('new note should appear in recent section', async ({ page }) => {
|
||||
// Create a new note
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const noteContent = `Test note for recent section - ${Date.now()}`
|
||||
|
||||
// Type in note input
|
||||
const noteInput = page.locator('[data-testid="note-input-textarea"]')
|
||||
await noteInput.fill(noteContent)
|
||||
|
||||
// Submit note
|
||||
const submitButton = page.locator('button[type="submit"]')
|
||||
await submitButton.click()
|
||||
|
||||
// Wait for note to be created
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Reload page to refresh recent notes
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check if recent notes section is visible
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
|
||||
// The section should now be visible with the new note
|
||||
await expect(recentSection).toBeVisible()
|
||||
})
|
||||
|
||||
test('editing note should update its position in recent section', async ({ page }) => {
|
||||
// This test verifies that edited notes move to top
|
||||
// It requires at least one note to exist
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
|
||||
// If recent notes exist
|
||||
if (await recentSection.isVisible()) {
|
||||
// Get first note card
|
||||
const firstNote = recentSection.locator('[data-testid^="note-card-"]').first()
|
||||
|
||||
// Click to edit
|
||||
await firstNote.click()
|
||||
|
||||
// Wait for editor to open
|
||||
const editor = page.locator('[data-testid="note-editor"]')
|
||||
await expect(editor).toBeVisible()
|
||||
|
||||
// Make a small edit
|
||||
const contentArea = editor.locator('textarea').first()
|
||||
await contentArea.press('End')
|
||||
await contentArea.type(' - edited')
|
||||
|
||||
// Save changes
|
||||
const saveButton = editor.locator('button:has-text("Save")').first()
|
||||
await saveButton.click()
|
||||
|
||||
// Wait for save and reload
|
||||
await page.waitForTimeout(1000)
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// The edited note should still be in recent section
|
||||
await expect(recentSection).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
423
memento-note/tests/reminder-dialog.spec.ts
Normal file
423
memento-note/tests/reminder-dialog.spec.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Note Input - Reminder Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Expand the note input
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open dialog when clicking Bell icon (not prompt)', async ({ page }) => {
|
||||
// Set up listener for prompt dialogs - should NOT appear
|
||||
let promptAppeared = false;
|
||||
page.on('dialog', () => {
|
||||
promptAppeared = true;
|
||||
});
|
||||
|
||||
// Click the Bell button
|
||||
const bellButton = page.locator('button:has(svg.lucide-bell)');
|
||||
await bellButton.click();
|
||||
|
||||
// Verify dialog opened (NOT a browser prompt)
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Verify dialog title
|
||||
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
|
||||
|
||||
// Verify no prompt appeared
|
||||
expect(promptAppeared).toBe(false);
|
||||
|
||||
// Verify date and time inputs exist
|
||||
await expect(page.locator('input[type="date"]')).toBeVisible();
|
||||
await expect(page.locator('input[type="time"]')).toBeVisible();
|
||||
|
||||
// Verify buttons
|
||||
await expect(page.locator('button:has-text("Cancel")')).toBeVisible();
|
||||
await expect(page.locator('button:has-text("Set Reminder")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have default values (tomorrow 9am)', async ({ page }) => {
|
||||
// Click Bell
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Get tomorrow's date
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const expectedDate = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
// Check date input
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await expect(dateInput).toHaveValue(expectedDate);
|
||||
|
||||
// Check time input
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
await expect(timeInput).toHaveValue('09:00');
|
||||
});
|
||||
|
||||
test('should close dialog on Cancel', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
|
||||
// Click Cancel
|
||||
await page.click('button:has-text("Cancel")');
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should close dialog on X button', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
|
||||
// Click X button (close button in dialog)
|
||||
const closeButton = page.locator('[role="dialog"] button[data-slot="dialog-close"]');
|
||||
await closeButton.click();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate empty date/time', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Clear date
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await dateInput.fill('');
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show warning toast (not close dialog)
|
||||
// We look for the toast notification
|
||||
await expect(page.locator('text=Please enter date and time')).toBeVisible();
|
||||
|
||||
// Dialog should still be open
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate past date', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set date to yesterday
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const pastDate = yesterday.toISOString().split('T')[0];
|
||||
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await dateInput.fill(pastDate);
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show error toast
|
||||
await expect(page.locator('text=Reminder must be in the future')).toBeVisible();
|
||||
|
||||
// Dialog should still be open
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should set reminder successfully with valid date', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set future date (tomorrow already default)
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
await timeInput.fill('14:30');
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show success toast
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should clear fields after successful reminder', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set and confirm
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Wait for dialog to close
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
|
||||
// Open again
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Should have default values again (tomorrow 9am)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const expectedDate = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await expect(page.locator('input[type="date"]')).toHaveValue(expectedDate);
|
||||
await expect(page.locator('input[type="time"]')).toHaveValue('09:00');
|
||||
});
|
||||
|
||||
test('should allow custom date and time selection', async ({ page }) => {
|
||||
// Open dialog
|
||||
await page.click('button:has(svg.lucide-bell)');
|
||||
|
||||
// Set custom date (next week)
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
const customDate = nextWeek.toISOString().split('T')[0];
|
||||
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
await dateInput.fill(customDate);
|
||||
|
||||
// Set custom time
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
await timeInput.fill('15:45');
|
||||
|
||||
// Submit
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show success with the date/time
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
await expect(page.locator('[role="dialog"]')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Note Editor - Reminder Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Create a test note
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await page.fill('input[placeholder="Title"]', 'Test Note for Reminder');
|
||||
await page.fill('textarea[placeholder="Take a note..."]', 'This note will have a reminder');
|
||||
await page.click('button:has-text("Add")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open the note for editing
|
||||
await page.click('text=Test Note for Reminder');
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
|
||||
test('should open reminder dialog in note editor', async ({ page }) => {
|
||||
// Click the Bell button in note editor
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
await bellButton.click();
|
||||
|
||||
// Should open a second dialog for reminder
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Verify reminder dialog opened
|
||||
await expect(page.locator('h2:has-text("Set Reminder")')).toBeVisible();
|
||||
|
||||
// Verify date and time inputs exist
|
||||
await expect(page.locator('input[type="date"]')).toBeVisible();
|
||||
await expect(page.locator('input[type="time"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should set reminder on existing note', async ({ page }) => {
|
||||
// Click Bell button
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Set date and time
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '14:30');
|
||||
|
||||
// Click Set Reminder
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
// Should show success toast
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
|
||||
// Reminder dialog should close
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('h2:has-text("Set Reminder")')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should show bell button as active when reminder is set', async ({ page }) => {
|
||||
// Set reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Bell button should have active styling (text-blue-600)
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
const className = await bellButton.getAttribute('class');
|
||||
expect(className).toContain('text-blue-600');
|
||||
});
|
||||
|
||||
test('should allow editing existing reminder', async ({ page }) => {
|
||||
// Set initial reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open reminder dialog again
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Should show previous values
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
|
||||
await expect(dateInput).toHaveValue(dateString);
|
||||
await expect(timeInput).toHaveValue('10:00');
|
||||
|
||||
// Change time
|
||||
await timeInput.fill('15:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
|
||||
await expect(page.locator('text=/Reminder set for/')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow removing reminder', async ({ page }) => {
|
||||
// Set reminder first
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Open reminder dialog again
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Should see "Remove Reminder" button
|
||||
await expect(page.locator('button:has-text("Remove Reminder")')).toBeVisible();
|
||||
|
||||
// Click Remove Reminder
|
||||
await page.click('button:has-text("Remove Reminder")');
|
||||
|
||||
// Should show success toast
|
||||
await expect(page.locator('text=Reminder removed')).toBeVisible();
|
||||
|
||||
// Bell button should not be active anymore
|
||||
await page.waitForTimeout(300);
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
const className = await bellButton.getAttribute('class');
|
||||
expect(className).not.toContain('text-blue-600');
|
||||
});
|
||||
|
||||
test('should persist reminder after saving note', async ({ page }) => {
|
||||
// Set reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '14:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Save the note
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Reopen the note
|
||||
await page.click('text=Test Note for Reminder');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Bell button should still be active
|
||||
const bellButton = page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first();
|
||||
const className = await bellButton.getAttribute('class');
|
||||
expect(className).toContain('text-blue-600');
|
||||
|
||||
// Open reminder dialog to verify values
|
||||
await bellButton.click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const timeInput = page.locator('input[type="time"]');
|
||||
|
||||
await expect(dateInput).toHaveValue(dateString);
|
||||
await expect(timeInput).toHaveValue('14:00');
|
||||
});
|
||||
|
||||
test('should show bell icon on note card when reminder is set', async ({ page }) => {
|
||||
// Set reminder
|
||||
await page.locator('[role="dialog"]:visible button:has(svg.lucide-bell)').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateString = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
await page.fill('input[type="date"]', dateString);
|
||||
await page.fill('input[type="time"]', '10:00');
|
||||
await page.click('button:has-text("Set Reminder")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Save and close
|
||||
await page.click('button:has-text("Save")');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that note card has bell icon
|
||||
const noteCard = page.locator('text=Test Note for Reminder').locator('..');
|
||||
await expect(noteCard.locator('svg.lucide-bell')).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Close any open dialogs
|
||||
const dialogs = page.locator('[role="dialog"]');
|
||||
const count = await dialogs.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cancelButton = page.locator('button:has-text("Cancel")').first();
|
||||
if (await cancelButton.isVisible()) {
|
||||
await cancelButton.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete test note
|
||||
try {
|
||||
const testNote = page.locator('text=Test Note for Reminder').first();
|
||||
if (await testNote.isVisible()) {
|
||||
await testNote.hover();
|
||||
await page.click('button:has(svg.lucide-more-vertical)').first();
|
||||
await page.click('text=Delete').first();
|
||||
|
||||
// Confirm delete
|
||||
page.once('dialog', dialog => dialog.accept());
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
} catch (e) {
|
||||
// Note might already be deleted
|
||||
}
|
||||
});
|
||||
});
|
||||
63
memento-note/tests/search-quality.spec.ts
Normal file
63
memento-note/tests/search-quality.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Search Quality Tests', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 1. Go to Home
|
||||
await page.goto('/');
|
||||
|
||||
// Handle Login if redirected (Mock behavior or real login)
|
||||
if (page.url().includes('login')) {
|
||||
// Assuming a simple dev login or we need to bypass.
|
||||
// For this environment, let's try to just continue or fail explicitly if stuck.
|
||||
console.log('Redirected to login. Please ensure you are logged in for tests.');
|
||||
}
|
||||
});
|
||||
|
||||
test('should find notes by semantic concept', async ({ page }) => {
|
||||
// Clean up existing notes to avoid noise? (Optional, better to just create unique ones)
|
||||
const timestamp = Date.now();
|
||||
const techTitle = `React Tech ${timestamp}`;
|
||||
const foodTitle = `Italian Food ${timestamp}`;
|
||||
|
||||
// 2. Create Tech Note
|
||||
await page.getByPlaceholder('Take a note...').click();
|
||||
await page.getByPlaceholder('Title').fill(techTitle);
|
||||
await page.getByPlaceholder('Take a note...').fill('Hooks, useEffect, State management, Components logic.');
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.getByText(techTitle)).toBeVisible();
|
||||
|
||||
// 3. Create Food Note
|
||||
await page.getByPlaceholder('Take a note...').click();
|
||||
await page.getByPlaceholder('Title').fill(foodTitle);
|
||||
await page.getByPlaceholder('Take a note...').fill('Tomato sauce, Basil, Parmesan cheese, boiling water.');
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.getByText(foodTitle)).toBeVisible();
|
||||
|
||||
// Wait a bit for potential async indexing (even if server action is awaited, good practice)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. Search for "Coding" (Semantic match for React)
|
||||
const searchInput = page.getByPlaceholder('Search');
|
||||
await searchInput.fill('Coding');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
// Allow time for search results
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Assertions
|
||||
// Tech note should be visible
|
||||
await expect(page.getByText(techTitle)).toBeVisible();
|
||||
// Food note should NOT be visible (or at least ranked lower/hidden if filtering is strict)
|
||||
await expect(page.getByText(foodTitle)).toBeHidden();
|
||||
|
||||
// 5. Search for "Cooking" (Semantic match for Food)
|
||||
await searchInput.fill('');
|
||||
await searchInput.fill('Cooking dinner');
|
||||
await searchInput.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText(foodTitle)).toBeVisible();
|
||||
await expect(page.getByText(techTitle)).toBeHidden();
|
||||
});
|
||||
});
|
||||
143
memento-note/tests/search/non-regression.spec.ts
Normal file
143
memento-note/tests/search/non-regression.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Non-regression tests for semantic search
|
||||
* These tests ensure that increasing the threshold from 0.40 to 0.65
|
||||
* doesn't lose valid positive matches
|
||||
*/
|
||||
test.describe('Search Non-Regression Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should find notes with exact keyword matches', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('test');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const results = await page.locator('.note-card').count();
|
||||
|
||||
// Even with higher semantic threshold, exact keyword matches should always appear
|
||||
if (results > 0) {
|
||||
const titles = await page.locator('.note-card .note-title').allTextContents();
|
||||
const hasExactMatch = titles.some(title =>
|
||||
title.toLowerCase().includes('test')
|
||||
);
|
||||
expect(hasExactMatch).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should find notes with title matches', async ({ page }) => {
|
||||
// Search for something that should be in titles
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Try a generic search term
|
||||
await searchInput.fill('note');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const results = await page.locator('.note-card');
|
||||
const count = await results.count();
|
||||
|
||||
// Should find results with "note" in them
|
||||
if (count > 0) {
|
||||
const allText = await results.allTextContents();
|
||||
const hasNote = allText.some(text =>
|
||||
text.toLowerCase().includes('note')
|
||||
);
|
||||
expect(hasNote).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle multi-word queries correctly', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Multi-word search should work
|
||||
await searchInput.fill('test note');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should not error and should return results
|
||||
const results = await page.locator('.note-card').count();
|
||||
expect(results).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should be case-insensitive', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Search with uppercase
|
||||
await searchInput.fill('TEST');
|
||||
await page.waitForTimeout(500);
|
||||
const upperCount = await page.locator('.note-card').count();
|
||||
|
||||
// Search with lowercase
|
||||
await searchInput.fill('test');
|
||||
await page.waitForTimeout(500);
|
||||
const lowerCount = await page.locator('.note-card').count();
|
||||
|
||||
// Should return same results
|
||||
expect(upperCount).toBe(lowerCount);
|
||||
});
|
||||
|
||||
test('should handle empty search gracefully', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Empty search should show all notes or handle gracefully
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should not error
|
||||
const results = await page.locator('.note-card').count();
|
||||
expect(results).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should clear search results', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Search for something
|
||||
await searchInput.fill('temporary search');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Clear search
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should return to showing all notes (or initial state)
|
||||
const results = await page.locator('.note-card').count();
|
||||
expect(results).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for threshold behavior
|
||||
*/
|
||||
test.describe('Threshold Behavior Unit Tests', () => {
|
||||
test('threshold 0.65 should filter more than 0.40', () => {
|
||||
// Simulate semantic scores
|
||||
const scores = [0.80, 0.65, 0.50, 0.45, 0.40, 0.30, 0.20];
|
||||
|
||||
// Old threshold (0.40)
|
||||
const oldThresholdMatches = scores.filter(s => s > 0.40).length;
|
||||
|
||||
// New threshold (0.65)
|
||||
const newThresholdMatches = scores.filter(s => s > 0.65).length;
|
||||
|
||||
// New threshold should filter more strictly
|
||||
expect(newThresholdMatches).toBeLessThan(oldThresholdMatches);
|
||||
});
|
||||
|
||||
test('threshold 0.65 should keep high-quality matches', () => {
|
||||
const highQualityScores = [0.95, 0.85, 0.75, 0.70, 0.68];
|
||||
|
||||
// All high-quality scores should pass the threshold
|
||||
const matches = highQualityScores.filter(s => s > 0.65);
|
||||
expect(matches.length).toBe(highQualityScores.length);
|
||||
});
|
||||
|
||||
test('threshold 0.65 should filter low-quality matches', () => {
|
||||
const lowQualityScores = [0.30, 0.40, 0.50, 0.60, 0.64];
|
||||
|
||||
// None should pass the 0.65 threshold
|
||||
const matches = lowQualityScores.filter(s => s > 0.65);
|
||||
expect(matches.length).toBe(0);
|
||||
});
|
||||
});
|
||||
125
memento-note/tests/search/semantic-threshold.spec.ts
Normal file
125
memento-note/tests/search/semantic-threshold.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Semantic Search Threshold Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('http://localhost:3000');
|
||||
// Wait for potential redirect or login
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should use default threshold of 0.65 for semantic matching', async ({ page }) => {
|
||||
// This test verifies that the default threshold is applied
|
||||
// We'll search for something and check that only relevant results appear
|
||||
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Get initial note count
|
||||
const initialNotes = await page.locator('.note-card').count();
|
||||
|
||||
// Search for something specific
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('programming code');
|
||||
await page.waitForTimeout(500); // Wait for search debounce
|
||||
|
||||
// Get search results
|
||||
const searchResults = await page.locator('.note-card').count();
|
||||
|
||||
// With threshold 0.65, we should have fewer false positives
|
||||
// This is a basic sanity check - exact assertions depend on your data
|
||||
expect(searchResults).toBeLessThanOrEqual(initialNotes);
|
||||
|
||||
// Clear search
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('should not show unrelated notes when searching for specific terms', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Search for technical terms
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('javascript programming');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get all search result titles
|
||||
const resultTitles = await page.locator('.note-card .note-title').allTextContents();
|
||||
|
||||
// Check that results are related to the search (basic keyword check)
|
||||
// With higher threshold (0.65), semantic matches should be more relevant
|
||||
const hasIrrelevantResults = resultTitles.some(title => {
|
||||
const lowerTitle = title.toLowerCase();
|
||||
// If a result mentions completely unrelated topics like cooking/food
|
||||
// when searching for programming, that's a false positive
|
||||
return lowerTitle.includes('recipe') ||
|
||||
lowerTitle.includes('cooking') ||
|
||||
lowerTitle.includes('food') ||
|
||||
lowerTitle.includes('shopping');
|
||||
});
|
||||
|
||||
// With threshold 0.65, we expect fewer false positives
|
||||
// This test may need adjustment based on your actual data
|
||||
expect(hasIrrelevantResults).toBe(false);
|
||||
});
|
||||
|
||||
test('should still show highly relevant semantic matches', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Search for a concept and its close synonyms
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('coding');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get search results
|
||||
const searchResults = await page.locator('.note-card');
|
||||
const count = await searchResults.count();
|
||||
|
||||
// Even with higher threshold, we should find semantic matches
|
||||
// for closely related terms like "programming", "development", etc.
|
||||
if (count > 0) {
|
||||
const titles = await page.locator('.note-card .note-title').allTextContents();
|
||||
const hasRelevantContent = titles.some(title => {
|
||||
const lowerTitle = title.toLowerCase();
|
||||
return lowerTitle.includes('code') ||
|
||||
lowerTitle.includes('program') ||
|
||||
lowerTitle.includes('develop') ||
|
||||
lowerTitle.includes('software') ||
|
||||
lowerTitle.includes('app');
|
||||
});
|
||||
|
||||
// Should find semantically related content
|
||||
expect(hasRelevantContent).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('threshold can be configured via SystemConfig', async ({ page }) => {
|
||||
// This is an integration test verifying the configuration system
|
||||
// In a real scenario, you'd make an API call to update the config
|
||||
|
||||
// Default threshold should be 0.65
|
||||
const response = await page.request.get('/api/admin/embeddings/validate');
|
||||
|
||||
// This endpoint requires admin auth, so we're just checking it exists
|
||||
// The actual threshold value is tested in unit tests
|
||||
expect(response.ok()).toBeFalsy(); // Should be 401/403 without auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Semantic Threshold Unit Tests', () => {
|
||||
test('default threshold should be 0.65', () => {
|
||||
// This is a compile-time check
|
||||
// The actual value is in SEARCH_DEFAULTS.SEMANTIC_THRESHOLD
|
||||
const { SEARCH_DEFAULTS } = require('../../lib/config');
|
||||
expect(SEARCH_DEFAULTS.SEMANTIC_THRESHOLD).toBe(0.65);
|
||||
});
|
||||
|
||||
test('threshold should be higher than old value', () => {
|
||||
const { SEARCH_DEFAULTS } = require('../../lib/config');
|
||||
expect(SEARCH_DEFAULTS.SEMANTIC_THRESHOLD).toBeGreaterThan(0.40);
|
||||
});
|
||||
|
||||
test('threshold should be less than 1.0', () => {
|
||||
const { SEARCH_DEFAULTS } = require('../../lib/config');
|
||||
expect(SEARCH_DEFAULTS.SEMANTIC_THRESHOLD).toBeLessThan(1.0);
|
||||
});
|
||||
});
|
||||
568
memento-note/tests/settings.spec.ts
Normal file
568
memento-note/tests/settings.spec.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Tests complets pour les Settings UX (Story 11-2)
|
||||
*
|
||||
* Ce fichier teste toutes les fonctionnalités implémentées:
|
||||
* - General Settings: Notifications (Email), Privacy (Analytics)
|
||||
* - Profile Settings: Language, Font Size, Show Recent Notes
|
||||
* - Appearance Settings: Theme Persistence (Light/Dark/Auto)
|
||||
* - SettingsSearch: Functional search with filtering
|
||||
*
|
||||
* Prérequis:
|
||||
* - Être connecté avec un compte utilisateur
|
||||
* - Avoir accès aux pages de settings
|
||||
* - Base de données avec les nouveaux champs (emailNotifications, anonymousAnalytics)
|
||||
*/
|
||||
|
||||
test.describe('Settings UX - Story 11-2', () => {
|
||||
// Variables pour stocker les credentials
|
||||
let email: string
|
||||
let password: string
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Les credentials seront fournis par l'utilisateur
|
||||
console.log('Credentials nécessaires pour exécuter les tests')
|
||||
console.log('Veuillez fournir email et password')
|
||||
})
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Se connecter avant chaque test
|
||||
await page.goto('http://localhost:3000/login')
|
||||
await page.fill('input[name="email"]', email)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Attendre la connexion et vérifier qu'on est sur la page d'accueil
|
||||
await page.waitForURL('**/main**')
|
||||
await expect(page).toHaveURL(/\/main/)
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests pour General Settings
|
||||
*/
|
||||
test.describe('General Settings', () => {
|
||||
test('devrait afficher la page General Settings', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Vérifier le titre de la page
|
||||
const title = await page.textContent('h1')
|
||||
expect(title).toContain('General')
|
||||
|
||||
// Vérifier que les sections sont présentes
|
||||
await expect(page.locator('#language')).toBeVisible()
|
||||
await expect(page.locator('#notifications')).toBeVisible()
|
||||
await expect(page.locator('#privacy')).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait avoir le toggle Email Notifications', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Scroll vers la section notifications
|
||||
await page.locator('#notifications').scrollIntoViewIfNeeded()
|
||||
|
||||
// Vérifier que le toggle est présent
|
||||
const emailToggle = page.getByRole('switch', { name: /email notifications/i })
|
||||
await expect(emailToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait pouvoir activer/désactiver Email Notifications', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Scroll vers la section notifications
|
||||
await page.locator('#notifications').scrollIntoViewIfNeeded()
|
||||
|
||||
// Récupérer l'état initial du toggle
|
||||
const emailToggle = page.getByRole('switch', { name: /email notifications/i })
|
||||
const initialState = await emailToggle.getAttribute('aria-checked')
|
||||
const initialEnabled = initialState === 'true'
|
||||
|
||||
// Cliquer sur le toggle
|
||||
await emailToggle.click()
|
||||
|
||||
// Attendre un peu pour l'opération asynchrone
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Vérifier que l'état a changé
|
||||
const newState = await emailToggle.getAttribute('aria-checked')
|
||||
const newEnabled = newState === 'true'
|
||||
expect(newEnabled).toBe(!initialEnabled)
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('devrait avoir le toggle Anonymous Analytics', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Scroll vers la section privacy
|
||||
await page.locator('#privacy').scrollIntoViewIfNeeded()
|
||||
|
||||
// Vérifier que le toggle est présent
|
||||
const analyticsToggle = page.getByRole('switch', { name: /anonymous analytics/i })
|
||||
await expect(analyticsToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait pouvoir activer/désactiver Anonymous Analytics', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Scroll vers la section privacy
|
||||
await page.locator('#privacy').scrollIntoViewIfNeeded()
|
||||
|
||||
// Récupérer l'état initial du toggle
|
||||
const analyticsToggle = page.getByRole('switch', { name: /anonymous analytics/i })
|
||||
const initialState = await analyticsToggle.getAttribute('aria-checked')
|
||||
const initialEnabled = initialState === 'true'
|
||||
|
||||
// Cliquer sur le toggle
|
||||
await analyticsToggle.click()
|
||||
|
||||
// Attendre un peu pour l'opération asynchrone
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Vérifier que l'état a changé
|
||||
const newState = await analyticsToggle.getAttribute('aria-checked')
|
||||
const newEnabled = newState === 'true'
|
||||
expect(newEnabled).toBe(!initialEnabled)
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('devrait avoir le composant SettingsSearch', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Vérifier que la barre de recherche est présente
|
||||
const searchInput = page.getByPlaceholder(/search/i)
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
// Vérifier l'icône de recherche
|
||||
await expect(page.locator('svg').filter({ hasText: '' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait filtrer les sections avec SettingsSearch', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Cliquer sur la barre de recherche
|
||||
const searchInput = page.getByPlaceholder(/search/i)
|
||||
await searchInput.click()
|
||||
|
||||
// Taper "notification"
|
||||
await searchInput.fill('notification')
|
||||
|
||||
// Vérifier que la section notifications est visible
|
||||
await expect(page.locator('#notifications')).toBeVisible()
|
||||
|
||||
// Vérifier que les autres sections ne sont plus visibles (ou sont filtrées)
|
||||
// Note: Cela dépend de l'implémentation exacte du filtrage
|
||||
})
|
||||
|
||||
test('devrait effacer la recherche avec le bouton X', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
const searchInput = page.getByPlaceholder(/search/i)
|
||||
await searchInput.fill('test')
|
||||
|
||||
// Vérifier que le bouton X apparaît
|
||||
const clearButton = page.getByRole('button', { name: /clear search/i })
|
||||
await expect(clearButton).toBeVisible()
|
||||
|
||||
// Cliquer sur le bouton X
|
||||
await clearButton.click()
|
||||
|
||||
// Vérifier que la recherche est vide
|
||||
await expect(searchInput).toHaveValue('')
|
||||
})
|
||||
|
||||
test('devrait effacer la recherche avec la touche Escape', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
const searchInput = page.getByPlaceholder(/search/i)
|
||||
await searchInput.fill('test')
|
||||
|
||||
// Appuyer sur Escape
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Vérifier que la recherche est vide
|
||||
await expect(searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests pour Profile Settings
|
||||
*/
|
||||
test.describe('Profile Settings', () => {
|
||||
test('devrait afficher la page Profile Settings', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Vérifier le titre de la page
|
||||
const title = await page.textContent('h1')
|
||||
expect(title).toContain('Profile')
|
||||
|
||||
// Vérifier les sections
|
||||
await expect(page.getByText(/display name/i)).toBeVisible()
|
||||
await expect(page.getByText(/email/i)).toBeVisible()
|
||||
await expect(page.getByText(/language preferences/i)).toBeVisible()
|
||||
await expect(page.getByText(/display settings/i)).toBeVisible()
|
||||
await expect(page.getByText(/change password/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait avoir le sélecteur de langue', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Scroll vers la section language
|
||||
await page.getByText(/language preferences/i).scrollIntoViewIfNeeded()
|
||||
|
||||
// Vérifier que le sélecteur est présent
|
||||
const languageSelect = page.locator('#language')
|
||||
await expect(languageSelect).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait pouvoir changer la langue', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Scroll vers la section language
|
||||
await page.getByText(/language preferences/i).scrollIntoViewIfNeeded()
|
||||
|
||||
// Cliquer sur le sélecteur
|
||||
const languageSelect = page.locator('#language')
|
||||
await languageSelect.click()
|
||||
|
||||
// Sélectionner une langue (ex: français)
|
||||
await page.getByRole('option', { name: /français/i }).click()
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('devrait avoir le sélecteur de taille de police', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Scroll vers la section display settings
|
||||
await page.getByText(/display settings/i).scrollIntoViewIfNeeded()
|
||||
|
||||
// Vérifier que le sélecteur est présent
|
||||
const fontSizeSelect = page.locator('#fontSize')
|
||||
await expect(fontSizeSelect).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait pouvoir changer la taille de police', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Scroll vers la section display settings
|
||||
await page.getByText(/display settings/i).scrollIntoViewIfNeeded()
|
||||
|
||||
// Cliquer sur le sélecteur
|
||||
const fontSizeSelect = page.locator('#fontSize')
|
||||
await fontSizeSelect.click()
|
||||
|
||||
// Sélectionner une taille (ex: large)
|
||||
await page.getByRole('option', { name: /large/i }).click()
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Vérifier que la taille de police a changé (vérifier la variable CSS)
|
||||
const rootFontSize = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--user-font-size')
|
||||
})
|
||||
expect(rootFontSize).toBeTruthy()
|
||||
})
|
||||
|
||||
test('devrait avoir le toggle Show Recent Notes', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Scroll vers la section display settings
|
||||
await page.getByText(/display settings/i).scrollIntoViewIfNeeded()
|
||||
|
||||
// Vérifier que le toggle est présent
|
||||
const recentNotesToggle = page.getByRole('switch', { name: /show recent notes/i })
|
||||
await expect(recentNotesToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait pouvoir activer/désactiver Show Recent Notes', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Scroll vers la section display settings
|
||||
await page.getByText(/display settings/i).scrollIntoViewIfNeeded()
|
||||
|
||||
// Récupérer l'état initial du toggle
|
||||
const recentNotesToggle = page.getByRole('switch', { name: /show recent notes/i })
|
||||
const initialState = await recentNotesToggle.getAttribute('aria-checked')
|
||||
const initialEnabled = initialState === 'true'
|
||||
|
||||
// Cliquer sur le toggle
|
||||
await recentNotesToggle.click()
|
||||
|
||||
// Attendre un peu pour l'opération asynchrone
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Vérifier que l'état a changé
|
||||
const newState = await recentNotesToggle.getAttribute('aria-checked')
|
||||
const newEnabled = newState === 'true'
|
||||
expect(newEnabled).toBe(!initialEnabled)
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('devrait pouvoir changer le nom d\'affichage', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
|
||||
// Remplir le champ nom
|
||||
const nameInput = page.getByLabel(/display name/i)
|
||||
const newName = 'Test User ' + Date.now()
|
||||
await nameInput.fill(newName)
|
||||
|
||||
// Soumettre le formulaire
|
||||
await page.getByRole('button', { name: /save/i }).click()
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Recharger la page et vérifier que le nom a été sauvegardé
|
||||
await page.reload()
|
||||
await expect(nameInput).toHaveValue(newName)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests pour Appearance Settings
|
||||
*/
|
||||
test.describe('Appearance Settings', () => {
|
||||
test('devrait afficher la page Appearance Settings', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Vérifier le titre de la page
|
||||
const title = await page.textContent('h1')
|
||||
expect(title).toContain('Appearance')
|
||||
|
||||
// Vérifier les sections
|
||||
await expect(page.getByText(/theme/i)).toBeVisible()
|
||||
await expect(page.getByText(/typography/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait avoir le sélecteur de thème', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Vérifier que le sélecteur est présent
|
||||
const themeSelect = page.locator('#theme')
|
||||
await expect(themeSelect).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait pouvoir changer le thème pour Light', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Cliquer sur le sélecteur de thème
|
||||
const themeSelect = page.locator('#theme')
|
||||
await themeSelect.click()
|
||||
|
||||
// Sélectionner "Light"
|
||||
await page.getByRole('option', { name: /light/i }).click()
|
||||
|
||||
// Vérifier que le thème est appliqué immédiatement
|
||||
await expect(page.locator('html')).toHaveClass(/light/)
|
||||
await expect(page.locator('html')).not.toHaveClass(/dark/)
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Vérifier que le thème est sauvegardé dans localStorage
|
||||
const localStorageTheme = await page.evaluate(() => {
|
||||
return localStorage.getItem('theme')
|
||||
})
|
||||
expect(localStorageTheme).toBe('light')
|
||||
})
|
||||
|
||||
test('devrait pouvoir changer le thème pour Dark', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Cliquer sur le sélecteur de thème
|
||||
const themeSelect = page.locator('#theme')
|
||||
await themeSelect.click()
|
||||
|
||||
// Sélectionner "Dark"
|
||||
await page.getByRole('option', { name: /dark/i }).click()
|
||||
|
||||
// Vérifier que le thème est appliqué immédiatement
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(page.locator('html')).not.toHaveClass(/light/)
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Vérifier que le thème est sauvegardé dans localStorage
|
||||
const localStorageTheme = await page.evaluate(() => {
|
||||
return localStorage.getItem('theme')
|
||||
})
|
||||
expect(localStorageTheme).toBe('dark')
|
||||
})
|
||||
|
||||
test('devrait pouvoir changer le thème pour Auto', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Cliquer sur le sélecteur de thème
|
||||
const themeSelect = page.locator('#theme')
|
||||
await themeSelect.click()
|
||||
|
||||
// Sélectionner "Auto"
|
||||
await page.getByRole('option', { name: /auto/i }).click()
|
||||
|
||||
// Vérifier qu'un toast de succès apparaît
|
||||
await expect(page.getByText(/success/i)).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Vérifier que le thème est sauvegardé dans localStorage
|
||||
const localStorageTheme = await page.evaluate(() => {
|
||||
return localStorage.getItem('theme')
|
||||
})
|
||||
expect(localStorageTheme).toBe('auto')
|
||||
})
|
||||
|
||||
test('devrait charger le thème depuis localStorage', async ({ page }) => {
|
||||
// Définir le thème dans localStorage avant de charger la page
|
||||
await page.goto('about:blank')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('theme', 'dark')
|
||||
})
|
||||
|
||||
// Aller sur la page Appearance Settings
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Attendre que la page charge
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Vérifier que le thème est chargé et appliqué
|
||||
const themeSelect = page.locator('#theme')
|
||||
await expect(themeSelect).toHaveValue('dark')
|
||||
|
||||
// Vérifier que le thème est appliqué au DOM
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
})
|
||||
|
||||
test('devrait persister le thème après rechargement de page', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
// Changer le thème pour dark
|
||||
const themeSelect = page.locator('#theme')
|
||||
await themeSelect.click()
|
||||
await page.getByRole('option', { name: /dark/i }).click()
|
||||
|
||||
// Attendre que le thème soit appliqué
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
|
||||
// Recharger la page
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Vérifier que le thème est toujours appliqué
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
await expect(themeSelect).toHaveValue('dark')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests d'intégration cross-pages
|
||||
*/
|
||||
test.describe('Integration Tests', () => {
|
||||
test('devrait naviguer entre les pages de settings', async ({ page }) => {
|
||||
// Commencer sur General Settings
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Cliquer sur Appearance dans la navigation
|
||||
await page.getByRole('link', { name: /appearance/i }).click()
|
||||
await expect(page).toHaveURL(/\/settings\/appearance/)
|
||||
|
||||
// Cliquer sur Profile dans la navigation
|
||||
await page.getByRole('link', { name: /profile/i }).click()
|
||||
await expect(page).toHaveURL(/\/settings\/profile/)
|
||||
|
||||
// Cliquer sur General dans la navigation
|
||||
await page.getByRole('link', { name: /general/i }).click()
|
||||
await expect(page).toHaveURL(/\/settings\/general/)
|
||||
})
|
||||
|
||||
test('devrait persister les settings entre les pages', async ({ page }) => {
|
||||
// Changer le thème sur Appearance Settings
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
const themeSelect = page.locator('#theme')
|
||||
await themeSelect.click()
|
||||
await page.getByRole('option', { name: /dark/i }).click()
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
|
||||
// Aller sur General Settings et vérifier que le thème est toujours appliqué
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
|
||||
// Aller sur Profile Settings et vérifier que le thème est toujours appliqué
|
||||
await page.goto('http://localhost:3000/settings/profile')
|
||||
await expect(page.locator('html')).toHaveClass(/dark/)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests de responsive design
|
||||
*/
|
||||
test.describe('Responsive Design', () => {
|
||||
test('devrait fonctionner sur mobile', async ({ page }) => {
|
||||
// Simuler un viewport mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 })
|
||||
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Vérifier que la page est utilisable
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
await expect(page.getByPlaceholder(/search/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait fonctionner sur tablet', async ({ page }) => {
|
||||
// Simuler un viewport tablet
|
||||
await page.setViewportSize({ width: 768, height: 1024 })
|
||||
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Vérifier que la page est utilisable
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
await expect(page.getByPlaceholder(/search/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('devrait fonctionner sur desktop', async ({ page }) => {
|
||||
// Simuler un viewport desktop
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
|
||||
// Vérifier que la page est utilisable
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
await expect(page.getByPlaceholder(/search/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tests de performance
|
||||
*/
|
||||
test.describe('Performance Tests', () => {
|
||||
test('devrait charger rapidement General Settings', async ({ page }) => {
|
||||
const startTime = Date.now()
|
||||
await page.goto('http://localhost:3000/settings/general')
|
||||
await page.waitForLoadState('networkidle')
|
||||
const loadTime = Date.now() - startTime
|
||||
|
||||
// La page devrait charger en moins de 2 secondes
|
||||
expect(loadTime).toBeLessThan(2000)
|
||||
})
|
||||
|
||||
test('devrait appliquer le thème rapidement', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/settings/appearance')
|
||||
|
||||
const themeSelect = page.locator('#theme')
|
||||
const startTime = Date.now()
|
||||
await themeSelect.click()
|
||||
await page.getByRole('option', { name: /dark/i }).click()
|
||||
await page.waitForSelector('html.dark', { timeout: 1000 })
|
||||
const applyTime = Date.now() - startTime
|
||||
|
||||
// Le thème devrait être appliqué en moins de 500ms
|
||||
expect(applyTime).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
})
|
||||
9
memento-note/tests/setup.ts
Normal file
9
memento-note/tests/setup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Vitest setup file
|
||||
* This file is loaded before all tests
|
||||
*/
|
||||
|
||||
import { beforeAll, afterAll } from 'vitest'
|
||||
|
||||
// Global setup can be added here if needed
|
||||
// For now, we keep it minimal as each test suite has its own setup
|
||||
167
memento-note/tests/undo-redo.spec.ts
Normal file
167
memento-note/tests/undo-redo.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Note Input - Undo/Redo', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Expand the note input
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
await expect(page.locator('input[placeholder="Title"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should save history after 1 second of inactivity', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type "Hello"
|
||||
await contentArea.fill('Hello');
|
||||
|
||||
// Wait for debounce to save
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type " World"
|
||||
await contentArea.fill('Hello World');
|
||||
|
||||
// Wait for debounce
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Click Undo button
|
||||
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
|
||||
await expect(undoButton).toBeEnabled();
|
||||
await undoButton.click();
|
||||
|
||||
// Should show "Hello" only
|
||||
await expect(contentArea).toHaveValue('Hello');
|
||||
|
||||
// Undo should now be disabled (back to initial state)
|
||||
// Actually not disabled, there's the initial empty state
|
||||
await undoButton.click();
|
||||
await expect(contentArea).toHaveValue('');
|
||||
|
||||
// Undo should be disabled now
|
||||
await expect(undoButton).toBeDisabled();
|
||||
|
||||
// Click Redo
|
||||
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
|
||||
await expect(redoButton).toBeEnabled();
|
||||
await redoButton.click();
|
||||
|
||||
// Should show "Hello"
|
||||
await expect(contentArea).toHaveValue('Hello');
|
||||
|
||||
// Redo again
|
||||
await redoButton.click();
|
||||
await expect(contentArea).toHaveValue('Hello World');
|
||||
|
||||
// Redo should be disabled now
|
||||
await expect(redoButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should undo/redo with keyboard shortcuts', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type and wait
|
||||
await contentArea.fill('First');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
await contentArea.fill('Second');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Ctrl+Z to undo
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('First');
|
||||
|
||||
// Ctrl+Z again
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('');
|
||||
|
||||
// Ctrl+Y to redo
|
||||
await page.keyboard.press('Control+y');
|
||||
await expect(contentArea).toHaveValue('First');
|
||||
|
||||
// Ctrl+Shift+Z also works for redo
|
||||
await page.keyboard.press('Control+Shift+z');
|
||||
await expect(contentArea).toHaveValue('Second');
|
||||
});
|
||||
|
||||
test('should work with title and content', async ({ page }) => {
|
||||
const titleInput = page.locator('input[placeholder="Title"]');
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type title
|
||||
await titleInput.fill('My Title');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type content
|
||||
await contentArea.fill('My Content');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Undo
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(titleInput).toHaveValue('My Title');
|
||||
await expect(contentArea).toHaveValue('');
|
||||
|
||||
// Undo again
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(titleInput).toHaveValue('');
|
||||
await expect(contentArea).toHaveValue('');
|
||||
});
|
||||
|
||||
test('should reset history after creating note', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
const undoButton = page.locator('button:has(svg.lucide-undo-2)');
|
||||
|
||||
// Type something
|
||||
await contentArea.fill('Test note');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Undo should be enabled
|
||||
await expect(undoButton).toBeEnabled();
|
||||
|
||||
// Submit note
|
||||
await page.click('button:has-text("Add")');
|
||||
|
||||
// Wait for note to be created and form to reset
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Expand again
|
||||
await page.click('input[placeholder="Take a note..."]');
|
||||
|
||||
// Undo should be disabled (fresh start)
|
||||
await expect(undoButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should not create history during undo/redo', async ({ page }) => {
|
||||
const contentArea = page.locator('textarea[placeholder="Take a note..."]');
|
||||
|
||||
// Type "A"
|
||||
await contentArea.fill('A');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type "B"
|
||||
await contentArea.fill('B');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Type "C"
|
||||
await contentArea.fill('C');
|
||||
await page.waitForTimeout(1100);
|
||||
|
||||
// Undo to B
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('B');
|
||||
|
||||
// Undo to A
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(contentArea).toHaveValue('A');
|
||||
|
||||
// Redo to B
|
||||
await page.keyboard.press('Control+y');
|
||||
await expect(contentArea).toHaveValue('B');
|
||||
|
||||
// Redo to C
|
||||
await page.keyboard.press('Control+y');
|
||||
await expect(contentArea).toHaveValue('C');
|
||||
|
||||
// Should not be able to redo further
|
||||
const redoButton = page.locator('button:has(svg.lucide-redo-2)');
|
||||
await expect(redoButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
192
memento-note/tests/unit/adaptive-weighting.test.ts
Normal file
192
memento-note/tests/unit/adaptive-weighting.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { detectQueryType, getSearchWeights } from '../../lib/utils';
|
||||
import { QueryType } from '../../lib/types';
|
||||
|
||||
test.describe('Query Type Detection Tests', () => {
|
||||
test('should detect exact queries with quotes', () => {
|
||||
expect(detectQueryType('"Error 404"')).toBe('exact');
|
||||
expect(detectQueryType("'exact phrase'")).toBe('exact');
|
||||
expect(detectQueryType('"multiple words"')).toBe('exact');
|
||||
});
|
||||
|
||||
test('should detect conceptual queries', () => {
|
||||
// Question words
|
||||
expect(detectQueryType('how to cook pasta')).toBe('conceptual');
|
||||
expect(detectQueryType('what is python')).toBe('conceptual');
|
||||
expect(detectQueryType('where to find')).toBe('conceptual');
|
||||
expect(detectQueryType('why does this happen')).toBe('conceptual');
|
||||
expect(detectQueryType('who invented')).toBe('conceptual');
|
||||
|
||||
// Conceptual phrases
|
||||
expect(detectQueryType('ways to improve')).toBe('conceptual');
|
||||
expect(detectQueryType('best way to learn')).toBe('conceptual');
|
||||
expect(detectQueryType('guide for beginners')).toBe('conceptual');
|
||||
expect(detectQueryType('tips for cooking')).toBe('conceptual');
|
||||
expect(detectQueryType('learn about javascript')).toBe('conceptual');
|
||||
expect(detectQueryType('understand recursion')).toBe('conceptual');
|
||||
|
||||
// Learning patterns
|
||||
expect(detectQueryType('tutorial on react')).toBe('conceptual');
|
||||
expect(detectQueryType('guide to typescript')).toBe('conceptual');
|
||||
expect(detectQueryType('introduction to python')).toBe('conceptual');
|
||||
expect(detectQueryType('overview of microservices')).toBe('conceptual');
|
||||
expect(detectQueryType('explanation of quantum computing')).toBe('conceptual');
|
||||
expect(detectQueryType('examples of callbacks')).toBe('conceptual');
|
||||
});
|
||||
|
||||
test('should detect mixed queries', () => {
|
||||
// Simple terms
|
||||
expect(detectQueryType('javascript')).toBe('mixed');
|
||||
expect(detectQueryType('programming')).toBe('mixed');
|
||||
expect(detectQueryType('cooking')).toBe('mixed');
|
||||
|
||||
// Multiple words without patterns
|
||||
expect(detectQueryType('javascript programming')).toBe('mixed');
|
||||
expect(detectQueryType('react components')).toBe('mixed');
|
||||
expect(detectQueryType('database design')).toBe('mixed');
|
||||
});
|
||||
|
||||
test('should be case-insensitive', () => {
|
||||
expect(detectQueryType('How To Cook')).toBe('conceptual');
|
||||
expect(detectQueryType('WHAT IS PYTHON')).toBe('conceptual');
|
||||
expect(detectQueryType('"Error 404"')).toBe('exact');
|
||||
});
|
||||
|
||||
test('should handle empty and whitespace queries', () => {
|
||||
expect(detectQueryType('')).toBe('mixed');
|
||||
expect(detectQueryType(' ')).toBe('mixed');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Search Weight Calculation Tests', () => {
|
||||
test('should return correct weights for exact queries', () => {
|
||||
const weights = getSearchWeights('exact');
|
||||
|
||||
expect(weights.keywordWeight).toBe(2.0);
|
||||
expect(weights.semanticWeight).toBe(0.7);
|
||||
});
|
||||
|
||||
test('should return correct weights for conceptual queries', () => {
|
||||
const weights = getSearchWeights('conceptual');
|
||||
|
||||
expect(weights.keywordWeight).toBe(0.7);
|
||||
expect(weights.semanticWeight).toBe(1.5);
|
||||
});
|
||||
|
||||
test('should return correct weights for mixed queries', () => {
|
||||
const weights = getSearchWeights('mixed');
|
||||
|
||||
expect(weights.keywordWeight).toBe(1.0);
|
||||
expect(weights.semanticWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
test('should handle unknown query type as mixed', () => {
|
||||
const weights = getSearchWeights('unknown' as QueryType);
|
||||
|
||||
expect(weights.keywordWeight).toBe(1.0);
|
||||
expect(weights.semanticWeight).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Adaptive Weighting Integration Tests', () => {
|
||||
test('exact query boosts keyword matches', () => {
|
||||
const queryType = detectQueryType('"exact phrase"');
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Keyword matches should be 2x more important
|
||||
expect(weights.keywordWeight).toBeGreaterThan(weights.semanticWeight);
|
||||
expect(weights.keywordWeight / weights.semanticWeight).toBeCloseTo(2.0 / 0.7, 1);
|
||||
});
|
||||
|
||||
test('conceptual query boosts semantic matches', () => {
|
||||
const queryType = detectQueryType('how to cook');
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Semantic matches should be more important
|
||||
expect(weights.semanticWeight).toBeGreaterThan(weights.keywordWeight);
|
||||
expect(weights.semanticWeight / weights.keywordWeight).toBeCloseTo(1.5 / 0.7, 1);
|
||||
});
|
||||
|
||||
test('mixed query treats both equally', () => {
|
||||
const queryType = detectQueryType('javascript programming');
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Both should have equal weight
|
||||
expect(weights.keywordWeight).toBe(weights.semanticWeight);
|
||||
expect(weights.keywordWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
test('weights significantly affect ranking scores', () => {
|
||||
const k = 20;
|
||||
const rank = 5;
|
||||
|
||||
// Same rank with different weights
|
||||
const exactQueryScore = (1 / (k + rank)) * 2.0; // keyword
|
||||
const conceptualQueryScore = (1 / (k + rank)) * 1.5; // semantic
|
||||
|
||||
// Exact keyword match should get highest score
|
||||
expect(exactQueryScore).toBeGreaterThan(conceptualQueryScore);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Weight Impact on Scenarios', () => {
|
||||
test('scenario 1: User searches for "Error 404"', () => {
|
||||
const query = '"Error 404"';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be exact match type
|
||||
expect(queryType).toBe('exact');
|
||||
|
||||
// Keyword matches should be heavily prioritized
|
||||
expect(weights.keywordWeight).toBe(2.0);
|
||||
|
||||
// Semantic matches should be deprioritized
|
||||
expect(weights.semanticWeight).toBe(0.7);
|
||||
|
||||
// This ensures that notes with "Error 404" appear first,
|
||||
// even if semantic search might suggest other error types
|
||||
});
|
||||
|
||||
test('scenario 2: User searches for "how to cook pasta"', () => {
|
||||
const query = 'how to cook pasta';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be conceptual type
|
||||
expect(queryType).toBe('conceptual');
|
||||
|
||||
// Semantic matches should be boosted
|
||||
expect(weights.semanticWeight).toBe(1.5);
|
||||
|
||||
// Keyword matches should be reduced
|
||||
expect(weights.keywordWeight).toBe(0.7);
|
||||
|
||||
// This ensures that notes about cooking, pasta, recipes appear,
|
||||
// even if they don't contain the exact words "how to cook pasta"
|
||||
});
|
||||
|
||||
test('scenario 3: User searches for "tutorial javascript"', () => {
|
||||
const query = 'tutorial javascript';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be conceptual type (starts with "tutorial")
|
||||
expect(queryType).toBe('conceptual');
|
||||
|
||||
// Semantic search should be prioritized
|
||||
expect(weights.semanticWeight).toBeGreaterThan(weights.keywordWeight);
|
||||
});
|
||||
|
||||
test('scenario 4: User searches for "react hooks"', () => {
|
||||
const query = 'react hooks';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be mixed type (no specific pattern)
|
||||
expect(queryType).toBe('mixed');
|
||||
|
||||
// Both should have equal weight
|
||||
expect(weights.keywordWeight).toBe(weights.semanticWeight);
|
||||
});
|
||||
});
|
||||
136
memento-note/tests/unit/embedding-validation.test.ts
Normal file
136
memento-note/tests/unit/embedding-validation.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { validateEmbedding, calculateL2Norm, normalizeEmbedding } from '../../lib/utils'
|
||||
|
||||
test.describe('Embedding Validation', () => {
|
||||
test.describe('validateEmbedding()', () => {
|
||||
test('should validate a normal embedding', () => {
|
||||
const embedding = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should reject empty embedding', () => {
|
||||
const result = validateEmbedding([])
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding is empty or has zero dimensionality')
|
||||
})
|
||||
|
||||
test('should reject null embedding', () => {
|
||||
const result = validateEmbedding(null as any)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding is empty or has zero dimensionality')
|
||||
})
|
||||
|
||||
test('should reject embedding with NaN values', () => {
|
||||
const embedding = [0.1, NaN, 0.3, 0.4, 0.5]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding contains NaN values')
|
||||
})
|
||||
|
||||
test('should reject embedding with Infinity values', () => {
|
||||
const embedding = [0.1, 0.2, Infinity, 0.4, 0.5]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding contains Infinity values')
|
||||
})
|
||||
|
||||
test('should reject zero vector', () => {
|
||||
const embedding = [0, 0, 0, 0, 0]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding is a zero vector (all values are 0)')
|
||||
})
|
||||
|
||||
test('should warn about L2 norm outside normal range', () => {
|
||||
// Very small norm
|
||||
const smallEmbedding = [0.01, 0.01, 0.01]
|
||||
const result1 = validateEmbedding(smallEmbedding)
|
||||
|
||||
expect(result1.valid).toBe(false)
|
||||
expect(result1.issues.some(issue => issue.includes('L2 norm'))).toBe(true)
|
||||
|
||||
// Very large norm
|
||||
const largeEmbedding = [2, 2, 2]
|
||||
const result2 = validateEmbedding(largeEmbedding)
|
||||
|
||||
expect(result2.valid).toBe(false)
|
||||
expect(result2.issues.some(issue => issue.includes('L2 norm'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should detect multiple issues', () => {
|
||||
const embedding = [NaN, Infinity, 0]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues.length).toBeGreaterThan(1)
|
||||
expect(result.issues).toContain('Embedding contains NaN values')
|
||||
expect(result.issues).toContain('Embedding contains Infinity values')
|
||||
// Note: NaN and Infinity are not zero, so it won't detect zero vector
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('calculateL2Norm()', () => {
|
||||
test('should calculate correct L2 norm', () => {
|
||||
const vector = [3, 4]
|
||||
const norm = calculateL2Norm(vector)
|
||||
|
||||
expect(norm).toBe(5) // sqrt(3^2 + 4^2) = 5
|
||||
})
|
||||
|
||||
test('should return 0 for zero vector', () => {
|
||||
const vector = [0, 0, 0]
|
||||
const norm = calculateL2Norm(vector)
|
||||
|
||||
expect(norm).toBe(0)
|
||||
})
|
||||
|
||||
test('should handle negative values', () => {
|
||||
const vector = [-3, -4]
|
||||
const norm = calculateL2Norm(vector)
|
||||
|
||||
expect(norm).toBe(5) // sqrt((-3)^2 + (-4)^2) = 5
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('normalizeEmbedding()', () => {
|
||||
test('should normalize a vector to unit L2 norm', () => {
|
||||
const embedding = [3, 4]
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
const norm = calculateL2Norm(normalized)
|
||||
|
||||
expect(norm).toBeCloseTo(1.0, 5)
|
||||
})
|
||||
|
||||
test('should preserve direction of vector', () => {
|
||||
const embedding = [1, 2, 3]
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
|
||||
// Check that ratios are preserved
|
||||
expect(normalized[1] / normalized[0]).toBeCloseTo(embedding[1] / embedding[0], 5)
|
||||
expect(normalized[2] / normalized[1]).toBeCloseTo(embedding[2] / embedding[1], 5)
|
||||
})
|
||||
|
||||
test('should return zero vector unchanged', () => {
|
||||
const embedding = [0, 0, 0]
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
|
||||
expect(normalized).toEqual(embedding)
|
||||
})
|
||||
|
||||
test('should handle already normalized vectors', () => {
|
||||
const embedding = [0.707, 0.707] // Already approximately unit norm
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
const norm = calculateL2Norm(normalized)
|
||||
|
||||
expect(norm).toBeCloseTo(1.0, 5)
|
||||
})
|
||||
})
|
||||
})
|
||||
152
memento-note/tests/unit/rrf.test.ts
Normal file
152
memento-note/tests/unit/rrf.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { calculateRRFK } from '../../lib/utils';
|
||||
|
||||
test.describe('RRF K Calculation Tests', () => {
|
||||
test('should return minimum k=20 for small datasets', () => {
|
||||
// For small datasets (< 200 notes), k should be 20
|
||||
expect(calculateRRFK(0)).toBe(20);
|
||||
expect(calculateRRFK(10)).toBe(20);
|
||||
expect(calculateRRFK(50)).toBe(20);
|
||||
expect(calculateRRFK(100)).toBe(20);
|
||||
expect(calculateRRFK(199)).toBe(20);
|
||||
});
|
||||
|
||||
test('should return k=20 for exactly 200 notes', () => {
|
||||
expect(calculateRRFK(200)).toBe(20);
|
||||
});
|
||||
|
||||
test('should scale k for larger datasets', () => {
|
||||
// k = max(20, totalNotes / 10)
|
||||
expect(calculateRRFK(500)).toBe(50); // 500/10 = 50
|
||||
expect(calculateRRFK(1000)).toBe(100); // 1000/10 = 100
|
||||
expect(calculateRRFK(250)).toBe(25); // 250/10 = 25
|
||||
});
|
||||
|
||||
test('should handle edge cases', () => {
|
||||
expect(calculateRRFK(201)).toBe(20); // 201/10 = 20.1 → floor → 20, max(20,20) = 20
|
||||
expect(calculateRRFK(210)).toBe(21); // 210/10 = 21
|
||||
expect(calculateRRFK(10000)).toBe(1000); // 10000/10 = 1000
|
||||
});
|
||||
|
||||
test('k should always be at least 20', () => {
|
||||
// Even for very small datasets, k should not be below 20
|
||||
for (let i = 0; i <= 200; i++) {
|
||||
expect(calculateRRFK(i)).toBeGreaterThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be lower than old value for typical datasets', () => {
|
||||
// For typical user datasets (< 500 notes), new k should be lower than old k=60
|
||||
expect(calculateRRFK(100)).toBeLessThan(60);
|
||||
expect(calculateRRFK(200)).toBeLessThan(60);
|
||||
expect(calculateRRFK(300)).toBe(30); // 300/10 = 30
|
||||
expect(calculateRRFK(500)).toBe(50); // 500/10 = 50
|
||||
});
|
||||
|
||||
test('should surpass old value for very large datasets', () => {
|
||||
// For very large datasets (> 600 notes), k should be higher than 60
|
||||
expect(calculateRRFK(700)).toBe(70); // 700/10 = 70
|
||||
expect(calculateRRFK(1000)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('RRF Ranking Behavior Tests', () => {
|
||||
test('RRF with lower k penalizes low ranks more', () => {
|
||||
// Simulate RRF scores for a note at rank 5
|
||||
// Formula: score = 1 / (k + rank)
|
||||
|
||||
const rank = 5;
|
||||
const scoreWithK20 = 1 / (20 + rank); // 1/25 = 0.04
|
||||
const scoreWithK60 = 1 / (60 + rank); // 1/65 = 0.015
|
||||
|
||||
// Lower k gives higher score to low ranks
|
||||
expect(scoreWithK20).toBeGreaterThan(scoreWithK60);
|
||||
});
|
||||
|
||||
test('RRF favors items ranked high in both lists', () => {
|
||||
// Note A: rank 1 in both lists
|
||||
const scoreA_K20 = 1 / (20 + 1) + 1 / (20 + 1); // 2/21 ≈ 0.095
|
||||
|
||||
// Note B: rank 1 in one list, rank 10 in other
|
||||
const scoreB_K20 = 1 / (20 + 1) + 1 / (20 + 10); // 1/21 + 1/30 ≈ 0.081
|
||||
|
||||
// Note C: rank 5 in both lists
|
||||
const scoreC_K20 = 1 / (20 + 5) + 1 / (20 + 5); // 2/25 = 0.08
|
||||
|
||||
// Note A should have highest score (consistently high)
|
||||
expect(scoreA_K20).toBeGreaterThan(scoreB_K20);
|
||||
expect(scoreA_K20).toBeGreaterThan(scoreC_K20);
|
||||
});
|
||||
|
||||
test('k=20 vs k=60 ranking difference', () => {
|
||||
// Note at rank 20
|
||||
const rank = 20;
|
||||
|
||||
const scoreWithK20 = 1 / (20 + rank); // 1/40 = 0.025
|
||||
const scoreWithK60 = 1 / (60 + rank); // 1/80 = 0.0125
|
||||
|
||||
// With k=20, rank 20 is scored 2x higher than with k=60
|
||||
expect(scoreWithK20 / scoreWithK60).toBeCloseTo(2.0, 1);
|
||||
});
|
||||
|
||||
test('RRF score should decrease as rank increases', () => {
|
||||
const k = 20;
|
||||
|
||||
const scoreRank1 = 1 / (k + 1);
|
||||
const scoreRank5 = 1 / (k + 5);
|
||||
const scoreRank10 = 1 / (k + 10);
|
||||
const scoreRank50 = 1 / (k + 50);
|
||||
|
||||
expect(scoreRank1).toBeGreaterThan(scoreRank5);
|
||||
expect(scoreRank5).toBeGreaterThan(scoreRank10);
|
||||
expect(scoreRank10).toBeGreaterThan(scoreRank50);
|
||||
});
|
||||
|
||||
test('RRF handles missing ranks gracefully', () => {
|
||||
// If a note is not in a list, it gets the max rank
|
||||
const k = 20;
|
||||
const totalNotes = 100;
|
||||
const missingRank = totalNotes; // Treated as worst rank
|
||||
|
||||
const scoreWithMissing = 1 / (k + missingRank);
|
||||
const scoreWithRank50 = 1 / (k + 50);
|
||||
|
||||
// Missing rank should give much lower score
|
||||
expect(scoreWithMissing).toBeLessThan(scoreWithRank50);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('RRF Adaptive Behavior Tests', () => {
|
||||
test('adaptive k provides better rankings for small datasets', () => {
|
||||
// For 50 notes: k=20 (adaptive) vs k=60 (old)
|
||||
const totalNotes = 50;
|
||||
const kAdaptive = calculateRRFK(totalNotes); // 20
|
||||
const kOld = 60;
|
||||
|
||||
// Compare scores for rank 10 (20% of dataset)
|
||||
const rank = 10;
|
||||
const scoreAdaptive = 1 / (kAdaptive + rank);
|
||||
const scoreOld = 1 / (kOld + rank);
|
||||
|
||||
// Adaptive k gives higher score to mid-rank items in small datasets
|
||||
expect(scoreAdaptive).toBeGreaterThan(scoreOld);
|
||||
});
|
||||
|
||||
test('adaptive k scales appropriately', () => {
|
||||
const datasets = [10, 50, 100, 200, 500, 1000];
|
||||
|
||||
datasets.forEach(notes => {
|
||||
const k = calculateRRFK(notes);
|
||||
|
||||
// k should always be at least 20
|
||||
expect(k).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// k should scale with dataset size
|
||||
if (notes < 200) {
|
||||
expect(k).toBe(20);
|
||||
} else {
|
||||
expect(k).toBe(Math.floor(notes / 10));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user