feat: add order history page with API endpoint and seeded test data

- Create OrderController with GET /api/orders endpoint (authenticated)
- Add OrderResponse DTO (id, plate, template, status, trackingId, createdAt)
- Seed 3 test orders for test@bilhalsning.se via V6 migration (sent, pending_payment, delivered)
- Create OrderControllerTest with 4 tests (auth, empty list, full fields, user not found)
- Create frontend api/orders.ts with typed fetchOrders() client
- Build out OrdersPage.vue with card list: plate, template, status badge, tracking link
- Add 12 Vitest tests for OrdersPage (loading, data, badges, links, empty, error)
- Add 5 Playwright E2E tests (auth guard, seeded data, badges, tracking, templates)
This commit is contained in:
Joakim Mörling 2026-05-14 15:30:36 +02:00
parent a74bb89824
commit 32b315654e
8 changed files with 648 additions and 1 deletions

View file

@ -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<OrderResponse>> list(@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
List<OrderResponse> 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()
);
}
}

View file

@ -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
) {}

View file

@ -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');

View file

@ -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());
}
}

View file

@ -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()
})
})

View file

@ -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: '<div>Home</div>' } },
],
})
}
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')
})
})

View file

@ -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<Order[]> {
return request<Order[]>('/orders')
}

View file

@ -1,10 +1,98 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchOrders, type Order } from '@/api/orders'
const orders = ref<Order[]>([])
const loading = ref(true)
const error = ref('')
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
lookup_started: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
}
const statusClasses: Record<string, string> = {
pending_payment: 'badge--gray',
paid: 'badge--blue',
lookup_started: 'badge--blue',
sent: 'badge--green',
delivered: 'badge--green',
failed: 'badge--red',
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('sv-SE', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
onMounted(async () => {
try {
orders.value = await fetchOrders()
} catch {
error.value = 'Kunde inte hämta beställningar. Försök igen senare.'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="orders">
<h1 class="orders__title">Mina beställningar</h1>
<p class="orders__subtitle">Här kan du se dina tidigare beställningar.</p>
<p v-if="loading" class="orders__loading">Laddar beställningar...</p>
<p v-else-if="error" class="orders__error">{{ error }}</p>
<p v-else-if="orders.length === 0" class="orders__empty">
Du har inga beställningar ännu.
</p>
<div v-else class="orders__list">
<div v-for="order in orders" :key="order.id" class="orders__card">
<div class="orders__card-header">
<span class="orders__plate">{{ order.plate }}</span>
<span
class="orders__badge"
:class="statusClasses[order.status] || 'badge--gray'"
>
{{ statusLabels[order.status] || order.status }}
</span>
</div>
<div class="orders__card-body">
<div class="orders__detail">
<span class="orders__label">Mall</span>
<span class="orders__value">{{ order.template || 'Fritt meddelande' }}</span>
</div>
<div class="orders__detail">
<span class="orders__label">Datum</span>
<span class="orders__value">{{ formatDate(order.createdAt) }}</span>
</div>
<div v-if="order.trackingId" class="orders__detail">
<span class="orders__label">Spårning</span>
<a
class="orders__tracking-link"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
>
{{ order.trackingId }}
</a>
</div>
</div>
</div>
</div>
</div>
</template>
@ -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;
}
</style>