diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..c6192b3 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,100 @@ +name: Build and Test + +on: + push: + branches: [ main, 'claude/**' ] + pull_request: + branches: [ main ] + +jobs: + build-and-test: + name: Build and Test + runs-on: macos-15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: List available Xcode versions + run: ls -la /Applications/ | grep Xcode + + - name: Select Xcode version + run: | + # Use the latest available Xcode + XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1) + echo "Using Xcode at: $XCODE_PATH" + sudo xcode-select -s "$XCODE_PATH/Contents/Developer" + + - name: Show Xcode version + run: | + xcodebuild -version + swift --version + + - name: Show project info + run: | + xcodebuild -list -project JobScout.xcodeproj + + - name: Resolve Swift Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project JobScout.xcodeproj \ + -scheme JobScout \ + -clonedSourcePackagesDirPath ./SPM + + - name: Build + run: | + # Build and capture ALL output including errors + xcodebuild build \ + -project JobScout.xcodeproj \ + -scheme JobScout \ + -configuration Debug \ + -destination 'platform=macOS' \ + -clonedSourcePackagesDirPath ./SPM \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee build.log + + BUILD_STATUS=${PIPESTATUS[0]} + + # Show last 200 lines of build output on failure + if [ $BUILD_STATUS -ne 0 ]; then + echo "========================================" + echo "BUILD FAILED - Last 200 lines of log:" + echo "========================================" + tail -200 build.log + echo "" + echo "========================================" + echo "All error: lines:" + echo "========================================" + grep -i "error:" build.log || echo "No error: lines found" + echo "" + echo "========================================" + echo "All warning: lines:" + echo "========================================" + grep -i "warning:" build.log | tail -50 || echo "No warning: lines found" + exit $BUILD_STATUS + fi + + - name: Run Tests + run: | + xcodebuild test \ + -project JobScout.xcodeproj \ + -scheme JobScout \ + -configuration Debug \ + -destination 'platform=macOS' \ + -clonedSourcePackagesDirPath ./SPM \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + 2>&1 | tee test.log + + - name: Upload build log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-logs + path: | + build.log + test.log + retention-days: 7 diff --git a/JobScout.xcodeproj/project.pbxproj b/JobScout.xcodeproj/project.pbxproj index c40dc19..4f41b05 100644 --- a/JobScout.xcodeproj/project.pbxproj +++ b/JobScout.xcodeproj/project.pbxproj @@ -636,7 +636,7 @@ /* Begin XCRemoteSwiftPackageReference section */ 493E1D5C2EFF830C00DEE268 /* XCRemoteSwiftPackageReference "html2md" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "git@github.com:tim-gromeyer/html2md.git"; + repositoryURL = "https://github.com/tim-gromeyer/html2md.git"; requirement = { branch = main; kind = branch; @@ -644,7 +644,7 @@ }; 493E1D5F2EFF869300DEE268 /* XCRemoteSwiftPackageReference "SwiftAgents" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "git@github.com:iliasaz/SwiftAgents.git"; + repositoryURL = "https://github.com/iliasaz/SwiftAgents.git"; requirement = { branch = main; kind = branch; diff --git a/JobScout/Database/DatabaseManager.swift b/JobScout/Database/DatabaseManager.swift index 2f7d381..2b6a220 100644 --- a/JobScout/Database/DatabaseManager.swift +++ b/JobScout/Database/DatabaseManager.swift @@ -494,6 +494,68 @@ actor DatabaseManager { """) } + // Migration 10: Add user_resume table for storing uploaded resume PDFs + migrator.registerMigration("010_AddUserResume") { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS "user_resume" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "file_name" TEXT NOT NULL, + "pdf_data" BLOB NOT NULL, + "file_size" INTEGER NOT NULL, + "uploaded_at" TEXT NOT NULL DEFAULT (datetime('now')), + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + } + + // Migration 11: Add extracted_text column to user_resume table + migrator.registerMigration("011_AddResumeExtractedText") { db in + // Check if column already exists + let columns = try Row.fetchAll(db, sql: "PRAGMA table_info(user_resume)") + let columnNames = columns.map { $0["name"] as String } + + if !columnNames.contains("extracted_text") { + try db.execute(sql: """ + ALTER TABLE user_resume ADD COLUMN "extracted_text" TEXT + """) + } + + if !columnNames.contains("extraction_status") { + try db.execute(sql: """ + ALTER TABLE user_resume ADD COLUMN "extraction_status" TEXT DEFAULT 'pending' + """) + } + + if !columnNames.contains("extraction_error") { + try db.execute(sql: """ + ALTER TABLE user_resume ADD COLUMN "extraction_error" TEXT + """) + } + } + + // Migration 12: Add resume_chunks table for storing chunked text + migrator.registerMigration("012_AddResumeChunks") { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS "resume_chunks" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "resume_id" INTEGER NOT NULL, + "chunk_index" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "character_count" INTEGER NOT NULL, + "word_count" INTEGER NOT NULL, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (resume_id) REFERENCES user_resume(id) ON DELETE CASCADE, + UNIQUE(resume_id, chunk_index) + ) + """) + + // Create index for efficient chunk retrieval + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS idx_resume_chunks_resume_id ON resume_chunks(resume_id) + """) + } + // Run all migrations try migrator.migrate(dbQueue) } diff --git a/JobScout/Models/UserResume.swift b/JobScout/Models/UserResume.swift new file mode 100644 index 0000000..764ee41 --- /dev/null +++ b/JobScout/Models/UserResume.swift @@ -0,0 +1,88 @@ +// +// UserResume.swift +// JobScout +// +// Created by Claude on 1/11/26. +// + +import Foundation +import GRDB + +/// Status of text extraction from the resume PDF +enum ExtractionStatus: String, Sendable, Codable { + case pending = "pending" + case processing = "processing" + case completed = "completed" + case failed = "failed" +} + +/// Represents a user's uploaded resume stored in the database +struct UserResume: Identifiable, Sendable { + let id: Int + let fileName: String + let pdfData: Data + let fileSize: Int + let uploadedAt: Date + let createdAt: Date + let updatedAt: Date + + // Text extraction fields + let extractedText: String? + let extractionStatus: ExtractionStatus + let extractionError: String? + + /// Formatted file size for display (e.g., "1.2 MB") + var formattedFileSize: String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(fileSize)) + } + + /// Whether text has been successfully extracted + var hasExtractedText: Bool { + extractionStatus == .completed && extractedText != nil && !extractedText!.isEmpty + } + + /// Create from database row + static func from(row: Row) -> UserResume { + let statusString: String = row["extraction_status"] ?? "pending" + let extractionStatus = ExtractionStatus(rawValue: statusString) ?? .pending + + return UserResume( + id: row["id"], + fileName: row["file_name"], + pdfData: row["pdf_data"], + fileSize: row["file_size"], + uploadedAt: row["uploaded_at"], + createdAt: row["created_at"], + updatedAt: row["updated_at"], + extractedText: row["extracted_text"], + extractionStatus: extractionStatus, + extractionError: row["extraction_error"] + ) + } +} + +/// Represents a chunk of text extracted from a resume +struct ResumeChunk: Identifiable, Sendable { + let id: Int + let resumeId: Int + let chunkIndex: Int + let content: String + let characterCount: Int + let wordCount: Int + let createdAt: Date + + /// Create from database row + static func from(row: Row) -> ResumeChunk { + ResumeChunk( + id: row["id"], + resumeId: row["resume_id"], + chunkIndex: row["chunk_index"], + content: row["content"], + characterCount: row["character_count"], + wordCount: row["word_count"], + createdAt: row["created_at"] + ) + } +} diff --git a/JobScout/Repositories/ResumeRepository.swift b/JobScout/Repositories/ResumeRepository.swift new file mode 100644 index 0000000..12192de --- /dev/null +++ b/JobScout/Repositories/ResumeRepository.swift @@ -0,0 +1,289 @@ +// +// ResumeRepository.swift +// JobScout +// +// Created by Claude on 1/11/26. +// + +import Foundation +import GRDB + +/// Repository for managing user resume storage +actor ResumeRepository { + static let shared = ResumeRepository() + + private let dbManager: DatabaseManager + + init(dbManager: DatabaseManager = .shared) { + self.dbManager = dbManager + } + + // MARK: - Resume CRUD Operations + + /// Get the current resume if one exists + /// Only one resume is stored at a time (the most recent one) + func getCurrentResume() async throws -> UserResume? { + let db = try await dbManager.getDatabase() + + return try await db.read { db in + guard let row = try Row.fetchOne(db, sql: """ + SELECT * FROM user_resume ORDER BY uploaded_at DESC LIMIT 1 + """) else { + return nil + } + return UserResume.from(row: row) + } + } + + /// Save a new resume, replacing any existing one + /// - Parameters: + /// - fileName: Original file name of the PDF + /// - pdfData: The PDF file data + /// - Returns: The saved UserResume + @discardableResult + func saveResume(fileName: String, pdfData: Data) async throws -> UserResume { + let db = try await dbManager.getDatabase() + + return try await db.write { db in + // Delete any existing chunks first (foreign key constraint) + try db.execute(sql: "DELETE FROM resume_chunks") + + // Delete any existing resume (we only keep one) + try db.execute(sql: "DELETE FROM user_resume") + + // Insert the new resume with pending extraction status + try db.execute(sql: """ + INSERT INTO user_resume (file_name, pdf_data, file_size, extraction_status, uploaded_at, created_at, updated_at) + VALUES (?, ?, ?, 'pending', datetime('now'), datetime('now'), datetime('now')) + """, arguments: [fileName, pdfData, pdfData.count]) + + let id = db.lastInsertedRowID + + // Fetch and return the inserted record + guard let row = try Row.fetchOne(db, sql: "SELECT * FROM user_resume WHERE id = ?", arguments: [id]) else { + throw ResumeError.saveFailed + } + + return UserResume.from(row: row) + } + } + + /// Update the existing resume with new data + /// - Parameters: + /// - fileName: New file name of the PDF + /// - pdfData: The new PDF file data + /// - Returns: The updated UserResume + @discardableResult + func updateResume(fileName: String, pdfData: Data) async throws -> UserResume { + // Since we only keep one resume, updating is the same as saving + return try await saveResume(fileName: fileName, pdfData: pdfData) + } + + /// Delete the current resume and its chunks + func deleteResume() async throws { + let db = try await dbManager.getDatabase() + + try await db.write { db in + // Delete chunks first (foreign key constraint) + try db.execute(sql: "DELETE FROM resume_chunks") + try db.execute(sql: "DELETE FROM user_resume") + } + } + + /// Check if a resume exists + func hasResume() async throws -> Bool { + let db = try await dbManager.getDatabase() + + return try await db.read { db in + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM user_resume") ?? 0 + return count > 0 + } + } + + // MARK: - Text Extraction Operations + + /// Update the extraction status for a resume + func updateExtractionStatus(resumeId: Int, status: ExtractionStatus, error: String? = nil) async throws { + let db = try await dbManager.getDatabase() + + try await db.write { db in + if let error = error { + try db.execute(sql: """ + UPDATE user_resume SET + extraction_status = ?, + extraction_error = ?, + updated_at = datetime('now') + WHERE id = ? + """, arguments: [status.rawValue, error, resumeId]) + } else { + try db.execute(sql: """ + UPDATE user_resume SET + extraction_status = ?, + extraction_error = NULL, + updated_at = datetime('now') + WHERE id = ? + """, arguments: [status.rawValue, resumeId]) + } + } + } + + /// Save extracted text for a resume + func saveExtractedText(resumeId: Int, text: String) async throws { + let db = try await dbManager.getDatabase() + + try await db.write { db in + try db.execute(sql: """ + UPDATE user_resume SET + extracted_text = ?, + extraction_status = 'completed', + extraction_error = NULL, + updated_at = datetime('now') + WHERE id = ? + """, arguments: [text, resumeId]) + } + } + + /// Get the extracted text for the current resume + func getExtractedText() async throws -> String? { + let db = try await dbManager.getDatabase() + + return try await db.read { db in + try String.fetchOne(db, sql: """ + SELECT extracted_text FROM user_resume ORDER BY uploaded_at DESC LIMIT 1 + """) + } + } + + // MARK: - Chunk Operations + + /// Save chunks for a resume, replacing any existing chunks + func saveChunks(resumeId: Int, chunks: [TextChunk]) async throws { + let db = try await dbManager.getDatabase() + + try await db.write { db in + // Delete existing chunks for this resume + try db.execute(sql: "DELETE FROM resume_chunks WHERE resume_id = ?", arguments: [resumeId]) + + // Insert new chunks + for chunk in chunks { + try db.execute(sql: """ + INSERT INTO resume_chunks (resume_id, chunk_index, content, character_count, word_count, created_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + """, arguments: [ + resumeId, + chunk.index, + chunk.content, + chunk.characterCount, + chunk.wordCount + ]) + } + } + } + + /// Get all chunks for the current resume + func getChunks() async throws -> [ResumeChunk] { + let db = try await dbManager.getDatabase() + + return try await db.read { db in + // First get the current resume ID + guard let resumeId = try Int.fetchOne(db, sql: """ + SELECT id FROM user_resume ORDER BY uploaded_at DESC LIMIT 1 + """) else { + return [] + } + + let rows = try Row.fetchAll(db, sql: """ + SELECT * FROM resume_chunks WHERE resume_id = ? ORDER BY chunk_index ASC + """, arguments: [resumeId]) + + return rows.map { ResumeChunk.from(row: $0) } + } + } + + /// Get chunks for a specific resume + func getChunks(forResumeId resumeId: Int) async throws -> [ResumeChunk] { + let db = try await dbManager.getDatabase() + + return try await db.read { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT * FROM resume_chunks WHERE resume_id = ? ORDER BY chunk_index ASC + """, arguments: [resumeId]) + + return rows.map { ResumeChunk.from(row: $0) } + } + } + + /// Get the count of chunks for the current resume + func getChunkCount() async throws -> Int { + let db = try await dbManager.getDatabase() + + return try await db.read { db in + guard let resumeId = try Int.fetchOne(db, sql: """ + SELECT id FROM user_resume ORDER BY uploaded_at DESC LIMIT 1 + """) else { + return 0 + } + + return try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM resume_chunks WHERE resume_id = ? + """, arguments: [resumeId]) ?? 0 + } + } + + // MARK: - Combined Operations + + /// Save extracted text and chunks in a single transaction + func saveExtractedTextAndChunks(resumeId: Int, text: String, chunks: [TextChunk]) async throws { + let db = try await dbManager.getDatabase() + + try await db.write { db in + // Update resume with extracted text + try db.execute(sql: """ + UPDATE user_resume SET + extracted_text = ?, + extraction_status = 'completed', + extraction_error = NULL, + updated_at = datetime('now') + WHERE id = ? + """, arguments: [text, resumeId]) + + // Delete existing chunks + try db.execute(sql: "DELETE FROM resume_chunks WHERE resume_id = ?", arguments: [resumeId]) + + // Insert new chunks + for chunk in chunks { + try db.execute(sql: """ + INSERT INTO resume_chunks (resume_id, chunk_index, content, character_count, word_count, created_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + """, arguments: [ + resumeId, + chunk.index, + chunk.content, + chunk.characterCount, + chunk.wordCount + ]) + } + } + } +} + +/// Errors specific to resume operations +enum ResumeError: LocalizedError { + case saveFailed + case invalidPDF + case fileTooLarge(maxSize: Int) + + var errorDescription: String? { + switch self { + case .saveFailed: + return "Failed to save the resume to the database." + case .invalidPDF: + return "The selected file is not a valid PDF." + case .fileTooLarge(let maxSize): + let formatter = ByteCountFormatter() + formatter.countStyle = .file + let maxSizeStr = formatter.string(fromByteCount: Int64(maxSize)) + return "The file is too large. Maximum allowed size is \(maxSizeStr)." + } + } +} diff --git a/JobScout/Services/ResumeTextService.swift b/JobScout/Services/ResumeTextService.swift new file mode 100644 index 0000000..2c049a0 --- /dev/null +++ b/JobScout/Services/ResumeTextService.swift @@ -0,0 +1,184 @@ +// +// ResumeTextService.swift +// JobScout +// +// Created by Claude on 1/11/26. +// + +import Foundation +import NaturalLanguage +import PDFKit + +/// Service for extracting text from PDF resumes and chunking the text +actor ResumeTextService { + static let shared = ResumeTextService() + + // Chunking configuration optimized for resume content + private let targetChunkSize = 500 + private let minChunkSize = 50 + private let maxChunkSize = 1000 + + /// Extract text from PDF data using PDFKit + /// - Parameter pdfData: The raw PDF file data + /// - Returns: The extracted text content + /// - Throws: ResumeTextError if extraction fails + func extractText(from pdfData: Data) async throws -> String { + guard let pdfDocument = PDFDocument(data: pdfData) else { + throw ResumeTextError.extractionFailed("Failed to load PDF document") + } + + var fullText = "" + + for pageIndex in 0.. [TextChunk] { + guard !text.isEmpty else { + throw ResumeTextError.emptyContent + } + + // Split text into sentences + let sentences = splitIntoSentences(text) + + guard !sentences.isEmpty else { + // If no sentences found, create a single chunk from the whole text + return [TextChunk( + index: 0, + content: text, + characterCount: text.count, + wordCount: countWords(text) + )] + } + + var chunks: [TextChunk] = [] + var currentChunk = "" + var chunkIndex = 0 + + for sentence in sentences { + let potentialChunk = currentChunk.isEmpty ? sentence : currentChunk + " " + sentence + + if potentialChunk.count > maxChunkSize && !currentChunk.isEmpty { + // Current chunk is full, save it and start new one + chunks.append(createChunk(content: currentChunk, index: chunkIndex)) + chunkIndex += 1 + currentChunk = sentence + } else if potentialChunk.count >= targetChunkSize { + // Reached target size, save chunk + chunks.append(createChunk(content: potentialChunk, index: chunkIndex)) + chunkIndex += 1 + currentChunk = "" + } else { + // Keep accumulating + currentChunk = potentialChunk + } + } + + // Don't forget the last chunk + if !currentChunk.isEmpty { + // Merge with previous chunk if too small + if currentChunk.count < minChunkSize && !chunks.isEmpty { + let lastChunk = chunks.removeLast() + let mergedContent = lastChunk.content + " " + currentChunk + chunks.append(createChunk(content: mergedContent, index: lastChunk.index)) + } else { + chunks.append(createChunk(content: currentChunk, index: chunkIndex)) + } + } + + return chunks + } + + /// Extract text from PDF and chunk it in one operation + /// - Parameter pdfData: The raw PDF file data + /// - Returns: A tuple containing the full extracted text and the chunks + /// - Throws: ResumeTextError if either operation fails + func extractAndChunk(from pdfData: Data) async throws -> (text: String, chunks: [TextChunk]) { + let text = try await extractText(from: pdfData) + let chunks = try await chunkText(text) + return (text, chunks) + } + + // MARK: - Private Helpers + + private func splitIntoSentences(_ text: String) -> [String] { + var sentences: [String] = [] + + let tokenizer = NLTokenizer(unit: .sentence) + tokenizer.string = text + + tokenizer.enumerateTokens(in: text.startIndex.. Int { + let words = text.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + return words.count + } + + private func createChunk(content: String, index: Int) -> TextChunk { + TextChunk( + index: index, + content: content, + characterCount: content.count, + wordCount: countWords(content) + ) + } +} + +/// Represents a chunk of text with metadata +struct TextChunk: Sendable { + let index: Int + let content: String + let characterCount: Int + let wordCount: Int +} + +/// Errors specific to resume text processing +enum ResumeTextError: LocalizedError { + case extractionFailed(String) + case chunkingFailed(String) + case emptyContent + case unsupportedPlatform + + var errorDescription: String? { + switch self { + case .extractionFailed(let reason): + return "Failed to extract text from PDF: \(reason)" + case .chunkingFailed(let reason): + return "Failed to chunk text: \(reason)" + case .emptyContent: + return "The PDF contains no extractable text content." + case .unsupportedPlatform: + return "PDF text extraction is not supported on this platform." + } + } +} diff --git a/JobScout/Views/SettingsView.swift b/JobScout/Views/SettingsView.swift index 810826c..44b89ee 100644 --- a/JobScout/Views/SettingsView.swift +++ b/JobScout/Views/SettingsView.swift @@ -6,6 +6,8 @@ // import SwiftUI +import AppKit +import UniformTypeIdentifiers /// Settings view for configuring API keys and app preferences struct SettingsView: View { @@ -18,7 +20,26 @@ struct SettingsView: View { @State private var enableAnalysis = true @State private var maxParallelAnalysis: Int = 3 + // Resume upload state + @State private var currentResume: UserResume? + @State private var resumeUploadStatus: ResumeUploadStatus = .idle + @State private var isLoadingResume = true + @State private var chunkCount: Int = 0 + @State private var isExtracting = false + private let keychainService = KeychainService.shared + private let resumeRepository = ResumeRepository.shared + private let resumeTextService = ResumeTextService.shared + + /// Maximum allowed resume file size (10 MB) + private let maxResumeFileSize = 10 * 1024 * 1024 + + enum ResumeUploadStatus: Equatable { + case idle + case uploading + case success + case error(String) + } /// Key for storing max rows setting in UserDefaults static let maxRowsKey = "maxRowsToIngest" @@ -171,11 +192,149 @@ struct SettingsView: View { } footer: { Text("Set to 0 for no limit. Useful for testing with large job lists.") } + + Section { + if isLoadingResume { + HStack { + ProgressView() + .controlSize(.small) + Text("Loading...") + .foregroundStyle(.secondary) + } + } else if let resume = currentResume { + // Display current resume info + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "doc.fill") + .foregroundStyle(.red) + VStack(alignment: .leading, spacing: 2) { + Text(resume.fileName) + .fontWeight(.medium) + Text(resume.formattedFileSize) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(action: selectResumeFile) { + Text("Replace") + } + .disabled(resumeUploadStatus == .uploading || isExtracting) + + Button(action: deleteResume) { + Image(systemName: "trash") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + .disabled(resumeUploadStatus == .uploading || isExtracting) + } + + Text("Uploaded: \(resume.uploadedAt.formatted(date: .abbreviated, time: .shortened))") + .font(.caption) + .foregroundStyle(.secondary) + + // Extraction status + HStack(spacing: 4) { + switch resume.extractionStatus { + case .pending: + Image(systemName: "clock") + .foregroundStyle(.orange) + Text("Text extraction pending") + .font(.caption) + .foregroundStyle(.secondary) + case .processing: + ProgressView() + .controlSize(.small) + Text("Extracting text...") + .font(.caption) + .foregroundStyle(.secondary) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Text extracted • \(chunkCount) chunks") + .font(.caption) + .foregroundStyle(.secondary) + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(resume.extractionError ?? "Extraction failed") + .font(.caption) + .foregroundStyle(.red) + } + } + + // Retry button if extraction failed + if resume.extractionStatus == .failed || resume.extractionStatus == .pending { + Button(action: { extractTextFromResume(resume) }) { + HStack { + Image(systemName: "arrow.clockwise") + Text(resume.extractionStatus == .failed ? "Retry Extraction" : "Extract Text") + } + .font(.caption) + } + .disabled(isExtracting) + } + } + } else { + // No resume uploaded yet + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("No resume uploaded") + .foregroundStyle(.secondary) + Text("Upload a PDF file to use with job applications") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(action: selectResumeFile) { + HStack { + Image(systemName: "arrow.up.doc") + Text("Upload PDF") + } + } + .disabled(resumeUploadStatus == .uploading) + } + } + + if resumeUploadStatus == .uploading { + HStack { + ProgressView() + .controlSize(.small) + Text("Uploading...") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if case .success = resumeUploadStatus { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Resume uploaded successfully") + .font(.caption) + .foregroundStyle(.green) + } + } + + if case .error(let message) = resumeUploadStatus { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(message) + .font(.caption) + .foregroundStyle(.red) + } + } + } header: { + Text("Resume") + } footer: { + Text("Your resume is stored locally and can be used when applying to jobs. Maximum file size: 10 MB.") + } } .formStyle(.grouped) - .frame(width: 450, height: 550) + .frame(width: 450, height: 700) .onAppear { loadSettings() + loadResume() } } @@ -243,6 +402,176 @@ struct SettingsView: View { } } } + + // MARK: - Resume Methods + + private func loadResume() { + Task { + do { + let resume = try await resumeRepository.getCurrentResume() + let count = try await resumeRepository.getChunkCount() + await MainActor.run { + currentResume = resume + chunkCount = count + isLoadingResume = false + } + + // Auto-extract if pending and not already extracted + if let resume = resume, resume.extractionStatus == .pending { + extractTextFromResume(resume) + } + } catch { + await MainActor.run { + isLoadingResume = false + } + } + } + } + + private func selectResumeFile() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.pdf] + panel.message = "Select a PDF resume to upload" + panel.prompt = "Upload" + + panel.begin { response in + if response == .OK, let url = panel.url { + uploadResume(from: url) + } + } + } + + private func uploadResume(from url: URL) { + resumeUploadStatus = .uploading + + Task { + do { + // Read the file data + let data = try Data(contentsOf: url) + + // Check file size + guard data.count <= maxResumeFileSize else { + await MainActor.run { + resumeUploadStatus = .error("File is too large. Maximum size is 10 MB.") + } + return + } + + // Validate it's a PDF + guard data.starts(with: [0x25, 0x50, 0x44, 0x46]) else { // %PDF magic bytes + await MainActor.run { + resumeUploadStatus = .error("The selected file is not a valid PDF.") + } + return + } + + let fileName = url.lastPathComponent + + // Save to database + let savedResume = try await resumeRepository.saveResume(fileName: fileName, pdfData: data) + + await MainActor.run { + currentResume = savedResume + chunkCount = 0 + resumeUploadStatus = .success + } + + // Reset success status after delay + try? await Task.sleep(for: .seconds(2)) + + await MainActor.run { + resumeUploadStatus = .idle + } + + // Trigger text extraction + extractTextFromResume(savedResume) + } catch { + await MainActor.run { + resumeUploadStatus = .error(error.localizedDescription) + } + } + } + } + + private func deleteResume() { + Task { + do { + try await resumeRepository.deleteResume() + await MainActor.run { + currentResume = nil + chunkCount = 0 + } + } catch { + await MainActor.run { + resumeUploadStatus = .error("Failed to delete resume: \(error.localizedDescription)") + } + } + } + } + + private func extractTextFromResume(_ resume: UserResume) { + guard !isExtracting else { return } + + Task { + await MainActor.run { + isExtracting = true + } + + // Update status to processing + try? await resumeRepository.updateExtractionStatus(resumeId: resume.id, status: .processing) + + // Refresh the view to show processing status + if let updatedResume = try? await resumeRepository.getCurrentResume() { + await MainActor.run { + currentResume = updatedResume + } + } + + do { + // Extract text and create chunks + let (text, chunks) = try await resumeTextService.extractAndChunk(from: resume.pdfData) + + // Save to database + try await resumeRepository.saveExtractedTextAndChunks( + resumeId: resume.id, + text: text, + chunks: chunks + ) + + // Refresh the view + let updatedResume = try await resumeRepository.getCurrentResume() + let count = try await resumeRepository.getChunkCount() + + await MainActor.run { + currentResume = updatedResume + chunkCount = count + isExtracting = false + } + } catch { + // Update status to failed + try? await resumeRepository.updateExtractionStatus( + resumeId: resume.id, + status: .failed, + error: error.localizedDescription + ) + + // Refresh the view + if let updatedResume = try? await resumeRepository.getCurrentResume() { + await MainActor.run { + currentResume = updatedResume + isExtracting = false + } + } else { + await MainActor.run { + isExtracting = false + } + } + } + } + } } #Preview { diff --git a/JobScoutTests/ResumeTests.swift b/JobScoutTests/ResumeTests.swift new file mode 100644 index 0000000..2d0e89a --- /dev/null +++ b/JobScoutTests/ResumeTests.swift @@ -0,0 +1,704 @@ +// +// ResumeTests.swift +// JobScoutTests +// +// Created by Claude on 1/11/26. +// + +import Testing +import Foundation +@testable import JobScout + +// MARK: - UserResume Model Tests + +struct UserResumeTests { + + @Test func formattedFileSizeDisplaysBytes() async throws { + let resume = UserResume( + id: 1, + fileName: "resume.pdf", + pdfData: Data(count: 500), + fileSize: 500, + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: nil, + extractionStatus: .pending, + extractionError: nil + ) + + // 500 bytes should display as bytes + let formatted = resume.formattedFileSize + #expect(formatted.contains("bytes") || formatted.contains("B")) + } + + @Test func formattedFileSizeDisplaysKilobytes() async throws { + let resume = UserResume( + id: 1, + fileName: "resume.pdf", + pdfData: Data(count: 1024), + fileSize: 2048, // 2 KB + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: nil, + extractionStatus: .pending, + extractionError: nil + ) + + let formatted = resume.formattedFileSize + #expect(formatted.contains("KB")) + } + + @Test func formattedFileSizeDisplaysMegabytes() async throws { + let resume = UserResume( + id: 1, + fileName: "resume.pdf", + pdfData: Data(count: 1024), + fileSize: 1_500_000, // ~1.5 MB + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: nil, + extractionStatus: .pending, + extractionError: nil + ) + + let formatted = resume.formattedFileSize + #expect(formatted.contains("MB")) + } + + @Test func formattedFileSizeHandlesZero() async throws { + let resume = UserResume( + id: 1, + fileName: "empty.pdf", + pdfData: Data(), + fileSize: 0, + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: nil, + extractionStatus: .pending, + extractionError: nil + ) + + let formatted = resume.formattedFileSize + #expect(formatted == "Zero KB" || formatted.contains("0")) + } + + @Test func hasExtractedTextReturnsTrueWhenCompleted() async throws { + let resume = UserResume( + id: 1, + fileName: "resume.pdf", + pdfData: Data(count: 100), + fileSize: 100, + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: "Sample extracted text from resume", + extractionStatus: .completed, + extractionError: nil + ) + + #expect(resume.hasExtractedText == true) + } + + @Test func hasExtractedTextReturnsFalseWhenPending() async throws { + let resume = UserResume( + id: 1, + fileName: "resume.pdf", + pdfData: Data(count: 100), + fileSize: 100, + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: nil, + extractionStatus: .pending, + extractionError: nil + ) + + #expect(resume.hasExtractedText == false) + } + + @Test func hasExtractedTextReturnsFalseWhenEmpty() async throws { + let resume = UserResume( + id: 1, + fileName: "resume.pdf", + pdfData: Data(count: 100), + fileSize: 100, + uploadedAt: Date(), + createdAt: Date(), + updatedAt: Date(), + extractedText: "", + extractionStatus: .completed, + extractionError: nil + ) + + #expect(resume.hasExtractedText == false) + } +} + +// MARK: - ExtractionStatus Tests + +struct ExtractionStatusTests { + + @Test func extractionStatusRawValues() async throws { + #expect(ExtractionStatus.pending.rawValue == "pending") + #expect(ExtractionStatus.processing.rawValue == "processing") + #expect(ExtractionStatus.completed.rawValue == "completed") + #expect(ExtractionStatus.failed.rawValue == "failed") + } + + @Test func extractionStatusFromRawValue() async throws { + #expect(ExtractionStatus(rawValue: "pending") == .pending) + #expect(ExtractionStatus(rawValue: "processing") == .processing) + #expect(ExtractionStatus(rawValue: "completed") == .completed) + #expect(ExtractionStatus(rawValue: "failed") == .failed) + #expect(ExtractionStatus(rawValue: "invalid") == nil) + } +} + +// MARK: - TextChunk Tests + +struct TextChunkTests { + + @Test func textChunkStoresProperties() async throws { + let chunk = TextChunk( + index: 0, + content: "This is a sample chunk of text.", + characterCount: 31, + wordCount: 7 + ) + + #expect(chunk.index == 0) + #expect(chunk.content == "This is a sample chunk of text.") + #expect(chunk.characterCount == 31) + #expect(chunk.wordCount == 7) + } + + @Test func textChunkIsSendable() async throws { + let chunk = TextChunk( + index: 1, + content: "Sendable content", + characterCount: 16, + wordCount: 2 + ) + + // Test that chunk can be passed across actor boundaries + let result = await Task.detached { + return chunk.content + }.value + + #expect(result == "Sendable content") + } +} + +// MARK: - ResumeRepository Tests + +/// Tests for ResumeRepository - run serially to avoid race conditions on shared database +@Suite(.serialized) +struct ResumeRepositoryTests { + + /// Create sample PDF data with valid PDF magic bytes + private func createSamplePDFData(size: Int = 1024) -> Data { + var data = Data() + // PDF magic bytes: %PDF-1.4 + data.append(contentsOf: [0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]) + // Fill with random bytes to reach desired size + if size > 8 { + data.append(Data(count: size - 8)) + } + return data + } + + @Test func saveResumeCreatesNewRecord() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "test-resume-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up any existing resume first + try await repository.deleteResume() + + // Save a new resume + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + #expect(savedResume.fileName == fileName) + #expect(savedResume.pdfData == pdfData) + #expect(savedResume.fileSize == pdfData.count) + #expect(savedResume.extractionStatus == .pending) + + // Clean up + try await repository.deleteResume() + } + + @Test func getCurrentResumeReturnsNilWhenEmpty() async throws { + let repository = ResumeRepository.shared + + // Clean up any existing resume + try await repository.deleteResume() + + // Should return nil when no resume exists + let resume = try await repository.getCurrentResume() + #expect(resume == nil) + } + + @Test func getCurrentResumeReturnsSavedResume() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "test-resume-\(testId).pdf" + let pdfData = createSamplePDFData(size: 2048) + + // Clean up and save a resume + try await repository.deleteResume() + try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + // Get the current resume + let resume = try await repository.getCurrentResume() + + #expect(resume != nil) + #expect(resume?.fileName == fileName) + #expect(resume?.pdfData == pdfData) + #expect(resume?.fileSize == pdfData.count) + + // Clean up + try await repository.deleteResume() + } + + @Test func saveResumeReplacesExistingResume() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + + // Clean up first + try await repository.deleteResume() + + // Save first resume + let firstFileName = "first-resume-\(testId).pdf" + let firstData = createSamplePDFData(size: 1024) + try await repository.saveResume(fileName: firstFileName, pdfData: firstData) + + // Save second resume (should replace) + let secondFileName = "second-resume-\(testId).pdf" + let secondData = createSamplePDFData(size: 2048) + try await repository.saveResume(fileName: secondFileName, pdfData: secondData) + + // Get current resume - should be the second one + let resume = try await repository.getCurrentResume() + + #expect(resume != nil) + #expect(resume?.fileName == secondFileName) + #expect(resume?.fileSize == secondData.count) + + // Clean up + try await repository.deleteResume() + } + + @Test func deleteResumeRemovesRecord() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "delete-test-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + // Verify it exists + var hasResume = try await repository.hasResume() + #expect(hasResume == true) + + // Delete it + try await repository.deleteResume() + + // Verify it's gone + hasResume = try await repository.hasResume() + #expect(hasResume == false) + + let resume = try await repository.getCurrentResume() + #expect(resume == nil) + } + + @Test func hasResumeReturnsTrueWhenResumeExists() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "has-resume-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + let hasResume = try await repository.hasResume() + #expect(hasResume == true) + + // Clean up + try await repository.deleteResume() + } + + @Test func hasResumeReturnsFalseWhenEmpty() async throws { + let repository = ResumeRepository.shared + + // Clean up any existing resume + try await repository.deleteResume() + + let hasResume = try await repository.hasResume() + #expect(hasResume == false) + } + + @Test func updateResumeReplacesContent() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + + // Clean up first + try await repository.deleteResume() + + // Save initial resume + let initialFileName = "initial-\(testId).pdf" + let initialData = createSamplePDFData(size: 1024) + try await repository.saveResume(fileName: initialFileName, pdfData: initialData) + + // Update with new content + let updatedFileName = "updated-\(testId).pdf" + let updatedData = createSamplePDFData(size: 3072) + try await repository.updateResume(fileName: updatedFileName, pdfData: updatedData) + + // Verify update + let resume = try await repository.getCurrentResume() + #expect(resume?.fileName == updatedFileName) + #expect(resume?.fileSize == updatedData.count) + + // Clean up + try await repository.deleteResume() + } + + @Test func resumePersistsAcrossRepositoryInstances() async throws { + let testId = UUID().uuidString.prefix(8) + let fileName = "persist-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Save using shared instance + try await ResumeRepository.shared.deleteResume() + try await ResumeRepository.shared.saveResume(fileName: fileName, pdfData: pdfData) + + // Create new repository instance and verify data persists + let newRepository = ResumeRepository() + let resume = try await newRepository.getCurrentResume() + + #expect(resume != nil) + #expect(resume?.fileName == fileName) + + // Clean up + try await ResumeRepository.shared.deleteResume() + } + + // MARK: - Extraction Status Tests + + @Test func updateExtractionStatusUpdatesResume() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "extraction-test-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + // Update status to processing + try await repository.updateExtractionStatus(resumeId: savedResume.id, status: .processing) + var resume = try await repository.getCurrentResume() + #expect(resume?.extractionStatus == .processing) + + // Update status to completed + try await repository.updateExtractionStatus(resumeId: savedResume.id, status: .completed) + resume = try await repository.getCurrentResume() + #expect(resume?.extractionStatus == .completed) + + // Clean up + try await repository.deleteResume() + } + + @Test func updateExtractionStatusWithError() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "error-test-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + // Update status to failed with error + let errorMessage = "Test extraction error" + try await repository.updateExtractionStatus(resumeId: savedResume.id, status: .failed, error: errorMessage) + + let resume = try await repository.getCurrentResume() + #expect(resume?.extractionStatus == .failed) + #expect(resume?.extractionError == errorMessage) + + // Clean up + try await repository.deleteResume() + } + + // MARK: - Chunk Tests + + @Test func saveAndGetChunks() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "chunks-test-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + // Create test chunks + let chunks = [ + TextChunk(index: 0, content: "First chunk of text", characterCount: 19, wordCount: 4), + TextChunk(index: 1, content: "Second chunk of text", characterCount: 20, wordCount: 4), + TextChunk(index: 2, content: "Third chunk of text", characterCount: 19, wordCount: 4) + ] + + // Save chunks + try await repository.saveChunks(resumeId: savedResume.id, chunks: chunks) + + // Get chunks + let savedChunks = try await repository.getChunks() + + #expect(savedChunks.count == 3) + #expect(savedChunks[0].content == "First chunk of text") + #expect(savedChunks[1].content == "Second chunk of text") + #expect(savedChunks[2].content == "Third chunk of text") + + // Clean up + try await repository.deleteResume() + } + + @Test func getChunkCount() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "count-test-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + // Initially no chunks + var count = try await repository.getChunkCount() + #expect(count == 0) + + // Add chunks + let chunks = [ + TextChunk(index: 0, content: "Chunk 1", characterCount: 7, wordCount: 2), + TextChunk(index: 1, content: "Chunk 2", characterCount: 7, wordCount: 2) + ] + try await repository.saveChunks(resumeId: savedResume.id, chunks: chunks) + + count = try await repository.getChunkCount() + #expect(count == 2) + + // Clean up + try await repository.deleteResume() + } + + @Test func saveExtractedTextAndChunks() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "combined-test-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume + try await repository.deleteResume() + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + let extractedText = "This is the full extracted text from the resume." + let chunks = [ + TextChunk(index: 0, content: "This is the full", characterCount: 16, wordCount: 4), + TextChunk(index: 1, content: "extracted text from", characterCount: 19, wordCount: 3), + TextChunk(index: 2, content: "the resume.", characterCount: 11, wordCount: 2) + ] + + // Save text and chunks together + try await repository.saveExtractedTextAndChunks( + resumeId: savedResume.id, + text: extractedText, + chunks: chunks + ) + + // Verify + let resume = try await repository.getCurrentResume() + #expect(resume?.extractedText == extractedText) + #expect(resume?.extractionStatus == .completed) + + let savedChunks = try await repository.getChunks() + #expect(savedChunks.count == 3) + + // Clean up + try await repository.deleteResume() + } + + @Test func deleteResumeAlsoDeletesChunks() async throws { + let repository = ResumeRepository.shared + let testId = UUID().uuidString.prefix(8) + let fileName = "delete-chunks-\(testId).pdf" + let pdfData = createSamplePDFData() + + // Clean up and save a resume with chunks + try await repository.deleteResume() + let savedResume = try await repository.saveResume(fileName: fileName, pdfData: pdfData) + + let chunks = [ + TextChunk(index: 0, content: "Chunk to delete", characterCount: 15, wordCount: 3) + ] + try await repository.saveChunks(resumeId: savedResume.id, chunks: chunks) + + // Verify chunks exist + var count = try await repository.getChunkCount() + #expect(count == 1) + + // Delete resume + try await repository.deleteResume() + + // Chunks should be gone too + count = try await repository.getChunkCount() + #expect(count == 0) + } +} + +// MARK: - PDF Validation Tests + +struct PDFValidationTests { + + /// Valid PDF magic bytes: %PDF + private let validPDFHeader: [UInt8] = [0x25, 0x50, 0x44, 0x46] + + @Test func validPDFStartsWithMagicBytes() async throws { + var data = Data() + data.append(contentsOf: validPDFHeader) + data.append(contentsOf: [0x2D, 0x31, 0x2E, 0x34]) // -1.4 + data.append(Data(count: 100)) // Additional content + + let isValidPDF = data.starts(with: validPDFHeader) + #expect(isValidPDF == true) + } + + @Test func invalidPDFDoesNotStartWithMagicBytes() async throws { + // Plain text file + let textData = "Hello, World!".data(using: .utf8)! + let isValidPDF = textData.starts(with: validPDFHeader) + #expect(isValidPDF == false) + + // PNG file (starts with 0x89 0x50 0x4E 0x47) + let pngHeader: [UInt8] = [0x89, 0x50, 0x4E, 0x47] + var pngData = Data() + pngData.append(contentsOf: pngHeader) + let isPNG = pngData.starts(with: validPDFHeader) + #expect(isPNG == false) + + // JPEG file (starts with 0xFF 0xD8 0xFF) + let jpegHeader: [UInt8] = [0xFF, 0xD8, 0xFF, 0xE0] + var jpegData = Data() + jpegData.append(contentsOf: jpegHeader) + let isJPEG = jpegData.starts(with: validPDFHeader) + #expect(isJPEG == false) + } + + @Test func emptyDataIsNotValidPDF() async throws { + let emptyData = Data() + let isValidPDF = emptyData.starts(with: validPDFHeader) + #expect(isValidPDF == false) + } + + @Test func shortDataIsNotValidPDF() async throws { + // Data shorter than the magic bytes + var shortData = Data() + shortData.append(contentsOf: [0x25, 0x50]) // Only first 2 bytes + let isValidPDF = shortData.starts(with: validPDFHeader) + #expect(isValidPDF == false) + } +} + +// MARK: - File Size Validation Tests + +struct FileSizeValidationTests { + + /// Maximum allowed file size (10 MB) + private let maxFileSize = 10 * 1024 * 1024 + + @Test func fileSizeWithinLimitIsValid() async throws { + let smallFile = Data(count: 1024) // 1 KB + #expect(smallFile.count <= maxFileSize) + + let mediumFile = Data(count: 5 * 1024 * 1024) // 5 MB + #expect(mediumFile.count <= maxFileSize) + + let exactLimit = Data(count: maxFileSize) // Exactly 10 MB + #expect(exactLimit.count <= maxFileSize) + } + + @Test func fileSizeExceedingLimitIsInvalid() async throws { + let tooLarge = Data(count: maxFileSize + 1) // 10 MB + 1 byte + #expect(tooLarge.count > maxFileSize) + + let wayTooLarge = Data(count: 20 * 1024 * 1024) // 20 MB + #expect(wayTooLarge.count > maxFileSize) + } + + @Test func emptyFileIsWithinLimit() async throws { + let emptyFile = Data() + #expect(emptyFile.count <= maxFileSize) + } +} + +// MARK: - ResumeError Tests + +struct ResumeErrorTests { + + @Test func saveFailedErrorHasDescription() async throws { + let error = ResumeError.saveFailed + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("save") == true) + } + + @Test func invalidPDFErrorHasDescription() async throws { + let error = ResumeError.invalidPDF + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("PDF") == true) + } + + @Test func fileTooLargeErrorIncludesMaxSize() async throws { + let maxSize = 10 * 1024 * 1024 + let error = ResumeError.fileTooLarge(maxSize: maxSize) + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("10") == true || error.errorDescription?.contains("MB") == true) + } +} + +// MARK: - ResumeTextError Tests + +struct ResumeTextErrorTests { + + @Test func extractionFailedErrorHasDescription() async throws { + let error = ResumeTextError.extractionFailed("Test reason") + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("Test reason") == true) + } + + @Test func chunkingFailedErrorHasDescription() async throws { + let error = ResumeTextError.chunkingFailed("Chunking issue") + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("Chunking issue") == true) + } + + @Test func emptyContentErrorHasDescription() async throws { + let error = ResumeTextError.emptyContent + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("text") == true || error.errorDescription?.contains("content") == true) + } + + @Test func unsupportedPlatformErrorHasDescription() async throws { + let error = ResumeTextError.unsupportedPlatform + #expect(error.errorDescription != nil) + #expect(error.errorDescription?.contains("platform") == true || error.errorDescription?.contains("supported") == true) + } +}