Compare commits

...

6 commits

Author SHA1 Message Date
df539f7cb7 test: update unit tests for real vehicle API and fuel field
- HomePage.spec.ts: replace setTimeout fake data with mocked lookupVehicle()
  API call, mock resolved/rejected/pending states, add fuel to mock responses
- VehicleInfo.spec.ts: add fuel field to mockVehicle data,
  update assertion to include Bensin in rendered text
2026-05-19 15:16:52 +02:00
be7775f680 test: add E2E tests for homepage vehicle lookup flow
- enters plate and sees vehicle info with CTA button:
  types HDO732, verifies Peugeot 107 1.0, 2011, Gul, Bensin appear,
  verifies Fortsatt till brevet link has correct href
- shows not found for unknown plate (ZZZ999)
- CTA navigates to compose when authenticated:
  logs in as test@bilhalsning.se, performs lookup, clicks CTA,
  verifies redirect to /compose?plate=HDO732
2026-05-19 15:16:34 +02:00
1b87e15a21 feat: replace fake vehicle data with real API lookup on homepage
- Add typed API module api/vehicles.ts with lookupVehicle(plate) function
- Replace FAKE_VEHICLES record with async API call in HomePage.vue
- Remove setTimeout-based fake lookup, use lookupVehicle() instead
- Handle errors: show not-found for unknown plates, catch network failures
- Add fuel field to VehicleInfo interface and display (e.g. 'Gul, Bensin')
- VehicleInfo now shows make, model, year, color, and fuel from API
2026-05-19 15:16:23 +02:00
3792fdec82 test: add service and controller tests for vehicle lookup
- Add real HTML fixture from biluppgifter.se/fordon/hdo732/ containing:
  summary cards (.info > em + span) for Modellar, Typ, Farg, Bransle
  Fordonsdata section (ul.list with span.label/span.value) for Fabrikat, Modell, Variant, Fordonsar/Modellar
- Add VehicleLookupServiceTest with 6 cases:
  shouldParseAllFieldsFromFixture, shouldParseSummaryFields,
  shouldParseDataSectionFields, shouldReturnEmptyFieldsForEmptyDocument,
  shouldBuildModelWithoutVariant, shouldFallbackToModellarWhenNoFordonsar
- Add VehicleControllerTest with 4 cases:
  shouldReturnVehicleInfoForValidPlate (200 with all fields),
  shouldReturn404WhenVehicleNotFound, shouldBeAccessibleWithoutAuthentication,
  shouldReturnVehicleInfoWithFuelField
2026-05-19 15:15:50 +02:00
18f462c5c1 feat: add real vehicle lookup via biluppgifter.se scraping
- Add VehicleInfoResponse DTO record with make, model, year, color, fuel fields
- Add VehicleNotFoundException for unknown plates (returns 404)
- Add VehicleLookupException for scrape failures (returns 500)
- Add handlers in GlobalExceptionHandler: 404 'Inget fordon hittades', 500 'Ett internt fel uppstod'
- Add VehicleLookupService that fetches biluppgifter.se/fordon/{plate}/ HTML
- Parse summary cards (.info > em + span) for Farg, Bransle, Modellar
- Parse Fordonsdata section (ul.list > li with span.label / span.value) for Fabrikat, Modell, Variant, Fordonsar
- Build model from Modell + Variant, parse year from Fordonsar / Modellar with Modellar fallback
- Filter out 'Logga in' placeholder values from gated fields
- Add VehicleController with GET /api/vehicles/{plate}, public endpoint (already permitAll)
2026-05-19 15:15:20 +02:00
6dc9b6de33 feat: add Jsoup 1.18.1 dependency for HTML parsing
- Add org.jsoup:jsoup:1.18.1 to backend dependencies
- Will be used by VehicleLookupService to scrape vehicle data from biluppgifter.se
2026-05-19 15:11:01 +02:00
16 changed files with 577 additions and 19 deletions

View file

@ -25,6 +25,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.jsoup:jsoup:1.18.1'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'

View file

@ -0,0 +1,24 @@
package se.bilhalsning.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.service.VehicleLookupService;
@RestController
@RequestMapping("/api/vehicles")
@RequiredArgsConstructor
public class VehicleController {
private final VehicleLookupService vehicleLookupService;
@GetMapping("/{plate}")
public ResponseEntity<VehicleInfoResponse> lookup(@PathVariable String plate) {
VehicleInfoResponse info = vehicleLookupService.lookup(plate);
return ResponseEntity.ok(info);
}
}

View file

@ -0,0 +1,9 @@
package se.bilhalsning.dto;
public record VehicleInfoResponse(
String make,
String model,
int year,
String color,
String fuel
) {}

View file

@ -35,6 +35,21 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(VehicleNotFoundException.class)
public ResponseEntity<ErrorResponse> handleVehicleNotFound(VehicleNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Inget fordon hittades"));
}
@ExceptionHandler(VehicleLookupException.class)
public ResponseEntity<ErrorResponse> handleVehicleLookup(VehicleLookupException ex) {
log.error("Vehicle lookup failed", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Ett internt fel uppstod"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class VehicleLookupException extends RuntimeException {
public VehicleLookupException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class VehicleNotFoundException extends RuntimeException {
public VehicleNotFoundException(String plate) {
super("Vehicle not found: " + plate);
}
}

View file

@ -0,0 +1,141 @@
package se.bilhalsning.service;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.exception.VehicleLookupException;
import se.bilhalsning.exception.VehicleNotFoundException;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class VehicleLookupService {
private static final Logger log = LoggerFactory.getLogger(VehicleLookupService.class);
private static final String BASE_URL = "https://biluppgifter.se/fordon";
public VehicleInfoResponse lookup(String plate) {
Document doc = fetchPage(plate);
Map<String, String> fields = new LinkedHashMap<>();
extractSummaryFields(doc, fields);
extractDataSectionFields(doc, fields);
if (fields.isEmpty() || !fields.containsKey("Fabrikat")) {
throw new VehicleNotFoundException(plate);
}
String make = fields.getOrDefault("Fabrikat", "");
String model = buildModel(fields);
int year = parseYearFromFields(fields);
String color = fields.getOrDefault("Färg", "");
String fuel = fields.getOrDefault("Bränsle", "");
return new VehicleInfoResponse(make, model, year, color, fuel);
}
Document fetchPage(String plate) {
String url = BASE_URL + "/" + plate.toLowerCase() + "/";
try {
return Jsoup.connect(url)
.userAgent("BilHej/1.0")
.timeout(10_000)
.get();
} catch (IOException e) {
log.warn("Failed to fetch vehicle data for plate {}", plate, e);
throw new VehicleLookupException("Failed to fetch vehicle data", e);
}
}
void extractSummaryFields(Document doc, Map<String, String> fields) {
Elements infoDivs = doc.select(".info > em");
for (Element em : infoDivs) {
Element span = em.nextElementSibling();
if (span != null && span.tagName().equals("span")) {
String label = span.text().trim();
String value = em.ownText().trim();
if (!label.isEmpty() && !value.isEmpty()) {
fields.putIfAbsent(label, value);
}
}
}
}
void extractDataSectionFields(Document doc, Map<String, String> fields) {
Element heading = doc.selectFirst("h2:containsOwn(Fordonsdata)");
if (heading == null) {
return;
}
Element section = findParentSection(heading);
if (section == null) {
return;
}
Elements listItems = section.select("ul.list > li");
for (Element li : listItems) {
Element labelEl = li.selectFirst("span.label");
Element valueEl = li.selectFirst("span.value");
if (labelEl != null && valueEl != null) {
String label = labelEl.text().trim();
String value = valueEl.text().trim();
if (!label.isEmpty() && !value.isEmpty() && !value.equals("Logga in")) {
fields.putIfAbsent(label, value);
}
}
}
}
Map<String, String> extractFields(Document doc) {
Map<String, String> fields = new LinkedHashMap<>();
extractSummaryFields(doc, fields);
extractDataSectionFields(doc, fields);
return fields;
}
private Element findParentSection(Element heading) {
Element current = heading.parent();
while (current != null) {
if ("section".equals(current.tagName())) {
return current;
}
current = current.parent();
}
return heading.parent();
}
private String buildModel(Map<String, String> fields) {
String model = fields.getOrDefault("Modell", "");
String variant = fields.getOrDefault("Variant", "");
if (!model.isEmpty() && !variant.isEmpty()) {
return model + " " + variant;
}
return model.isEmpty() ? variant : model;
}
private int parseYearFromFields(Map<String, String> fields) {
String yearText = fields.getOrDefault("Fordonsår / Modellår", "");
if (yearText.isEmpty()) {
yearText = fields.getOrDefault("Modellår", "");
}
if (yearText.isEmpty()) {
return 0;
}
String[] parts = yearText.split("/");
try {
return Integer.parseInt(parts[0].trim());
} catch (NumberFormatException e) {
return 0;
}
}
}

View file

@ -0,0 +1,70 @@
package se.bilhalsning.controller;
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.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.exception.VehicleNotFoundException;
import se.bilhalsning.service.VehicleLookupService;
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;
@SpringBootTest
@AutoConfigureMockMvc
class VehicleControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private VehicleLookupService vehicleLookupService;
@Test
void shouldReturnVehicleInfoForValidPlate() throws Exception {
when(vehicleLookupService.lookup("ABC123"))
.thenReturn(new VehicleInfoResponse("Volvo", "V70", 2009, "Silver", "Bensin"));
mockMvc.perform(get("/api/vehicles/ABC123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.make").value("Volvo"))
.andExpect(jsonPath("$.model").value("V70"))
.andExpect(jsonPath("$.year").value(2009))
.andExpect(jsonPath("$.color").value("Silver"))
.andExpect(jsonPath("$.fuel").value("Bensin"));
}
@Test
void shouldReturn404WhenVehicleNotFound() throws Exception {
when(vehicleLookupService.lookup("ZZZ999"))
.thenThrow(new VehicleNotFoundException("ZZZ999"));
mockMvc.perform(get("/api/vehicles/ZZZ999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Inget fordon hittades"));
}
@Test
void shouldBeAccessibleWithoutAuthentication() throws Exception {
when(vehicleLookupService.lookup("ABC123"))
.thenReturn(new VehicleInfoResponse("Volvo", "V70", 2009, "Silver", "Bensin"));
mockMvc.perform(get("/api/vehicles/ABC123"))
.andExpect(status().isOk());
}
@Test
void shouldReturnVehicleInfoWithFuelField() throws Exception {
when(vehicleLookupService.lookup("DEF456"))
.thenReturn(new VehicleInfoResponse("Saab", "9-3", 2005, "Röd", "Diesel"));
mockMvc.perform(get("/api/vehicles/DEF456"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fuel").value("Diesel"));
}
}

View file

@ -0,0 +1,124 @@
package se.bilhalsning.service;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.junit.jupiter.api.Test;
import se.bilhalsning.dto.VehicleInfoResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.*;
class VehicleLookupServiceTest {
private final VehicleLookupService service = new VehicleLookupService();
@Test
void shouldParseAllFieldsFromFixture() throws Exception {
Document doc = loadFixture("biluppgifter-hdo732.html");
VehicleLookupService spy = new VehicleLookupService() {
@Override
Document fetchPage(String plate) {
return doc;
}
};
VehicleInfoResponse result = spy.lookup("hdo732");
assertEquals("Peugeot", result.make());
assertEquals("107 1.0", result.model());
assertEquals(2011, result.year());
assertEquals("Gul", result.color());
assertEquals("Bensin", result.fuel());
}
@Test
void shouldParseSummaryFields() {
Document doc = loadFixture("biluppgifter-hdo732.html");
var fields = new java.util.LinkedHashMap<String, String>();
service.extractSummaryFields(doc, fields);
assertEquals("Gul", fields.get("Färg"));
assertEquals("Bensin", fields.get("Bränsle"));
assertEquals("2011", fields.get("Modellår"));
assertEquals("Personbil", fields.get("Typ"));
}
@Test
void shouldParseDataSectionFields() {
Document doc = loadFixture("biluppgifter-hdo732.html");
var fields = new java.util.LinkedHashMap<String, String>();
service.extractDataSectionFields(doc, fields);
assertEquals("Peugeot", fields.get("Fabrikat"));
assertEquals("107", fields.get("Modell"));
assertEquals("1.0", fields.get("Variant"));
assertEquals("2011 / 2011", fields.get("Fordonsår / Modellår"));
}
@Test
void shouldReturnEmptyFieldsForEmptyDocument() {
Document doc = Jsoup.parse("<html><body><p>No data</p></body></html>");
var fields = service.extractFields(doc);
assertTrue(fields.isEmpty());
}
@Test
void shouldBuildModelWithoutVariant() {
Document doc = Jsoup.parse(
"<html><body><section><h2>Fordonsdata</h2>" +
"<ul class='list'>" +
"<li><span class='label'>Fabrikat</span><span class='value'>Volvo</span></li>" +
"<li><span class='label'>Modell</span><span class='value'>V70</span></li>" +
"</ul></section></body></html>"
);
VehicleLookupService spy = new VehicleLookupService() {
@Override
Document fetchPage(String plate) {
return doc;
}
};
var result = spy.lookup("test");
assertEquals("V70", result.model());
}
@Test
void shouldFallbackToModellårWhenNoFordonsår() {
Document doc = Jsoup.parse(
"<html><body>" +
"<div class='info'><em>2009</em><span>Modellår</span></div>" +
"<section><h2>Fordonsdata</h2>" +
"<ul class='list'>" +
"<li><span class='label'>Fabrikat</span><span class='value'>Volvo</span></li>" +
"</ul></section></body></html>"
);
VehicleLookupService spy = new VehicleLookupService() {
@Override
Document fetchPage(String plate) {
return doc;
}
};
var result = spy.lookup("test");
assertEquals(2009, result.year());
}
private Document loadFixture(String name) {
try {
String path = "/fixtures/" + name;
var stream = getClass().getResourceAsStream(path);
assertNotNull(stream, "Fixture not found: " + path);
String html = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
return Jsoup.parse(html);
} catch (IOException e) {
throw new RuntimeException("Failed to load fixture: " + name, e);
}
}
}

View file

@ -0,0 +1,69 @@
<html>
<body>
<ul class="icon-grid list-view" id="vehicle-icon-grid">
<li onclick="location.href='#fordonsdata';forceOpenSection('vehicle-data')" style="cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#fff" fill="none"><path opacity=".2" d="M21.5 12.757v-.514c0-1.73 0-3.115-.078-4.243H2.578C2.5 9.128 2.5 10.514 2.5 12.243v.514c0 4.357 0 6.536 1.252 7.89C5.004 22 7.02 22 11.05 22h1.9c4.03 0 6.046 0 7.298-1.354 1.252-1.353 1.252-3.532 1.252-7.89" fill="currentColor"/><path d="M18 2v2M6 2v2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.996 13h.008m-.008 4h.008m3.987-4H16m-8 0h.009M8 17h.009" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.5 8h17m-18 4.243c0-4.357 0-6.536 1.252-7.89C5.004 3 7.02 3 11.05 3h1.9c4.03 0 6.046 0 7.298 1.354C21.5 5.707 21.5 7.886 21.5 12.244v.513c0 4.357 0 6.536-1.252 7.89C18.996 22 16.98 22 12.95 22h-1.9c-4.03 0-6.046 0-7.298-1.354C2.5 19.293 2.5 17.114 2.5 12.756zM3 8h18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div class="info">
<em>2011</em>
<span>Modellår</span>
</div>
</li>
<li onclick="location.href='#fordonsdata';forceOpenSection('vehicle-data')" style="cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#fff" fill="none"><path opacity=".2" d="M20.251 17.756h-1v-.003a2 2 0 0 0-4 0v.003h-6v-.003a2 2 0 0 0-4 0v.003c-.172-.041-.373-.076-.589-.114-.825-.146-1.86-.328-2.262-.979-.149-.241-.149-.542-.149-1.143v-4.764l14.55-.319c.81-.018 1.59.324 2.331.652.233.102.474.193.715.283.927.348 1.843.692 2.238 1.72.166.433.166.936.166 1.943v.72c0 .943 0 1.415-.293 1.708s-.764.293-1.707.293" fill="currentColor"/><path d="M9.251 17.753a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm10 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z" stroke="currentColor" stroke-width="1.5"/><path d="M2.258 10.753h16m-16 0c0 .78-.02 3.04.004 5.26.036.72.156 1.32 2.997 1.74m-3.001-7c.216-1.74 1.155-3.8 1.634-4.58m5.366 4.58v-5m5.99 12H9.252m-6.978-12H12.49s.54 0 1.02.048c.898.084 1.654.492 2.41 1.512.8 1.08 1.414 2.448 2.23 3.18 1.355 1.216 3.933.84 4.076 3.42.036 1.32.036 2.76-.023 3-.097.707-.642.822-1.32.84-.588.015-1.297-.028-1.642 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<div class="info">
<em>Personbil</em>
<span>Typ</span>
</div>
</li>
<li onclick="location.href='#tekniskdata';forceOpenSection('technical-data')" style="cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#fff" fill="none"><path opacity=".2" d="m14.162 12.75-2.977-2.958.249-.246c4.384-4.355 8.604-7.224 9.426-6.407s-2.066 5.01-6.45 9.364zM3 20.882s3.72.74 5.953-1.479a3.12 3.12 0 0 0 0-4.435 3.17 3.17 0 0 0-4.465 0c-1.86 1.848-.372 3.326-1.488 5.914" fill="currentColor"/><path d="m11.186 9.792 2.977 2.957m-4.293 3.87c1.294-1.034 3.094-2.678 4.54-4.115 4.384-4.355 7.271-8.547 6.45-9.364-.823-.817-5.043 2.052-9.427 6.407-1.446 1.436-3.101 3.225-4.142 4.51m1.661 5.347C6.721 21.621 3 20.882 3 20.882c1.116-2.588-.372-4.066 1.488-5.915a3.17 3.17 0 0 1 4.465 0 3.12 3.12 0 0 1 0 4.436Z" stroke="currentColor" stroke-width="1.5"/></svg>
<div class="info">
<em>Gul <div tooltip="Gul" class="color " style="background-color: #FFFF00"></div></em>
<span>Färg</span>
</div>
</li>
<li onclick="location.href='#tekniskdata';forceOpenSection('technical-data')" style="cursor: pointer;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#fff" fill="none"><path opacity=".2" d="M4 10v11.002h12v-11z" fill="currentColor"/><path d="m10.463 13-1.394 1.812a.33.33 0 0 0 .2.526l1.461.31a.33.33 0 0 1 .177.553L9.177 18M4 10h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 21V9c0-2.828 0-4.243.879-5.121C5.757 3 7.172 3 10 3s4.243 0 5.121.879C16 4.757 16 6.172 16 9v12z" stroke="currentColor" stroke-width="1.5"/><path d="M2 21h16m-2-7h1.667c.31 0 .465 0 .592.034a1 1 0 0 1 .707.707c.034.127.034.282.034.592V16.5a1.5 1.5 0 0 0 3 0v-6.289c0-.601 0-.902-.086-1.185s-.252-.534-.586-1.034l-.773-1.16A1.87 1.87 0 0 0 19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div class="info">
<em>Bensin</em>
<span>Bränsle</span>
</div>
</li>
</ul>
<section id="vehicle-data" class="">
<a id="fordonsdata" class="sub"></a>
<div class="bar fancy" onclick="toggleSection('vehicle-data')">
<div class="icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#fff" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.137 1.417c-.475-.168-.98-.168-1.55-.167-1.525 0-2.933 0-3.912.115-1.013.119-1.88.372-2.615.968q-.406.328-.733.736c-.593.74-.845 1.612-.963 2.63-.114.983-.114 2.22-.114 3.753v4.574c0 1.782 0 3.218.15 4.348.158 1.173.493 2.16 1.274 2.945.78.784 1.762 1.121 2.929 1.28 1.124.151 2.695.151 4.468.15 1.773.001 3.201.001 4.325-.15 1.167-.159 2.15-.496 2.93-1.28s1.116-1.772 1.272-2.945c.152-1.13.152-2.566.152-4.348v-3.474c0-.664.002-1.252-.223-1.796-.224-.544-.638-.96-1.106-1.428L14.638 2.52c-.402-.405-.758-.764-1.213-.983a3 3 0 0 0-.288-.12m4.814 7.197c.618.622.723.752.78.89-1.368 0-2.016 0-2.883-.117-.9-.121-1.658-.38-2.26-.982s-.86-1.36-.982-2.26c-.116-.865-.116-1.513-.116-2.875v-.008c.182.054.323.188.856.723zM16.75 17a.75.75 0 0 0-.75-.75H8a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 .75-.75M12 12.25a.75.75 0 0 1 0 1.5H8a.75.75 0 1 1 0-1.5z" fill="currentColor"/></svg>
</div>
<h2>Fordonsdata</h2>
<div class="chevron">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" color="#fff" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="16" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="inner">
<ul class="list">
<li>
<span class="label">Fabrikat</span>
<span class="value">Peugeot</span>
</li>
<li>
<span class="label">Modell</span>
<span class="value">107</span>
</li>
<li>
<span class="label">Variant</span>
<span class="value">1.0</span>
</li>
</ul>
<ul class="list top-30">
<li>
<span class="label">Fordonsår / Modellår</span>
<span class="value">2011 / 2011</span>
</li>
</ul>
</div>
</section>
</body>
</html>

View file

@ -0,0 +1,51 @@
import { test, expect } from '@playwright/test'
test.describe('Vehicle lookup', () => {
test('enters plate and sees vehicle info with CTA button', async ({
page,
}) => {
await page.goto('/')
await page.getByPlaceholder('ABC 123').fill('HDO732')
await page.getByPlaceholder('ABC 123').press('Enter')
await expect(page.getByText(/Peugeot 107 1\.0/)).toBeVisible({
timeout: 15_000,
})
await expect(page.getByText('2011').first()).toBeVisible()
await expect(page.getByText(/Gul/)).toBeVisible()
await expect(page.getByText(/Bensin/)).toBeVisible()
const cta = page.getByRole('link', { name: 'Fortsätt till brevet' })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', '/compose?plate=HDO732')
})
test('shows not found for unknown plate', async ({ page }) => {
await page.goto('/')
await page.getByPlaceholder('ABC 123').fill('ZZZ999')
await page.getByPlaceholder('ABC 123').press('Enter')
await expect(
page.getByText('Inget fordon hittades'),
).toBeVisible({ timeout: 15_000 })
})
test('CTA navigates to compose when authenticated', 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.getByPlaceholder('ABC 123').fill('HDO732')
await page.getByPlaceholder('ABC 123').press('Enter')
const cta = page.getByRole('link', { name: 'Fortsätt till brevet' })
await expect(cta).toBeVisible({ timeout: 15_000 })
await cta.click()
await expect(page).toHaveURL('/compose?plate=HDO732')
})
})

View file

@ -1,9 +1,16 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import HomePage from '@/pages/HomePage.vue'
import ComposePage from '@/pages/ComposePage.vue'
vi.mock('@/api/vehicles', () => ({
lookupVehicle: vi.fn(),
}))
import { lookupVehicle } from '@/api/vehicles'
const mockLookupVehicle = vi.mocked(lookupVehicle)
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
@ -34,6 +41,8 @@ describe('HomePage', () => {
})
it('does not show CTA while loading', async () => {
mockLookupVehicle.mockImplementation(() => new Promise(() => {}))
const router = createTestRouter()
const wrapper = mountHome(router)
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
@ -45,24 +54,34 @@ describe('HomePage', () => {
})
it('does not show CTA after not-found', async () => {
mockLookupVehicle.mockRejectedValue(new Error('Inget fordon hittades'))
const router = createTestRouter()
const wrapper = mountHome(router)
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
await plateInput.vm.$emit('lookup', 'UNKNOWN')
await new Promise((resolve) => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 100))
expect(wrapper.find('.btn--primary').exists()).toBe(false)
})
it('shows CTA button when vehicle data present', async () => {
mockLookupVehicle.mockResolvedValue({
make: 'Volvo',
model: 'V70',
year: 2009,
color: 'Silver',
fuel: 'Bensin',
})
const router = createTestRouter()
const wrapper = mountHome(router)
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
await plateInput.vm.$emit('update:modelValue', 'ABC123')
await plateInput.vm.$emit('lookup', 'ABC123')
await new Promise((resolve) => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 100))
const cta = wrapper.find('.btn--primary')
expect(cta.exists()).toBe(true)
@ -70,13 +89,21 @@ describe('HomePage', () => {
})
it('CTA links to compose page with plate query param', async () => {
mockLookupVehicle.mockResolvedValue({
make: 'Volvo',
model: 'V70',
year: 2009,
color: 'Silver',
fuel: 'Bensin',
})
const router = createTestRouter()
const wrapper = mountHome(router)
const plateInput = wrapper.findComponent({ name: 'PlateInput' })
await plateInput.vm.$emit('update:modelValue', 'ABC123')
await plateInput.vm.$emit('lookup', 'ABC123')
await new Promise((resolve) => setTimeout(resolve, 500))
await new Promise((resolve) => setTimeout(resolve, 100))
const cta = wrapper.find('.btn--primary')
const href = cta.attributes('href')

View file

@ -8,6 +8,7 @@ const mockVehicle: VehicleData = {
model: 'V70',
year: 2009,
color: 'Silver',
fuel: 'Bensin',
}
function createWrapper(props: Record<string, unknown> = {}) {
@ -28,12 +29,13 @@ describe('VehicleInfo', () => {
expect(wrapper.text()).toContain('Söker...')
})
it('shows vehicle card with make, model, year, and color', () => {
it('shows vehicle card with make, model, year, color, and fuel', () => {
const wrapper = createWrapper({ vehicle: mockVehicle })
expect(wrapper.text()).toContain('Volvo')
expect(wrapper.text()).toContain('V70')
expect(wrapper.text()).toContain('2009')
expect(wrapper.text()).toContain('Silver')
expect(wrapper.text()).toContain('Bensin')
})
it('shows not-found message when notFound is true', () => {

View file

@ -0,0 +1,13 @@
import { request } from './client'
export interface VehicleData {
make: string
model: string
year: number
color: string
fuel: string
}
export function lookupVehicle(plate: string): Promise<VehicleData> {
return request<VehicleData>(`/vehicles/${plate}`)
}

View file

@ -4,6 +4,7 @@ export interface VehicleInfo {
model: string
year: number
color: string
fuel: string
}
defineProps<{
@ -23,7 +24,7 @@ defineProps<{
>
<p class="vehicle-info__text">
{{ vehicle.make }} {{ vehicle.model }} ({{ vehicle.year }}) &mdash;
{{ vehicle.color }}
{{ vehicle.color }}, {{ vehicle.fuel }}
</p>
</div>
<div v-else-if="loading" class="vehicle-info__loading" role="status">

View file

@ -4,34 +4,31 @@ import { RouterLink } from 'vue-router'
import PlateInput from '@/components/PlateInput.vue'
import VehicleInfo from '@/components/VehicleInfo.vue'
import type { VehicleInfo as VehicleData } from '@/components/VehicleInfo.vue'
const FAKE_VEHICLES: Record<string, VehicleData> = {
ABC123: { make: 'Volvo', model: 'V70', year: 2009, color: 'Silver' },
ABC12D: { make: 'Volkswagen', model: 'Golf', year: 2020, color: 'Blå' },
XYZ789: { make: 'Saab', model: '9-3', year: 2005, color: 'Röd' },
}
import { lookupVehicle } from '@/api/vehicles'
const plate = ref('')
const vehicle = ref<VehicleData | null>(null)
const notFound = ref(false)
const lookingUp = ref(false)
function handleLookup(lookedUpPlate: string) {
async function handleLookup(lookedUpPlate: string) {
lookingUp.value = true
notFound.value = false
vehicle.value = null
setTimeout(() => {
const found = FAKE_VEHICLES[lookedUpPlate]
if (found) {
vehicle.value = found
notFound.value = false
try {
const result = await lookupVehicle(lookedUpPlate)
vehicle.value = result
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('Inget fordon')) {
notFound.value = true
} else {
vehicle.value = null
notFound.value = true
}
} finally {
lookingUp.value = false
}, 400)
}
}
</script>