Initial commit: Brachnha Insight project setup
- Next.js 14+ with App Router and TypeScript - Tailwind CSS and ShadCN UI styling - Zustand state management - Dexie.js for IndexedDB (local-first data) - Auth.js v5 for authentication - BMAD framework integration Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
53
tests/README.md
Normal file
53
tests/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Test Suite Documentation
|
||||
|
||||
This project uses **Playwright** for End-to-End (E2E) testing.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
\`\`\`bash
|
||||
npm install
|
||||
\`\`\`
|
||||
|
||||
2. Setup environment:
|
||||
\`\`\`bash
|
||||
cp .env.example .env
|
||||
# Update .env with local configuration if needed
|
||||
\`\`\`
|
||||
|
||||
3. Install Playwright browsers:
|
||||
\`\`\`bash
|
||||
npx playwright install
|
||||
\`\`\`
|
||||
|
||||
## Running Tests
|
||||
|
||||
- **Run all E2E tests:**
|
||||
\`\`\`bash
|
||||
npm run test:e2e
|
||||
\`\`\`
|
||||
|
||||
- **Run in UI mode (interactive):**
|
||||
\`\`\`bash
|
||||
npx playwright test --ui
|
||||
\`\`\`
|
||||
|
||||
- **Run specific test file:**
|
||||
\`\`\`bash
|
||||
npx playwright test tests/e2e/example.spec.ts
|
||||
\`\`\`
|
||||
|
||||
## Architecture
|
||||
|
||||
We follow a structured pattern for maintainable tests:
|
||||
|
||||
- **`tests/e2e/`**: Contains the actual test files.
|
||||
- **`tests/support/fixtures/`**: Playwright fixtures. Use `test` from here, not `@playwright/test`.
|
||||
- **`tests/support/fixtures/factories/`**: Data factories for generating test data.
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use `data-testid`**: Prefer `page.getByTestId('submit-btn')` over CSS/XPath selectors.
|
||||
2. **Atomic Tests**: Each test should be independent and run in isolation.
|
||||
3. **Use Factories**: Don't hardcode data. Use `userFactory.createUser()` to get fresh data.
|
||||
4. **Network First**: When testing external APIs, use network interception to avoid flakiness.
|
||||
20
tests/component/DeleteEntryDialog.test.tsx
Normal file
20
tests/component/DeleteEntryDialog.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
// import { DeleteEntryDialog } from '../../src/components/journal/DeleteEntryDialog';
|
||||
|
||||
describe('DeleteEntryDialog', () => {
|
||||
it('should show dialog and optimistically remove item from UI on confirm', async () => {
|
||||
// GIVEN: Dialog is open
|
||||
// const onDeleteMock = vi.fn();
|
||||
// render(<DeleteEntryDialog isOpen={true} onDelete={onDeleteMock} />);
|
||||
|
||||
// WHEN: 'Delete' confirmed
|
||||
// fireEvent.click(screen.getByText('Delete'));
|
||||
|
||||
// THEN: Dialog closes and onConfirm called
|
||||
// expect(onDeleteMock).toHaveBeenCalled();
|
||||
|
||||
// For failing test purpose
|
||||
expect(true).toBe(false);
|
||||
});
|
||||
});
|
||||
73
tests/e2e/04-settings-provider.spec.ts
Normal file
73
tests/e2e/04-settings-provider.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createProviderConfig } from '../support/factories/provider.factory';
|
||||
|
||||
test.describe('Settings - API Provider Configuration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear local storage to start fresh
|
||||
await page.goto('/settings'); // Navigate first to access localStorage
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
test('should allow user to enter and save provider credentials', async ({ page }) => {
|
||||
const providerData = createProviderConfig();
|
||||
|
||||
// GIVEN: User is on settings page
|
||||
await page.goto('/settings');
|
||||
|
||||
// WHEN: User enters API Key and Base URL
|
||||
await page.getByLabel('API Key').fill(providerData.apiKey);
|
||||
await page.getByLabel('Base URL').fill(providerData.baseUrl);
|
||||
await page.getByLabel('Model Name').fill(providerData.modelId);
|
||||
|
||||
// AND: User clicks Save
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// THEN: Success feedback is shown
|
||||
await expect(page.getByText('Settings saved')).toBeVisible();
|
||||
|
||||
// AND: Values are persisted after reload
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('API Key')).toHaveValue(providerData.apiKey);
|
||||
await expect(page.getByLabel('Base URL')).toHaveValue(providerData.baseUrl);
|
||||
await expect(page.getByLabel('Model Name')).toHaveValue(providerData.modelId);
|
||||
});
|
||||
|
||||
test('should verify connection with valid credentials', async ({ page }) => {
|
||||
const providerData = createProviderConfig();
|
||||
|
||||
// Setup network mock for "Hello" check
|
||||
await page.route('**/models', async route => {
|
||||
await route.fulfill({ status: 200, json: { data: [] } });
|
||||
});
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.getByLabel('API Key').fill(providerData.apiKey);
|
||||
await page.getByLabel('Base URL').fill(providerData.baseUrl);
|
||||
|
||||
// WHEN: User clicks "Test Connection"
|
||||
await page.getByRole('button', { name: 'Test Connection' }).click();
|
||||
|
||||
// THEN: User sees success message
|
||||
await expect(page.getByText('Connected ✅')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error for invalid connection', async ({ page }) => {
|
||||
const providerData = createProviderConfig();
|
||||
|
||||
// Setup network mock for failure
|
||||
await page.route('**/models', async route => {
|
||||
await route.fulfill({ status: 401, json: { error: 'Invalid API Key' } });
|
||||
});
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.getByLabel('API Key').fill(providerData.apiKey);
|
||||
await page.getByLabel('Base URL').fill(providerData.baseUrl);
|
||||
|
||||
// WHEN: User clicks "Test Connection"
|
||||
await page.getByRole('button', { name: 'Test Connection' }).click();
|
||||
|
||||
// THEN: User sees error message
|
||||
await expect(page.getByText('Connection failed')).toBeVisible();
|
||||
});
|
||||
});
|
||||
46
tests/e2e/chat-flow.spec.ts
Normal file
46
tests/e2e/chat-flow.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Chat Flow with Mocked LLM', async ({ page }) => {
|
||||
// 1. Setup Mock API - must be set before navigation
|
||||
await page.route('**/v1/chat/completions', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
choices: [{
|
||||
message: { content: "This is a mock AI response." }
|
||||
}]
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Configure Settings
|
||||
await page.goto('/settings');
|
||||
await page.getByLabel('API Key').fill('sk-test-key');
|
||||
await page.getByLabel('Base URL').fill('https://api.mock.com/v1');
|
||||
await page.getByLabel('Model Name').fill('gpt-mock');
|
||||
|
||||
// Wait for settings to be saved (Zustand persist uses localStorage)
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 3. Go to Chat
|
||||
await page.goto('/chat');
|
||||
|
||||
// Wait for empty state to appear (indicates session is ready)
|
||||
await expect(page.getByRole('heading', { name: /frustrating you/i })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 4. Send Message
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('I hate writing tests.');
|
||||
|
||||
// Wait for button to be enabled
|
||||
const sendButton = page.getByRole('button').first();
|
||||
await expect(sendButton).toBeEnabled({ timeout: 3000 });
|
||||
await sendButton.click();
|
||||
|
||||
// 5. Verify User Message - wait for it to appear in the chat
|
||||
await expect(page.getByText('I hate writing tests.')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 6. Verify AI Response
|
||||
await expect(page.getByText('This is a mock AI response.')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
77
tests/e2e/chat.spec.ts
Normal file
77
tests/e2e/chat.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '../support/fixtures';
|
||||
|
||||
test.describe('Chat Interface (Story 1.2)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// GIVEN: User is on the homepage
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('[P0] should allow user to send a message', async ({ page }) => {
|
||||
// GIVEN: Input field is visible
|
||||
const input = page.getByTestId('chat-input');
|
||||
const sendButton = page.getByTestId('send-button');
|
||||
|
||||
// WHEN: User types "Hello" and clicks send
|
||||
await input.fill('Hello World');
|
||||
await sendButton.click();
|
||||
|
||||
// THEN: Input should be cleared
|
||||
await expect(input).toHaveValue('');
|
||||
|
||||
// THEN: Message should appear in the chat
|
||||
// We look for the bubble with the specific text
|
||||
// Note: The app might render markdown, so exact text match usually works
|
||||
await expect(page.getByTestId('chat-bubble-user')).toContainText('Hello World');
|
||||
});
|
||||
|
||||
test('[P0] should display AI typing indicator', async ({ page }) => {
|
||||
// This test relies on the simulation delay added in the store
|
||||
|
||||
// WHEN: User sends a message
|
||||
await page.getByTestId('chat-input').fill('Tell me a story');
|
||||
await page.getByTestId('send-button').click();
|
||||
|
||||
// THEN: Typing indicator should appear immediately (before AI response)
|
||||
const indicator = page.getByTestId('typing-indicator');
|
||||
await expect(indicator).toBeVisible();
|
||||
|
||||
// THEN: Typing indicator should disappear eventually (after response)
|
||||
// The delay is simulated as 1000-2000ms in the store
|
||||
await expect(indicator).toBeHidden({ timeout: 5000 });
|
||||
|
||||
// THEN: AI response should appear
|
||||
await expect(page.getByTestId('chat-bubble-ai')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[P0] should persist messages across reload', async ({ page }) => {
|
||||
// GIVEN: User sends a message
|
||||
const uniqueMessage = `Persistence Test ${Date.now()}`;
|
||||
await page.getByTestId('chat-input').fill(uniqueMessage);
|
||||
await page.getByTestId('send-button').click();
|
||||
|
||||
// Wait for message to appear
|
||||
await expect(page.getByText(uniqueMessage)).toBeVisible();
|
||||
|
||||
// WHEN: Page is reloaded
|
||||
await page.reload();
|
||||
|
||||
// THEN: Message should still be visible
|
||||
await expect(page.getByText(uniqueMessage)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[P1] should adapt to mobile viewport', async ({ page }) => {
|
||||
// GIVEN: Mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// WHEN: Page loads
|
||||
await page.goto('/');
|
||||
|
||||
// THEN: Important elements are visible
|
||||
await expect(page.getByTestId('chat-input')).toBeVisible();
|
||||
await expect(page.getByTestId('send-button')).toBeVisible();
|
||||
|
||||
// Check elements fit within width (approximate check)
|
||||
const inputBox = await page.getByTestId('chat-input').boundingBox();
|
||||
expect(inputBox?.width).toBeLessThan(375); // Should have padding
|
||||
});
|
||||
});
|
||||
28
tests/e2e/example.spec.ts
Normal file
28
tests/e2e/example.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '../support/fixtures';
|
||||
|
||||
test.describe('Example Test Suite', () => {
|
||||
test('should load homepage', async ({ page }) => {
|
||||
// Navigating to the base URL
|
||||
await page.goto('/');
|
||||
|
||||
// Check that we're on the right page
|
||||
// Note: Adjust the title expectation to match your actual app title
|
||||
await expect(page).toHaveTitle(/Test01/i);
|
||||
|
||||
// Example of using data-testid selector (preferred)
|
||||
// await expect(page.getByTestId('main-container')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create valid user data using factory', async ({ userFactory }) => {
|
||||
// This demonstrates using the factory to generate data
|
||||
// Even though we don't login yet, it proves the factory works
|
||||
const user = await userFactory.createUser();
|
||||
|
||||
expect(user.email).toBeTruthy();
|
||||
expect(user.email).toContain('@');
|
||||
expect(user.password).toBeTruthy();
|
||||
|
||||
// Console log to debug (remove in real tests)
|
||||
console.log('Generated test user:', user.name);
|
||||
});
|
||||
});
|
||||
68
tests/e2e/ghostwriter-flow.spec.ts
Normal file
68
tests/e2e/ghostwriter-flow.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Ghostwriter Flow', () => {
|
||||
test('Scenario 2.3.2 (P0): Regeneration respects user critique', async ({ page }) => {
|
||||
// GIVEN: A generated draft exists (mocked)
|
||||
await page.goto('/session/chat');
|
||||
// Mock the "Drafting" completion state by injecting state or ensuring mock network response
|
||||
await page.route('**/api/llm/generate', async (route) => {
|
||||
const json = { content: 'Draft Version 1' };
|
||||
await route.fulfill({ json });
|
||||
});
|
||||
|
||||
// Trigger generation (assuming UI state is ready or fast-track used)
|
||||
// For E2E, we might need to simulate the chat flow or use a "Debug: Force Draft" button if available in dev mode
|
||||
// Assuming we can start from a state where the draft sheet is open for this test, or we navigate through chat.
|
||||
// Let's assume we simulate the chat end first.
|
||||
await page.fill('[data-testid="chat-input"]', 'Fast track this');
|
||||
await page.click('[data-testid="fast-track-btn"]');
|
||||
await expect(page.locator('[data-testid="draft-sheet"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="markdown-content"]')).toContainText('Draft Version 1');
|
||||
|
||||
// WHEN: User clicks Thumbs Down and critiques
|
||||
await page.click('[data-testid="thumbs-down-btn"]');
|
||||
await expect(page.locator('[data-testid="critique-input"]')).toBeVisible();
|
||||
await page.fill('[data-testid="critique-input"]', 'Make it shorter');
|
||||
|
||||
// Intercept the regeneration request
|
||||
let critiqueSent = false;
|
||||
await page.route('**/api/llm/regenerate', async (route) => {
|
||||
critiqueSent = true;
|
||||
const postData = route.request().postDataJSON();
|
||||
expect(postData.critique).toBe('Make it shorter');
|
||||
await route.fulfill({ json: { content: 'Draft Version 2 (Shorter)' } });
|
||||
});
|
||||
|
||||
await page.click('[data-testid="regenerate-submit-btn"]');
|
||||
|
||||
// THEN: A new draft is requested with critique context
|
||||
await expect(page.locator('[data-testid="markdown-content"]')).toContainText('Draft Version 2 (Shorter)');
|
||||
expect(critiqueSent).toBe(true);
|
||||
});
|
||||
|
||||
test('Scenario 2.4.1 (P0): Copy button places text in clipboard', async ({ page, context }) => {
|
||||
// Grant clipboard permissions
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
// GIVEN: A draft is visible
|
||||
await page.goto('/session/chat');
|
||||
// Mock generation
|
||||
await page.route('**/api/llm/generate', async (route) => {
|
||||
await route.fulfill({ json: { content: 'Final Draft Content' } });
|
||||
});
|
||||
await page.fill('[data-testid="chat-input"]', 'Fast track');
|
||||
await page.click('[data-testid="fast-track-btn"]');
|
||||
await expect(page.locator('[data-testid="draft-sheet"]')).toBeVisible();
|
||||
|
||||
// WHEN: User clicks Copy
|
||||
await page.click('[data-testid="copy-btn"]');
|
||||
|
||||
// THEN: Clipboard contains text
|
||||
// Note: Reading clipboard in headless might be tricky, checking for "Copied" toast is a good proxy for PWA checks
|
||||
await expect(page.locator('[data-testid="toast-success"]')).toContainText('Copied to clipboard');
|
||||
|
||||
// Optional: layout check (R-2.5) - Verify rendering didn't break
|
||||
await expect(page.locator('[data-testid="markdown-content"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="markdown-content"]')).not.toHaveCSS('overflow-x', 'hidden'); // Crude check for broken layout
|
||||
});
|
||||
});
|
||||
13
tests/e2e/initial-load.spec.ts
Normal file
13
tests/e2e/initial-load.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Initial Load', () => {
|
||||
test('should render empty state history feed for new user', async ({ page }) => {
|
||||
// GIVEN: New user (empty DB)
|
||||
|
||||
// WHEN: Load home page
|
||||
await page.goto('/');
|
||||
|
||||
// THEN: 'No entries yet' message visible
|
||||
await expect(page.getByText('No entries yet')).toBeVisible();
|
||||
});
|
||||
});
|
||||
86
tests/e2e/settings-byod.spec.ts
Normal file
86
tests/e2e/settings-byod.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Epic 4: Power User Settings (BYOD)', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear storage to ensure clean state
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
||||
// Mock API responses for validation
|
||||
await page.route('**/chat/completions', async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ choices: [{ message: { content: 'mock success' } }] })
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('P0: Provider Switching Configuration', async ({ page }) => {
|
||||
// Navigate to settings
|
||||
await page.goto('/settings');
|
||||
await expect(page).toHaveURL(/.*settings/);
|
||||
|
||||
// 1. Add First Provider
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Fill Provider 1
|
||||
await page.getByRole('textbox', { name: /Provider Name/i }).fill('Mock Provider 1');
|
||||
await page.getByRole('textbox', { name: /Base URL/i }).fill('https://mock-provider-1.com/v1');
|
||||
await page.getByRole('textbox', { name: /API Key/i }).fill('sk-key-1');
|
||||
await page.getByRole('textbox', { name: /Model Name/i }).fill('model-1');
|
||||
await page.getByRole('button', { name: /Save/i }).click();
|
||||
|
||||
// Verify Modal Closes (implicit success check)
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
|
||||
// 2. Add Second Provider (Switching Test)
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Fill Provider 2
|
||||
await page.getByRole('textbox', { name: /Provider Name/i }).fill('Mock Provider 2');
|
||||
await page.getByRole('textbox', { name: /Base URL/i }).fill('https://mock-provider-2.com/v1');
|
||||
await page.getByRole('textbox', { name: /API Key/i }).fill('sk-key-2');
|
||||
await page.getByRole('textbox', { name: /Model Name/i }).fill('model-2');
|
||||
await page.getByRole('button', { name: /Save/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
|
||||
// 3. Verify Local Storage has the LATEST active provider (Model 2)
|
||||
const settings = await page.evaluate(() => localStorage);
|
||||
const storageString = JSON.stringify(settings);
|
||||
|
||||
console.log('Storage:', storageString);
|
||||
|
||||
expect(storageString).toContain('https://mock-provider-2.com/v1');
|
||||
expect(storageString).toContain('model-2');
|
||||
});
|
||||
|
||||
test('P0: Key Storage Security (Obfuscation)', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
const secretKey = 'sk-secret-key-12345';
|
||||
|
||||
// Open Modal
|
||||
await page.getByRole('button', { name: 'Add Provider', exact: true }).click();
|
||||
|
||||
// Fill Sensitive Data
|
||||
await page.getByRole('textbox', { name: /Provider Name/i }).fill('Security Test');
|
||||
await page.getByRole('textbox', { name: /Base URL/i }).fill('https://api.openai.com/v1');
|
||||
await page.getByRole('textbox', { name: /API Key/i }).fill(secretKey);
|
||||
await page.getByRole('textbox', { name: /Model Name/i }).fill('gpt-4');
|
||||
await page.getByRole('button', { name: /Save/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeHidden();
|
||||
|
||||
// Verify key is NOT stored in plain text
|
||||
const settings = await page.evaluate(() => localStorage);
|
||||
const storageValues = Object.values(settings).join('');
|
||||
|
||||
// The raw key should NOT be found exactly as entered if obfuscation works
|
||||
// Note: If this fails, it means Security P0 Failed (Critical Issue)
|
||||
expect(storageValues).not.toContain(secretKey);
|
||||
});
|
||||
|
||||
});
|
||||
12
tests/integration/deletion-persistence.test.ts
Normal file
12
tests/integration/deletion-persistence.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Deletion Persistence', () => {
|
||||
test('should permanently remove item from Dexie DB', async () => {
|
||||
// GIVEN: Item exists in DB
|
||||
|
||||
// WHEN: Delete action (via Service or UI)
|
||||
|
||||
// THEN: Item is gone from DB
|
||||
expect(true).toBe(false);
|
||||
});
|
||||
});
|
||||
27
tests/integration/ghostwriter-persistence.test.ts
Normal file
27
tests/integration/ghostwriter-persistence.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Ghostwriter Persistence', () => {
|
||||
test('Scenario 2.4.2 (P0): Session Saved as Completed', async ({ request }) => {
|
||||
// GIVEN: A chat session flow is completed
|
||||
// This is an API/Integration test, so we hit the endpoint directly if possible,
|
||||
// OR we use the service capability if we were in a unit test.
|
||||
// For Playwright Integration (API), we assume an endpoint exists to finalize.
|
||||
|
||||
// Setup: Create a session
|
||||
const createRes = await request.post('/api/sessions', { data: { title: 'Test Session' } });
|
||||
const { sessionId } = await createRes.json();
|
||||
|
||||
// WHEN: User saves the final draft
|
||||
const saveRes = await request.post(`/api/sessions/${sessionId}/finalize`, {
|
||||
data: { finalContent: 'My Lesson' }
|
||||
});
|
||||
expect(saveRes.status()).toBe(200);
|
||||
|
||||
// THEN: Session status is COMPLETED
|
||||
const getRes = await request.get(`/api/sessions/${sessionId}`);
|
||||
const session = await getRes.json();
|
||||
|
||||
expect(session.status).toBe('COMPLETED');
|
||||
expect(session.finalArtifact).toBe('My Lesson');
|
||||
});
|
||||
});
|
||||
79
tests/integration/offline-action-queueing.test.ts
Normal file
79
tests/integration/offline-action-queueing.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../support/fixtures/offline.fixture';
|
||||
|
||||
test.describe('Offline Action Queueing', () => {
|
||||
test('should enqueue SAVE_DRAFT action when offline', async ({ offlineControl, page }) => {
|
||||
// GIVEN: App is loaded
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => (window as any).db !== undefined && (window as any).DraftService !== undefined, { timeout: 10000 });
|
||||
|
||||
// GIVEN: App goes offline
|
||||
await offlineControl.goOffline(page.context());
|
||||
|
||||
// WHEN: DraftService.saveDraft is called (Service Integration)
|
||||
await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
await window.DraftService.saveDraft({
|
||||
title: 'Offline Draft',
|
||||
content: 'Content written offline',
|
||||
tags: [],
|
||||
createdAt: Date.now(),
|
||||
status: 'draft',
|
||||
sessionId: 'test-session'
|
||||
});
|
||||
});
|
||||
|
||||
// THEN: 'syncQueue' table has 1 item
|
||||
const queueCount = await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
return await window.db.syncQueue.count();
|
||||
});
|
||||
|
||||
expect(queueCount).toBe(1);
|
||||
|
||||
const firstItem = await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
return await window.db.syncQueue.orderBy('createdAt').first();
|
||||
});
|
||||
expect(firstItem.action).toBe('saveDraft');
|
||||
expect(firstItem.payload.draftData.title).toBe('Offline Draft');
|
||||
});
|
||||
|
||||
test('should enqueue DELETE_ENTRY action when offline', async ({ offlineControl, page }) => {
|
||||
// GIVEN: App is loaded and has an entry
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => (window as any).db !== undefined && (window as any).DraftService !== undefined, { timeout: 10000 });
|
||||
|
||||
// Setup entry
|
||||
const draftId = await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
const id = await window.db.drafts.add({
|
||||
title: 'To Delete',
|
||||
content: 'Delete me',
|
||||
tags: [],
|
||||
createdAt: Date.now(),
|
||||
status: 'draft',
|
||||
sessionId: 'test-session'
|
||||
});
|
||||
return id;
|
||||
});
|
||||
|
||||
await offlineControl.goOffline(page.context());
|
||||
|
||||
// WHEN: DraftService.deleteDraft is called
|
||||
await page.evaluate(async (id) => {
|
||||
// @ts-ignore
|
||||
await window.DraftService.deleteDraft(id);
|
||||
}, draftId);
|
||||
|
||||
// THEN: SyncQueue has delete action
|
||||
const lastItem = await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
return await window.db.syncQueue.orderBy('createdAt').last();
|
||||
});
|
||||
|
||||
expect(lastItem.action).toBe('deleteDraft');
|
||||
expect(lastItem.payload.draftId).toBe(draftId);
|
||||
});
|
||||
});
|
||||
45
tests/integration/pwa-manifest.test.ts
Normal file
45
tests/integration/pwa-manifest.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('PWA Manifest', () => {
|
||||
test('should serve a valid manifest.webmanifest', async ({ request }) => {
|
||||
const response = await request.get('/manifest.webmanifest');
|
||||
expect(response.status()).toBe(200);
|
||||
expect(response.headers()['content-type']).toContain('application/manifest+json');
|
||||
|
||||
const manifest = await response.json();
|
||||
|
||||
// Core properties matching manifest.ts
|
||||
expect(manifest.name).toBe('Test01');
|
||||
expect(manifest.short_name).toBe('Test01');
|
||||
expect(manifest.display).toBe('standalone');
|
||||
expect(manifest.start_url).toBe('/');
|
||||
expect(manifest.background_color).toBe('#F8FAFC');
|
||||
expect(manifest.theme_color).toBe('#64748B');
|
||||
|
||||
// Icons
|
||||
expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manifest.icons).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
src: '/icons/icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
src: '/icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
test('should have accessible icon files', async ({ request }) => {
|
||||
const icon192 = await request.get('/icons/icon-192x192.png');
|
||||
expect(icon192.status()).toBe(200);
|
||||
|
||||
const icon512 = await request.get('/icons/icon-512x512.png');
|
||||
expect(icon512.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
40
tests/integration/sync-action-replay.test.ts
Normal file
40
tests/integration/sync-action-replay.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../support/fixtures/offline.fixture';
|
||||
|
||||
test.describe('Sync Action Replay', () => {
|
||||
test('should process pending actions when network restores', async ({ offlineControl, page }) => {
|
||||
// GIVEN: Offline queue has actions
|
||||
await page.goto('/'); // Ensure page is loaded to initialize DB
|
||||
await page.waitForFunction(() => (window as any).db !== undefined, { timeout: 10000 });
|
||||
|
||||
await offlineControl.goOffline(page.context());
|
||||
|
||||
// Seed queue via evaluate
|
||||
await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
await window.db.syncQueue.add({
|
||||
action: 'saveDraft',
|
||||
payload: { draftData: { title: 'Pending Sync', content: '...' } },
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
retries: 0
|
||||
});
|
||||
});
|
||||
|
||||
// WHEN: Network restores
|
||||
await offlineControl.goOnline(page.context());
|
||||
|
||||
// Trigger sync - The SyncManager listens to 'online' event.
|
||||
// Note: The 'online' event listeners might execute async.
|
||||
|
||||
// Wait for processing
|
||||
// We can poll the DB
|
||||
await expect.poll(async () => {
|
||||
return await page.evaluate(async () => {
|
||||
// @ts-ignore
|
||||
return await window.db.syncQueue.where('status').equals('pending').count();
|
||||
});
|
||||
}, { timeout: 5000 }).toBe(0);
|
||||
});
|
||||
});
|
||||
48
tests/support/factories/history-entry.factory.ts
Normal file
48
tests/support/factories/history-entry.factory.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string; // ISO string
|
||||
preview: string;
|
||||
tags: string[];
|
||||
status: 'active' | 'completed';
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const createChatSession = (overrides: Partial<ChatSession> = {}): ChatSession => {
|
||||
const timestamp = faker.date.recent().getTime();
|
||||
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
title: faker.lorem.sentence(3),
|
||||
date: new Date(timestamp).toISOString(),
|
||||
preview: faker.lorem.sentence(),
|
||||
tags: [faker.word.sample(), faker.word.sample()],
|
||||
status: 'completed',
|
||||
messages: [
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
role: 'user',
|
||||
content: faker.lorem.paragraph(),
|
||||
timestamp: timestamp - 10000,
|
||||
},
|
||||
{
|
||||
id: faker.string.uuid(),
|
||||
role: 'assistant',
|
||||
content: faker.lorem.paragraph(),
|
||||
timestamp: timestamp,
|
||||
}
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
export const createChatSessions = (count: number, overrides: Partial<ChatSession> = {}): ChatSession[] => {
|
||||
return Array.from({ length: count }, () => createChatSession(overrides));
|
||||
};
|
||||
24
tests/support/factories/provider.factory.ts
Normal file
24
tests/support/factories/provider.factory.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export const createProviderConfig = (overrides: Partial<ProviderConfig> = {}): ProviderConfig => {
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
name: faker.company.name() + ' AI',
|
||||
baseUrl: faker.internet.url(),
|
||||
apiKey: 'sk-' + faker.string.alphanumeric(20),
|
||||
modelId: faker.helpers.arrayElement(['gpt-4', 'claude-3', 'deepseek-chat']),
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
export const createProviderConfigs = (count: number): ProviderConfig[] => {
|
||||
return Array.from({ length: count }, () => createProviderConfig());
|
||||
};
|
||||
50
tests/support/fixtures/db.fixture.ts
Normal file
50
tests/support/fixtures/db.fixture.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
|
||||
type DbFixture = {
|
||||
resetDb: () => Promise<void>;
|
||||
seedHistory: (data: any[]) => Promise<void>;
|
||||
getHistory: () => Promise<any[]>;
|
||||
getSyncQueue: () => Promise<any[]>;
|
||||
};
|
||||
|
||||
export const test = base.extend<{ db: DbFixture }>({
|
||||
db: async ({ page }, use) => {
|
||||
const dbFixture = {
|
||||
resetDb: async () => {
|
||||
await page.evaluate(async () => {
|
||||
// Assuming 'Dexie' is globally available or we use indexedDB directly
|
||||
// Using indexedDB directly to be safe as Dexie might be bundled
|
||||
const req = indexedDB.deleteDatabase('Test01DB'); // Match your DB name
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(true);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onblocked = () => console.warn('Delete DB blocked');
|
||||
});
|
||||
});
|
||||
},
|
||||
seedHistory: async (data: any[]) => {
|
||||
// This is tricky if logic is encapsulated.
|
||||
// We might need to expose a hidden window helper or just rely on UI for seeding in E2E
|
||||
// OR use a specialized seeding script.
|
||||
// For Integration/Component, we might assume we can import the DB client directly if running in same context.
|
||||
// Since playwight runs in browser context, we'd need to expose it.
|
||||
// For now, let's assume we can invoke a window helper if useful, or just implement specific logic
|
||||
await page.evaluate(async (items) => {
|
||||
// We'll need the app to expose a way to seed, or we assume specific DB structure
|
||||
// This requires the app to have the DB initialized.
|
||||
// Implementation deferred to actual test via page.evaluate if needed
|
||||
}, data);
|
||||
},
|
||||
getHistory: async () => {
|
||||
// Placeholder for getting history from DB
|
||||
return [];
|
||||
},
|
||||
getSyncQueue: async () => {
|
||||
// Placeholder
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
await use(dbFixture);
|
||||
},
|
||||
});
|
||||
37
tests/support/fixtures/factories/user-factory.ts
Normal file
37
tests/support/fixtures/factories/user-factory.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
/**
|
||||
* UserFactory
|
||||
*
|
||||
* Handles creation and cleanup of test users.
|
||||
* Note: Since this is a local-first app without a real backend API for user creation yet,
|
||||
* this factory currently generates mock data. adapting to real API calls later.
|
||||
*/
|
||||
export class UserFactory {
|
||||
// In a real app, we would track IDs here for cleanup
|
||||
// private createdUserIds: string[] = [];
|
||||
|
||||
async createUser(overrides = {}) {
|
||||
const user = {
|
||||
id: faker.string.uuid(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
password: faker.internet.password(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
// Placeholder: In a real app, you would POST to API here
|
||||
// const response = await fetch(\`\${process.env.API_URL}/users\`, ...);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
// Placeholder: In a real app, you would DELETE users here
|
||||
// for (const id of this.createdUserIds) { ... }
|
||||
|
||||
// For now, no cleanup needed for transient mock data
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
16
tests/support/fixtures/index.ts
Normal file
16
tests/support/fixtures/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
import { UserFactory } from './factories/user-factory';
|
||||
|
||||
type TestFixtures = {
|
||||
userFactory: UserFactory;
|
||||
};
|
||||
|
||||
export const test = base.extend<TestFixtures>({
|
||||
userFactory: async ({ }, use) => {
|
||||
const factory = new UserFactory();
|
||||
await use(factory);
|
||||
await factory.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
27
tests/support/fixtures/offline.fixture.ts
Normal file
27
tests/support/fixtures/offline.fixture.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { test as base, BrowserContext } from '@playwright/test';
|
||||
|
||||
type OfflineFixture = {
|
||||
goOffline: (context: BrowserContext) => Promise<void>;
|
||||
goOnline: (context: BrowserContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export const test = base.extend<{ offlineControl: OfflineFixture }>({
|
||||
offlineControl: async ({ }, use) => {
|
||||
const offlineFixture = {
|
||||
goOffline: async (context: BrowserContext) => {
|
||||
await context.setOffline(true);
|
||||
for (const page of context.pages()) {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('offline'))).catch(() => { });
|
||||
}
|
||||
},
|
||||
goOnline: async (context: BrowserContext) => {
|
||||
await context.setOffline(false);
|
||||
for (const page of context.pages()) {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('online'))).catch(() => { });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
await use(offlineFixture);
|
||||
},
|
||||
});
|
||||
28
tests/unit/prompt-engine.test.ts
Normal file
28
tests/unit/prompt-engine.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @ts-ignore - Module likely not created yet
|
||||
import { PromptEngine } from '@/lib/llm/prompt-engine';
|
||||
|
||||
describe('PromptEngine (Ghostwriter)', () => {
|
||||
it('should construct a grounded prompt with user insight', () => {
|
||||
// GIVEN: A user insight and chat history
|
||||
const history = [
|
||||
{ role: 'user', content: 'I feel stupid.' },
|
||||
{ role: 'assistant', content: 'Why?' },
|
||||
{ role: 'user', content: 'I made a typo.' }
|
||||
];
|
||||
const insight = "I need a checklist for typos.";
|
||||
|
||||
// WHEN: Constructing the prompt
|
||||
// @ts-ignore
|
||||
const prompt = PromptEngine.constructGhostwriterPrompt(history, insight);
|
||||
|
||||
// THEN: The system prompt should enforce grounding
|
||||
expect(prompt[0].role).toBe('system');
|
||||
expect(prompt[0].content).toContain('You are an expert Ghostwriter');
|
||||
expect(prompt[0].content).toContain('Ground your answer in the user insight'); // R-2.1 Mitigation
|
||||
|
||||
// AND: The user insight should be clearly labeled
|
||||
const lastMessage = prompt[prompt.length - 1];
|
||||
expect(lastMessage.content).toContain(`INSIGHT: ${insight}`);
|
||||
});
|
||||
});
|
||||
52
tests/unit/services/secure-storage.test.ts
Normal file
52
tests/unit/services/secure-storage.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SecureStorage } from '../../../src/services/secure-storage'; // Implementation doesn't exist yet
|
||||
|
||||
describe('SecureStorage Service', () => {
|
||||
const TEST_KEY = 'test_api_key_123';
|
||||
const STORAGE_KEY = 'llm_provider_config';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should save API key with basic encoding (not plain text)', () => {
|
||||
SecureStorage.saveKey(TEST_KEY);
|
||||
|
||||
// Direct access to localStorage to verify obfuscation
|
||||
const storedValue = localStorage.getItem(STORAGE_KEY);
|
||||
expect(storedValue).toBeDefined();
|
||||
expect(storedValue).not.toBe(TEST_KEY); // Should NOT be plain text
|
||||
expect(storedValue).not.toContain(TEST_KEY); // Should not contain the key
|
||||
});
|
||||
|
||||
it('should retrieve the original API key correctly', () => {
|
||||
SecureStorage.saveKey(TEST_KEY);
|
||||
|
||||
const retrievedKey = SecureStorage.getKey();
|
||||
expect(retrievedKey).toBe(TEST_KEY);
|
||||
});
|
||||
|
||||
it('should return null if no key is stored', () => {
|
||||
const retrievedKey = SecureStorage.getKey();
|
||||
expect(retrievedKey).toBeNull();
|
||||
});
|
||||
|
||||
it('should overwrite existing key when saving new one', () => {
|
||||
SecureStorage.saveKey('old_key');
|
||||
SecureStorage.saveKey('new_key');
|
||||
|
||||
expect(SecureStorage.getKey()).toBe('new_key');
|
||||
});
|
||||
|
||||
it('should clear key when requested', () => {
|
||||
SecureStorage.saveKey(TEST_KEY);
|
||||
SecureStorage.clearKey();
|
||||
|
||||
expect(SecureStorage.getKey()).toBeNull();
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
111
tests/unit/services/sync-manager.test.ts
Normal file
111
tests/unit/services/sync-manager.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SyncManager } from '../../../src/services/sync-manager';
|
||||
|
||||
// Define hoisted mocks to be accessible inside vi.mock
|
||||
const { mockAdd, mockWhere, mockUpdate, mockDelete, mockSortBy } = vi.hoisted(() => {
|
||||
const mockSortBy = vi.fn();
|
||||
const mockEquals = vi.fn(() => ({ sortBy: mockSortBy }));
|
||||
const mockWhere = vi.fn(() => ({ equals: mockEquals }));
|
||||
return {
|
||||
mockAdd: vi.fn(),
|
||||
mockWhere,
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockSortBy
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Dexie
|
||||
vi.mock('../../../src/lib/db', () => ({
|
||||
db: {
|
||||
syncQueue: {
|
||||
add: mockAdd,
|
||||
where: mockWhere,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SyncManager Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default return for sortBy (mockPendingItems)
|
||||
mockSortBy.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('queueAction', () => {
|
||||
it('should add action to syncQueue with pending status', async () => {
|
||||
// GIVEN: A draft payload
|
||||
const payload = { draftData: { title: 'Test', content: 'Content' } as any };
|
||||
mockAdd.mockResolvedValue(1);
|
||||
|
||||
// WHEN: queueAction is called
|
||||
const id = await SyncManager.queueAction('saveDraft', payload);
|
||||
|
||||
// THEN: It returns the new ID
|
||||
expect(id).toBe(1);
|
||||
|
||||
// AND: It calls db.syncQueue.add with correct data
|
||||
expect(mockAdd).toHaveBeenCalledWith({
|
||||
action: 'saveDraft',
|
||||
payload,
|
||||
status: 'pending',
|
||||
createdAt: expect.any(Number),
|
||||
retries: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processQueue', () => {
|
||||
it('should process pending items in order', async () => {
|
||||
// GIVEN: Pending items in queue
|
||||
const items = [
|
||||
{ id: 1, action: 'saveDraft', payload: {}, status: 'pending', retries: 0 },
|
||||
{ id: 2, action: 'deleteDraft', payload: { draftId: 123 }, status: 'pending', retries: 0 },
|
||||
];
|
||||
|
||||
mockSortBy.mockResolvedValue(items);
|
||||
|
||||
// WHEN: processQueue is called
|
||||
await SyncManager.processQueue();
|
||||
|
||||
// THEN: Items are processed
|
||||
// Verify update to 'processing'
|
||||
expect(mockUpdate).toHaveBeenCalledWith(1, { status: 'processing' });
|
||||
expect(mockUpdate).toHaveBeenCalledWith(2, { status: 'processing' });
|
||||
|
||||
// Verify deletion after success (assuming success path)
|
||||
expect(mockDelete).toHaveBeenCalledWith(1);
|
||||
expect(mockDelete).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should handle execution errors and increment retries', async () => {
|
||||
// GIVEN: An item that will fail execution (invalid action)
|
||||
const item = {
|
||||
id: 1,
|
||||
action: 'invalidAction', // Will throw error in executeAction
|
||||
payload: {},
|
||||
status: 'pending',
|
||||
retries: 0
|
||||
};
|
||||
|
||||
mockSortBy.mockResolvedValue([item]);
|
||||
|
||||
// WHEN: processQueue is called
|
||||
await SyncManager.processQueue();
|
||||
|
||||
// THEN: Item should be updated to processing
|
||||
expect(mockUpdate).toHaveBeenCalledWith(1, { status: 'processing' });
|
||||
|
||||
// AND: Should be updated with incremented retry count (not deleted)
|
||||
expect(mockUpdate).toHaveBeenCalledWith(1, {
|
||||
status: 'pending',
|
||||
retries: 1
|
||||
});
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user