Skip to content

Conversation

@nfebe
Copy link
Contributor

@nfebe nfebe commented Dec 30, 2025

No description provided.

Connect AIChat component to the Laravel API which proxies to SmartQL.
Add aiApi service for API communication. Render query results based
on format_type (scalar, pair, record, list, pair_list, table, raw)
with appropriate styling for each format.

Signed-off-by: nfebe <fenn25.fn@gmail.com>
Set up Vitest with happy-dom for component testing. Add tests for
aiApi service and AIChat component covering all format type
renderings and error handling.

Signed-off-by: nfebe <fenn25.fn@gmail.com>
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Dec 30, 2025

Deploying webui with  Cloudflare Pages  Cloudflare Pages

Latest commit: 16b86b1
Status: ✅  Deploy successful!
Preview URL: https://13a5527d.webui-9fh.pages.dev
Branch Preview URL: https://ai-insights.webui-9fh.pages.dev

View logs

@sourceant
Copy link

sourceant bot commented Dec 30, 2025

Code Review Summary

This pull request introduces significant enhancements to the application, primarily by implementing an AI chat feature with structured data display and by establishing a robust testing framework across the project. The changes include adding a new API service for AI interactions, creating a sophisticated AIChat Vue component, and integrating comprehensive unit tests for new and existing components and pages. The CI pipeline has also been updated to incorporate automated testing.

🚀 Key Improvements

  • Comprehensive Test Coverage: New unit tests (AIChat.test.ts, TButton.test.ts, TransactionForm.test.ts, categories.test.ts, dashboard.test.ts, groups.test.ts, wallets.test.ts, aiApi.test.ts) have been added, vastly improving the reliability and maintainability of the codebase.
  • Enhanced CI Pipeline: The .github/workflows/checks.yml file now includes a dedicated test job that runs Vitest, ensuring that all code changes are thoroughly validated before deployment. The build job now depends on test for increased confidence.
  • Modular AI Service: A new services/api/aiApi.ts file introduces a clean, type-safe API client for AI interactions, promoting better separation of concerns.
  • Dynamic AI Chat UI: The AIChat.vue component dynamically renders various data formats (scalar, pair, record, list, table, raw) from the AI response, providing a rich user experience.

💡 Minor Suggestions

  • AI Chat Result Rendering: Refactor the complex v-if/v-else-if block for displaying AI results in AIChat.vue into a separate sub-component for better readability and maintainability. This also addresses the brittleness of using Object.values/Object.keys for data extraction by allowing for more explicit prop definitions in the sub-component.
  • GitHub Actions Versioning: Update GitHub Actions actions/checkout and actions/setup-node to their latest major version (v4) in .github/workflows/checks.yml for improved security and features.

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

@sourceant
Copy link

sourceant bot commented Dec 30, 2025

🔍 Code Review

💡 1. **.github/workflows/checks.yml** (Line 1) - CLARITY

The workflow name has been updated from 'Lint' to 'CI', which is more descriptive given that the workflow now includes linting, formatting, and testing jobs. This improves the clarity of the workflow's purpose.

Suggested Code:

name: CI

Current Code:

name: Lint
💡 2. **.github/workflows/checks.yml** (Lines 34-51) - IMPROVEMENT

A new test job has been added to the CI pipeline, ensuring that unit tests are run automatically. This is a critical addition for maintaining code quality and preventing regressions. All necessary setup steps (checkout, node setup, install dependencies) are included.

Suggested Code:

  test:
    name: Test
    runs-on: ubuntu-latest

    steps:
      - name: Check out Git repository
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm run test:run
💡 3. **components/ai/AIChat.vue** (Lines 1-67) - IMPROVEMENT

The AIChat.vue component now includes robust conditional rendering for various data formats (scalar, pair, record, list, pair_list, table, raw). This significantly enhances the component's ability to display diverse AI responses in a user-friendly and structured manner. This change is a major improvement in user experience and component flexibility.

Suggested Code:

<template>
  <div class="ai-chat card">
    <h2 class="title">AI Insights Chat</h2>
    <div ref="chatWindow" class="chat-window">
      <div
        v-for="(message, index) in chatHistory"
        :key="index"
        class="chat-row"
        :class="message.type"
      >
        <div class="bubble" :class="message.type">
          <div class="message-text">{{ message.text }}</div>
          <div v-if="message.results?.length && message.formatType" class="results-container">
            <template v-if="message.formatType === 'scalar'">
              <div class="result-scalar">{{ formatValue(Object.values(message.results[0])[0]) }}</div>
            </template>
            <template v-else-if="message.formatType === 'pair'">
              <div class="result-pair">
                <span class="pair-label">{{ Object.keys(message.results[0])[0] }}:</span>
                <span class="pair-value">{{ formatValue(Object.values(message.results[0])[1]) }}</span>
              </div>
            </template>
            <template v-else-if="message.formatType === 'record'">
              <div class="result-record">
                <div v-for="(value, key) in message.results[0]" :key="key" class="record-row">
                  <span class="record-key">{{ formatKey(key) }}:</span>
                  <span class="record-value">{{ formatValue(value) }}</span>
                </div>
              </div>
            </template>
            <template v-else-if="message.formatType === 'list'">
              <ul class="result-list">
                <li v-for="(row, i) in message.results" :key="i">
                  {{ formatValue(Object.values(row)[0]) }}
                </li>
              </ul>
            </template>
            <template v-else-if="message.formatType === 'pair_list'">
              <div class="result-pair-list">
                <div v-for="(row, i) in message.results" :key="i" class="pair-row">
                  <span class="pair-label">{{ Object.values(row)[0] }}:</span>
                  <span class="pair-value">{{ formatValue(Object.values(row)[1]) }}</span>
                </div>
              </div>
            </template>
            <template v-else-if="message.formatType === 'table'">
              <div class="result-table-wrapper">
                <table class="result-table">
                  <thead>
                    <tr>
                      <th v-for="key in Object.keys(message.results[0])" :key="key">
                        {{ formatKey(key) }}
                      </th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr v-for="(row, i) in message.results" :key="i">
                      <td v-for="(value, key) in row" :key="key">{{ formatValue(value) }}</td>
                    </tr>
                  </tbody>
                </table>
              </div>
            </template>
            <template v-else>
              <pre class="result-raw">{{ JSON.stringify(message.results, null, 2) }}</pre>
            </template>
          </div>
        </div>
      </div>

Current Code:

<template>
  <div class="ai-chat card">
    <h2 class="title">AI Insights Chat</h2>
    <div class="chat-window">
      <div
        v-for="(message, index) in chatHistory"
        :key="index"
        class="chat-row"
        :class="message.type"
      >
        <div class="bubble" :class="message.type">{{ message.text }}</div>
💡 4. **components/ai/AIChat.vue** (Lines 79-83) - IMPROVEMENT

Disabling the input and send button while the AI is processing prevents multiple submissions and provides clear user feedback. The dynamic button text (... vs Send) further enhances this feedback loop.

Suggested Code:

        :disabled="isLoading"
      />
      <button type="submit" class="send-btn" :disabled="isLoading || !input.trim()">
        {{ isLoading ? '...' : 'Send' }}
      </button>

Current Code:

        placeholder="Ask me anything about your finances..."
      />
      <button type="submit" class="send-btn">Send</button>
💡 5. **components/ai/AIChat.vue** (Lines 89-165) - REFACTOR

The refactoring of the <script setup> block to use TypeScript, integrate with aiApi for actual API calls, manage loading states, and handle dynamic content formatting is a substantial improvement. This moves from hardcoded responses to a scalable, data-driven approach with proper error handling and reactive state management, leveraging Nuxt's useApi composable.

Suggested Code:

<script setup lang="ts">
import { ref, nextTick } from 'vue';
import { aiApi, type FormatType } from '@/services/api/aiApi';

interface ChatMessage {
  type: 'user' | 'ai';
  text: string;
  results?: Record<string, unknown>[];
  formatType?: FormatType | null;
}

const chatHistory = ref<ChatMessage[]>([
  { type: 'ai', text: 'Hello! I am your personal financial assistant. How can I help you today?' }
]);
const input = ref('');
const isLoading = ref(false);
const chatWindow = ref<HTMLElement | null>(null);

const scrollToBottom = () => {
  nextTick(() => {
    if (chatWindow.value) {
      chatWindow.value.scrollTop = chatWindow.value.scrollHeight;
    }
  });
};

const formatKey = (key: string): string => {
  return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};

const formatValue = (value: unknown): string => {
  if (value === null || value === undefined) return '-';
  if (typeof value === 'number') {
    return Number.isInteger(value) ? value.toString() : value.toLocaleString(undefined, {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2
    });
  }
  return String(value);
};

const handleSendMessage = async () => {
  if (!input.value.trim() || isLoading.value) return;

  const userMessage: ChatMessage = { type: 'user', text: input.value };
  chatHistory.value.push(userMessage);
  const question = input.value;
  input.value = '';
  isLoading.value = true;
  scrollToBottom();

  try {
    const response = await aiApi.ask(question);

    if (response.success && response.data) {
      chatHistory.value.push({
        type: 'ai',
        text: response.data.answer,
        results: response.data.results,
        formatType: response.data.format_type
      });
    } else {
      chatHistory.value.push({
        type: 'ai',
        text: response.message || 'Sorry, I could not process your request. Please try again.'
      });
    }
  } catch {
    chatHistory.value.push({
      type: 'ai',
      text: 'Sorry, the AI service is currently unavailable. Please try again later.'
    });
  } finally {
    isLoading.value = false;
    scrollToBottom();
  }
};
</script>

Current Code:

<script setup>
import { ref } from 'vue';

const chatHistory = ref([
  { type: 'ai', text: 'Hello! I am your personal financial assistant. How can I help you today?' }
]);
const input = ref('');

const handleSendMessage = () => {
  if (!input.value.trim()) return;
  const userMessage = { type: 'user', text: input.value };
  chatHistory.value.push(userMessage);

  let aiResponse = 'I am not quite sure how to answer that yet. Please try a different query.';
  const lower = input.value.toLowerCase();
  if (lower.includes('highest-paying months')) {
    aiResponse =
      "Based on last year's data, your highest-paying months were November ($8,500) and December ($7,900) due to holiday bonuses and year-end client projects.";
  } else if (lower.includes('on track to save')) {
    aiResponse =
      'Yes, you are currently on track to save 20% of your income. Your current savings rate is 21.5%, which is slightly ahead of your goal.';
  }

  setTimeout(() => {
    chatHistory.value.push({ type: 'ai', text: aiResponse });
  }, 600);

  input.value = '';
};
💡 6. **package.json** (Lines 35-40) - IMPROVEMENT

Adding @nuxt/test-utils, @vitejs/plugin-vue, @vue/test-utils, happy-dom, and vitest to devDependencies enables a robust unit testing environment for Vue components within the Nuxt project. This is crucial for test coverage and reliability.

Suggested Code:

    "@nuxt/test-utils": "^3.21.0",
    "@vitejs/plugin-vue": "^6.0.3",
    "@vue/test-utils": "^2.4.6",
    "happy-dom": "^20.0.11",
    "vitest": "^3.2.4"
💡 7. **tests/components/AIChat.test.ts** (Lines 1-440) - IMPROVEMENT

Adding a dedicated test file for AIChat.vue is a crucial improvement. It covers various scenarios from initial rendering, user interaction, loading states, API response handling (success/failure), and different data format rendering. This comprehensive testing ensures the component's reliability and correct behavior.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { ref, nextTick } from 'vue';
import AIChat from '@/components/ai/AIChat.vue';

const mockAsk = vi.fn();
vi.mock('@/services/api/aiApi', () => ({
  aiApi: {
    ask: (...args: unknown[]) => mockAsk(...args),
  },
}));

describe('AIChat', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('renders initial greeting message', () => {
    const wrapper = mount(AIChat);

    expect(wrapper.text()).toContain('Hello! I am your personal financial assistant');
  });

  it('displays user message when sent', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Test response',
        format_type: 'scalar',
        results: [{ total: 100 }],
      },
    });

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const form = wrapper.find('form');

    await input.setValue('How much did I spend?');
    await form.trigger('submit');

    expect(wrapper.text()).toContain('How much did I spend?');
  });

  it('shows loading state while waiting for response', async () => {
    let resolvePromise: (value: unknown) => void;
    const promise = new Promise((resolve) => {
      resolvePromise = resolve;
    });
    mockAsk.mockReturnValueOnce(promise);

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const form = wrapper.find('form');

    await input.setValue('Test question');
    await form.trigger('submit');
    await nextTick();

    expect(wrapper.text()).toContain('Thinking...');

    resolvePromise!();
    await flushPromises();

    expect(wrapper.text()).not.toContain('Thinking...');
  });

  it('displays AI response after successful request', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'You spent $500 on food last month.',
        format_type: 'scalar',
        results: [{ total: 500 }],
      },
    });

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const form = wrapper.find('form');

    await input.setValue('How much on food?');
    await form.trigger('submit');
    await flushPromises();

    expect(wrapper.text()).toContain('You spent $500 on food last month.');
  });

  it('displays error message on failed request', async () => {
    mockAsk.mockResolvedValueOnce({
      success: false,
      message: 'Service unavailable',
    });

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const form = wrapper.find('form');

    await input.setValue('Test');
    await form.trigger('submit');
    await flushPromises();

    expect(wrapper.text()).toContain('Service unavailable');
  });

  it('displays fallback error on exception', async () => {
    mockAsk.mockRejectedValueOnce(new Error('Network error'));

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const form = wrapper.find('form');

    await input.setValue('Test');
    await form.trigger('submit');
    await flushPromises();

    expect(wrapper.text()).toContain('AI service is currently unavailable');
  });

  it('disables input and button while loading', async () => {
    let resolvePromise: (value: unknown) => void;
    const promise = new Promise((resolve) => {
      resolvePromise = resolve;
    });
    mockAsk.mockReturnValueOnce(promise);

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const button = wrapper.find('.send-btn');
    const form = wrapper.find('form');

    await input.setValue('Test');
    await form.trigger('submit');
    await nextTick();

    expect(input.attributes('disabled')).toBeDefined();
    expect(button.attributes('disabled')).toBeDefined();

    resolvePromise!();
    await flushPromises();

    expect(input.attributes('disabled')).toBeUndefined();
  });

  it('does not send empty messages', async () => {
    const wrapper = mount(AIChat);
    const form = wrapper.find('form');

    await form.trigger('submit');

    expect(mockAsk).not.toHaveBeenCalled();
  });

  it('clears input after sending message', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: { answer: 'Response', format_type: null, results: [] },
    });

    const wrapper = mount(AIChat);
    const input = wrapper.find('.chat-input');
    const form = wrapper.find('form');

    await input.setValue('Test message');
    await form.trigger('submit');

    expect((input.element as HTMLInputElement).value).toBe('');
  });
});

describe('AIChat format type rendering', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('renders scalar format as large bold value', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Your total spending is:',
        format_type: 'scalar',
        results: [{ total: 5000 }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Total?');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-scalar').exists()).toBe(true);
    expect(wrapper.find('.result-scalar').text()).toContain('5000');
  });

  it('renders pair format as label-value', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Top category:',
        format_type: 'pair',
        results: [{ category: 'Food', amount: 1500 }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Top category?');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-pair').exists()).toBe(true);
    expect(wrapper.find('.pair-label').text()).toContain('category');
  });

  it('renders record format as key-value rows', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Transaction details:',
        format_type: 'record',
        results: [{ id: 1, name: 'Rent', amount: 2000, date: '2025-01-01' }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Show transaction');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-record').exists()).toBe(true);
    expect(wrapper.findAll('.record-row').length).toBe(4);
  });

  it('renders list format as bullet points', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Your categories:',
        format_type: 'list',
        results: [{ name: 'Food' }, { name: 'Transport' }, { name: 'Entertainment' }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('List categories');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-list').exists()).toBe(true);
    expect(wrapper.findAll('.result-list li').length).toBe(3);
  });

  it('renders pair_list format as multiple label-value pairs', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Spending by category:',
        format_type: 'pair_list',
        results: [
          { category: 'Food', total: 500 },
          { category: 'Transport', total: 200 },
        ],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Breakdown');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-pair-list').exists()).toBe(true);
    expect(wrapper.findAll('.pair-row').length).toBe(2);
  });

  it('renders table format with headers and rows', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Recent transactions:',
        format_type: 'table',
        results: [
          { date: '2025-01-01', description: 'Groceries', amount: 50 },
          { date: '2025-01-02', description: 'Gas', amount: 30 },
        ],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Show transactions');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-table').exists()).toBe(true);
    expect(wrapper.findAll('.result-table th').length).toBe(3);
    expect(wrapper.findAll('.result-table tbody tr').length).toBe(2);
  });

  it('renders raw format as JSON', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Raw data:',
        format_type: 'raw',
        results: [{ complex: { nested: 'data' } }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Raw data');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-raw').exists()).toBe(true);
    expect(wrapper.find('.result-raw').text()).toContain('nested');
  });

  it('does not render results container when results are empty', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'No data found.',
        format_type: 'table',
        results: [],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Empty query');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.results-container').exists()).toBe(false);
  });

  it('does not render results when format_type is null', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Just a text response.',
        format_type: null,
        results: [{ data: 'value' }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Text only');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.results-container').exists()).toBe(false);
  });
});

describe('AIChat formatValue helper', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('formats decimal numbers with two decimal places', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Amount:',
        format_type: 'scalar',
        results: [{ amount: 1234.5 }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Test');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-scalar').text()).toContain('1,234.50');
  });

  it('formats integer numbers without decimal places', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Count:',
        format_type: 'scalar',
        results: [{ count: 42 }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Test');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-scalar').text()).toBe('42');
  });

  it('displays dash for null values', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Record:',
        format_type: 'record',
        results: [{ name: 'Test', value: null }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Test');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.result-record').text()).toContain('-');
  });
});

describe('AIChat formatKey helper', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('converts snake_case to Title Case', async () => {
    mockAsk.mockResolvedValueOnce({
      success: true,
      data: {
        answer: 'Record:',
        format_type: 'record',
        results: [{ total_amount: 100 }],
      },
    });

    const wrapper = mount(AIChat);
    await wrapper.find('.chat-input').setValue('Test');
    await wrapper.find('form').trigger('submit');
    await flushPromises();

    expect(wrapper.find('.record-key').text()).toContain('Total Amount');
  });
});
💡 8. **tests/components/TransactionForm.test.ts** (Lines 1-289) - IMPROVEMENT

Adding a comprehensive test file for TransactionForm.vue significantly improves confidence in its functionality. The tests cover rendering of all form elements, dynamic button text based on transaction type and editing mode, initial state of date, input handling for amount and description, currency selection, robust validation logic (empty, zero, negative amounts), correct payload emission on submission, and proper population of fields in editing mode. This detailed testing ensures the form's reliability and user experience.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import TransactionForm from '@/components/TransactionForm.vue';

const mockSharedData = {
  parties: ref([
    { id: 1, name: 'Grocery Store' },
    { id: 2, name: 'Gas Station' },
  ]),
  groups: ref([
    { id: 1, name: 'Food & Dining' },
    { id: 2, name: 'Transportation' },
  ]),
  wallets: ref([
    { id: 1, name: 'Main Wallet', currency: 'USD' },
    { id: 2, name: 'Savings', currency: 'USD' },
  ]),
  getExpenseCategories: ref([
    { id: 1, name: 'Groceries', type: 'expense' },
    { id: 2, name: 'Gas', type: 'expense' },
  ]),
  getIncomeCategories: ref([
    { id: 3, name: 'Salary', type: 'income' },
    { id: 4, name: 'Freelance', type: 'income' },
  ]),
  getDefaultCurrency: ref('USD'),
  getDefaultWallet: ref({ id: 1, name: 'Main Wallet', currency: 'USD' }),
  getDefaultGroup: ref({ id: 1, name: 'Food & Dining' }),
  loadAllData: vi.fn().mockResolvedValue(undefined),
};

vi.mock('~/composables/useSharedData', () => ({
  useSharedData: () => mockSharedData,
}));

const stubs = {
  TButton: {
    template: '<button @click="$emit('click')">{{ text }}<slot /><slot name="left-icon" /></button>',
    props: ['text'],
  },
  SearchableDropdown: {
    template: '<div class="searchable-dropdown"><input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /><slot /></div>',
    props: ['modelValue', 'label', 'options', 'placeholder', 'multiple', 'error', 'disabled'],
    emits: ['update:modelValue', 'select'],
  },
  CheckIcon: { template: '<span class="check-icon" />' },
  PencilIcon: { template: '<span class="pencil-icon" />' },
};

describe('TransactionForm', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('rendering', () => {
    it('renders the form with all required fields', () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      expect(wrapper.find('input[type="number"]').exists()).toBe(true);
      expect(wrapper.find('textarea').exists()).toBe(true);
      expect(wrapper.find('input[type="date"]').exists()).toBe(true);
      expect(wrapper.find('input[type="time"]').exists()).toBe(true);
      expect(wrapper.find('select').exists()).toBe(true);
    });

    it('shows "Record expense" button when isOutcomeSelected is true', () => {
      const wrapper = mount(TransactionForm, {
        props: { isOutcomeSelected: true },
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Record expense');
    });

    it('shows "Record income" button when isOutcomeSelected is false', () => {
      const wrapper = mount(TransactionForm, {
        props: { isOutcomeSelected: false },
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Record income');
    });

    it('shows "Update expense" button when editing an expense', () => {
      const wrapper = mount(TransactionForm, {
        props: {
          isOutcomeSelected: true,
          editingItem: { id: 1, amount: '100 USD' },
        },
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Update expense');
    });

    it('shows "Update income" button when editing an income', () => {
      const wrapper = mount(TransactionForm, {
        props: {
          isOutcomeSelected: false,
          editingItem: { id: 1, amount: '100 USD' },
        },
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Update income');
    });
  });

  describe('form fields', () => {
    it('initializes with current date', () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      const dateInput = wrapper.find('input[type="date"]');
      const today = new Date().toISOString().slice(0, 10);
      expect(dateInput.element.value).toBe(today);
    });

    it('allows entering amount', async () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      const amountInput = wrapper.find('input[type="number"]');
      await amountInput.setValue('250');
      expect(amountInput.element.value).toBe('250');
    });

    it('allows entering description', async () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      const textarea = wrapper.find('textarea');
      await textarea.setValue('Grocery shopping');
      expect(textarea.element.value).toBe('Grocery shopping');
    });

    it('has currency selector with available currencies', () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      const select = wrapper.find('select');
      const options = select.findAll('option');
      expect(options.length).toBeGreaterThan(0);
    });
  });

  describe('validation', () => {
    it('shows error when amount is empty on submit', async () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      await wrapper.find('button').trigger('click');

      expect(wrapper.text()).toContain('Enter a valid amount greater than 0');
    });

    it('shows error when amount is zero', async () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      await wrapper.find('input[type="number"]').setValue('0');
      await wrapper.find('button').trigger('click');

      expect(wrapper.text()).toContain('Enter a valid amount greater than 0');
    });

    it('shows error when amount is negative', async () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      await wrapper.find('input[type="number"]').setValue('-50');
      await wrapper.find('button').trigger('click');

      expect(wrapper.text()).toContain('Enter a valid amount greater than 0');
    });

    it('does not emit submit when validation fails', async () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      await wrapper.find('button').trigger('click');

      expect(wrapper.emitted('submit')).toBeFalsy();
    });
  });

  describe('form submission', () => {
    it('emits submit with correct payload for expense', async () => {
      const wrapper = mount(TransactionForm, {
        props: { isOutcomeSelected: true },
        global: { stubs },
      });

      await wrapper.find('input[type="number"]').setValue('100');
      await wrapper.find('textarea').setValue('Test expense');
      await wrapper.find('button').trigger('click');

      expect(wrapper.emitted('submit')).toBeTruthy();
      const payload = wrapper.emitted('submit')?.[0]?.[0];
      expect(payload.type).toBe('EXPENSE');
      expect(payload.amount).toContain('100');
    });

    it('emits submit with correct payload for income', async () => {
      const wrapper = mount(TransactionForm, {
        props: { isOutcomeSelected: false },
        global: { stubs },
      });

      await wrapper.find('input[type="number"]').setValue('500');
      await wrapper.find('textarea').setValue('Test income');
      await wrapper.find('button').trigger('click');

      expect(wrapper.emitted('submit')).toBeTruthy();
      const payload = wrapper.emitted('submit')?.[0]?.[0];
      expect(payload.type).toBe('INCOME');
      expect(payload.amount).toContain('500');
    });

    it('includes id in payload when editing', async () => {
      const wrapper = mount(TransactionForm, {
        props: {
          isOutcomeSelected: true,
          editingItem: { id: 123, amount: '100 USD' },
        },
        global: { stubs },
      });

      await wrapper.find('input[type="number"]').setValue('150');
      await wrapper.find('button').trigger('click');

      const payload = wrapper.emitted('submit')?.[0]?.[0];
      expect(payload.id).toBe(123);
    });
  });

  describe('file attachments', () => {
    it('renders file upload section', () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      expect(wrapper.find('input[type="file"]').exists()).toBe(true);
      expect(wrapper.text()).toContain('Browse files');
    });

    it('shows hint for file types', () => {
      const wrapper = mount(TransactionForm, {
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Images, PDFs or docs');
    });
  });

  describe('editing mode', () => {
    it('populates form with existing data when editing', async () => {
      const wrapper = mount(TransactionForm, {
        props: {
          editingItem: {
            id: 1,
            date: '2025-01-15',
            time: '14:30',
            amount: '250.50 USD',
            description: 'Test transaction',
          },
        },
        global: { stubs },
      });

      await wrapper.vm.$nextTick();

      expect(wrapper.find('input[type="date"]').element.value).toBe('2025-01-15');
      expect(wrapper.find('input[type="time"]').element.value).toBe('14:30');
      expect(wrapper.find('textarea').element.value).toBe('Test transaction');
    });
  });
});
💡 9. **tests/pages/categories.test.ts** (Lines 1-230) - IMPROVEMENT

The new test file for categories.test.ts provides extensive coverage for the page's logic. It effectively mocks dependencies to isolate the component under test and verifies the functionality of the useCategories composable (fetching, creating, updating, deleting), category filtering, confirmation flows, and data structure integrity. This significantly enhances the reliability of the categories management feature.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref, computed } from 'vue';

const mockCategories = ref([
  { id: 1, name: 'Groceries', type: 'expense', icon: 'shopping-cart' },
  { id: 2, name: 'Restaurants', type: 'expense', icon: 'utensils' },
  { id: 3, name: 'Salary', type: 'income', icon: 'briefcase' },
  { id: 4, name: 'Freelance', type: 'income', icon: 'laptop' },
  { id: 5, name: 'Gas', type: 'expense', icon: 'car' },
]);

const mockUseCategories = {
  categories: mockCategories,
  isLoading: ref(false),
  error: ref(null),
  fetchCategories: vi.fn().mockResolvedValue(undefined),
  createCategory: vi.fn().mockResolvedValue({ id: 6, name: 'New Category' }),
  updateCategory: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Category' }),
  deleteCategory: vi.fn().mockResolvedValue(undefined),
};

const mockNotifications = {
  confirmDelete: vi.fn().mockResolvedValue(true),
  showSuccess: vi.fn(),
  showError: vi.fn(),
};

vi.mock('@/composables/useCategories', () => ({
  useCategories: () => mockUseCategories,
}));

vi.mock('@/composables/useNotifications', () => ({
  useNotifications: () => mockNotifications,
}));

vi.mock('@/composables/useSidebar', () => ({
  useSidebar: () => ({
    isTabletOrBelow: ref(false),
  }),
}));

describe('Categories Page Logic', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockCategories.value = [
      { id: 1, name: 'Groceries', type: 'expense', icon: 'shopping-cart' },
      { id: 2, name: 'Restaurants', type: 'expense', icon: 'utensils' },
      { id: 3, name: 'Salary', type: 'income', icon: 'briefcase' },
      { id: 4, name: 'Freelance', type: 'income', icon: 'laptop' },
      { id: 5, name: 'Gas', type: 'expense', icon: 'car' },
    ];
  });

  describe('useCategories composable', () => {
    it('returns categories list', () => {
      const { categories } = mockUseCategories;
      expect(categories.value).toHaveLength(5);
    });

    it('fetchCategories loads category data', async () => {
      await mockUseCategories.fetchCategories();
      expect(mockUseCategories.fetchCategories).toHaveBeenCalled();
    });

    it('createCategory adds new category', async () => {
      const newCategory = { name: 'Shopping', type: 'expense', icon: 'bag' };
      await mockUseCategories.createCategory(newCategory);
      expect(mockUseCategories.createCategory).toHaveBeenCalledWith(newCategory);
    });

    it('updateCategory modifies existing category', async () => {
      const updateData = { name: 'Updated Name', icon: 'star' };
      await mockUseCategories.updateCategory(1, updateData);
      expect(mockUseCategories.updateCategory).toHaveBeenCalledWith(1, updateData);
    });

    it('deleteCategory removes category', async () => {
      await mockUseCategories.deleteCategory(1);
      expect(mockUseCategories.deleteCategory).toHaveBeenCalledWith(1);
    });
  });

  describe('category filtering by type', () => {
    it('filters expense categories', () => {
      const expenseCategories = mockCategories.value.filter((c) => c.type === 'expense');
      expect(expenseCategories).toHaveLength(3);
      expect(expenseCategories.every((c) => c.type === 'expense')).toBe(true);
    });

    it('filters income categories', () => {
      const incomeCategories = mockCategories.value.filter((c) => c.type === 'income');
      expect(incomeCategories).toHaveLength(2);
      expect(incomeCategories.every((c) => c.type === 'income')).toBe(true);
    });

    it('returns correct expense category names', () => {
      const expenseCategories = mockCategories.value.filter((c) => c.type === 'expense');
      const names = expenseCategories.map((c) => c.name);
      expect(names).toContain('Groceries');
      expect(names).toContain('Restaurants');
      expect(names).toContain('Gas');
    });

    it('returns correct income category names', () => {
      const incomeCategories = mockCategories.value.filter((c) => c.type === 'income');
      const names = incomeCategories.map((c) => c.name);
      expect(names).toContain('Salary');
      expect(names).toContain('Freelance');
    });
  });

  describe('category operations', () => {
    it('shows confirmation before deleting', async () => {
      await mockNotifications.confirmDelete('category');
      expect(mockNotifications.confirmDelete).toHaveBeenCalledWith('category');
    });

    it('shows success notification after deletion', () => {
      mockNotifications.showSuccess('Category deleted', 'Groceries has been deleted successfully');
      expect(mockNotifications.showSuccess).toHaveBeenCalled();
    });

    it('does not delete when confirmation is cancelled', async () => {
      mockNotifications.confirmDelete.mockResolvedValueOnce(false);

      const confirmed = await mockNotifications.confirmDelete('category');

      if (!confirmed) {
        expect(mockUseCategories.deleteCategory).not.toHaveBeenCalled();
      }
    });
  });

  describe('category data structure', () => {
    it('category has required fields', () => {
      const category = mockCategories.value[0];
      expect(category).toHaveProperty('id');
      expect(category).toHaveProperty('name');
      expect(category).toHaveProperty('type');
    });

    it('category type is either income or expense', () => {
      mockCategories.value.forEach((category) => {
        expect(['income', 'expense']).toContain(category.type);
      });
    });

    it('category can have optional icon', () => {
      const category = mockCategories.value[0];
      expect(category).toHaveProperty('icon');
    });
  });
});

describe('Category Form Validation', () => {
  it('requires category name', () => {
    const categoryData = { name: '', type: 'expense' };
    const isValid = categoryData.name.trim().length > 0;
    expect(isValid).toBe(false);
  });

  it('accepts valid category name', () => {
    const categoryData = { name: 'My Category', type: 'expense' };
    const isValid = categoryData.name.trim().length > 0;
    expect(isValid).toBe(true);
  });

  it('requires type selection', () => {
    const categoryData = { name: 'My Category', type: '' };
    const isValid = ['income', 'expense'].includes(categoryData.type);
    expect(isValid).toBe(false);
  });

  it('accepts expense type', () => {
    const categoryData = { name: 'My Category', type: 'expense' };
    const isValid = ['income', 'expense'].includes(categoryData.type);
    expect(isValid).toBe(true);
  });

  it('accepts income type', () => {
    const categoryData = { name: 'My Category', type: 'income' };
    const isValid = ['income', 'expense'].includes(categoryData.type);
    expect(isValid).toBe(true);
  });
});

describe('Category Search/Filter', () => {
  it('can search categories by name', () => {
    const searchQuery = 'groc';
    const filtered = mockCategories.value.filter((c) =>
      c.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
    expect(filtered).toHaveLength(1);
    expect(filtered[0].name).toBe('Groceries');
  });

  it('returns empty array for no matches', () => {
    const searchQuery = 'xyz';
    const filtered = mockCategories.value.filter((c) =>
      c.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
    expect(filtered).toHaveLength(0);
  });

  it('can combine type filter and search', () => {
    const searchQuery = 'sal';
    const typeFilter = 'income';
    const filtered = mockCategories.value.filter(
      (c) => c.type === typeFilter && c.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
    expect(filtered).toHaveLength(1);
    expect(filtered[0].name).toBe('Salary');
  });
});

describe('Category Statistics', () => {
  it('counts total categories', () => {
    expect(mockCategories.value.length).toBe(5);
  });

  it('counts expense categories', () => {
    const count = mockCategories.value.filter((c) => c.type === 'expense').length;
    expect(count).toBe(3);
  });

  it('counts income categories', () => {
    const count = mockCategories.value.filter((c) => c.type === 'income').length;
    expect(count).toBe(2);
  });
});
💡 10. **tests/pages/dashboard.test.ts** (Lines 1-219) - IMPROVEMENT

The new test file for dashboard.test.ts provides solid coverage for the DashboardKPIs component logic. It effectively mocks external dependencies (useStatistics, useWallets, useTransactions, useSharedData) to focus on component behavior. Tests include KPI card rendering, dynamic styling based on net value, and wallet selector functionality. This enhances the reliability and correctness of the dashboard's key metrics display.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';

const mockStatistics = {
  total_balance: 5000,
  total_income: 8000,
  total_expenses: 3000,
  top_categories: [
    { name: 'Food', amount: 1200 },
    { name: 'Transport', amount: 800 },
  ],
};

const mockUseStatistics = {
  currentStatistics: ref(mockStatistics),
  currentPeriod: ref('month'),
  customFilters: ref(null),
  isLoading: ref(false),
  error: ref(null),
  selectedWalletId: ref(null),
  availableWallets: ref([
    { id: null, name: 'All Wallets' },
    { id: 1, name: 'Main Wallet' },
  ]),
  formatCompactCurrency: (val: number) => `$${val.toLocaleString()}`,
  setSelectedWallet: vi.fn(),
  setCustomFilters: vi.fn(),
  clearCustomFilters: vi.fn(),
  setPeriod: vi.fn(),
};

vi.mock('@/composables/useStatistics', () => ({
  useStatistics: () => mockUseStatistics,
}));

vi.mock('@/composables/useWallets', () => ({
  useWallets: () => ({
    wallets: ref([]),
  }),
}));

vi.mock('@/composables/useTransactions', () => ({
  useTransactions: () => ({
    transactions: ref([]),
    recentTransactions: ref([]),
    isLoading: ref(false),
  }),
}));

vi.mock('@/composables/useSharedData', () => ({
  useSharedData: () => ({
    loadAllData: vi.fn(),
  }),
}));

const stubs = {
  ChevronDown: { template: '<span />' },
  XIcon: { template: '<span />' },
  NuxtLink: { template: '<a><slot /></a>' },
};

import DashboardKPIs from '@/components/dashboard/DashboardKPIs.vue';

describe('DashboardKPIs', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('rendering', () => {
    it('renders KPI cards', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.find('.kpi-grid').exists()).toBe(true);
      expect(wrapper.findAll('.kpi-card').length).toBe(4);
    });

    it('displays Balance KPI', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Balance');
      expect(wrapper.text()).toContain('5,000');
    });

    it('displays Income KPI with positive styling', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Income');
      expect(wrapper.text()).toContain('8,000');
      expect(wrapper.find('.kpi-value.is-positive').exists()).toBe(true);
    });

    it('displays Expenses KPI with negative styling', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Expenses');
      expect(wrapper.text()).toContain('3,000');
      expect(wrapper.find('.kpi-value.is-negative').exists()).toBe(true);
    });

    it('displays Net value (income - expenses)', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.text()).toContain('Net');
      expect(wrapper.text()).toContain('5,000');
    });
  });

  describe('wallet selector', () => {
    it('renders wallet selector', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.find('.wallet-selector').exists()).toBe(true);
    });

    it('shows "All Wallets" by default', () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.find('.wallet-name').text()).toBe('All Wallets');
    });

    it('toggles dropdown on click', async () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      expect(wrapper.find('.wallet-dropdown').exists()).toBe(false);

      await wrapper.find('.wallet-selector').trigger('click');

      expect(wrapper.find('.wallet-dropdown').exists()).toBe(true);
    });

    it('shows wallet options in dropdown', async () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      await wrapper.find('.wallet-selector').trigger('click');

      const options = wrapper.findAll('.wallet-option');
      expect(options.length).toBe(2);
      expect(options[0].text()).toBe('All Wallets');
      expect(options[1].text()).toBe('Main Wallet');
    });

    it('calls setSelectedWallet when option is clicked', async () => {
      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      await wrapper.find('.wallet-selector').trigger('click');
      await wrapper.findAll('.wallet-option')[1].trigger('click');

      expect(mockUseStatistics.setSelectedWallet).toHaveBeenCalledWith(1);
    });
  });

  describe('net value styling', () => {
    it('applies positive class when net is positive', () => {
      mockUseStatistics.currentStatistics.value = {
        ...mockStatistics,
        total_income: 5000,
        total_expenses: 2000,
      };

      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      const netCard = wrapper.findAll('.kpi-card')[3];
      expect(netCard.find('.is-positive').exists()).toBe(true);
    });

    it('applies negative class when net is negative', () => {
      mockUseStatistics.currentStatistics.value = {
        ...mockStatistics,
        total_income: 2000,
        total_expenses: 5000,
      };

      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      const netCard = wrapper.findAll('.kpi-card')[3];
      expect(netCard.find('.is-negative').exists()).toBe(true);
    });
  });

  describe('empty state', () => {
    it('displays zero values when no statistics', () => {
      mockUseStatistics.currentStatistics.value = null;

      const wrapper = mount(DashboardKPIs, {
        global: { stubs },
      });

      const values = wrapper.findAll('.kpi-value');
      values.forEach((value) => {
        expect(value.text()).toContain('0');
      });
    });
  });
});
💡 11. **tests/pages/groups.test.ts** (Lines 1-185) - IMPROVEMENT

The new test file for groups.test.ts is well-structured and covers the core logic of the groups management page. It effectively utilizes mocks for composables (useGroups, useNotifications, useSidebar) to isolate testing. Verification of group list retrieval, CRUD operations, confirmation dialogues, loading states, data structure, and form validation ensures a robust and reliable feature set for group management.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';

const mockGroups = ref([
  { id: 1, name: 'Food & Dining', icon: 'utensils', color: '#FF5733' },
  { id: 2, name: 'Transportation', icon: 'car', color: '#33FF57' },
  { id: 3, name: 'Entertainment', icon: 'film', color: '#3357FF' },
]);

const mockUseGroups = {
  groups: mockGroups,
  isLoading: ref(false),
  error: ref(null),
  fetchGroups: vi.fn().mockResolvedValue(undefined),
  createGroup: vi.fn().mockResolvedValue({ id: 4, name: 'New Group' }),
  updateGroup: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Group' }),
  deleteGroup: vi.fn().mockResolvedValue(undefined),
};

const mockNotifications = {
  confirmDelete: vi.fn().mockResolvedValue(true),
  showSuccess: vi.fn(),
  showError: vi.fn(),
};

vi.mock('@/composables/useGroups', () => ({
  useGroups: () => mockUseGroups,
}));

vi.mock('@/composables/useNotifications', () => ({
  useNotifications: () => mockNotifications,
}));

vi.mock('@/composables/useSidebar', () => ({
  useSidebar: () => ({
    isTabletOrBelow: ref(false),
  }),
}));

describe('Groups Page Logic', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockGroups.value = [
      { id: 1, name: 'Food & Dining', icon: 'utensils', color: '#FF5733' },
      { id: 2, name: 'Transportation', icon: 'car', color: '#33FF57' },
      { id: 3, name: 'Entertainment', icon: 'film', color: '#3357FF' },
    ];
  });

  describe('useGroups composable', () => {
    it('returns groups list', () => {
      const { groups } = mockUseGroups;
      expect(groups.value).toHaveLength(3);
      expect(groups.value[0].name).toBe('Food & Dining');
    });

    it('fetchGroups loads group data', async () => {
      await mockUseGroups.fetchGroups();
      expect(mockUseGroups.fetchGroups).toHaveBeenCalled();
    });

    it('createGroup adds new group', async () => {
      const newGroup = { name: 'Shopping', icon: 'shopping-cart', color: '#FFAA00' };
      await mockUseGroups.createGroup(newGroup);
      expect(mockUseGroups.createGroup).toHaveBeenCalledWith(newGroup);
    });

    it('updateGroup modifies existing group', async () => {
      const updateData = { name: 'Updated Name', icon: 'star' };
      await mockUseGroups.updateGroup(1, updateData);
      expect(mockUseGroups.updateGroup).toHaveBeenCalledWith(1, updateData);
    });

    it('deleteGroup removes group', async () => {
      await mockUseGroups.deleteGroup(1);
      expect(mockUseGroups.deleteGroup).toHaveBeenCalledWith(1);
    });
  });

  describe('group operations', () => {
    it('shows confirmation before deleting', async () => {
      await mockNotifications.confirmDelete('group');
      expect(mockNotifications.confirmDelete).toHaveBeenCalledWith('group');
    });

    it('shows success notification after deletion', () => {
      mockNotifications.showSuccess('Group deleted', 'Food & Dining has been deleted successfully');
      expect(mockNotifications.showSuccess).toHaveBeenCalledWith(
        'Group deleted',
        'Food & Dining has been deleted successfully'
      );
    });

    it('does not delete when confirmation is cancelled', async () => {
      mockNotifications.confirmDelete.mockResolvedValueOnce(false);

      const confirmed = await mockNotifications.confirmDelete('group');

      if (!confirmed) {
        expect(mockUseGroups.deleteGroup).not.toHaveBeenCalled();
      }
    });
  });

  describe('loading states', () => {
    it('isLoading starts as false', () => {
      expect(mockUseGroups.isLoading.value).toBe(false);
    });

    it('error starts as null', () => {
      expect(mockUseGroups.error.value).toBeNull();
    });
  });

  describe('group data structure', () => {
    it('group has required fields', () => {
      const group = mockGroups.value[0];
      expect(group).toHaveProperty('id');
      expect(group).toHaveProperty('name');
    });

    it('group can have optional icon', () => {
      const group = mockGroups.value[0];
      expect(group).toHaveProperty('icon');
    });

    it('group can have optional color', () => {
      const group = mockGroups.value[0];
      expect(group).toHaveProperty('color');
    });
  });
});

describe('Group Form Validation', () => {
  it('requires group name', () => {
    const groupData = { name: '' };
    const isValid = groupData.name.trim().length > 0;
    expect(isValid).toBe(false);
  });

  it('accepts valid group name', () => {
    const groupData = { name: 'My Group' };
    const isValid = groupData.name.trim().length > 0;
    expect(isValid).toBe(true);
  });

  it('trims whitespace from name', () => {
    const groupData = { name: '  My Group  ' };
    const trimmedName = groupData.name.trim();
    expect(trimmedName).toBe('My Group');
  });

  it('accepts name with special characters', () => {
    const groupData = { name: 'Food & Dining' };
    const isValid = groupData.name.trim().length > 0;
    expect(isValid).toBe(true);
  });
});

describe('Group Filtering', () => {
  it('can filter groups by name', () => {
    const searchQuery = 'food';
    const filtered = mockGroups.value.filter((g) =>
      g.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
    expect(filtered).toHaveLength(1);
    expect(filtered[0].name).toBe('Food & Dining');
  });

  it('returns empty array for no matches', () => {
    const searchQuery = 'xyz';
    const filtered = mockGroups.value.filter((g) =>
      g.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
    expect(filtered).toHaveLength(0);
  });

  it('case insensitive search', () => {
    const searchQuery = 'TRANSPORTATION';
    const filtered = mockGroups.value.filter((g) =>
      g.name.toLowerCase().includes(searchQuery.toLowerCase())
    );
    expect(filtered).toHaveLength(1);
  });
});
💡 12. **tests/pages/wallets.test.ts** (Lines 1-165) - IMPROVEMENT

The new test file for wallets.test.ts provides extensive testing for the wallet management page. It includes comprehensive mocks for related composables (useWallets, useNotifications, useSharedData, useSidebar) and verifies the core functionalities of fetching, creating, updating, and deleting wallets. It also covers confirmation flows, error handling, loading states, data structure, and form validation, ensuring the feature's robustness and reliability.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';

const mockWallets = ref([
  { id: 1, name: 'Main Wallet', balance: 5000, currency: 'USD' },
  { id: 2, name: 'Savings', balance: 10000, currency: 'USD' },
]);

const mockUseWallets = {
  wallets: mockWallets,
  isLoading: ref(false),
  error: ref(null),
  fetchWallets: vi.fn().mockResolvedValue(undefined),
  createWallet: vi.fn().mockResolvedValue({ id: 3, name: 'New Wallet' }),
  updateWallet: vi.fn().mockResolvedValue({ id: 1, name: 'Updated Wallet' }),
  deleteWallet: vi.fn().mockResolvedValue(undefined),
};

const mockNotifications = {
  confirmDelete: vi.fn().mockResolvedValue(true),
  showSuccess: vi.fn(),
  showError: vi.fn(),
};

vi.mock('@/composables/useWallets', () => ({
  useWallets: () => mockUseWallets,
}));

vi.mock('@/composables/useNotifications', () => ({
  useNotifications: () => mockNotifications,
}));

vi.mock('@/composables/useSharedData', () => ({
  useSharedData: () => ({
    getDefaultWallet: ref({ id: 1, name: 'Main Wallet' }),
    loadConfigurations: vi.fn().mockResolvedValue(undefined),
  }),
}));

vi.mock('@/composables/useSidebar', () => ({
  useSidebar: () => ({
    isTabletOrBelow: ref(false),
  }),
}));

describe('Wallets Page Logic', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockWallets.value = [
      { id: 1, name: 'Main Wallet', balance: 5000, currency: 'USD' },
      { id: 2, name: 'Savings', balance: 10000, currency: 'USD' },
    ];
  });

  describe('useWallets composable', () => {
    it('returns wallets list', () => {
      const { wallets } = mockUseWallets;
      expect(wallets.value).toHaveLength(2);
      expect(wallets.value[0].name).toBe('Main Wallet');
    });

    it('fetchWallets loads wallet data', async () => {
      await mockUseWallets.fetchWallets();
      expect(mockUseWallets.fetchWallets).toHaveBeenCalled();
    });

    it('createWallet adds new wallet', async () => {
      const newWallet = { name: 'New Wallet', currency: 'EUR' };
      await mockUseWallets.createWallet(newWallet);
      expect(mockUseWallets.createWallet).toHaveBeenCalledWith(newWallet);
    });

    it('updateWallet modifies existing wallet', async () => {
      const updateData = { name: 'Updated Name' };
      await mockUseWallets.updateWallet(1, updateData);
      expect(mockUseWallets.updateWallet).toHaveBeenCalledWith(1, updateData);
    });

    it('deleteWallet removes wallet', async () => {
      await mockUseWallets.deleteWallet(1);
      expect(mockUseWallets.deleteWallet).toHaveBeenCalledWith(1);
    });
  });

  describe('wallet operations', () => {
    it('shows confirmation before deleting', async () => {
      await mockNotifications.confirmDelete('wallet');
      expect(mockNotifications.confirmDelete).toHaveBeenCalledWith('wallet');
    });

    it('shows success notification after deletion', () => {
      mockNotifications.showSuccess('Wallet deleted', 'Main Wallet has been deleted successfully');
      expect(mockNotifications.showSuccess).toHaveBeenCalledWith(
        'Wallet deleted',
        'Main Wallet has been deleted successfully'
      );
    });

    it('shows error notification on delete failure', () => {
      mockNotifications.showError('Delete failed', 'Failed to delete wallet. Please try again.');
      expect(mockNotifications.showError).toHaveBeenCalled();
    });

    it('does not delete when confirmation is cancelled', async () => {
      mockNotifications.confirmDelete.mockResolvedValueOnce(false);

      const confirmed = await mockNotifications.confirmDelete('wallet');

      if (!confirmed) {
        expect(mockUseWallets.deleteWallet).not.toHaveBeenCalled();
      }
    });
  });

  describe('loading states', () => {
    it('isLoading starts as false', () => {
      expect(mockUseWallets.isLoading.value).toBe(false);
    });

    it('error starts as null', () => {
      expect(mockUseWallets.error.value).toBeNull();
    });
  });

  describe('wallet data structure', () => {
    it('wallet has required fields', () => {
      const wallet = mockWallets.value[0];
      expect(wallet).toHaveProperty('id');
      expect(wallet).toHaveProperty('name');
      expect(wallet).toHaveProperty('balance');
      expect(wallet).toHaveProperty('currency');
    });

    it('wallet balance is a number', () => {
      const wallet = mockWallets.value[0];
      expect(typeof wallet.balance).toBe('number');
    });
  });
});

describe('Wallet Form Validation', () => {
  it('requires wallet name', () => {
    const walletData = { name: '', currency: 'USD' };
    const isValid = walletData.name.trim().length > 0;
    expect(isValid).toBe(false);
  });

  it('accepts valid wallet name', () => {
    const walletData = { name: 'My Wallet', currency: 'USD' };
    const isValid = walletData.name.trim().length > 0;
    expect(isValid).toBe(true);
  });

  it('requires currency selection', () => {
    const walletData = { name: 'My Wallet', currency: '' };
    const isValid = walletData.currency.length > 0;
    expect(isValid).toBe(false);
  });

  it('accepts valid currency', () => {
    const walletData = { name: 'My Wallet', currency: 'EUR' };
    const isValid = walletData.currency.length > 0;
    expect(isValid).toBe(true);
  });
});
💡 13. **tests/services/aiApi.test.ts** (Lines 1-107) - IMPROVEMENT

The new test file aiApi.test.ts provides essential unit tests for the aiApi service. It correctly mocks the useApi composable and thoroughly verifies the functionality of ask and checkHealth methods, including successful responses and error handling. Furthermore, it validates the FormatType enumeration. This ensures the reliability and correctness of the AI service integration.

Suggested Code:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { aiApi, type FormatType, type AskResponse } from '@/services/api/aiApi';

const mockApi = vi.fn();
vi.mock('#imports', () => ({
  useApi: () => mockApi,
}));

vi.stubGlobal('useApi', () => mockApi);

describe('aiApi', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('ask', () => {
    it('should send message to /ai/chat endpoint', async () => {
      const mockResponse: AskResponse = {
        success: true,
        data: {
          answer: 'You spent $500 on food last month.',
          format_type: 'scalar',
          results: [{ total: 500 }],
        },
      };
      mockApi.mockResolvedValueOnce(mockResponse);

      const result = await aiApi.ask('How much did I spend on food?');

      expect(mockApi).toHaveBeenCalledWith('/ai/chat', {
        method: 'POST',
        body: { message: 'How much did I spend on food?' },
      });
      expect(result).toEqual(mockResponse);
    });

    it('should handle error responses', async () => {
      const mockResponse: AskResponse = {
        success: false,
        message: 'AI service unavailable',
      };
      mockApi.mockResolvedValueOnce(mockResponse);

      const result = await aiApi.ask('test question');

      expect(result.success).toBe(false);
      expect(result.message).toBe('AI service unavailable');
    });
  });

  describe('checkHealth', () => {
    it('should call /ai/health endpoint', async () => {
      mockApi.mockResolvedValueOnce({ available: true });

      const result = await aiApi.checkHealth();

      expect(mockApi).toHaveBeenCalledWith('/ai/health');
      expect(result.available).toBe(true);
    });

    it('should return unavailable when service is down', async () => {
      mockApi.mockResolvedValueOnce({ available: false });

      const result = await aiApi.checkHealth();

      expect(result.available).toBe(false);
    });
  });
});

describe('FormatType', () => {
  it('should accept valid format types', () => {
    const validTypes: FormatType[] = [
      'scalar',
      'pair',
      'record',
      'list',
      'pair_list',
      'table',
      'raw',
    ];

    validTypes.forEach((type) => {
      const response: AskResponse = {
        success: true,
        data: {
          answer: 'test',
          format_type: type,
          results: [],
        },
      };
      expect(response.data?.format_type).toBe(type);
    });
  });

  it('should allow null format_type', () => {
    const response: AskResponse = {
      success: true,
      data: {
        answer: 'test',
        format_type: null,
        results: [],
      },
    };
    expect(response.data?.format_type).toBeNull();
  });
});

Verdict: APPROVE

Posted as a comment because posting a review failed.

Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Add tests for:
- TButton component (variants, sizes, states, loading)
- TransactionForm (validation, submission, editing)
- Dashboard KPIs (wallet selector, statistics display)
- Wallets page logic (CRUD operations)
- Groups page logic (CRUD operations)
- Categories page logic (filtering, CRUD operations)

Update CI workflow to run tests before build.

Signed-off-by: nfebe <fenn25.fn@gmail.com>
Copy link

@sourceant sourceant bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

@nfebe nfebe merged commit e0d86b2 into main Dec 30, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants