Compare commits
6 commits
2506a0283c
...
df539f7cb7
| Author | SHA1 | Date | |
|---|---|---|---|
| df539f7cb7 | |||
| be7775f680 | |||
| 1b87e15a21 | |||
| 3792fdec82 | |||
| 18f462c5c1 | |||
| 6dc9b6de33 |
16 changed files with 577 additions and 19 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record VehicleInfoResponse(
|
||||
String make,
|
||||
String model,
|
||||
int year,
|
||||
String color,
|
||||
String fuel
|
||||
) {}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class VehicleLookupException extends RuntimeException {
|
||||
public VehicleLookupException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class VehicleNotFoundException extends RuntimeException {
|
||||
public VehicleNotFoundException(String plate) {
|
||||
super("Vehicle not found: " + plate);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
backend/src/test/resources/fixtures/biluppgifter-hdo732.html
Normal file
69
backend/src/test/resources/fixtures/biluppgifter-hdo732.html
Normal 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>
|
||||
51
frontend/e2e/vehicle-lookup.spec.ts
Normal file
51
frontend/e2e/vehicle-lookup.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
13
frontend/src/api/vehicles.ts
Normal file
13
frontend/src/api/vehicles.ts
Normal 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}`)
|
||||
}
|
||||
|
|
@ -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 }}) —
|
||||
{{ vehicle.color }}
|
||||
{{ vehicle.color }}, {{ vehicle.fuel }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="loading" class="vehicle-info__loading" role="status">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue