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
This commit is contained in:
Joakim Mörling 2026-05-19 15:15:50 +02:00
parent 18f462c5c1
commit 3792fdec82
3 changed files with 263 additions and 0 deletions

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>