Implement pagination for modification history and E2E tests (#162)

This commit is contained in:
Icereed 2025-02-03 09:04:12 +01:00 committed by GitHub
parent 4b9ba45eb3
commit 16eebc93d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1981 additions and 51 deletions

View file

@ -1,3 +1,7 @@
.env .env
Dockerfile Dockerfile
.github web-app/e2e
web-app/node_modules
web-app/playwright_report
web-app/test-results
.github

View file

@ -1,5 +1,9 @@
name: Build and Push Docker Images name: Build and Push Docker Images
permissions:
pull-requests: write
contents: read
on: on:
push: push:
branches: branches:
@ -16,29 +20,22 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: 1.22 go-version: 1.22
- name: Install mupdf - name: Install mupdf
run: sudo apt-get install -y mupdf run: sudo apt-get install -y mupdf
- name: Set library path - name: Set library path
run: echo "/usr/lib" | sudo tee -a /etc/ld.so.conf.d/mupdf.conf && sudo ldconfig run: echo "/usr/lib" | sudo tee -a /etc/ld.so.conf.d/mupdf.conf && sudo ldconfig
- name: Install dependencies - name: Install dependencies
run: go mod download run: go mod download
- name: Run Go tests - name: Run Go tests
run: go test ./... run: go test ./...
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Cache npm dependencies - name: Cache npm dependencies
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@ -46,11 +43,9 @@ jobs:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-node-
- name: Install frontend dependencies - name: Install frontend dependencies
run: npm install run: npm install
working-directory: web-app working-directory: web-app
- name: Run frontend tests - name: Run frontend tests
run: npm test run: npm test
working-directory: web-app working-directory: web-app
@ -60,13 +55,13 @@ jobs:
needs: test needs: test
outputs: outputs:
digest: ${{ steps.build_amd64.outputs.digest }} digest: ${{ steps.build_amd64.outputs.digest }}
image_tag: ${{ steps.set_image_tag.outputs.image_tag }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
@ -74,7 +69,9 @@ jobs:
- name: Set Docker tags - name: Set Docker tags
id: set_tags id: set_tags
run: | run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
echo "TAGS=icereed/paperless-gpt:pr-${GITHUB_SHA}-amd64" >> $GITHUB_ENV
elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
VERSION=${GITHUB_REF#refs/tags/} VERSION=${GITHUB_REF#refs/tags/}
echo "TAGS=icereed/paperless-gpt:${VERSION}-amd64" >> $GITHUB_ENV echo "TAGS=icereed/paperless-gpt:${VERSION}-amd64" >> $GITHUB_ENV
else else
@ -86,7 +83,7 @@ jobs:
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' }} push: true
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
tags: ${{ env.TAGS }} tags: ${{ env.TAGS }}
@ -94,6 +91,9 @@ jobs:
VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }} VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }}
COMMIT=${{ github.sha }} COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.repository.pushed_at }} BUILD_DATE=${{ github.event.repository.pushed_at }}
- name: Set image tag output
id: set_image_tag
run: echo "image_tag=${TAGS}" >> $GITHUB_OUTPUT
- name: Export digest for amd64 - name: Export digest for amd64
run: | run: |
mkdir -p ${{ runner.temp }}/digests mkdir -p ${{ runner.temp }}/digests
@ -203,4 +203,45 @@ jobs:
docker buildx imagetools inspect ${DOCKERHUB_REPO}:${VERSION} docker buildx imagetools inspect ${DOCKERHUB_REPO}:${VERSION}
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
docker buildx imagetools inspect ${DOCKERHUB_REPO}:latest docker buildx imagetools inspect ${DOCKERHUB_REPO}:latest
fi fi
e2e-tests:
name: E2E Tests
needs: build-amd64
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web-app
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './web-app/package-lock.json'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run Playwright tests
run: npm run test:e2e
env:
CI: true
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PAPERLESS_GPT_IMAGE: icereed/paperless-gpt:pr-${{ github.sha }}-amd64
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web-app/playwright-report/
retention-days: 30
- name: Upload test screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: web-app/test-results/
retention-days: 30

5
.gitignore vendored
View file

@ -3,4 +3,7 @@
prompts/ prompts/
tests/tmp tests/tmp
tmp/ tmp/
db/ db/
web-app/playwright-report/
web-app/test-results/.last-run.json
web-app/test-results

View file

@ -248,13 +248,34 @@ func (app *App) getDocumentHandler() gin.HandlerFunc {
// Section for local-db actions // Section for local-db actions
func (app *App) getModificationHistoryHandler(c *gin.Context) { func (app *App) getModificationHistoryHandler(c *gin.Context) {
modifications, err := GetAllModifications(app.Database) // Parse pagination parameters
page := 1
pageSize := 20
if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 {
page = p
}
if ps, err := strconv.Atoi(c.DefaultQuery("pageSize", "20")); err == nil && ps > 0 && ps <= 100 {
pageSize = ps
}
// Get paginated modifications and total count
modifications, total, err := GetPaginatedModifications(app.Database, page, pageSize)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve modification history"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve modification history"})
log.Errorf("Failed to retrieve modification history: %v", err) log.Errorf("Failed to retrieve modification history: %v", err)
return return
} }
c.JSON(http.StatusOK, modifications)
totalPages := (int(total) + pageSize - 1) / pageSize
c.JSON(http.StatusOK, gin.H{
"items": modifications,
"totalItems": total,
"totalPages": totalPages,
"currentPage": page,
"pageSize": pageSize,
})
} }
func (app *App) undoModificationHandler(c *gin.Context) { func (app *App) undoModificationHandler(c *gin.Context) {

View file

@ -63,13 +63,35 @@ func GetModification(db *gorm.DB, id uint) (*ModificationHistory, error) {
return &record, result.Error return &record, result.Error
} }
// GetAllModifications retrieves all modification records from the database // GetAllModifications retrieves all modification records from the database (deprecated - use GetPaginatedModifications instead)
func GetAllModifications(db *gorm.DB) ([]ModificationHistory, error) { func GetAllModifications(db *gorm.DB) ([]ModificationHistory, error) {
var records []ModificationHistory var records []ModificationHistory
result := db.Order("date_changed DESC").Find(&records) // GORM's Find method retrieves all records result := db.Order("date_changed DESC").Find(&records)
return records, result.Error return records, result.Error
} }
// GetPaginatedModifications retrieves a page of modification records with total count
func GetPaginatedModifications(db *gorm.DB, page int, pageSize int) ([]ModificationHistory, int64, error) {
var records []ModificationHistory
var total int64
// Get total count
if err := db.Model(&ModificationHistory{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// Calculate offset
offset := (page - 1) * pageSize
// Get paginated records
result := db.Order("date_changed DESC").
Offset(offset).
Limit(pageSize).
Find(&records)
return records, total, result.Error
}
// UndoModification marks a modification record as undone and sets the undo date // UndoModification marks a modification record as undone and sets the undo date
func SetModificationUndone(db *gorm.DB, record *ModificationHistory) error { func SetModificationUndone(db *gorm.DB, record *ModificationHistory) error {
record.Undone = true record.Undone = true

8
web-app/.env.test Normal file
View file

@ -0,0 +1,8 @@
# Environment variables for E2E tests
PAPERLESS_NGX_URL=http://localhost:8000
PAPERLESS_GPT_URL=http://localhost:8080
PAPERLESS_ADMIN_USER=admin
PAPERLESS_ADMIN_PASSWORD=admin
# Add your OpenAI API key here (required for document processing)
# OPENAI_API_KEY=sk-uhB.... # Replace with your actual OpenAI API key from https://platform.openai.com/api-keys

View file

@ -0,0 +1,57 @@
version: '3.8'
services:
paperless-ngx:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
environment:
PAPERLESS_URL: http://localhost:8001
PAPERLESS_SECRET_KEY: change-me
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: admin
PAPERLESS_TIME_ZONE: Europe/Berlin
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_REDIS: redis://redis:6379
ports:
- "8001:8000"
volumes:
- paperless-data:/usr/src/paperless/data
- paperless-media:/usr/src/paperless/media
- paperless-export:/usr/src/paperless/export
depends_on:
- redis
- postgres
redis:
image: redis:7
restart: unless-stopped
postgres:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless
volumes:
- pgdata:/var/lib/postgresql/data
paperless-gpt:
build:
context: ..
dockerfile: Dockerfile
image: icereed/paperless-gpt:e2e
environment:
PAPERLESS_URL: http://paperless-ngx:8000
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: admin
OPENAI_API_KEY: ${OPENAI_API_KEY}
ports:
- "8080:8080"
depends_on:
- paperless-ngx
volumes:
paperless-data:
paperless-media:
paperless-export:
pgdata:

View file

@ -0,0 +1,135 @@
import { expect, Page, test } from '@playwright/test';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import { addTagToDocument, PORTS, setupTestEnvironment, TestEnvironment, uploadDocument } from './test-environment';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let testEnv: TestEnvironment;
let page: Page;
test.beforeAll(async () => {
testEnv = await setupTestEnvironment();
});
test.afterAll(async () => {
await testEnv.cleanup();
});
test.beforeEach(async ({ page: testPage }) => {
page = testPage;
await page.goto(`http://localhost:${testEnv.paperlessGpt.getMappedPort(PORTS.paperlessGpt)}`);
await page.screenshot({ path: 'test-results/initial-state.png' });
});
test.afterEach(async () => {
await page.close();
});
test('should process document and show changes in history', async () => {
const paperlessNgxPort = testEnv.paperlessNgx.getMappedPort(PORTS.paperlessNgx);
const paperlessGptPort = testEnv.paperlessGpt.getMappedPort(PORTS.paperlessGpt);
const credentials = { username: 'admin', password: 'admin' };
// 1. Upload document and add initial tag via API
const baseUrl = `http://localhost:${paperlessNgxPort}`;
const documentPath = path.join(__dirname, 'fixtures', 'test-document.txt');
// Get the paperless-gpt tag ID
const response = await fetch(`${baseUrl}/api/tags/?name=paperless-gpt`, {
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!response.ok) {
throw new Error('Failed to fetch paperless-gpt tag');
}
const tags = await response.json();
if (!tags.results || tags.results.length === 0) {
throw new Error('paperless-gpt tag not found');
}
const tagId = tags.results[0].id;
// Upload document and get ID
const { id: documentId } = await uploadDocument(
baseUrl,
documentPath,
'Original Title',
credentials
);
console.log(`Document ID: ${documentId}`);
// Add tag to document
await addTagToDocument(
baseUrl,
documentId,
tagId,
credentials
);
// 2. Navigate to Paperless-GPT UI and process the document
await page.goto(`http://localhost:${paperlessGptPort}`);
// Wait for document to appear in the list
await page.waitForSelector('.document-card', { timeout: 1000 * 60 });
await page.screenshot({ path: 'test-results/document-loaded.png' });
// Click the process button
await page.click('button:has-text("Generate Suggestions")');
// Wait for processing to complete
await page.waitForSelector('.suggestions-review', { timeout: 30000 });
await page.screenshot({ path: 'test-results/suggestions-loaded.png' });
// Apply the suggestions
await page.click('button:has-text("Apply")');
// Wait for success message
await page.waitForSelector('.success-modal', { timeout: 10000 });
await page.screenshot({ path: 'test-results/suggestions-applied.png' });
// Click "OK" on success modal
await page.click('button:has-text("OK")');
// 3. Check history page for the modifications
await page.click('a:has-text("History")');
// Wait for history page to load
await page.waitForSelector('.modification-history', { timeout: 5000 });
await page.screenshot({ path: 'test-results/history-page.png' });
// Verify at least one modification entry exists
const modifications = await page.locator('.undo-card').count();
expect(modifications).toBeGreaterThan(0);
// Verify modification details
const firstModification = await page.locator('.undo-card').first();
// Check if title was modified
const titleChange = await firstModification.locator('text=Original Title').isVisible();
expect(titleChange).toBeTruthy();
// Test pagination if there are multiple modifications
const paginationVisible = await page.locator('.pagination-controls').isVisible();
if (paginationVisible) {
// Click next page if available
const nextButton = page.locator('button:has-text("Next")');
if (await nextButton.isEnabled()) {
await nextButton.click();
// Wait for new items to load
await page.waitForSelector('.undo-card');
}
}
// 4. Test undo functionality
const undoButton = await firstModification.locator('button:has-text("Undo")');
if (await undoButton.isEnabled()) {
await undoButton.click();
// Wait for undo to complete. Text should change to "Undone"
await page.waitForSelector('text=Undone');
}
});

View file

@ -0,0 +1,20 @@
Invoice #12345
From: Test Company Ltd.
To: Sample Client Inc.
Date: January 27, 2025
Item Description:
1. Software Development Services - $5,000
2. Cloud Infrastructure Setup - $2,500
3. Technical Consulting - $1,500
Total: $9,000
Payment Terms: Net 30
Due Date: February 26, 2025
Please make payment to:
Bank: Test Bank
Account: 1234567890

View file

@ -0,0 +1,24 @@
import { chromium } from '@playwright/test';
import * as nodeFetch from 'node-fetch';
// Polyfill fetch for Node.js environment
if (!globalThis.fetch) {
(globalThis as any).fetch = nodeFetch.default;
(globalThis as any).Headers = nodeFetch.Headers;
(globalThis as any).Request = nodeFetch.Request;
(globalThis as any).Response = nodeFetch.Response;
(globalThis as any).FormData = nodeFetch.FormData;
}
async function globalSetup() {
// Install Playwright browser if needed
const browser = await chromium.launch();
await browser.close();
// Load environment variables
if (!process.env.OPENAI_API_KEY) {
console.warn('Warning: OPENAI_API_KEY environment variable is not set');
}
}
export default globalSetup;

View file

@ -0,0 +1,281 @@
import { Browser, chromium } from '@playwright/test';
import * as fs from 'fs';
import { GenericContainer, Network, StartedTestContainer, Wait } from 'testcontainers';
export interface TestEnvironment {
paperlessNgx: StartedTestContainer;
paperlessGpt: StartedTestContainer;
browser: Browser;
cleanup: () => Promise<void>;
}
export const PORTS = {
paperlessNgx: 8000,
paperlessGpt: 8080,
};
export async function setupTestEnvironment(): Promise<TestEnvironment> {
console.log('Setting up test environment...');
const paperlessPort = PORTS.paperlessNgx;
const gptPort = PORTS.paperlessGpt;
// Create a network for the containers
const network = await new Network().start();
console.log('Starting Redis container...');
const redis = await new GenericContainer('redis:7')
.withNetwork(network)
.withNetworkAliases('redis')
.start();
console.log('Starting Postgres container...');
const postgres = await new GenericContainer('postgres:15')
.withNetwork(network)
.withNetworkAliases('postgres')
.withEnvironment({
POSTGRES_DB: 'paperless',
POSTGRES_USER: 'paperless',
POSTGRES_PASSWORD: 'paperless'
})
.start();
console.log('Starting Paperless-ngx container...');
const paperlessNgx = await new GenericContainer('ghcr.io/paperless-ngx/paperless-ngx:latest')
.withNetwork(network)
.withNetworkAliases('paperless-ngx')
.withEnvironment({
PAPERLESS_URL: `http://localhost:${paperlessPort}`,
PAPERLESS_SECRET_KEY: 'change-me',
PAPERLESS_ADMIN_USER: 'admin',
PAPERLESS_ADMIN_PASSWORD: 'admin',
PAPERLESS_TIME_ZONE: 'Europe/Berlin',
PAPERLESS_OCR_LANGUAGE: 'eng',
PAPERLESS_REDIS: 'redis://redis:6379',
PAPERLESS_DBHOST: 'postgres',
PAPERLESS_DBNAME: 'paperless',
PAPERLESS_DBUSER: 'paperless',
PAPERLESS_DBPASS: 'paperless'
})
.withExposedPorts(paperlessPort)
.withWaitStrategy(Wait.forHttp('/api/', paperlessPort))
.start();
const mappedPort = paperlessNgx.getMappedPort(paperlessPort);
console.log(`Paperless-ngx container started, mapped port: ${mappedPort}`);
// Create required tag before starting paperless-gpt
const baseUrl = `http://localhost:${mappedPort}`;
const credentials = { username: 'admin', password: 'admin' };
try {
console.log('Creating paperless-gpt tag...');
await createTag(baseUrl, 'paperless-gpt', credentials);
} catch (error) {
console.error('Failed to create paperless-gpt tag:', error);
await paperlessNgx.stop();
throw error;
}
console.log('Starting Paperless-gpt container...');
const paperlessGptImage = process.env.PAPERLESS_GPT_IMAGE || 'icereed/paperless-gpt:e2e';
console.log(`Using image: ${paperlessGptImage}`);
const paperlessGpt = await new GenericContainer(paperlessGptImage)
.withNetwork(network)
.withEnvironment({
PAPERLESS_BASE_URL: `http://paperless-ngx:${paperlessPort}`,
PAPERLESS_API_TOKEN: await getApiToken(baseUrl, credentials),
LLM_PROVIDER: "openai",
LLM_MODEL: "gpt-4o-mini",
LLM_LANGUAGE: "english",
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
})
.withExposedPorts(gptPort)
.withWaitStrategy(Wait.forHttp('/', gptPort))
.start();
console.log('Paperless-gpt container started');
console.log('Launching browser...');
const browser = await chromium.launch();
console.log('Browser launched');
const cleanup = async () => {
console.log('Cleaning up test environment...');
await browser.close();
await paperlessGpt.stop();
await paperlessNgx.stop();
await redis.stop();
await postgres.stop();
await network.stop();
console.log('Test environment cleanup completed');
};
console.log('Test environment setup completed');
return {
paperlessNgx,
paperlessGpt,
browser,
cleanup,
};
}
export async function waitForElement(page: any, selector: string, timeout = 5000): Promise<void> {
await page.waitForSelector(selector, { timeout });
}
export interface PaperlessDocument {
id: number;
title: string;
content: string;
tags: number[];
}
// Helper to upload a document via Paperless-ngx API
export async function uploadDocument(
baseUrl: string,
filePath: string,
title: string,
credentials: { username: string; password: string }
): Promise<PaperlessDocument> {
console.log(`Uploading document: ${title} from ${filePath}`);
const formData = new FormData();
const fileData = await fs.promises.readFile(filePath);
formData.append('document', new Blob([fileData]));
formData.append('title', title);
// Initial upload to get task ID
const uploadResponse = await fetch(`${baseUrl}/api/documents/post_document/`, {
method: 'POST',
body: formData,
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!uploadResponse.ok) {
console.error(`Upload failed with status ${uploadResponse.status}: ${uploadResponse.statusText}`);
throw new Error(`Failed to upload document: ${uploadResponse.statusText}`);
}
const task_id = await uploadResponse.json();
// Poll the tasks endpoint until document is processed
while (true) {
console.log(`Checking task status for ID: ${task_id}`);
const taskResponse = await fetch(`${baseUrl}/api/tasks/?task_id=${task_id}`, {
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!taskResponse.ok) {
throw new Error(`Failed to check task status: ${taskResponse.statusText}`);
}
const taskResultArr = await taskResponse.json();
console.log(`Task status: ${JSON.stringify(taskResultArr)}`);
if (taskResultArr.length === 0) {
continue;
}
const taskResult = taskResultArr[0];
// Check if task is completed
if (taskResult.status === 'SUCCESS' && taskResult.id) {
console.log(`Document processed successfully with ID: ${taskResult.id}`);
// Fetch the complete document details
const documentResponse = await fetch(`${baseUrl}/api/documents/${taskResult.id}/`, {
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!documentResponse.ok) {
throw new Error(`Failed to fetch document details: ${documentResponse.statusText}`);
}
return await documentResponse.json();
}
// Check for failure
if (taskResult.status === 'FAILED') {
throw new Error(`Document processing failed: ${taskResult.result}`);
}
// Wait before polling again
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Helper to create a tag via Paperless-ngx API
export async function createTag(
baseUrl: string,
name: string,
credentials: { username: string; password: string }
): Promise<number> {
console.log(`Creating tag: ${name}`);
const response = await fetch(`${baseUrl}/api/tags/`, {
method: 'POST',
body: JSON.stringify({ name }),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!response.ok) {
console.error(`Tag creation failed with status ${response.status}: ${response.statusText}`);
throw new Error(`Failed to create tag: ${response.statusText}`);
}
const tag = await response.json();
console.log(`Tag created successfully with ID: ${tag.id}`);
return tag.id;
}
// Helper to get an API token
export async function getApiToken(
baseUrl: string,
credentials: { username: string; password: string }
): Promise<string> {
console.log('Fetching API token');
const response = await fetch(`${baseUrl}/api/token/`, {
method: 'POST',
body: new URLSearchParams({
username: credentials.username,
password: credentials.password,
}),
});
if (!response.ok) {
console.error(`API token fetch failed with status ${response.status}: ${response.statusText}`);
throw new Error(`Failed to fetch API token: ${response.statusText}`);
}
const token = await response.json();
console.log(`API token fetched successfully: ${token.token}`);
return token.token;
}
// Helper to add a tag to a document
export async function addTagToDocument(
baseUrl: string,
documentId: number,
tagId: number,
credentials: { username: string; password: string }
): Promise<void> {
console.log(`Adding tag ${tagId} to document ${documentId}`);
const response = await fetch(`${baseUrl}/api/documents/${documentId}/`, {
method: 'PATCH',
body: JSON.stringify({
tags: [tagId],
}),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!response.ok) {
console.error(`Tag addition failed with status ${response.status}: ${response.statusText}`);
throw new Error(`Failed to add tag to document: ${response.statusText}`);
}
console.log('Tag added successfully');
}

1229
web-app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,11 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "echo \"TODO\"" "test": "echo \"TODO\"",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"docker:test:up": "docker compose -f docker-compose.test.yml up -d",
"docker:test:down": "docker compose -f docker-compose.test.yml down -v"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.1.8", "@headlessui/react": "^2.1.8",
@ -29,19 +33,23 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.0", "@eslint/js": "^9.9.0",
"@types/node": "^22.10.1", "@playwright/test": "^1.50.0",
"@types/node": "^22.10.10",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dotenv": "^16.4.7",
"eslint": "^9.9.0", "eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9", "eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0", "globals": "^15.9.0",
"node-fetch": "^3.3.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.12", "tailwindcss": "^3.4.12",
"testcontainers": "^10.17.1",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.1", "typescript-eslint": "^8.0.1",
"vite": "^5.4.1" "vite": "^5.4.1"
} }
} }

View file

@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
// Load test environment variables
dotenv.config({ path: '.env.test' });
export default defineConfig({
testDir: './e2e',
timeout: 120000, // Increased timeout for container startup
expect: {
timeout: 10000,
},
globalSetup: './e2e/setup/global-setup.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
screenshot: 'on',
baseURL: process.env.PAPERLESS_GPT_URL || 'http://localhost:8080',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
outputDir: 'test-results',
preserveOutput: 'failures-only',
});

View file

@ -12,11 +12,23 @@ interface ModificationHistory {
UndoneDate: string | null; UndoneDate: string | null;
} }
interface PaginatedResponse {
items: ModificationHistory[];
totalItems: number;
totalPages: number;
currentPage: number;
pageSize: number;
}
const History: React.FC = () => { const History: React.FC = () => {
const [modifications, setModifications] = useState<ModificationHistory[]>([]); const [modifications, setModifications] = useState<ModificationHistory[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [paperlessUrl, setPaperlessUrl] = useState<string>(''); const [paperlessUrl, setPaperlessUrl] = useState<string>('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const pageSize = 20;
// Get Paperless URL // Get Paperless URL
useEffect(() => { useEffect(() => {
@ -36,19 +48,22 @@ const History: React.FC = () => {
fetchUrl(); fetchUrl();
}, []); }, []);
// Get all modifications // Get modifications with pagination
useEffect(() => { useEffect(() => {
fetchModifications(); fetchModifications(currentPage);
}, []); }, [currentPage]);
const fetchModifications = async () => { const fetchModifications = async (page: number) => {
setLoading(true);
try { try {
const response = await fetch('/api/modifications'); const response = await fetch(`/api/modifications?page=${page}&pageSize=${pageSize}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch modifications'); throw new Error('Failed to fetch modifications');
} }
const data = await response.json(); const data: PaginatedResponse = await response.json();
setModifications(data); setModifications(data.items);
setTotalPages(data.totalPages);
setTotalItems(data.totalItems);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred'); setError(err instanceof Error ? err.message : 'Unknown error occurred');
} finally { } finally {
@ -96,7 +111,7 @@ const History: React.FC = () => {
} }
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="modification-history container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200"> <h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">
Modification History Modification History
</h1> </h1>
@ -108,19 +123,55 @@ const History: React.FC = () => {
No modifications found No modifications found
</p> </p>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1"> <>
{modifications.map((modification) => ( <div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1 mb-6">
<UndoCard {modifications.map((modification) => (
key={modification.ID} <UndoCard
{...modification} key={modification.ID}
onUndo={handleUndo} {...modification}
paperlessUrl={paperlessUrl} onUndo={handleUndo}
/> paperlessUrl={paperlessUrl}
))} />
</div> ))}
</div>
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
<span>
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalItems)} of {totalItems} results
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded-md ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800'
: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
}`}
>
Previous
</button>
<span className="text-sm text-gray-600 dark:text-gray-300">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded-md ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800'
: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
}`}
>
Next
</button>
</div>
</div>
</>
)} )}
</div> </div>
); );
}; };
export default History; export default History;

View file

@ -6,7 +6,7 @@ interface DocumentCardProps {
} }
const DocumentCard: React.FC<DocumentCardProps> = ({ document }) => ( const DocumentCard: React.FC<DocumentCardProps> = ({ document }) => (
<div className="bg-white dark:bg-gray-800 shadow-lg shadow-blue-500/50 rounded-md p-4 relative group overflow-hidden"> <div className="document-card bg-white dark:bg-gray-800 shadow-lg shadow-blue-500/50 rounded-md p-4 relative group overflow-hidden">
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">{document.title}</h3> <h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">{document.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 truncate"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-2 truncate">
{document.content.length > 100 {document.content.length > 100

View file

@ -11,7 +11,7 @@ const SuccessModal: React.FC<SuccessModalProps> = ({ isOpen, onClose }) => (
<Transition show={isOpen} as={Fragment}> <Transition show={isOpen} as={Fragment}>
<Dialog <Dialog
as="div" as="div"
className="fixed z-10 inset-0 overflow-y-auto" className="success-modal fixed z-10 inset-0 overflow-y-auto"
open={isOpen} open={isOpen}
onClose={onClose} onClose={onClose}
> >

View file

@ -25,7 +25,7 @@ const SuggestionsReview: React.FC<SuggestionsReviewProps> = ({
onUpdate, onUpdate,
updating, updating,
}) => ( }) => (
<section> <section className="suggestions-review">
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-6"> <h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-6">
Review and Edit Suggested Titles Review and Edit Suggested Titles
</h2> </h2>

View file

@ -1,6 +1,6 @@
// UndoCard.tsx // UndoCard.tsx
import React from 'react'; import React from 'react';
import { Tooltip } from 'react-tooltip' import { Tooltip } from 'react-tooltip';
interface ModificationProps { interface ModificationProps {
ID: number; ID: number;
@ -72,7 +72,7 @@ const UndoCard: React.FC<ModificationProps> = ({
}; };
return ( return (
<div className="relative bg-white dark:bg-gray-800 p-4 rounded-md shadow-md"> <div className="undo-card relative bg-white dark:bg-gray-800 p-4 rounded-md shadow-md">
<div className="grid grid-cols-6"> <div className="grid grid-cols-6">
<div className="col-span-5"> {/* Left content */} <div className="col-span-5"> {/* Left content */}
<div className="grid grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-3 gap-4 mb-4">