Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ mvn test -Dtest=ContractDriftTest -Dcontract.update=true

갱신하지 않으면 `ContractDriftTest`가 빌드를 실패시킵니다. 환경 변수
`PICKLE_CONTRACT_MASTER`에 수기로 쓴 설계 명세 YAML 경로를 주면 설계 표면과 구현 표면의
집합 대조까지 추가로 수행합니다.
집합 대조에 더해, 두 문서가 함께 가진 오퍼레이션의 `operationId`와 설계 명세가 붙인
스키마명이 생성본과 같은지까지 대조합니다.

실행 중인 서버도 같은 스펙을 제공합니다: https://pickle.pusan.ac.kr/api/v1/openapi

Expand Down
99 changes: 92 additions & 7 deletions src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig;
import org.junit.jupiter.api.Assumptions;
Expand Down Expand Up @@ -44,6 +47,14 @@
* parallel development against a frozen design contract; it must be empty
* once the matching endpoints ship.</p>
*
* <p><b>4. Design-contract names (optional)</b> — the design contract states
* that it reuses the generated names verbatim, so for every operation the two
* documents share, their {@code operationId} must be equal, and every schema
* the design contract names must exist under the generated name. Check 3 alone
* passes on a name mismatch because it compares path+method only; 20 operation
* ids had drifted that way unnoticed before the 2026-08-08 alignment, which is
* why this axis is a gate rather than a manual comparison step.</p>
*
* <p><b>Limitation:</b> checks 2 and 3 compare METHOD+path sets only.
* Parameters, schema shapes and error codes are covered by check 1 (the
* published snapshot is byte-stable) and by contract review.</p>
Expand Down Expand Up @@ -257,13 +268,7 @@ void runtimeExposesExactlyTheImplementedSet() throws Exception {

@Test
void designContractSurfaceMatchesImplementedPlusPlanned() throws Exception {
String master = System.getenv("PICKLE_CONTRACT_MASTER");
Assumptions.assumeTrue(master != null && !master.isBlank(),
"PICKLE_CONTRACT_MASTER not set — design-contract comparison skipped");

Path masterPath = Path.of(master);
assertThat(masterPath).as("design contract at $PICKLE_CONTRACT_MASTER").exists();
JsonNode contract = new YAMLMapper().readTree(Files.readString(masterPath));
JsonNode contract = designContractOrSkip();

// doesNotContainAnyElementsOf rejects an empty iterable with an
// IllegalArgumentException, so guard the all-shipped state (PLANNED empty).
Expand All @@ -281,6 +286,48 @@ void designContractSurfaceMatchesImplementedPlusPlanned() throws Exception {
.isEqualTo(contractSurface);
}

@Test
void designContractNamesMatchGeneratedNames() throws Exception {
JsonNode contract = designContractOrSkip();
JsonNode runtime = fetchRuntimeSpec();

Map<String, String> generatedIds = operationIdsOf(runtime, SERVER_PREFIX);
List<String> drifted = new ArrayList<>();
for (Map.Entry<String, String> operation : operationIdsOf(contract, "").entrySet()) {
String generated = generatedIds.get(operation.getKey());
if (generated != null && !generated.equals(operation.getValue())) {
drifted.add(operation.getKey() + " — design contract " + operation.getValue()
+ ", generated " + generated);
}
}
assertThat(drifted)
.as("operationId drift, listed per operation the two specs share")
.isEmpty();

// Containment runs one way: the generated spec names every DTO the
// runtime exposes, while the design contract names only the subset it
// documents. An unimplemented design operation may carry schemas the
// runtime cannot know yet, so the axis holds only while PLANNED is empty.
if (PLANNED.isEmpty()) {
Set<String> unknownSchemas = schemaNamesOf(contract);
unknownSchemas.removeAll(schemaNamesOf(runtime));
assertThat(unknownSchemas)
.as("design contract schema names absent from the generated spec")
.isEmpty();
}
}

/** Reads the design contract, or skips the test when it was not pointed at. */
private static JsonNode designContractOrSkip() throws Exception {
String master = System.getenv("PICKLE_CONTRACT_MASTER");
Assumptions.assumeTrue(master != null && !master.isBlank(),
"PICKLE_CONTRACT_MASTER not set — design-contract comparison skipped");

Path masterPath = Path.of(master);
assertThat(masterPath).as("design contract at $PICKLE_CONTRACT_MASTER").exists();
return new YAMLMapper().readTree(Files.readString(masterPath));
}

/**
* Published-spec convention: path keys are server-relative and the prefix
* lives in {@code servers[0].url} — matching how typed clients (the console
Expand Down Expand Up @@ -326,6 +373,44 @@ private static String toCanonicalYaml(JsonNode spec) throws Exception {
return yaml.writeValueAsString(tree);
}

/**
* Maps each "METHOD path" to its declared {@code operationId}, normalizing
* away {@code stripPrefix}. An operation without one maps to a placeholder
* so a missing id reads as drift instead of silently matching.
*/
private static Map<String, String> operationIdsOf(JsonNode spec, String stripPrefix) {
Map<String, String> operationIds = new TreeMap<>();
JsonNode paths = spec.path("paths");
for (Iterator<Map.Entry<String, JsonNode>> it = paths.properties().iterator(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
String path = entry.getKey();
if (!stripPrefix.isEmpty() && path.startsWith(stripPrefix)) {
path = path.substring(stripPrefix.length());
}
for (Iterator<Map.Entry<String, JsonNode>> ops = entry.getValue().properties().iterator();
ops.hasNext(); ) {
Map.Entry<String, JsonNode> operation = ops.next();
String method = operation.getKey();
if (!HTTP_METHODS.contains(method.toLowerCase(Locale.ROOT))) {
continue;
}
JsonNode operationId = operation.getValue().path("operationId");
operationIds.put(method.toUpperCase(Locale.ROOT) + " " + path,
operationId.isTextual() ? operationId.asText() : "(no operationId)");
}
}
return operationIds;
}

/** Names declared under {@code components.schemas}. */
private static Set<String> schemaNamesOf(JsonNode spec) {
Set<String> names = new TreeSet<>();
for (Iterator<String> it = spec.path("components").path("schemas").fieldNames(); it.hasNext(); ) {
names.add(it.next());
}
return names;
}

/** Extracts "METHOD path" pairs, normalizing away {@code stripPrefix}. */
private static Set<String> endpointsOf(JsonNode spec, String stripPrefix) {
Set<String> endpoints = new TreeSet<>();
Expand Down
Loading