Thymeleaf 호환성 테스트 추가:
- Mvc/Form/Security/Expression 관련 템플릿 및 테스트 구현 - 의존성 검증 및 업그레이드 스크립트 추가 (Thymeleaf 3.1.5 적용)
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
// ./gradlew -I gradle/thymeleaf-verification.init.gradle :thymeleafCompatibilityDependencies
|
||||||
|
// Read-only dependency resolution; does not override versions or the normal build.
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
def portal = gradle.rootProject
|
||||||
|
portal.tasks.register('thymeleafCompatibilityDependencies') {
|
||||||
|
doLast {
|
||||||
|
def output = new File(portal.buildDir, 'reports/thymeleaf-compatibility')
|
||||||
|
output.mkdirs()
|
||||||
|
['runtimeClasspath', 'testRuntimeClasspath'].each { name ->
|
||||||
|
def artifacts = portal.configurations.getByName(name).resolvedConfiguration.resolvedArtifacts
|
||||||
|
def rows = artifacts.findAll {
|
||||||
|
it.id.componentIdentifier instanceof org.gradle.api.artifacts.component.ModuleComponentIdentifier
|
||||||
|
}.collect {
|
||||||
|
"${it.moduleVersion.id.group}:${it.name}\t${it.moduleVersion.id.version}\t${it.file.name}"
|
||||||
|
}.sort()
|
||||||
|
new File(output, "${name}.tsv").text = rows.join('\n') + '\n'
|
||||||
|
}
|
||||||
|
println "Dependency evidence: ${output}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare resolved dependency TSVs and inspect both WARs; exits nonzero on regression."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_CHANGES = {
|
||||||
|
"org.thymeleaf:thymeleaf": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"org.thymeleaf:thymeleaf-spring5": ("3.1.4.RELEASE", "3.1.5.RELEASE"),
|
||||||
|
"ognl:ognl": ("3.3.4", "3.3.5"),
|
||||||
|
}
|
||||||
|
EXPECTED_CLASSES = {
|
||||||
|
"org/thymeleaf/TemplateEngine.class": "thymeleaf-3.1.5.RELEASE.jar",
|
||||||
|
"org/thymeleaf/spring5/SpringTemplateEngine.class": "thymeleaf-spring5-3.1.5.RELEASE.jar",
|
||||||
|
"ognl/Ognl.class": "ognl-3.3.5.jar",
|
||||||
|
}
|
||||||
|
LOCAL_PREFIXES = (
|
||||||
|
"spring-boot-devtools", "spring-boot-starter-actuator", "spring-boot-actuator",
|
||||||
|
"micrometer-", "spring-boot-admin-", "tomcat-embed-websocket-",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies(path):
|
||||||
|
result = {}
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
module, version, filename = line.split("\t")
|
||||||
|
entries = result.setdefault(module, [])
|
||||||
|
require((version, filename) not in entries, "Duplicate artifact: " + filename)
|
||||||
|
require(not entries or entries[0][0] == version, "Multiple versions: " + module)
|
||||||
|
entries.append((version, filename))
|
||||||
|
for entries in result.values():
|
||||||
|
entries.sort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before, after):
|
||||||
|
old, new = dependencies(before), dependencies(after)
|
||||||
|
require(old.keys() == new.keys(), "Added/removed external modules: " + str(old.keys() ^ new.keys()))
|
||||||
|
changed = {key: (old[key][0][0], new[key][0][0]) for key in old if old[key] != new[key]}
|
||||||
|
require(changed == EXPECTED_CHANGES, "Unexpected dependency changes: " + str(changed))
|
||||||
|
return {"external_modules": len(new), "changes": changed}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_war(path):
|
||||||
|
owners = {name: [] for name in EXPECTED_CLASSES}
|
||||||
|
with zipfile.ZipFile(path) as war:
|
||||||
|
names = war.namelist()
|
||||||
|
require(len(names) == len(set(names)), "Duplicate ZIP entries: " + str(path))
|
||||||
|
jars = sorted(name for name in names if name.endswith(".jar"))
|
||||||
|
basenames = [Path(name).name for name in jars]
|
||||||
|
require(len(basenames) == len(set(basenames)), "Duplicate JAR names: " + str(path))
|
||||||
|
require(not any(Path(name).name.startswith("application-local") and name.endswith(".yml")
|
||||||
|
for name in names), "Local profile packaged: " + str(path))
|
||||||
|
for name in jars:
|
||||||
|
basename = Path(name).name
|
||||||
|
require(not basename.startswith(LOCAL_PREFIXES), "Excluded library packaged: " + name)
|
||||||
|
require(not re.match(r"spring-[\w-]+-6\.", basename), "Spring 6 packaged: " + name)
|
||||||
|
with zipfile.ZipFile(io.BytesIO(war.read(name))) as jar:
|
||||||
|
classes = set(jar.namelist())
|
||||||
|
require(not any(c.startswith("jakarta/servlet/") for c in classes),
|
||||||
|
"Jakarta Servlet classes packaged: " + name)
|
||||||
|
for target in owners:
|
||||||
|
if target in classes:
|
||||||
|
owners[target].append(name)
|
||||||
|
# Java 8 compatibility of each upgraded library's entry class.
|
||||||
|
require(int.from_bytes(jar.read(target)[6:8], "big") <= 52,
|
||||||
|
"Java >8 class: " + name + "!" + target)
|
||||||
|
for target, expected in EXPECTED_CLASSES.items():
|
||||||
|
require(owners[target] == ["WEB-INF/lib/" + expected],
|
||||||
|
"Wrong/duplicate class provider: " + target + " " + str(owners[target]))
|
||||||
|
for pattern, expected in [
|
||||||
|
(r"thymeleaf-\d", "thymeleaf-3.1.5.RELEASE.jar"),
|
||||||
|
(r"thymeleaf-spring\d-", "thymeleaf-spring5-3.1.5.RELEASE.jar"),
|
||||||
|
(r"ognl-", "ognl-3.3.5.jar"),
|
||||||
|
]:
|
||||||
|
require([n for n in basenames if re.match(pattern, n)] == [expected],
|
||||||
|
"Wrong/duplicate library version for " + expected)
|
||||||
|
return {"file": path.name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"jar_count": len(jars), "class_providers": owners, "jars": jars}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--baseline", type=Path, required=True, help="Directory of baseline dependency TSVs")
|
||||||
|
parser.add_argument("--current", type=Path, required=True, help="Directory of current dependency TSVs")
|
||||||
|
parser.add_argument("--war", type=Path, action="append", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
require(len(args.war) == 2 and len(set(args.war)) == 2, "Provide the standard WAR and bootWar")
|
||||||
|
result = {
|
||||||
|
"dependencies": {name: compare(args.baseline / (name + ".tsv"), args.current / (name + ".tsv"))
|
||||||
|
for name in ["runtimeClasspath", "testRuntimeClasspath"]},
|
||||||
|
"wars": [inspect_war(path) for path in args.war],
|
||||||
|
}
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print("PASS: dependency changes limited to three modules; both WARs verified")
|
||||||
|
for war in result["wars"]:
|
||||||
|
print(war["file"], war["sha256"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package com.eactive.apim.portal.common.compatibility;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||||
|
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.support.StaticMessageSource;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.AuthorityUtils;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler;
|
||||||
|
import org.springframework.validation.BeanPropertyBindingResult;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
|
import org.springframework.web.context.WebApplicationContext;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import org.springframework.web.servlet.support.RequestDataValueProcessor;
|
||||||
|
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
|
||||||
|
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.spring5.view.ThymeleafView;
|
||||||
|
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ThymeleafBootMvcCompatibilityTest {
|
||||||
|
|
||||||
|
// No user-defined engine/resolver: Boot must create and initialize both.
|
||||||
|
private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration.class, ThymeleafAutoConfiguration.class))
|
||||||
|
.withUserConfiguration(SupportConfiguration.class)
|
||||||
|
.withPropertyValues("spring.thymeleaf.prefix=classpath:/templates/",
|
||||||
|
"spring.thymeleaf.suffix=.html", "spring.thymeleaf.encoding=UTF-8", "spring.thymeleaf.cache=false");
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearThreadContexts() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
RequestContextHolder.resetRequestAttributes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bootAutoConfigurationInitializesAndRendersLayout() {
|
||||||
|
runner.run(context -> {
|
||||||
|
assertThat(context).hasNotFailed().hasSingleBean(SpringTemplateEngine.class)
|
||||||
|
.hasSingleBean(ThymeleafViewResolver.class);
|
||||||
|
SpringTemplateEngine engine = context.getBean(SpringTemplateEngine.class);
|
||||||
|
assertThat(engine.getConfiguration()).isNotNull();
|
||||||
|
assertThat(engine.getDialects()).anyMatch(LayoutDialect.class::isInstance)
|
||||||
|
.anyMatch(SpringSecurityDialect.class::isInstance);
|
||||||
|
Map<String, Object> model = new HashMap<>();
|
||||||
|
model.put("name", "portal");
|
||||||
|
model.put("date", java.time.LocalDateTime.of(2026, 9, 7, 12, 0));
|
||||||
|
assertThat(render(context, "compatibility/page", model))
|
||||||
|
.contains("layout-shell", "<b>PORTAL</b>", "2026.09.07").doesNotContain(">default</main>");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mvcFormKeepsBindingSelectionErrorsMessagesAndRequestDataProcessing() {
|
||||||
|
runner.run(context -> {
|
||||||
|
Form form = new Form();
|
||||||
|
BindingResult errors = new BeanPropertyBindingResult(form, "form");
|
||||||
|
errors.rejectValue("email", "email.invalid");
|
||||||
|
Map<String, Object> model = new HashMap<>();
|
||||||
|
model.put("form", form);
|
||||||
|
model.put(BindingResult.MODEL_KEY_PREFIX + "form", errors);
|
||||||
|
String html = render(context, "compatibility/form", model);
|
||||||
|
assertThat(html).contains("가입 정보", "이메일 확인 필요", "name=\"email\"", "value=\"user@example.com\"",
|
||||||
|
"action=\"/portal/submit\"", "name=\"role\"", "value=\"USER\" selected=\"selected\"",
|
||||||
|
"name=\"_csrf\"", "value=\"compat-token\"")
|
||||||
|
.doesNotContain("value=\"ADMIN\" selected=\"selected\"", "th:field", "th:errors");
|
||||||
|
RecordingProcessor processor = context.getBean(RecordingProcessor.class);
|
||||||
|
assertThat(processor.fieldTypes).containsExactly("email", "option", "option");
|
||||||
|
assertThat(processor.actions).containsExactly("POST /portal/submit");
|
||||||
|
|
||||||
|
// A different selection and a valid form must clear the error and old selection.
|
||||||
|
form.setRole("ADMIN");
|
||||||
|
model.put(BindingResult.MODEL_KEY_PREFIX + "form", new BeanPropertyBindingResult(form, "form"));
|
||||||
|
assertThat(render(context, "compatibility/form", model))
|
||||||
|
.contains("value=\"ADMIN\" selected=\"selected\"")
|
||||||
|
.doesNotContain("이메일 확인 필요", "value=\"USER\" selected=\"selected\"");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anonymousUserSeesOnlyAnonymousContent() {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken(
|
||||||
|
"compatibility", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
|
||||||
|
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||||
|
.contains("로그인 안내").doesNotContain("회원 메뉴", "관리자 메뉴", "id=\"identity\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticatedUserSeesIdentityAndUserContent() {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
|
"portal-user", "unused", AuthorityUtils.createAuthorityList("ROLE_USER")));
|
||||||
|
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||||
|
.contains("회원 메뉴", ">portal-user</span>").doesNotContain("로그인 안내", "관리자 메뉴"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorSeesRoleProtectedContent() {
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||||
|
"portal-admin", "unused", AuthorityUtils.createAuthorityList("ROLE_ADMIN")));
|
||||||
|
runner.run(context -> assertThat(render(context, "compatibility/security", Collections.emptyMap()))
|
||||||
|
.contains("회원 메뉴", "관리자 메뉴", ">portal-admin</span>").doesNotContain("로그인 안내"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String render(WebApplicationContext context, String template, Map<String, Object> model) throws Exception {
|
||||||
|
context.getServletContext().setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context);
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest(context.getServletContext());
|
||||||
|
request.setContextPath("/portal");
|
||||||
|
request.setRequestURI("/portal/signup");
|
||||||
|
request.setMethod("GET");
|
||||||
|
request.setPreferredLocales(Collections.singletonList(Locale.KOREA));
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response));
|
||||||
|
try {
|
||||||
|
ThymeleafViewResolver resolver = context.getBean(ThymeleafViewResolver.class);
|
||||||
|
assertThat(resolver.resolveViewName(template, Locale.KOREA)).isInstanceOf(ThymeleafView.class);
|
||||||
|
resolver.resolveViewName(template, Locale.KOREA).render(model, request, response);
|
||||||
|
assertThat(response.getStatus()).isEqualTo(200);
|
||||||
|
assertThat(response.getCharacterEncoding()).isEqualTo("UTF-8");
|
||||||
|
return response.getContentAsString();
|
||||||
|
} finally {
|
||||||
|
RequestContextHolder.resetRequestAttributes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class SupportConfiguration {
|
||||||
|
@Bean
|
||||||
|
LayoutDialect layoutDialect() {
|
||||||
|
return new LayoutDialect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
StaticMessageSource messageSource() {
|
||||||
|
StaticMessageSource messages = new StaticMessageSource();
|
||||||
|
messages.addMessage("form.title", Locale.KOREA, "가입 정보");
|
||||||
|
messages.addMessage("email.invalid", Locale.KOREA, "이메일 확인 필요");
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
RecordingProcessor requestDataValueProcessor() {
|
||||||
|
return new RecordingProcessor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
DefaultWebSecurityExpressionHandler webSecurityExpressionHandler() {
|
||||||
|
return new DefaultWebSecurityExpressionHandler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class RecordingProcessor implements RequestDataValueProcessor {
|
||||||
|
final List<String> fieldTypes = new ArrayList<>();
|
||||||
|
final List<String> actions = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String processAction(HttpServletRequest request, String action, String method) {
|
||||||
|
actions.add(method.toUpperCase(Locale.ROOT) + " " + action);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {
|
||||||
|
fieldTypes.add(type);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String> getExtraHiddenFields(HttpServletRequest request) {
|
||||||
|
return Collections.singletonMap("_csrf", "compat-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String processUrl(HttpServletRequest request, String url) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Form {
|
||||||
|
private String email = "user@example.com";
|
||||||
|
private String role = "USER";
|
||||||
|
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public void setRole(String role) { this.role = role; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package com.eactive.apim.portal.common.compatibility;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||||
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestFactory;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
|
||||||
|
import org.thymeleaf.spring5.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||||
|
import org.thymeleaf.templateresolver.StringTemplateResolver;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
|
||||||
|
|
||||||
|
/** The original 15 probe scenarios, using only classpath fixtures and production fragments. */
|
||||||
|
class ThymeleafExpressionCompatibilityTest {
|
||||||
|
|
||||||
|
static Context context() {
|
||||||
|
Context context = new Context(Locale.KOREA);
|
||||||
|
context.setVariable("name", "portal");
|
||||||
|
context.setVariable("items", Arrays.asList("a", "b"));
|
||||||
|
context.setVariable("date", LocalDateTime.of(2026, 9, 7, 12, 0));
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
Stream<DynamicTest> expressionCompatibility() {
|
||||||
|
return Stream.of(false, true).flatMap(spring -> {
|
||||||
|
TemplateEngine engine = spring ? new SpringTemplateEngine() : new TemplateEngine();
|
||||||
|
engine.setTemplateResolver(new StringTemplateResolver());
|
||||||
|
String name = spring ? "SpringEL " : "OGNL ";
|
||||||
|
return Stream.of(
|
||||||
|
dynamicTest(name + "initialization", () -> assertThat(engine.getConfiguration()).isNotNull()),
|
||||||
|
dynamicTest(name + "property", () -> assertThat(engine.process(
|
||||||
|
"<p th:text=\"${name}\"></p>", context())).isEqualTo("<p>portal</p>")),
|
||||||
|
dynamicTest(name + "method", () -> assertThat(engine.process(
|
||||||
|
"<p th:text=\"${name.toUpperCase()}\"></p>", context())).isEqualTo("<p>PORTAL</p>")),
|
||||||
|
dynamicTest(name + "collection and condition", () -> assertThat(engine.process(
|
||||||
|
"<p th:if=\"${#lists.size(items) > 1}\" th:text=\"${items[1]}\"></p>", context()))
|
||||||
|
.isEqualTo("<p>b</p>")),
|
||||||
|
dynamicTest(name + "date", () -> assertThat(engine.process(
|
||||||
|
"<p th:text=\"${#temporals.format(date, 'yyyy.MM.dd')}\"></p>", context()))
|
||||||
|
.isEqualTo("<p>2026.09.07</p>")),
|
||||||
|
dynamicTest(name + "JavaScript inline", () -> assertThat(engine.process(
|
||||||
|
"<script th:inline=\"javascript\">var name = [[${name}]];</script>", context()))
|
||||||
|
.isEqualTo("<script>var name = \"portal\";</script>")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpringTemplateEngine resourceEngine() {
|
||||||
|
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||||
|
resolver.setPrefix("templates/");
|
||||||
|
resolver.setSuffix(".html");
|
||||||
|
resolver.setCharacterEncoding("UTF-8");
|
||||||
|
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||||
|
engine.setTemplateResolver(resolver);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void layoutCompositionWithSecurityDialect() {
|
||||||
|
SpringTemplateEngine engine = resourceEngine();
|
||||||
|
engine.addDialect(new LayoutDialect());
|
||||||
|
engine.addDialect(new SpringSecurityDialect());
|
||||||
|
assertThat(engine.process("compatibility/page", context()))
|
||||||
|
.contains("layout-shell", "<b>PORTAL</b>", "<p>2026.09.07</p>")
|
||||||
|
.doesNotContain(">default</main>", "layout:decorate", "th:text");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actualPaginationKeepsNumbersEventsAndBoundaryStates() {
|
||||||
|
SpringTemplateEngine engine = resourceEngine();
|
||||||
|
Context context = context();
|
||||||
|
context.setVariable("jsFunction", "loadPage");
|
||||||
|
for (int pageNumber : new int[]{0, 2, 4}) {
|
||||||
|
context.setVariable("page", new PageImpl<>(Arrays.asList("a", "b"),
|
||||||
|
PageRequest.of(pageNumber, 10), 50));
|
||||||
|
String html = engine.process("views/fragment/pagination", Collections.singleton("pagination"), context);
|
||||||
|
assertThat(html).contains("page-current\">" + (pageNumber + 1) + "</a>",
|
||||||
|
"onclick=\"loadPage(1, 10);\"", "onclick=\"loadPage(5, 10);\"",
|
||||||
|
"onclick=\"loadPage(" + Math.max(1, pageNumber) + ", 10);\"",
|
||||||
|
"onclick=\"loadPage(" + Math.min(5, pageNumber + 2) + ", 10);\"");
|
||||||
|
for (int number = 1; number <= 5; number++) {
|
||||||
|
if (Math.abs(number - (pageNumber + 1)) <= 2) {
|
||||||
|
assertThat(html).contains(">" + number + "</a>");
|
||||||
|
} else {
|
||||||
|
assertThat(html).doesNotContain(">" + number + "</a>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertThat(html.contains("class=\"page-first disabled\"")).isEqualTo(pageNumber == 0);
|
||||||
|
assertThat(html.contains("class=\"page-prev disabled\"")).isEqualTo(pageNumber == 0);
|
||||||
|
assertThat(html.contains("class=\"page-next disabled\"")).isEqualTo(pageNumber == 4);
|
||||||
|
assertThat(html.contains("class=\"page-last disabled\"")).isEqualTo(pageNumber == 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void actualSmsAndEmailNoticeVisibility() {
|
||||||
|
SpringTemplateEngine engine = resourceEngine();
|
||||||
|
Context context = context();
|
||||||
|
for (String fragment : Arrays.asList("smsNotice", "emailNotice")) {
|
||||||
|
for (boolean show : new boolean[]{true, false}) {
|
||||||
|
for (String code : new String[]{"123456", null}) {
|
||||||
|
context.setVariable("showTestAuthNotice", show);
|
||||||
|
context.setVariable("testAuthNumber", code);
|
||||||
|
String html = engine.process("views/fragment/test-env-auth-notice",
|
||||||
|
Collections.singleton(fragment), context);
|
||||||
|
if (show && code != null) {
|
||||||
|
assertThat(html).contains("123456", "테스트 환경 안내", fragment.equals("smsNotice") ? "SMS" : "이메일");
|
||||||
|
} else {
|
||||||
|
assertThat(html).doesNotContain("123456", "test-env-notice", "테스트 환경 안내");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<h1 th:text="#{form.title}">
|
||||||
|
</h1>
|
||||||
|
<form th:action="@{/submit}" th:object="${form}" method="post">
|
||||||
|
<input type="email" th:field="*{email}">
|
||||||
|
<p th:errors="*{email}">
|
||||||
|
</p>
|
||||||
|
<select th:field="*{role}">
|
||||||
|
<option value="USER">사용자</option>
|
||||||
|
<option value="ADMIN">관리자</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
<head>
|
||||||
|
<title>Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>layout-shell</header>
|
||||||
|
<main layout:fragment="content">default</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{compatibility/layout}">
|
||||||
|
<body>
|
||||||
|
<main layout:fragment="content">
|
||||||
|
<b th:text="${name.toUpperCase()}">
|
||||||
|
</b>
|
||||||
|
<p th:text="${#temporals.format(date, 'yyyy.MM.dd')}">
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||||
|
<body>
|
||||||
|
<p sec:authorize="isAnonymous()">로그인 안내</p>
|
||||||
|
<p sec:authorize="isAuthenticated()">회원 메뉴</p>
|
||||||
|
<p sec:authorize="hasRole('ADMIN')">관리자 메뉴</p>
|
||||||
|
<span id="identity" sec:authorize="isAuthenticated()" sec:authentication="name"></span>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user