Skip to content

Commit 4cde174

Browse files
committed
refactor: convert WeatherReport and related models to records, enhance immutability, and streamline test usage
docs: add advanced development and test guidelines refactor: replace synchronized blocks with `ReentrantLock` in `CitySearchService`, optimize imports, and improve readability
1 parent a493497 commit 4cde174

12 files changed

+270
-251
lines changed

.junie/guidelines.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
Project: Weather App Java – Development Guidelines (Advanced)
2+
3+
This document captures project-specific knowledge for advanced development, build, testing, and troubleshooting.
4+
5+
1. Build and Configuration
6+
- Toolchain
7+
- Java: 21 (configured via Gradle Toolchains). Ensure a JDK 21 is available; Gradle will auto-provision if required.
8+
- Build: Gradle with Spring Boot plugin 3.5.5 and io.spring.dependency-management 1.1.7.
9+
- Dependencies (high-level)
10+
- Web/UI: spring-boot-starter-web, spring-boot-starter-thymeleaf
11+
- AI: langchain4j 0.35.0, langchain4j-google-ai-gemini 0.35.0
12+
- Tests: spring-boot-starter-test (JUnit 5 / Jupiter)
13+
- Build commands
14+
- Full build (no tests): ./gradlew assemble
15+
- Build with tests: ./gradlew build
16+
- Run app locally: ./gradlew bootRun
17+
- Docker: docker build -t weather-app-java . && docker run -p 8080:8080 -e AI_API_KEY=... weather-app-java
18+
- Runtime configuration
19+
- AI config (Google Gemini via Google AI Studio):
20+
- Property: ai.gemini.api-key (String). If blank/missing, AI summary endpoints respond with fallback message and UI shows unavailable summary.
21+
- Property: ai.gemini.model (String). Default is gemini-1.5-flash; empty/blank coerced to default.
22+
- How to set:
23+
- application.properties (src/main/resources) or runtime environment e.g., -Dai.gemini.api-key=... or SPRING_APPLICATION_JSON; README shows examples too.
24+
- Ports: Spring Boot default 8080. Override with server.port if needed.
25+
- Data: City suggestions are loaded from src/main/resources/cities.json in CitySearchService.
26+
- Devcontainer
27+
- .devcontainer/devcontainer.json exists (if using VS Code / JetBrains Gateway). It provides a consistent dev environment; match Java 21.
28+
29+
2. Testing – Running, Adding, and Examples
30+
- Test profile
31+
- Active profile for tests: "test" (via @ActiveProfiles("test") in several integration tests).
32+
- src/test/resources/application-test.properties sets ai.gemini.api-key=test-key to avoid real external calls. AI beans can be further overridden/mocked within @TestConfiguration in tests where needed (see EndToEndIntegrationTest and web/AiSummaryControllerIntegrationTest for patterns).
33+
- Commands
34+
- Run all tests: ./gradlew test
35+
- Run a single class: ./gradlew test --tests "com.example.weatherapp.web.ReportControllerIntegrationTest"
36+
- Run a single method: ./gradlew test --tests "com.example.weatherapp.web.ReportControllerIntegrationTest.methodName"
37+
- Show stacktraces: add -i or --stacktrace
38+
- Continuous testing (watch mode): ./gradlew test --continuous (re-runs on changes)
39+
- Test types present
40+
- Controller Integration Tests (MockMvc):
41+
- web/CitySearchControllerIntegrationTest
42+
- web/ReportControllerIntegrationTest
43+
- web/HomeControllerIntegrationTest
44+
- web/AiSummaryControllerIntegrationTest (uses @TestConfiguration to override AiSummaryService with a mock)
45+
- Service Integration Tests:
46+
- city/CitySearchServiceIntegrationTest
47+
- ai/AiSummaryServiceIntegrationTest
48+
- End-to-End Integration Test:
49+
- EndToEndIntegrationTest bootstraps the full Spring context, overrides both CitySearchService and AiSummaryService via @TestConfiguration, and exercises a realistic flow: GET /, city search endpoint, /report page, then POST /api/ai-summary with a weather report JSON payload. This is a good blueprint for authoring system-level tests.
50+
- How to add a new test
51+
- Place tests under src/test/java mirroring main package structure.
52+
- For Spring tests, annotate with @SpringBootTest (or @WebMvcTest for slice tests) and @AutoConfigureMockMvc if you need MockMvc.
53+
- If the test should avoid external AI calls, either rely on the test profile (ai.gemini.api-key=test-key ensures AiSummaryService.isConfigured() true) or provide a @TestConfiguration with a @Primary bean overriding AiSummaryService to a Mockito mock and stub summarize(). Pattern:
54+
55+
@SpringBootTest
56+
@AutoConfigureMockMvc
57+
@ActiveProfiles("test")
58+
class MyControllerIT {
59+
@TestConfiguration
60+
static class Cfg {
61+
@Bean @Primary AiSummaryService ai() {
62+
var mock = org.mockito.Mockito.mock(AiSummaryService.class);
63+
org.mockito.Mockito.when(mock.isConfigured()).thenReturn(true);
64+
org.mockito.Mockito.when(mock.summarize(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString()))
65+
.thenReturn("Stubbed summary");
66+
return mock;
67+
}
68+
}
69+
@Autowired MockMvc mvc;
70+
@Test void responds200() throws Exception {
71+
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/"))
72+
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isOk());
73+
}
74+
}
75+
76+
- Run only this test to validate quickly: ./gradlew test --tests "com.example.weatherapp.MyControllerIT"
77+
- Creating and running a simple test example that works
78+
- Use any of the existing tests as a working example; they build and run with the provided test profile. For a minimal sanity test that does not require the web context:
79+
80+
package com.example.weatherapp;
81+
82+
import org.junit.jupiter.api.Test;
83+
import static org.junit.jupiter.api.Assertions.*;
84+
85+
class SanityTest {
86+
@Test void sanity() {
87+
assertTrue(21 >= 17, "Java toolchain is at least 17");
88+
}
89+
}
90+
91+
- Place it at src/test/java/com/example/weatherapp/SanityTest.java.
92+
- Run: ./gradlew test --tests "com.example.weatherapp.SanityTest".
93+
- Note: This example is not committed to the repository per instructions; create locally if needed and delete afterward.
94+
95+
3. Project Architecture and Development Notes
96+
- Packages
97+
- com.example.weatherapp.web – Controllers for endpoints and pages (Thymeleaf views: templates/index.html, templates/report.html).
98+
- com.example.weatherapp.city – CitySearchService uses cities.json; exposes searchSuggestions(query, limit) used by controller and tests.
99+
- com.example.weatherapp.ai – AiSummaryService integrates LangChain4j Google Gemini model; designed to degrade gracefully when not configured.
100+
- com.example.weatherapp.weather – WeatherReport is the domain model consumed by the AI summary endpoint.
101+
- AiSummaryService specifics
102+
- Configuration: ai.gemini.api-key and ai.gemini.model (default "gemini-1.5-flash").
103+
- Behavior:
104+
- isConfigured(): returns true if api key is non-blank.
105+
- summarize(report, timezone, city):
106+
- If not configured: returns an explicit "AI summary unavailable: missing Google Gemini API key." message.
107+
- Builds a compact prompt including timezone and location. JSON serialization uses Jackson. On serialization failure, falls back to a minimal safe JSON via safeReport().
108+
- Uses a lazily initialized ChatLanguageModel with double-checked locking and a volatile cachedModel field.
109+
- Any RuntimeException from model.generate() returns a controlled fallback: "AI summary unavailable at the moment. Reason: ..." to avoid failing the request.
110+
- Controller/API behavior
111+
- /api/ai-summary: Accepts WeatherReport JSON in body, optional timezone and city parameters. Response includes fields summary (String), model ("gemini"), configured (boolean). Integration tests assert these.
112+
- /api/cities/search: Validates parameters; limit parameter has defaults and caps (see tests for behavior and edge cases).
113+
- /report: Validates mandatory lat/lon; passes city and values to the Thymeleaf template.
114+
- Code style and testing style
115+
- Prefer Spring Boot test slices (@WebMvcTest) for controller-only units; use @SpringBootTest + MockMvc for integrated flows.
116+
- Mock external integrations using @TestConfiguration with @Primary bean overrides. Prefer Mockito stubs for clarity, as shown in EndToEndIntegrationTest and AiSummaryControllerIntegrationTest.
117+
- Keep tests deterministic; avoid network calls in tests. City data is static.
118+
- use final var instead of type where possible for local variables
119+
- prefer wrapper types instead of primitives
120+
- use List.of instead of Arrays.asList
121+
- Troubleshooting
122+
- Tests fail with missing AI key: Ensure ActiveProfiles("test") and application-test.properties is on the classpath. Alternatively, set -Dspring.profiles.active=test or define ai.gemini.api-key.
123+
- Unsupported Java version: Ensure local JDK 21; Gradle toolchain should provision, but corporate networks can block downloads—preinstall JDK 21 in that case.
124+
- Port conflicts when running bootRun: Set -Dserver.port=0 or another port.
125+
- Slow tests due to full context: Consider @WebMvcTest slices and mock only required beans.
126+
- Flaky AI tests: Do not rely on external AI responses; always stub summarize().
127+
128+
4. Commands Quick Reference
129+
- Build: ./gradlew build
130+
- Run app: ./gradlew bootRun
131+
- Run all tests: ./gradlew test
132+
- Run one class: ./gradlew test --tests "com.example.weatherapp.web.ReportControllerIntegrationTest"
133+
- Run one method: ./gradlew test --tests "com.example.weatherapp.web.ReportControllerIntegrationTest.methodName"
134+
- With profile override: ./gradlew test -Dspring.profiles.active=test
135+
136+
Notes
137+
- The examples above have been validated against current project structure and dependencies. For demonstration tests, create them locally and delete before committing, as per repository policy.

src/main/java/com/example/weatherapp/ai/AiSummaryService.java

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import dev.langchain4j.model.chat.ChatLanguageModel;
77
import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel;
8+
import org.jetbrains.annotations.NotNull;
89
import org.springframework.beans.factory.annotation.Value;
910
import org.springframework.stereotype.Service;
1011

11-
import java.util.Objects;
12-
1312
@Service
1413
public class AiSummaryService {
1514

@@ -35,18 +34,29 @@ public String summarize(WeatherReport report, String timezone, String city) {
3534
if (!isConfigured()) {
3635
return "AI summary unavailable: missing Google Gemini API key.";
3736
}
38-
ChatLanguageModel model = getModel();
37+
final var model = getModel();
3938
String reportJson;
4039
try {
4140
reportJson = mapper.writeValueAsString(report);
4241
} catch (JsonProcessingException e) {
4342
reportJson = safeReport(report);
4443
}
4544

46-
String location = city != null && !city.isBlank() ? city : "the provided coordinates";
47-
String tz = timezone != null ? timezone : "auto";
45+
final var location = city != null && !city.isBlank() ? city : "the provided coordinates";
46+
final var prompt = getPrompt(timezone, location, reportJson);
47+
48+
try {
49+
return model.generate(prompt);
50+
} catch (RuntimeException ex) {
51+
return "AI summary unavailable at the moment. Reason: " + ex.getMessage();
52+
}
53+
}
4854

49-
String prompt = "You are an assistant that summarizes short-term weather forecasts for lay people.\n" +
55+
@NotNull
56+
private static String getPrompt(String timezone, String location, String reportJson) {
57+
final var tz = timezone != null ? timezone : "auto";
58+
59+
return "You are an assistant that summarizes short-term weather forecasts for lay people.\n" +
5060
"Given the JSON weather report from Open-Meteo (hourly arrays for next ~72 hours) and context, provide:\n" +
5161
"1) A concise overview of the upcoming weather for the next 1-3 days in " + location + " (timezone: " + tz + ").\n" +
5262
"2) Practical tips.\n" +
@@ -55,29 +65,24 @@ public String summarize(WeatherReport report, String timezone, String city) {
5565
"Keep it under 180 words, use short paragraphs and bullet points.\n\n" +
5666
"Weather JSON:\n" + reportJson + "\n\n" +
5767
"Respond in plain text (no JSON).";
58-
59-
try {
60-
return model.generate(prompt);
61-
} catch (RuntimeException ex) {
62-
return "AI summary unavailable at the moment. Reason: " + ex.getMessage();
63-
}
6468
}
6569

6670
private String safeReport(WeatherReport r) {
6771
try { return mapper.writeValueAsString(r); } catch (Exception e) { return "{}"; }
6872
}
6973

7074
private ChatLanguageModel getModel() {
71-
ChatLanguageModel m = cachedModel;
75+
final var m = cachedModel;
7276
if (m == null) {
7377
synchronized (this) {
74-
m = cachedModel;
75-
if (m == null) {
76-
cachedModel = m = GoogleAiGeminiChatModel.builder()
78+
final var m2 = cachedModel;
79+
if (m2 == null) {
80+
cachedModel = GoogleAiGeminiChatModel.builder()
7781
.apiKey(apiKey)
7882
.modelName(modelName)
7983
.build();
8084
}
85+
return cachedModel;
8186
}
8287
}
8388
return m;

src/main/java/com/example/weatherapp/city/City.java

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,15 @@
55
import com.fasterxml.jackson.annotation.JsonProperty;
66

77
@JsonIgnoreProperties(ignoreUnknown = true)
8-
public class City {
9-
10-
private String name;
11-
// two-letter country code expected
12-
private String country;
13-
14-
private Double lat;
15-
16-
@JsonAlias({"lng", "lon"})
17-
@JsonProperty("lon")
18-
private Double lon;
19-
20-
// Optional field in some datasets (ignored for formatting)
21-
private String state;
22-
23-
public City() {}
24-
25-
public String getName() { return name; }
26-
public void setName(String name) { this.name = name; }
27-
28-
public String getCountry() { return country; }
29-
public void setCountry(String country) { this.country = country; }
30-
31-
public Double getLat() { return lat; }
32-
public void setLat(Double lat) { this.lat = lat; }
33-
34-
public Double getLon() { return lon; }
35-
public void setLon(Double lon) { this.lon = lon; }
36-
37-
public String getState() { return state; }
38-
public void setState(String state) { this.state = state; }
8+
public record City(
9+
@JsonProperty("name") String name,
10+
// two-letter country code expected
11+
@JsonProperty("country") String country,
12+
@JsonProperty("lat") Double lat,
13+
@JsonAlias({"lng", "lon"})
14+
@JsonProperty("lon") Double lon,
15+
// Optional field in some datasets (ignored for formatting)
16+
@JsonProperty("state") String state) {
3917

4018
public String toSuggestionString() {
4119
String n = name != null ? name : "";

src/main/java/com/example/weatherapp/city/CitySearchService.java

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,50 @@
66
import org.springframework.stereotype.Service;
77

88
import java.io.IOException;
9-
import java.io.InputStream;
109
import java.util.Collections;
1110
import java.util.List;
12-
import java.util.Locale;
13-
import java.util.Objects;
11+
import java.util.concurrent.locks.ReentrantLock;
1412
import java.util.stream.Collectors;
1513

14+
import static java.lang.Integer.MAX_VALUE;
15+
import static java.util.Locale.ROOT;
16+
import static java.util.Objects.compare;
17+
1618
@Service
1719
public class CitySearchService {
1820

1921
private static final String CLASSPATH_JSON = "cities.json";
2022

21-
private final Object lock = new Object();
23+
private final ReentrantLock lock = new ReentrantLock();
2224
private volatile List<City> cached;
2325

2426
private final ObjectMapper mapper = new ObjectMapper();
2527

2628
public List<String> searchSuggestions(String query, int limit) {
2729
if (query == null) return Collections.emptyList();
28-
String q = query.trim().toLowerCase(Locale.ROOT);
30+
final var q = query.trim().toLowerCase(ROOT);
2931
if (q.isEmpty()) return Collections.emptyList();
3032

31-
List<City> all = loadAllCities();
33+
final var all = loadAllCities();
3234
if (all.isEmpty()) return Collections.emptyList();
3335

34-
// Simple case-insensitive substring match against name and optionally state
35-
List<City> matched = all.stream()
36+
final var matched = all.stream()
3637
.filter(c -> {
37-
String name = safeLower(c.getName());
38-
String state = safeLower(c.getState());
38+
String name = safeLower(c.name());
39+
String state = safeLower(c.state());
3940
return (name != null && name.contains(q)) || (state != null && state.contains(q));
4041
})
41-
.limit(Math.max(limit, 10) * 10L) // scan a bit more then trim to allow for simple ordering
42-
.collect(Collectors.toList());
43-
44-
// Basic ordering: starts-with first, then by name length, then lexicographically
45-
matched.sort((a, b) -> {
46-
String an = safeLower(a.getName());
47-
String bn = safeLower(b.getName());
48-
int as = startsWithScore(an, q) - startsWithScore(bn, q);
49-
if (as != 0) return -as; // higher score first
50-
int al = an != null ? an.length() : Integer.MAX_VALUE;
51-
int bl = bn != null ? bn.length() : Integer.MAX_VALUE;
52-
int cmp = Integer.compare(al, bl);
53-
if (cmp != 0) return cmp;
54-
return Objects.compare(an, bn, String::compareTo);
55-
});
42+
.limit(Math.max(limit, 10) * 10L).sorted((a, b) -> {
43+
final var an = safeLower(a.name());
44+
final var bn = safeLower(b.name());
45+
final var as = startsWithScore(an, q) - startsWithScore(bn, q);
46+
if (as != 0) return -as; // higher score first
47+
final var al = an != null ? an.length() : MAX_VALUE;
48+
final var bl = bn != null ? bn.length() : MAX_VALUE;
49+
final var cmp = Integer.compare(al, bl);
50+
if (cmp != 0) return cmp;
51+
return compare(an, bn, String::compareTo);
52+
}).toList();
5653

5754
return matched.stream()
5855
.map(City::toSuggestionString)
@@ -67,26 +64,29 @@ private static int startsWithScore(String s, String q) {
6764
}
6865

6966
private static String safeLower(String s) {
70-
return s == null ? null : s.toLowerCase(Locale.ROOT);
67+
return s == null ? null : s.toLowerCase(ROOT);
7168
}
7269

7370
private List<City> loadAllCities() {
74-
List<City> local = cached;
71+
final var local = cached;
7572
if (local != null) return local;
76-
synchronized (lock) {
73+
lock.lock();
74+
try {
7775
if (cached != null) return cached;
78-
List<City> loaded = tryLoadFromClasspath();
79-
cached = loaded;
76+
cached = tryLoadFromClasspath();
8077
return cached;
78+
} finally {
79+
lock.unlock();
8180
}
8281
}
8382

8483
private List<City> tryLoadFromClasspath() {
8584
try {
86-
ClassPathResource resource = new ClassPathResource(CLASSPATH_JSON);
85+
final var resource = new ClassPathResource(CLASSPATH_JSON);
8786
if (!resource.exists()) return Collections.emptyList();
88-
try (InputStream is = resource.getInputStream()) {
89-
return mapper.readValue(is, new TypeReference<List<City>>() {});
87+
try (final var is = resource.getInputStream()) {
88+
return mapper.readValue(is, new TypeReference<>() {
89+
});
9090
}
9191
} catch (IOException e) {
9292
return Collections.emptyList();

0 commit comments

Comments
 (0)