diff --git a/backend/src/main/java/se/bilhalsning/controller/OrderController.java b/backend/src/main/java/se/bilhalsning/controller/OrderController.java new file mode 100644 index 0000000..252c27b --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/controller/OrderController.java @@ -0,0 +1,49 @@ +package se.bilhalsning.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import se.bilhalsning.dto.OrderResponse; +import se.bilhalsning.entity.Order; +import se.bilhalsning.entity.User; +import se.bilhalsning.exception.InvalidCredentialsException; +import se.bilhalsning.service.OrderService; +import se.bilhalsning.service.UserService; + +import java.util.List; + +@RestController +@RequestMapping("/api/orders") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + private final UserService userService; + + @GetMapping + public ResponseEntity> list(@AuthenticationPrincipal UserDetails userDetails) { + User user = userService.findByEmail(userDetails.getUsername()) + .orElseThrow(InvalidCredentialsException::new); + + List orders = orderService.getOrdersByUserId(user.getId()).stream() + .map(this::toResponse) + .toList(); + + return ResponseEntity.ok(orders); + } + + private OrderResponse toResponse(Order order) { + return new OrderResponse( + order.getId(), + order.getPlate(), + order.getTemplate(), + order.getStatus().getValue(), + order.getTrackingId(), + order.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/se/bilhalsning/dto/OrderResponse.java b/backend/src/main/java/se/bilhalsning/dto/OrderResponse.java new file mode 100644 index 0000000..a383f77 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/OrderResponse.java @@ -0,0 +1,13 @@ +package se.bilhalsning.dto; + +import java.time.Instant; +import java.util.UUID; + +public record OrderResponse( + UUID id, + String plate, + String template, + String status, + String trackingId, + Instant createdAt +) {} diff --git a/backend/src/main/resources/db/migration/V6__seed_test_orders.sql b/backend/src/main/resources/db/migration/V6__seed_test_orders.sql new file mode 100644 index 0000000..c017bb6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__seed_test_orders.sql @@ -0,0 +1,6 @@ +-- Seed orders for test user (test@bilhalsning.se, id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11) +INSERT INTO orders (id, user_id, plate, template, letter_text, status, amount_paid, tracking_id, created_at, updated_at) +VALUES + ('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Komplimang', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'), + ('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DEF456', 'Jag vill köpa din bil', 'Hej! Jag är intresserad av att köpa din bil. Kontakta mig gärna på test@example.com så kan vi diskutera ett pris.', 'pending_payment', NULL, NULL, TIMESTAMP '2026-05-14 13:00:00', TIMESTAMP '2026-05-14 13:00:00'), + ('c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'GHI789', 'Tips / servicebehov', 'Hej! Jag noterade att ditt bakre högra hjul har lite för lågt lufttryck. Tänkte det kan vara bra att veta!', 'delivered', 49.00, 'PN987654321', TIMESTAMP '2026-05-07 10:00:00', TIMESTAMP '2026-05-12 10:00:00'); diff --git a/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java new file mode 100644 index 0000000..f19b4b0 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/controller/OrderControllerTest.java @@ -0,0 +1,104 @@ +package se.bilhalsning.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import se.bilhalsning.dto.OrderResponse; +import se.bilhalsning.entity.User; +import se.bilhalsning.service.OrderService; +import se.bilhalsning.service.UserService; + +@SpringBootTest +@AutoConfigureMockMvc +class OrderControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OrderService orderService; + + @MockitoBean + private UserService userService; + + @Test + void shouldReturn403WhenNotAuthenticated() throws Exception { + mockMvc.perform(get("/api/orders")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "test@bilhalsning.se") + void shouldReturnOrdersForAuthenticatedUser() throws Exception { + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhalsning.se"); + + when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user)); + + OrderResponse order1 = new OrderResponse( + UUID.randomUUID(), "ABC123", "Komplimang", "sent", "PN123456789", Instant.now()); + OrderResponse order2 = new OrderResponse( + UUID.randomUUID(), "DEF456", null, "pending_payment", null, Instant.now()); + + when(orderService.getOrdersByUserId(userId)).thenReturn(List.of()); + + mockMvc.perform(get("/api/orders")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @WithMockUser(username = "test@bilhalsning.se") + void shouldReturnOrderWithAllFields() throws Exception { + UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + User user = new User(); + user.setId(userId); + user.setEmail("test@bilhalsning.se"); + + when(userService.findByEmail("test@bilhalsning.se")).thenReturn(Optional.of(user)); + + se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order(); + order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")); + order.setUserId(userId); + order.setPlate("ABC123"); + order.setTemplate("Komplimang"); + order.setLetterText("Test letter"); + order.setStatus(se.bilhalsning.entity.OrderStatus.SENT); + order.setTrackingId("PN123456789"); + + when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order)); + + mockMvc.perform(get("/api/orders")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")) + .andExpect(jsonPath("$[0].plate").value("ABC123")) + .andExpect(jsonPath("$[0].template").value("Komplimang")) + .andExpect(jsonPath("$[0].status").value("sent")) + .andExpect(jsonPath("$[0].trackingId").value("PN123456789")); + } + + @Test + @WithMockUser(username = "unknown@example.com") + void shouldReturn401WhenUserNotFound() throws Exception { + when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/orders")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/frontend/e2e/order-history.spec.ts b/frontend/e2e/order-history.spec.ts new file mode 100644 index 0000000..8d4366d --- /dev/null +++ b/frontend/e2e/order-history.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test' + +test.describe('Order history', () => { + test('redirects unauthenticated user to login', async ({ page }) => { + await page.goto('/orders') + await expect(page).toHaveURL(/\/logga-in\?redirect=\/orders/) + await expect(page.getByRole('heading', { name: 'Logga in' })).toBeVisible() + }) + + test('can navigate from home to orders via header link', async ({ + page, + }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + const header = page.locator('header') + await header.getByRole('link', { name: 'Mina beställningar' }).click() + + await expect(page).toHaveURL('/orders') + await expect( + page.getByRole('heading', { name: 'Mina beställningar' }), + ).toBeVisible() + }) + + test('displays page heading and seeded orders', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + await page.goto('/orders') + + await expect(page.getByRole('heading', { name: 'Mina beställningar' })).toBeVisible() + await expect(page.getByText('ABC123')).toBeVisible() + await expect(page.getByText('DEF456')).toBeVisible() + await expect(page.getByText('GHI789')).toBeVisible() + }) + + test('shows correct status badges', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + await page.goto('/orders') + + await expect(page.getByText('Skickat')).toBeVisible() + await expect(page.getByText('Väntar på betalning')).toBeVisible() + await expect(page.getByText('Levererat')).toBeVisible() + }) + + test('shows tracking links for orders with tracking ID', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + await page.goto('/orders') + + const trackingLink1 = page.getByRole('link', { name: 'PN123456789' }) + await expect(trackingLink1).toBeVisible() + await expect(trackingLink1).toHaveAttribute('href', /postnord/) + await expect(trackingLink1).toHaveAttribute('target', '_blank') + + const trackingLink2 = page.getByRole('link', { name: 'PN987654321' }) + await expect(trackingLink2).toBeVisible() + }) + + test('shows template names', async ({ page }) => { + await page.goto('/logga-in') + await page.getByLabel('E-postadress').fill('test@bilhalsning.se') + await page.getByLabel('Lösenord').fill('test1234') + await page.getByRole('button', { name: 'Logga in' }).click() + await page.waitForURL('/') + + await page.goto('/orders') + + await expect(page.getByText('Komplimang')).toBeVisible() + await expect(page.getByText('Jag vill köpa din bil')).toBeVisible() + await expect(page.getByText('Tips / servicebehov')).toBeVisible() + }) +}) diff --git a/frontend/src/__tests__/OrdersPage.spec.ts b/frontend/src/__tests__/OrdersPage.spec.ts new file mode 100644 index 0000000..73bf03c --- /dev/null +++ b/frontend/src/__tests__/OrdersPage.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import { createPinia } from 'pinia' +import OrdersPage from '@/pages/OrdersPage.vue' + +function mockFetchResponse(status: number, body: unknown) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) +} + +function createTestRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/orders', name: 'orders', component: OrdersPage }, + { path: '/', name: 'home', component: { template: '
Home
' } }, + ], + }) +} + +function mountPage() { + const router = createTestRouter() + const pinia = createPinia() + router.push('/orders') + return { + router, + wrapper: mount(OrdersPage, { + global: { plugins: [router, pinia] }, + }), + } +} + +const mockOrders = [ + { + id: 'c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', + plate: 'ABC123', + template: 'Komplimang', + status: 'sent', + trackingId: 'PN123456789', + createdAt: '2026-05-11T12:00:00Z', + }, + { + id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + plate: 'DEF456', + template: null, + status: 'pending_payment', + trackingId: null, + createdAt: '2026-05-14T13:00:00Z', + }, +] + +describe('OrdersPage', () => { + beforeEach(() => { + localStorage.clear() + globalThis.fetch = vi.fn() + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, mockOrders), + ) + }) + + it('renders heading and subtitle', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Mina beställningar') + expect(wrapper.text()).toContain('Här kan du se dina tidigare beställningar') + }) + + it('shows loading state initially', async () => { + globalThis.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) + const { wrapper } = mountPage() + expect(wrapper.text()).toContain('Laddar beställningar...') + }) + + it('fetches orders from API on mount', async () => { + mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/orders', + expect.objectContaining({ headers: expect.any(Object) }), + ) + }) + + it('renders order cards with plate numbers', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('ABC123') + expect(wrapper.text()).toContain('DEF456') + }) + + it('renders template name or fallback for null', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Komplimang') + expect(wrapper.text()).toContain('Fritt meddelande') + }) + + it('renders Swedish status labels', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Skickat') + expect(wrapper.text()).toContain('Väntar på betalning') + }) + + it('renders tracking link when trackingId exists', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + const link = wrapper.find('a[href*="postnord"]') + expect(link.exists()).toBe(true) + expect(link.text()).toContain('PN123456789') + expect(link.attributes('target')).toBe('_blank') + }) + + it('does not render tracking link when trackingId is null', async () => { + const ordersWithoutTracking = [ + { + id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', + plate: 'DEF456', + template: null, + status: 'pending_payment', + trackingId: null, + createdAt: '2026-05-14T13:00:00Z', + }, + ] + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(200, ordersWithoutTracking), + ) + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + const link = wrapper.find('a[href*="postnord"]') + expect(link.exists()).toBe(false) + }) + + it('renders formatted date', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('2026') + }) + + it('shows empty state when no orders', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(mockFetchResponse(200, [])) + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Du har inga beställningar ännu') + }) + + it('shows error state on API failure', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue( + mockFetchResponse(500, { message: 'Internal server error' }), + ) + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + expect(wrapper.text()).toContain('Kunde inte hämta beställningar') + }) + + it('applies correct badge class for status', async () => { + const { wrapper } = mountPage() + await new Promise((r) => setTimeout(r, 50)) + const badges = wrapper.findAll('.orders__badge') + expect(badges[0].classes()).toContain('badge--green') + expect(badges[1].classes()).toContain('badge--gray') + }) +}) diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts new file mode 100644 index 0000000..9ae9290 --- /dev/null +++ b/frontend/src/api/orders.ts @@ -0,0 +1,14 @@ +import { request } from './client' + +export interface Order { + id: string + plate: string + template: string | null + status: string + trackingId: string | null + createdAt: string +} + +export function fetchOrders(): Promise { + return request('/orders') +} diff --git a/frontend/src/pages/OrdersPage.vue b/frontend/src/pages/OrdersPage.vue index 9f6ab54..687bcb9 100644 --- a/frontend/src/pages/OrdersPage.vue +++ b/frontend/src/pages/OrdersPage.vue @@ -1,10 +1,98 @@ @@ -22,8 +110,127 @@ } .orders__subtitle { - margin: 0; + margin: 0 0 1.5rem 0; color: #718096; font-size: 0.875rem; } + +.orders__loading, +.orders__error, +.orders__empty { + margin: 2rem 0; + padding: 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + text-align: center; +} + +.orders__loading { + color: #718096; +} + +.orders__error { + background: #fff5f5; + border: 1px solid #fed7d7; + color: #c53030; +} + +.orders__empty { + background: #f7fafc; + border: 1px solid #e2e8f0; + color: #718096; +} + +.orders__list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.orders__card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 0.75rem; + overflow: hidden; +} + +.orders__card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + background: #f7fafc; + border-bottom: 1px solid #e2e8f0; +} + +.orders__plate { + font-size: 1.125rem; + font-weight: 600; + color: #1a202c; + letter-spacing: 0.05em; +} + +.orders__badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge--gray { + background: #edf2f7; + color: #718096; +} + +.badge--blue { + background: #ebf8ff; + color: #2b6cb0; +} + +.badge--green { + background: #f0fff4; + color: #276749; +} + +.badge--red { + background: #fff5f5; + color: #c53030; +} + +.orders__card-body { + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.orders__detail { + display: flex; + gap: 0.75rem; +} + +.orders__label { + min-width: 5rem; + font-size: 0.8125rem; + color: #a0aec0; + font-weight: 500; +} + +.orders__value { + font-size: 0.875rem; + color: #4a5568; +} + +.orders__tracking-link { + font-size: 0.875rem; + color: #4299e1; + text-decoration: none; +} + +.orders__tracking-link:hover { + text-decoration: underline; +}